Compare commits

...
Author SHA1 Message Date
Filip Hejmowski 71af6a4a0e fix: stabilize flaky CI tests and Windows service registration 2026-10-15 01:03:49 +02:00
Luke Parker 3943b84ef0 fix(cli): default the Windows service to port 45278 (#55547) 2026-10-15 08:41:49 +10:00
Filipandvegerot 1e63a3bb73 fix(core): load configured instructions (#55539)
Co-authored-by: vegerot <5297725+vegerot@users.noreply.github.com>
2026-10-15 00:34:17 +02:00
vprdev 7291f3e07a fix(console): keep API route handlers out of route modules (#55541) 2026-10-14 14:41:00 -07:00
opencode-agent[bot]andTarquinen 13afea6a1a docs(console): correct Cat Jam Free privacy on Go page (#55499)
Co-authored-by: Tarquinen <78942835+Tarquinen@users.noreply.github.com>
2026-10-14 14:00:46 -05:00
James Long 8ffbcc2c81 fix(tui): tidy docked diff panel spacing (#55467) 2026-10-14 13:12:43 -04:00
opencode-agent[bot]andrekram1-node 35bb8e44ef fix(core): treat empty configured provider and model names as omitted (#55465)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-14 11:29:44 -05:00
opencode-agent[bot]anddc85 0ccfe69ff3 docs(console): list Cat Jam Free unavailable regions (#55466)
Co-authored-by: dc85 <83497+dc85@users.noreply.github.com>
2026-10-14 09:28:55 -07:00
Stefan Avram 07c0ff5914 feat(console): enrich Salesforce leads with Console account status (#54387) 2026-10-14 12:28:23 -04:00
James Long fe1717b309 fix(tui): put the diff side panel behind an experiment (#55456) 2026-10-14 12:16:18 -04:00
Aiden Cline 943dc005f7 fix(vscode): use the sst/opencode repository URL for Marketplace publishing (#55463) 2026-10-14 11:04:25 -05:00
Aiden Cline cddf1dd3e9 docs: document top-level policies in Console API response (#55461) 2026-10-14 10:55:52 -05:00
James Long b474b84259 fix(tui): use the action disabled token for inactive key hints (#55452) 2026-10-14 11:28:46 -04:00
James Long c0d41d2b26 feat(tui): render diffs in plugin panel slots (#47152) 2026-10-14 11:26:36 -04:00
Filip c458ad72d3 fix(core): substitute empty for missing config file references (#55388) 2026-10-14 16:35:25 +02:00
opencode-agent[bot]andneriousy f44c40b4d3 docs: add mise install method (#55415)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-10-14 15:43:06 +02:00
opencode-agent[bot]andBrendonovich 5fa0a42386 feat(app): restore option arrow session tab navigation (#54277)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-14 21:35:52 +08:00
Jack d3e1de326b docs(www): remove Ling free models from Console models (#55410) 2026-10-14 21:29:21 +08:00
opencode-agent[bot]andBrendonovich 81d57087a4 feat(session-ui): summarize each turn's changed files (#55064)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-14 21:13:31 +08:00
opencode 34d8ded0c5 sync release versions for v2.0.29 [skip ci] 2026-10-14 09:03:25 +00:00
135 changed files with 3665 additions and 556 deletions

No files matched your search

+70
View File
@@ -2,6 +2,76 @@
Release notes for OpenCode V2, newest first.
## v2.0.29 — 2026-10-14
### Highlights
- **Remote Device Pairing**: In **Settings → Pairing**, select **Pair device** to enable OpenTunnel remote access and get a QR code or copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
- **Provider Account Management**: Use the connect dialog or **Settings → Providers** to switch, rename, remove, or add provider accounts. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
- **Automatic Response Continuation**: Responses that reach their output-token limit now continue automatically, preserving partial output instead of stopping abruptly. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
- **Trackpad Image Navigation**: Pinch to zoom and use two-finger scrolling to pan image and SVG previews. Select **Fit** to reset the view. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
### Core
#### Improvements
- Automatically continue responses after an output-token limit, with bounded retries and clearer recovery for truncated tool calls. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
- Stabilize access policies under the top-level `policies` configuration key while retaining compatibility with `experimental.policies`. ([#55297](https://github.com/anomalyco/opencode/pull/55297))
#### Bug fixes
- Give centrally managed configuration precedence over local and user configuration. ([#55267](https://github.com/anomalyco/opencode/pull/55267))
- Allow migrated V1 npm provider configuration to target built-in provider routes. ([#55254](https://github.com/anomalyco/opencode/pull/55254))
### TUI
#### Improvements
- Report working sessions and sessions awaiting permission or input through terminal program-status notifications. ([#55172](https://github.com/anomalyco/opencode/pull/55172))
### Desktop
#### Improvements
- Pair devices from **Settings → Pairing** through OpenTunnel remote access, with a QR code and copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
- Manage provider accounts from the connect dialog and **Settings → Providers**, including switching, renaming, removal, and adding another account. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
- Show session context tokens and accumulated family cost in the session view. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
- Pinch to zoom and use two-finger scrolling to pan image and SVG previews; select **Fit** to reset. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
- Cycle side-panel tabs with the configured previous-tab and next-tab shortcuts while the side panel is focused. ([#54899](https://github.com/anomalyco/opencode/pull/54899))
- Refresh the question composer and clarify timeline notices that apply only to a model variant. ([#54822](https://github.com/anomalyco/opencode/pull/54822), [#54880](https://github.com/anomalyco/opencode/pull/54880))
- Refine diff colors and move unread indicators into session tabs. ([#52551](https://github.com/anomalyco/opencode/pull/52551), [#53570](https://github.com/anomalyco/opencode/pull/53570))
#### Bug fixes
- Show steered messages as pending immediately and render inline attachments before their server echo arrives. ([#55251](https://github.com/anomalyco/opencode/pull/55251), [#54650](https://github.com/anomalyco/opencode/pull/54650))
- Preserve raw media data up to 256 MiB so artifact previews remain available. ([#55018](https://github.com/anomalyco/opencode/pull/55018))
- Retain session model selections when the model catalog refreshes. ([#54481](https://github.com/anomalyco/opencode/pull/54481))
- Close browser tabs from the address field and suppress incorrect missing-file notices for restored or already-open tabs. ([#54501](https://github.com/anomalyco/opencode/pull/54501), [#55022](https://github.com/anomalyco/opencode/pull/55022))
- Prevent a project-store eviction loop during launch and remove hover gaps between vertical tabs. ([#55078](https://github.com/anomalyco/opencode/pull/55078), [#54866](https://github.com/anomalyco/opencode/pull/54866))
### SDK
#### Improvements
- Export `lastAssistantWithUsage` from the Solid client to consistently locate the assistant step that determines current context usage. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
#### Bug fixes
- Include all paginated child sessions when loading a session family for usage totals. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
- Admit inline data-URL files into optimistic pending messages so attachments render immediately. ([#54650](https://github.com/anomalyco/opencode/pull/54650))
### Extensions
#### Improvements
- Add `ctx.permission.assert` for Effect and Promise plugins, allowing plugins to use configured permission rules and the native approval prompt. ([#54930](https://github.com/anomalyco/opencode/pull/54930))
#### Bug fixes
- Use Figma's public endpoint and default OAuth configuration, and support Git installations and development releases. ([#55252](https://github.com/anomalyco/opencode/pull/55252), [#55304](https://github.com/anomalyco/opencode/pull/55304))
- Resolve the Figma plugin peer dependency from its workspace. ([#55269](https://github.com/anomalyco/opencode/pull/55269))
- Associate each rejected Promise hook with its own tool call. Thanks [@1052326311](https://github.com/1052326311). ([#55263](https://github.com/anomalyco/opencode/pull/55263))
## v2.0.28 — 2026-10-13
### Highlights
+34 -34
View File
@@ -33,7 +33,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -55,7 +55,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.28",
"version": "2.0.29",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -180,7 +180,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -205,7 +205,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -218,7 +218,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -246,7 +246,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/console-resource": "workspace:*",
},
@@ -273,7 +273,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/perplexity": "3.0.26",
@@ -339,7 +339,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -385,7 +385,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -422,7 +422,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -438,7 +438,7 @@
},
"packages/gui-extensions": {
"name": "@opencode/gui-extensions",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@betteroffice/docx": "0.4.3",
"@betteroffice/fonts": "0.4.0",
@@ -487,7 +487,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@effect/platform-node-shared": "catalog:",
},
@@ -506,7 +506,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -519,7 +519,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -533,7 +533,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -548,7 +548,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -585,7 +585,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -600,7 +600,7 @@
},
"packages/plugins/figma": {
"name": "@opencode/plugin-figma",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"gray-matter": "4.0.3",
},
@@ -630,7 +630,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -645,7 +645,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -669,7 +669,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -690,7 +690,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -713,7 +713,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -748,7 +748,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -768,7 +768,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -795,7 +795,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@planetscale/database": "1.19.0",
"drizzle-orm": "catalog:",
@@ -813,7 +813,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode/stats-core": "workspace:*",
@@ -859,7 +859,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -873,7 +873,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -907,7 +907,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
@@ -943,7 +943,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -981,7 +981,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1035,7 +1035,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.28",
"version": "2.0.29",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+6
View File
@@ -28,6 +28,10 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN")
const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", {
properties: { value: `https://${domain}/console/api/internal/console-account` },
})
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
@@ -48,6 +52,8 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
CONSOLE_CRM_TOKEN,
CONSOLE_CRM_LOOKUP_URL,
],
environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!),
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.28",
"version": "2.0.29",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+26
View File
@@ -162,6 +162,32 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
await expectPath(page, sessionHref(c.id))
})
test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
sessionPermissions: {
[c.id]: [
{ id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
})
await page.goto(sessionHref(a.id))
await page.mouse.move(0, 0)
await expect(
page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-slot="tab-unread-dot"]`),
).toBeVisible()
await page.keyboard.press("Alt+ArrowDown")
await expectPath(page, sessionHref(b.id))
await page.keyboard.press("Alt+ArrowUp")
await expectPath(page, sessionHref(a.id))
await page.keyboard.press("Shift+Alt+ArrowDown")
await expectPath(page, sessionHref(c.id))
})
for (const row of [
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.28",
"version": "2.0.29",
"description": "",
"type": "module",
"exports": {
+6
View File
@@ -42,6 +42,10 @@ export const dict = {
"command.palette": "Command palette",
"command.session.new": "New session",
"command.session.previous": "Previous session",
"command.session.next": "Next session",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.file.open": "Open file",
"command.tab.close": "Close tab",
"command.tab.reopenClosed": "Reopen closed tab",
@@ -790,6 +794,8 @@ export const dict = {
"settings.timeline.description.text-only": "Hide all activity.",
"settings.timeline.description.custom": "Uses advanced settings.",
"settings.timeline.custom": "Custom",
"settings.timeline.row.turnDiffs.title": "Changed files",
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.group": "Group",
@@ -180,6 +180,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
timelineDetail,
pendingInputIDs,
queuedCompactionIDs,
turnDiffs: settings.general.showTurnDiffs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode/client/promise"
import { createRoot } from "solid-js"
import { createTurnDiffCache } from "./turn-diffs"
const turn = { sessionID: "ses_a", from: "msg_open" }
// Every pending promise callback runs before the next macrotask.
const settle = () => new Promise((resolve) => setImmediate(resolve))
const diff = { file: "src/a.ts", patch: "", additions: 1, deletions: 0, status: "modified" } satisfies FileDiffInfo
describe("turn diff cache", () => {
test("loads each turn once and retries after a failure", async () => {
const cache = createRoot(() => createTurnDiffCache())
const failed = Promise.withResolvers<readonly FileDiffInfo[]>()
const calls: string[] = []
cache.load(turn, () => (calls.push("failed"), failed.promise))
cache.load(turn, () => (calls.push("duplicate"), Promise.resolve([diff])))
failed.reject(new Error("offline"))
await settle()
expect(cache.get(turn)).toBeUndefined()
cache.load(turn, () => (calls.push("retry"), Promise.resolve([diff])))
await settle()
cache.load(turn, () => (calls.push("cached"), Promise.resolve([])))
expect(calls).toEqual(["failed", "retry"])
expect(cache.get(turn)).toEqual([diff])
})
})
@@ -0,0 +1,30 @@
import type { FileDiffInfo } from "@opencode/client/promise"
import type { TurnDiffInput } from "@opencode/session-ui/context"
import { createStore } from "solid-js/store"
/**
* One tab's in-memory cache of the files each finished turn changed. A finished turn's snapshots never
* change, so each turn loads once per tab and survives the timeline remounting; closing the tab drops it.
*/
export function createTurnDiffCache() {
const [diffs, setDiffs] = createStore<Record<string, readonly FileDiffInfo[] | undefined>>({})
const loading = new Set<string>()
const key = (input: TurnDiffInput) => `${input.sessionID}/${input.from}`
return {
get: (input: TurnDiffInput) => diffs[key(input)],
load(input: TurnDiffInput, fetch: () => Promise<readonly FileDiffInfo[]>) {
const id = key(input)
if (diffs[id] || loading.has(id)) return
loading.add(id)
// A failed load leaves the turn unloaded, so the next request retries.
void fetch()
.then(
(value) => setDiffs(id, value),
() => undefined,
)
.finally(() => loading.delete(id))
},
}
}
@@ -317,6 +317,17 @@ export const SettingsGeneral: Component = () => {
onChange={settings.general.setTimelineDetail}
/>
</div>
<SettingsRow
title={language.t("settings.timeline.row.turnDiffs.title")}
description={language.t("settings.timeline.row.turnDiffs.description")}
>
<div data-action="settings-show-turn-diffs">
<Switch
checked={settings.general.showTurnDiffs()}
onChange={(checked) => settings.general.setShowTurnDiffs(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</section>
+1
View File
@@ -93,6 +93,7 @@ describe("settings schema", () => {
showFileTree: false,
timelineDetail: timelinePresets[2].value,
showCustomAgents: false,
showTurnDiffs: false,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "steer",
+6
View File
@@ -116,6 +116,7 @@ const generalSchema = Persistence.struct({
tools: placementOnlySchema,
}),
showCustomAgents: Schema.Boolean,
showTurnDiffs: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
// Owned by the review extension, which copies it out once; kept so settings rewrites cannot drop it first.
mobileDiffWrap: Persistence.optional(Schema.Boolean),
@@ -278,6 +279,7 @@ export const defaultSettings: Settings = {
showFileTree: false,
timelineDetail: { ...timelinePresets[2].value },
showCustomAgents: false,
showTurnDiffs: false,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "steer",
@@ -351,6 +353,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
showTurnDiffs: withFallback(() => store.general?.showTurnDiffs, defaultSettings.general.showTurnDiffs),
setShowTurnDiffs(value: boolean) {
setStore("general", "showTurnDiffs", value)
},
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
@@ -8,10 +8,11 @@ import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useTabs, type Tab } from "@/shell/tabs/tabs"
import { readLocalImage } from "@/runtime/server/image"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { createTurnDiffCache } from "@/session/timeline/turn-diffs"
export function SessionUIProvider(
props: ParentProps<{
@@ -65,6 +66,23 @@ export function SessionUIProvider(
const providers = useProviders(directory)
// Turn diffs live in the tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
const turnDiffs = () => {
const id = params.id
if (!id) return
const tab =
tabs.store.find(
(item) =>
item.type === "session" &&
item.server === props.server &&
(item.sessionId === id || item.routeSessionId === id),
) ?? ({ type: "session", server: props.server, sessionId: id } satisfies Tab)
return tabs.state(tab, "turnDiffs", createTurnDiffCache)
}
const sessionUIData = createMemo(() => ({
provider: providers.ready()
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
@@ -88,6 +106,10 @@ export function SessionUIProvider(
sessionID={params.id}
shellRunning={(id) => !!data.shell.get(id)}
shellOutput={(input) => serverSDK.api.shell.output(input)}
turnDiff={{
get: (input) => turnDiffs()?.get(input),
load: (input) => turnDiffs()?.load(input, () => serverSDK.api.session.diff(input)),
}}
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
+4 -9
View File
@@ -12,7 +12,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { displayName } from "@opencode/ui/project-avatar"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode/client/promise"
import { sessionTabTitle } from "./tab-title"
@@ -28,6 +27,8 @@ export function TabNavItem(props: {
server: ServerConnection.Key
session: SessionInfo | undefined
preparing: boolean
unread: boolean
loading: boolean
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onClose: () => void
@@ -57,13 +58,7 @@ export function TabNavItem(props: {
const servers = useServers()
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const avatar = useSessionTabAvatarState(
() => props.server,
() => props.session?.id ?? "",
() => true,
)
const unread = () => !!props.session && !props.active && avatar.unread() && !avatar.loading()
const unread = () => !!props.session && !props.active && props.unread
const project = createMemo(() => {
const session = props.session
@@ -312,7 +307,7 @@ export function TabNavItem(props: {
project={project()}
directory={session.location.directory}
unread={false}
loading={avatar.loading()}
loading={props.loading}
/>
)}
</Show>
@@ -1,5 +1,16 @@
import { expect, test } from "bun:test"
import { mergeVisibleTabOrder } from "./tab-order"
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
test("finds the nearest accepted tab in either direction, wrapping around", () => {
const order = ["a", "b", "c", "d", "e"]
const unread = new Set(["a", "d"])
expect(adjacentTabKey(order, "b", 1, (key) => unread.has(key))).toBe("d")
expect(adjacentTabKey(order, "b", -1, (key) => unread.has(key))).toBe("a")
expect(adjacentTabKey(order, "e", 1, (key) => unread.has(key))).toBe("a")
expect(adjacentTabKey(order, "a", -1, (key) => unread.has(key))).toBe("d")
expect(adjacentTabKey(order, "a", 1, (key) => key === "a")).toBeUndefined()
})
test("merges reordered visible tabs around hidden tabs", () => {
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
+12 -3
View File
@@ -1,10 +1,19 @@
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
if (!current || order.length === 0) return
/** The nearest other tab in the offset's direction, wrapping around, that `include` accepts. */
export function adjacentTabKey(
order: string[],
current: string | undefined,
offset: -1 | 1,
include: (key: string) => boolean = () => true,
) {
if (!current) return
const index = order.indexOf(current)
if (index === -1) return
return order[(index + offset + order.length) % order.length]
return Array.from(
{ length: order.length - 1 },
(_, step) => order[(index + offset * (step + 1) + order.length) % order.length],
).find(include)
}
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
+50 -2
View File
@@ -13,6 +13,7 @@ import { useGlobal, useServerCtx, type ServerCtx } from "@/runtime/server/runtim
import { useLanguage } from "@/runtime/i18n/language"
import { useCommand } from "@/shell/commands/command"
import { useTabs } from "@/shell/tabs/tabs"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { createTabComposerState } from "@/composer/persistence"
import { base64Encode } from "@opencode/util/encode"
import { showToast } from "@/shell/notifications/toast"
@@ -27,6 +28,8 @@ function SessionTabSlot(props: {
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
preparing: boolean
unread: boolean
loading: boolean
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
@@ -64,6 +67,8 @@ function SessionTabSlot(props: {
server={props.tab.server}
session={props.session}
preparing={props.preparing}
unread={props.unread}
loading={props.loading}
fallbackTitle={props.fallbackTitle}
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
@@ -84,6 +89,7 @@ function SessionTabEntry(props: {
orientation: "horizontal" | "vertical"
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onUnreadChange: (unread: boolean) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
@@ -94,6 +100,15 @@ function SessionTabEntry(props: {
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const status = useSessionTabAvatarState(
() => props.tab.server,
() => props.tab.sessionId,
() => true,
)
// A busy session shows its progress indicator instead of the unread dot.
const unread = createMemo(() => status.unread() && !status.loading())
const [loadedSession] = createResource(
() => {
if (pending()) return null
@@ -135,6 +150,7 @@ function SessionTabEntry(props: {
}
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => props.onUnreadChange(unread()))
createEffect(() => {
const ctx = props.serverCtx
@@ -186,6 +202,8 @@ function SessionTabEntry(props: {
orientation={props.orientation}
session={session()}
preparing={!!pending()}
unread={unread()}
loading={status.loading()}
fallbackTitle={
pending()
? language.t("session.tab.session")
@@ -263,6 +281,7 @@ export function TitlebarTabStrip(props: {
const vertical = () => props.orientation === "vertical"
let listRef!: HTMLDivElement
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
const [unread, setUnread] = createStore<Record<string, boolean>>({})
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
const visibleTabIds = () => visibleTabs().map(tabKey)
@@ -283,11 +302,39 @@ export function TitlebarTabStrip(props: {
hidden: true,
onSelect: () => selectAdjacentTab(1),
},
{
id: "session.previous",
category: language.t("command.category.session"),
title: language.t("command.session.previous"),
keybind: "alt+arrowup",
onSelect: () => selectAdjacentTab(-1),
},
{
id: "session.next",
category: language.t("command.category.session"),
title: language.t("command.session.next"),
keybind: "alt+arrowdown",
onSelect: () => selectAdjacentTab(1),
},
{
id: "session.previous.unseen",
category: language.t("command.category.session"),
title: language.t("command.session.previous.unseen"),
keybind: "shift+alt+arrowup",
onSelect: () => selectAdjacentTab(-1, (key) => !!unread[key]),
},
{
id: "session.next.unseen",
category: language.t("command.category.session"),
title: language.t("command.session.next.unseen"),
keybind: "shift+alt+arrowdown",
onSelect: () => selectAdjacentTab(1, (key) => !!unread[key]),
},
])
function selectAdjacentTab(offset: -1 | 1) {
function selectAdjacentTab(offset: -1 | 1, include?: (key: string) => boolean) {
const current = props.currentTab
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset, include)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
@@ -390,6 +437,7 @@ export function TitlebarTabStrip(props: {
orientation={vertical() ? "vertical" : "horizontal"}
serverCtx={serverCtx()}
onVisibleChange={(visible) => setVisibility(id, visible)}
onUnreadChange={(value) => setUnread(id, value)}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"bin": {
+10 -1
View File
@@ -43,7 +43,16 @@ export function filename(channel = OPENCODE_CHANNEL) {
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function defaultPort(channel = OPENCODE_CHANNEL) {
export function defaultPort(channel = OPENCODE_CHANNEL, platform = process.platform) {
const port = channelPort(channel)
// Windows hands out 0xC000-0xFFFF for outgoing connections, and Hyper-V and WinNAT reserve blocks of that range at
// boot, so a port there can fail as in use while nothing listens on it. Moving into the 0xB000 block keeps the low
// 12 bits: 0xC0DE becomes 0xB0DE, which is 0xC0DE - 4096, a nod to the v1 port.
if (platform === "win32" && port >= 0xc000) return 0xb000 | (port & 0xfff)
return port
}
function channelPort(channel: string) {
if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
@@ -36,7 +36,18 @@ export const register = Effect.fnUntraced(function* (options: {
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, options.file)))
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(
Effect.andThen(
fs.rename(temp, options.file).pipe(
// Windows refuses to replace a file while another process has it open, as discovering clients briefly do.
Effect.retry({
while: (error) => process.platform === "win32" && error.reason._tag !== "NotFound",
schedule: Schedule.spaced("20 millis"),
times: 50,
}),
),
),
)
yield* current.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
+21 -7
View File
@@ -18,13 +18,27 @@ import { testEffect } from "../../core/test/lib/effect"
const it = testEffect(NodeFileSystem.layer)
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("dev")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("beta")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
expect(ServiceConfig.defaultPort("latest", "linux")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("dev", "linux")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("beta", "linux")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next", "linux")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local", "linux")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("latest", "darwin")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("preview-a", "linux")).toBe(ServiceConfig.defaultPort("preview-a", "linux"))
expect(ServiceConfig.defaultPort("preview-a", "linux")).not.toBe(ServiceConfig.defaultPort("preview-b", "linux"))
})
test("managed service ports on Windows stay below the range Windows reserves", () => {
expect(ServiceConfig.defaultPort("latest", "win32")).toBe(0xc0de - 4096)
expect(ServiceConfig.defaultPort("dev", "win32")).toBe(0xc0de - 4096)
expect(ServiceConfig.defaultPort("local", "win32")).toBe(0xc0df - 4096)
const ports = Array.from({ length: 200 }, (_, index) => ({
windows: ServiceConfig.defaultPort(`preview-${index}`, "win32"),
linux: ServiceConfig.defaultPort(`preview-${index}`, "linux"),
}))
expect(ports.some((port) => port.linux >= 0xc000)).toBe(true)
expect(ports.every((port) => port.windows >= 10_000 && port.windows < 0xc000)).toBe(true)
expect(ports.filter((port) => port.linux < 0xc000 && port.windows !== port.linux)).toEqual([])
})
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.28",
"version": "2.0.29",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "@opencode/console-app",
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:enterprise": "bun --watch script/dev-enterprise.ts",
"dev:remote": "bun sst shell --stage=dev bun dev",
"build": "bun ./script/generate-sitemap.ts && vite build",
"start": "vite start"
@@ -0,0 +1,99 @@
import { handleEnterpriseRequest } from "../src/lib/enterprise"
import { z } from "zod"
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
import { handleConsoleSignup } from "../src/lib/console-signup"
import { consoleAccountSchema } from "../src/lib/console-account"
const org = process.env.SF_TARGET_ORG || "opencode"
const crmToken = process.env.CONSOLE_CRM_TOKEN
if (!crmToken) throw new Error("Set CONSOLE_CRM_TOKEN to the shared local Console CRM token")
const accountUrl = process.env.CONSOLE_CRM_LOOKUP_URL || "http://localhost:3100/console/api/internal/console-account"
async function authenticatedRequest(
path: string,
payload?: Record<string, unknown>,
method: "GET" | "POST" | "PATCH" = payload ? "POST" : "GET",
) {
const child = Bun.spawn(
[
"sf",
"api",
"request",
"rest",
path,
"--target-org",
org,
"--json",
"--method",
method,
...(payload ? ["--body", "-", "--header", "Content-Type:application/json"] : []),
],
{
stdin: payload ? new Blob([JSON.stringify(payload)]) : "ignore",
stdout: "pipe",
stderr: "pipe",
},
)
const [output] = await Promise.all([
new Response(child.stdout).text(),
child.exited,
new Response(child.stderr).text(),
])
return salesforceCliResponse(output)
}
await authenticatedRequest("/services/data/v59.0/")
const server = Bun.serve({
hostname: "127.0.0.1",
port: Number(process.env.ENTERPRISE_DEV_PORT || 3101),
async fetch(request) {
const path = new URL(request.url).pathname
if (path === "/health" && request.method === "GET") {
return Response.json({ handler: "local-sst-enterprise", salesforceOrg: org })
}
if (path === "/api/console-signup" && request.method === "POST") {
return handleConsoleSignup(request, crmToken, (path, method, payload) =>
authenticatedRequest(path, payload, method),
)
}
if (path !== "/api/enterprise") return new Response("Not found", { status: 404 })
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 })
const failures: string[] = []
const response = await handleEnterpriseRequest(request, {
salesforceSubmit: async (payload) => {
const result = await authenticatedRequest("/services/data/v59.0/sobjects/Lead", payload).catch(
(error: unknown) => {
const code = error instanceof SalesforceCliError ? error.code : "CLI_ERROR"
failures.push(code)
console.error(`Local Salesforce submission rejected: ${code}`)
return undefined
},
)
return z.object({ success: z.literal(true) }).safeParse(result).success
},
notifications: false,
accountLookup: async (email) => {
const response = await fetch(accountUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${crmToken}` },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(8000),
})
if (!response.ok) throw new Error(`Local Console account lookup failed (${response.status})`)
return z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json()).account
},
})
if (failures.length > 0) {
return Response.json(
{ error: "Salesforce rejected the request.", errorCode: failures[0] },
{ status: 502, headers: { "X-Enterprise-Handler": "local-sst-enterprise" } },
)
}
response.headers.set("X-Enterprise-Handler", "local-sst-enterprise")
return response
},
})
console.log(`Local Enterprise handler: ${server.url.origin}/api/enterprise`)
console.log(`Salesforce CLI org: ${org}; local email and EmailOctopus delivery disabled.`)
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test"
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
test("unwraps a successful Salesforce Lead creation from the CLI HTTP envelope", () => {
expect(
salesforceCliResponse(
JSON.stringify({
status: 0,
result: { statusCode: 201, headers: {}, body: { id: "test-lead", success: true, errors: [] } },
}),
),
).toEqual({ id: "test-lead", success: true, errors: [] })
})
test("rejects a Salesforce HTTP failure even if the CLI exits successfully", () => {
expect(() =>
salesforceCliResponse(
JSON.stringify({
status: 0,
result: { statusCode: 400, body: [{ errorCode: "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST" }] },
}),
),
).toThrow()
})
test("rejects CLI errors and responses missing the HTTP envelope", () => {
expect(() => salesforceCliResponse(JSON.stringify({ status: 1, message: "CLI error" }))).toThrow()
expect(() => salesforceCliResponse(JSON.stringify({ status: 0, result: { success: true } }))).toThrow()
})
test("preserves the Salesforce rejection code when the CLI exits nonzero", () => {
expect(() =>
salesforceCliResponse(
JSON.stringify({
status: 1,
result: { statusCode: 400, body: [{ errorCode: "DUPLICATES_DETECTED", message: "Duplicate found" }] },
}),
),
).toThrow(new SalesforceCliError("DUPLICATES_DETECTED"))
})
@@ -0,0 +1,24 @@
import { z } from "zod"
export class SalesforceCliError extends Error {
constructor(readonly code: string) {
super(`Salesforce request failed: ${code}`)
}
}
export function salesforceCliResponse(output: string) {
const response = z
.object({
status: z.number(),
result: z.object({ statusCode: z.number().int(), body: z.unknown() }).optional(),
})
.parse(JSON.parse(output))
if (!response.result) throw new SalesforceCliError("CLI_ERROR")
if (response.status !== 0 || response.result.statusCode < 200 || response.result.statusCode > 299) {
const errors = z
.array(z.object({ errorCode: z.string().regex(/^[A-Z][A-Z0-9_]+$/) }))
.safeParse(response.result.body)
throw new SalesforceCliError(errors.success ? (errors.data[0]?.errorCode ?? "REQUEST_FAILED") : "REQUEST_FAILED")
}
return response.result.body
}
-1
View File
@@ -299,7 +299,6 @@ export const dict = {
"تُنشأ سجلات مراقبة إساءة الاستخدام لكل استخدام لميزات API، ويُحتفظ بها لمدة تصل إلى 30 يومًا.",
"go.faq.a5.museRetention":
"أسعار توكنات مخفّضة للغاية مقابل منح الإذن باستخدام مطالباتك وإكمالات النموذج لتدريب نماذج Meta المستقبلية.",
"go.faq.a5.catJamRetention": "خلال فترته المجانية، قد تُستخدم البيانات التي يتم جمعها لتحسين النموذج.",
"go.faq.a5.geminiRetention": "قد تُستخدم مطالباتك وإكمالات النموذج لتدريب نماذج Google المستقبلية.",
"go.faq.a5.learnMore": "اعرف المزيد",
"go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 31 أكتوبر 2026.",
-1
View File
@@ -306,7 +306,6 @@ export const dict = {
"Logs de monitoramento de abuso são gerados para todo uso de recursos da API e retidos por até 30 dias.",
"go.faq.a5.museRetention":
"Preços de tokens com grandes descontos em troca da permissão para usar seus prompts e respostas geradas para treinar futuros modelos da Meta.",
"go.faq.a5.catJamRetention": "Durante o período gratuito, os dados coletados podem ser usados para melhorar o modelo.",
"go.faq.a5.geminiRetention":
"Seus prompts e respostas geradas podem ser usados para treinar futuros modelos do Google.",
"go.faq.a5.learnMore": "Saiba mais",
-1
View File
@@ -304,7 +304,6 @@ export const dict = {
"Logfiler til overvågning af misbrug genereres ved al brug af API-funktioner og opbevares i op til 30 dage.",
"go.faq.a5.museRetention":
"Kraftigt nedsatte tokenpriser til gengæld for tilladelse til at bruge dine prompts og modelsvar til at træne fremtidige Meta-modeller.",
"go.faq.a5.catJamRetention": "I gratisperioden kan indsamlede data blive brugt til at forbedre modellen.",
"go.faq.a5.geminiRetention": "Dine prompts og modelsvar kan blive brugt til at træne fremtidige Google-modeller.",
"go.faq.a5.learnMore": "Læs mere",
"go.faq.a5.deepseekRetention":
-1
View File
@@ -306,7 +306,6 @@ export const dict = {
"Für die Nutzung aller API-Funktionen werden Protokolle zur Missbrauchsüberwachung erstellt und bis zu 30 Tage lang aufbewahrt.",
"go.faq.a5.museRetention":
"Stark vergünstigte Tokenpreise im Gegenzug für die Erlaubnis, deine Prompts und Vervollständigungen zum Trainieren zukünftiger Meta-Modelle zu verwenden.",
"go.faq.a5.catJamRetention": "Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.",
"go.faq.a5.geminiRetention":
"Deine Prompts und Vervollständigungen können zum Trainieren zukünftiger Google-Modelle verwendet werden.",
"go.faq.a5.learnMore": "Mehr erfahren",
-1
View File
@@ -304,7 +304,6 @@ export const dict = {
"Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models.",
"go.faq.a5.deepseekRetention":
"ZDR agreement is renewed monthly. The current agreement is valid through October 31, 2026.",
"go.faq.a5.catJamRetention": "During its free period, collected data may be used to improve the model.",
"go.faq.a5.geminiRetention": "Your prompts and completions may be used to train future Google models.",
"go.faq.a5.learnMore": "Learn more",
-1
View File
@@ -307,7 +307,6 @@ export const dict = {
"Se generan registros de supervisión de abusos para todo el uso de funciones de la API y se conservan durante un máximo de 30 días.",
"go.faq.a5.museRetention":
"Precios de tokens muy reducidos a cambio de permitir que tus prompts y las respuestas generadas se utilicen para entrenar futuros modelos de Meta.",
"go.faq.a5.catJamRetention": "Durante su periodo gratuito, los datos recopilados pueden usarse para mejorar el modelo.",
"go.faq.a5.geminiRetention":
"Tus prompts y las respuestas generadas pueden utilizarse para entrenar futuros modelos de Google.",
"go.faq.a5.learnMore": "Más información",
-1
View File
@@ -309,7 +309,6 @@ export const dict = {
"Des journaux de surveillance des abus sont générés pour toute utilisation des fonctionnalités API et conservés pendant un maximum de 30 jours.",
"go.faq.a5.museRetention":
"Des tarifs de tokens fortement réduits en échange de l’autorisation d’utiliser vos prompts et vos complétions pour entraîner de futurs modèles Meta.",
"go.faq.a5.catJamRetention": "Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.",
"go.faq.a5.geminiRetention":
"Vos prompts et vos complétions peuvent être utilisés pour entraîner de futurs modèles Google.",
"go.faq.a5.learnMore": "En savoir plus",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"ZDR disabilita importanti funzionalità API che dipendono dai dati archiviati, tra cui la Responses API con stato, Files and Collections e Batch API.",
"go.faq.a5.gptRetention":
"I log di monitoraggio degli abusi vengono generati per l'utilizzo di tutte le funzionalità API e conservati per un massimo di 30 giorni.",
"go.faq.a5.catJamRetention": "Durante il periodo gratuito, i dati raccolti possono essere utilizzati per migliorare il modello.",
"go.faq.a5.geminiRetention":
"I tuoi prompt e completamenti possono essere utilizzati per addestrare futuri modelli Google.",
"go.faq.a5.learnMore": "Scopri di più",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"go.faq.a5.gptRetention": "不正使用監視ログはすべてのAPI機能の使用時に生成され、最大30日間保持されます。",
"go.faq.a5.museRetention":
"将来のMetaモデルのトレーニングにプロンプトと生成結果を使用する許可と引き換えに、トークン料金が大幅に割引されます。",
"go.faq.a5.catJamRetention": "無料提供期間中、収集されたデータはモデルの改善に使用される場合があります。",
"go.faq.a5.geminiRetention": "プロンプトと生成結果は、将来のGoogleモデルのトレーニングに使用される場合があります。",
"go.faq.a5.learnMore": "詳しく見る",
"go.faq.a5.deepseekRetention": "ZDR契約は毎月更新されます。現在の契約は2026年10月31日まで有効です。",
-1
View File
@@ -297,7 +297,6 @@ export const dict = {
"go.faq.a5.gptRetention": "모든 API 기능 사용에 대해 악용 모니터링 로그가 생성되며 최대 30일 동안 보존됩니다.",
"go.faq.a5.museRetention":
"향후 Meta 모델 학습에 사용자의 프롬프트와 생성 결과를 사용할 수 있도록 허용하는 대신 토큰 가격이 대폭 할인됩니다.",
"go.faq.a5.catJamRetention": "무료 제공 기간 동안 수집된 데이터는 모델 개선에 사용될 수 있습니다.",
"go.faq.a5.geminiRetention": "사용자의 프롬프트와 생성 결과는 향후 Google 모델 학습에 사용될 수 있습니다.",
"go.faq.a5.learnMore": "자세히 알아보기",
"go.faq.a5.deepseekRetention": "ZDR 계약은 매월 갱신됩니다. 현재 계약은 2026년 10월 31일까지 유효합니다.",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"ZDR deaktiverer viktige API-funksjoner som er avhengige av lagrede data, inkludert den tilstandsbaserte Responses API, Files and Collections og Batch API.",
"go.faq.a5.gptRetention":
"Logger for overvåking av misbruk genereres for all bruk av API-funksjoner og oppbevares i opptil 30 dager.",
"go.faq.a5.catJamRetention": "I gratisperioden kan innsamlede data brukes til å forbedre modellen.",
"go.faq.a5.geminiRetention": "Ledetekstene og fullføringene dine kan brukes til å trene fremtidige Google-modeller.",
"go.faq.a5.learnMore": "Les mer",
"go.faq.a5.deepseekRetention":
-1
View File
@@ -303,7 +303,6 @@ export const dict = {
"ZDR wyłącza ważne funkcje API zależne od przechowywanych danych, w tym stanowy Responses API, Files and Collections oraz Batch API.",
"go.faq.a5.gptRetention":
"Dzienniki monitorowania nadużyć są generowane dla każdego użycia funkcji API i przechowywane przez maksymalnie 30 dni.",
"go.faq.a5.catJamRetention": "W okresie bezpłatnego dostępu zebrane dane mogą być wykorzystywane do ulepszania modelu.",
"go.faq.a5.geminiRetention":
"Twoje prompty i odpowiedzi mogą być wykorzystywane do trenowania przyszłych modeli Google.",
"go.faq.a5.learnMore": "Dowiedz się więcej",
-1
View File
@@ -306,7 +306,6 @@ export const dict = {
"ZDR отключает важные функции API, зависящие от сохраненных данных, включая Responses API с сохранением состояния, Files and Collections и Batch API.",
"go.faq.a5.gptRetention":
"Журналы мониторинга злоупотреблений создаются при любом использовании функций API и хранятся до 30 дней.",
"go.faq.a5.catJamRetention": "В течение бесплатного периода собранные данные могут использоваться для улучшения модели.",
"go.faq.a5.geminiRetention": "Ваши промпты и ответы могут использоваться для обучения будущих моделей Google.",
"go.faq.a5.learnMore": "Подробнее",
"go.faq.a5.deepseekRetention":
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"ระบบจะสร้างบันทึกการตรวจสอบการใช้งานในทางที่ผิดสำหรับการใช้งานฟีเจอร์ API ทั้งหมด และเก็บรักษาไว้นานสูงสุด 30 วัน",
"go.faq.a5.museRetention":
"ราคาของ token ลดลงอย่างมาก โดยแลกกับการอนุญาตให้นำพรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณไปใช้ฝึกโมเดล Meta ในอนาคต",
"go.faq.a5.catJamRetention": "ในช่วงที่ให้ใช้ฟรี ข้อมูลที่รวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล",
"go.faq.a5.geminiRetention": "พรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณอาจถูกนำไปใช้ฝึกโมเดล Google ในอนาคต",
"go.faq.a5.learnMore": "ดูข้อมูลเพิ่มเติม",
"go.faq.a5.deepseekRetention": "ข้อตกลง ZDR จะต่ออายุทุกเดือน ข้อตกลงปัจจุบันมีผลใช้ถึงวันที่ 31 ตุลาคม 2026",
-1
View File
@@ -304,7 +304,6 @@ export const dict = {
"ZDR, durum bilgisi tutan Responses API, Files and Collections ve Batch API dahil olmak üzere saklanan verilere bağlı önemli API özelliklerini devre dışı bırakır.",
"go.faq.a5.gptRetention":
"Tüm API özelliklerinin kullanımı için kötüye kullanım izleme günlükleri oluşturulur ve 30 güne kadar saklanır.",
"go.faq.a5.catJamRetention": "Ücretsiz dönem boyunca toplanan veriler modeli geliştirmek için kullanılabilir.",
"go.faq.a5.geminiRetention":
"İstemleriniz ve tamamlamalarınız gelecekteki Google modellerini eğitmek için kullanılabilir.",
"go.faq.a5.learnMore": "Daha fazla bilgi",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"ZDR вимикає важливі функції API, які залежать від збережених даних, зокрема Responses API зі збереженням стану, Files and Collections та Batch API.",
"go.faq.a5.gptRetention":
"Журнали моніторингу зловживань створюються для всіх випадків використання функцій API та зберігаються до 30 днів.",
"go.faq.a5.catJamRetention": "Протягом безкоштовного періоду зібрані дані можуть використовуватися для покращення моделі.",
"go.faq.a5.geminiRetention":
"Ваші промпти та відповіді можуть використовуватися для навчання майбутніх моделей Google.",
"go.faq.a5.learnMore": "Докладніше",
-1
View File
@@ -288,7 +288,6 @@ export const dict = {
"ZDR 会禁用依赖所存储数据的重要 API 功能,包括有状态的 Responses API、Files and Collections 和 Batch API。",
"go.faq.a5.gptRetention": "所有 API 功能的使用都会生成滥用监控日志,并最多保留 30 天。",
"go.faq.a5.museRetention": "以允许使用你的提示词和补全结果训练未来的 Meta 模型为交换,token 价格可获得大幅折扣。",
"go.faq.a5.catJamRetention": "在免费期间,收集的数据可能会用于改进模型。",
"go.faq.a5.geminiRetention": "你的提示词和模型输出可能会被用于训练未来的 Google 模型。",
"go.faq.a5.learnMore": "了解更多",
"go.faq.a5.deepseekRetention": "ZDR 协议每月续签。当前协议有效期至 2026 年 10 月 31 日。",
-1
View File
@@ -287,7 +287,6 @@ export const dict = {
"ZDR 會停用依賴儲存資料的重要 API 功能,包括具狀態的 Responses API、Files and Collections 與 Batch API。",
"go.faq.a5.gptRetention": "所有 API 功能的使用都會產生濫用監控日誌,並保留最多 30 天。",
"go.faq.a5.museRetention": "以允許使用您的提示詞和生成結果來訓練未來的 Meta 模型為交換,token 價格可享大幅折扣。",
"go.faq.a5.catJamRetention": "在免費期間,收集的資料可能會用於改進模型。",
"go.faq.a5.geminiRetention": "你的提示詞和模型輸出可能會被用於訓練未來的 Google 模型。",
"go.faq.a5.learnMore": "了解更多",
"go.faq.a5.deepseekRetention": "ZDR 協議每月續簽。目前的協議有效至 2026 年 10 月 31 日。",
@@ -0,0 +1,31 @@
import { Resource } from "@opencode/console-resource"
import { z } from "zod"
export const consoleAccountSchema = z.object({
id: z.string().min(1).max(255),
email: z.email(),
name: z.string().nullable(),
createdAt: z.iso.datetime(),
})
export type ConsoleAccount = z.infer<typeof consoleAccountSchema>
export async function lookupConsoleAccount(email: string): Promise<ConsoleAccount | null> {
const response = await fetch(`${Resource.CONSOLE_CRM_LOOKUP_URL.value}`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${Resource.CONSOLE_CRM_TOKEN.value}` },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
})
if (!response.ok) throw new Error(`Console account lookup failed (${response.status})`)
const result = z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json())
return result.account
}
export function consoleAccountFields(account: ConsoleAccount | null) {
if (!account) return { Has_Console_Account__c: false }
return {
Has_Console_Account__c: true,
Console_Account_ID__c: account.id,
Console_Signup_Date__c: account.createdAt,
}
}
@@ -0,0 +1,159 @@
import { afterEach, expect, test } from "bun:test"
import { syncConsoleAccount, type SalesforceRequest } from "./salesforce"
import { handleConsoleSignup } from "./console-signup"
import { z } from "zod"
const account = {
id: "user_console_test",
email: "person@example.test",
name: "Test Person",
createdAt: "2026-10-01T10:00:00.000Z",
}
const servers = new Set<ReturnType<typeof Bun.serve>>()
afterEach(async () => {
await Promise.all([...servers].map((server) => server.stop(true)))
servers.clear()
})
function fixture(
initial: { object: "Lead" | "Contact"; Id: string; LeadSource: string; Company?: string; AccountId?: string }[] = [],
email = account.email,
) {
const people: Record<string, unknown>[] = initial.map((person) => ({ ...person }))
const writes: { method: string; path: string; payload: Record<string, unknown> }[] = []
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname.endsWith("/query")) {
const query = url.searchParams.get("q") ?? ""
expect(query).toContain("Console_Account_ID__c")
expect(query).toContain(`Email = '${email.toLowerCase()}'`)
const object = query.includes("FROM Contact") ? "Contact" : "Lead"
if (object === "Lead") expect(query).toContain("IsConverted = false")
return Response.json({
done: true,
records: people.filter((person) => person.object === object).map((person) => ({ Id: person.Id })),
})
}
const payload = z.record(z.string(), z.unknown()).parse(await request.json())
writes.push({ method: request.method, path: url.pathname, payload })
if (request.method === "PATCH") {
const person = people.find((person) => person.Id === url.pathname.split("/").at(-1))
if (!person) return Response.json({ error: "Not found" }, { status: 404 })
Object.assign(person, payload)
return new Response(null, { status: 204 })
}
const id = "00Q000000000001AAA"
people.push({ object: "Lead", Id: id, ...payload })
return Response.json({ success: true, id }, { status: 201 })
},
})
servers.add(server)
const request: SalesforceRequest = async (path, method = "GET", payload) => {
const response = await fetch(new URL(path, server.url), {
method,
...(payload ? { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } } : {}),
})
if (!response.ok) throw new Error("CRM request failed")
return response.status === 204 ? null : response.json()
}
return { people, writes, request }
}
test("a new signup creates one Console lead and an exact retry updates it", async () => {
const crm = fixture()
await syncConsoleAccount(account, crm.request)
await syncConsoleAccount(account, crm.request)
expect(crm.people).toHaveLength(1)
expect(crm.writes.filter((write) => write.method === "POST")).toHaveLength(1)
expect(crm.people[0]).toMatchObject({
Company: "example.test",
LeadSource: "console signup",
Has_Console_Account__c: true,
Console_Account_ID__c: account.id,
Console_Signup_Date__c: account.createdAt,
})
expect(crm.writes[1].payload).not.toHaveProperty("LeadSource")
expect(crm.writes[1].payload).not.toHaveProperty("Company")
})
test.each([
["hello@acme.com", "acme.com"],
["jane@company.co.uk", "company.co.uk"],
["HELLO@ACME.COM", "acme.com"],
["person@gmail.com", "Individual"],
["person+signup@GOOGLEMAIL.COM", "Individual"],
["person@outlook.com", "Individual"],
["person@hotmail.co.uk", "Individual"],
["person@yahoo.com", "Individual"],
["person@icloud.com", "Individual"],
["person@proton.me", "Individual"],
["person@protonmail.com", "Individual"],
["person@pm.me", "Individual"],
["person@aol.com", "Individual"],
["person@gmail.com.example.test", "gmail.com.example.test"],
])("sets Company for a new signup with %s to %s", async (email, company) => {
const crm = fixture([], email)
await syncConsoleAccount({ ...account, email }, crm.request)
expect(crm.people).toHaveLength(1)
expect(crm.people[0]).toMatchObject({ Company: company, Email: email.toLowerCase(), LeadSource: "console signup" })
})
test("matching Leads and Contacts keep their source and receive the Console fields", async () => {
const crm = fixture([
{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Partner", AccountId: "001000000000001AAA" },
{ object: "Lead", Id: "00Q000000000002AAA", LeadSource: "enterprise website form", Company: "Visitor's Company" },
])
await syncConsoleAccount(account, crm.request)
expect(crm.writes.every((write) => write.method === "PATCH")).toBe(true)
expect(crm.people.map((person) => person.LeadSource)).toEqual(["Partner", "enterprise website form"])
expect(crm.people[0].AccountId).toBe("001000000000001AAA")
expect(crm.people[1].Company).toBe("Visitor's Company")
for (const write of crm.writes) {
expect(write.payload).not.toHaveProperty("Company")
expect(write.payload).not.toHaveProperty("AccountId")
}
for (const person of crm.people)
expect(person).toMatchObject({
Has_Console_Account__c: true,
Console_Account_ID__c: account.id,
Console_Signup_Date__c: account.createdAt,
})
})
test("a converted person's Contact is updated without creating another Lead", async () => {
const crm = fixture([{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Website" }])
await syncConsoleAccount(account, crm.request)
expect(crm.people).toHaveLength(1)
expect(crm.writes[0].path).toContain("/Contact/")
expect(crm.people[0].LeadSource).toBe("Website")
})
test("the signup endpoint requires its server credential before touching Salesforce", async () => {
const crm = fixture()
const response = await handleConsoleSignup(
new Request("http://localhost/api/console-signup", { method: "POST", body: JSON.stringify(account) }),
"test-shared-token",
crm.request,
)
expect(response.status).toBe(401)
expect(crm.writes).toHaveLength(0)
})
test("the authenticated signup endpoint reports confirmed delivery", async () => {
const crm = fixture()
const response = await handleConsoleSignup(
new Request("http://localhost/api/console-signup", {
method: "POST",
headers: { Authorization: "Bearer test-shared-token" },
body: JSON.stringify(account),
}),
"test-shared-token",
crm.request,
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ success: true })
expect(crm.people).toHaveLength(1)
})
@@ -0,0 +1,19 @@
import { timingSafeEqual } from "node:crypto"
import { consoleAccountSchema } from "~/lib/console-account"
import { salesforceRequest, syncConsoleAccount, type SalesforceRequest } from "~/lib/salesforce"
export async function handleConsoleSignup(request: Request, token: string, salesforce?: SalesforceRequest) {
const expected = Buffer.from(`Bearer ${token}`)
const actual = Buffer.from(request.headers.get("authorization") ?? "")
if (!token || expected.length !== actual.length || !timingSafeEqual(expected, actual))
return new Response(null, { status: 401 })
const data: unknown = await request.json().catch(() => null)
const account = consoleAccountSchema.safeParse(data)
if (!account.success) return Response.json({ error: "Invalid Console account" }, { status: 400 })
return Promise.resolve(salesforce)
.then(async (client) => syncConsoleAccount(account.data, client ?? (await salesforceRequest())))
.then(
(result) => Response.json(result),
() => Response.json({ error: "Salesforce sync unavailable" }, { status: 503 }),
)
}
+156
View File
@@ -0,0 +1,156 @@
import { sendEmail } from "~/lib/aws"
import { Resource } from "@opencode/console-resource"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createLead } from "~/lib/salesforce"
import { inferenceSpendOptions } from "~/lib/inference-spend"
import { lookupConsoleAccount, type ConsoleAccount } from "~/lib/console-account"
interface EnterpriseFormData {
name: string
role: string
company?: string
email: string
phone?: string
inferenceSpend?: string
alias?: string
message: string
}
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
function splitFullName(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter((p) => p.length > 0)
if (parts.length === 0) return { firstName: "", lastName: "" }
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
}
function subscribe(email: string, fullName: string) {
const name = splitFullName(fullName)
const fields: Record<string, string> = {}
if (name.firstName) fields.FirstName = name.firstName
if (name.lastName) fields.LastName = name.lastName
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
if (Object.keys(fields).length) payload.fields = fields
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
method: "PUT",
headers: {
Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}).then(
async (res) => {
if (!res.ok) {
console.error("EmailOctopus subscribe failed:", res.status, res.statusText, await res.text())
return false
}
return true
},
(err) => {
console.error("Failed to subscribe enterprise email:", err)
return false
},
)
}
export async function handleEnterpriseRequest(
request: Request,
options: {
salesforceSubmit?: (payload: Record<string, unknown>) => Promise<boolean>
notifications?: boolean
accountLookup?: (email: string) => Promise<ConsoleAccount | null>
} = {},
) {
const dict = i18n(localeFromRequest(request))
try {
const body = (await request.json()) as EnterpriseFormData
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
if (trap) {
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
if (!body.name || !body.role || !body.email || !body.message) {
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) {
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
}
const inferenceSpend = inferenceSpendOptions.find((option) => option.value === body.inferenceSpend)
if (body.inferenceSpend && !inferenceSpend) {
return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 })
}
const account = await (options.accountLookup ?? lookupConsoleAccount)(body.email).catch((err) => {
console.error("Failed to look up Console account:", err)
return undefined
})
const emailContent = `
${body.message}<br><br>
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
--<br>
${body.name}<br>
${body.role}<br>
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.trim()
const [lead, mail, octopus] = await Promise.all([
createLead(
{
name: body.name,
role: body.role,
company: body.company,
email: body.email,
phone: body.phone,
inferenceSpend: inferenceSpend?.salesforceValue,
message: body.message,
consoleAccount: account,
},
options.salesforceSubmit,
).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
return false
}),
options.notifications === false
? Promise.resolve(false)
: sendEmail({
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
}).then(
() => true,
(err) => {
console.error("Failed to send enterprise email:", err)
return false
},
),
options.notifications === false ? Promise.resolve(false) : subscribe(body.email, body.name),
])
if (!lead && !mail && !octopus) {
if (import.meta.env.DEV) {
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
console.error("Enterprise inquiry delivery failed", { email: body.email })
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
} catch (error) {
console.error("Error processing enterprise form:", error)
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
}
@@ -0,0 +1,112 @@
import { afterAll, expect, test } from "bun:test"
const leads: Record<string, unknown>[] = []
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request, instance) {
const path = new URL(request.url).pathname
if (path === "/services/oauth2/token") {
return Response.json({ access_token: "local-test-token", instance_url: instance.url.origin })
}
if (path === "/services/data/v59.0/sobjects/Lead" && request.method === "POST") {
leads.push(await request.json())
return Response.json({ id: "local-test-lead", success: true }, { status: 201 })
}
return new Response("Not found", { status: 404 })
},
})
const resources = {
SST_RESOURCE_SALESFORCE_INSTANCE_URL: server.url.origin,
SST_RESOURCE_SALESFORCE_CLIENT_ID: "local-test-client",
SST_RESOURCE_SALESFORCE_CLIENT_SECRET: "local-test-secret",
}
const previous = Object.fromEntries(Object.keys(resources).map((key) => [key, process.env[key]]))
Object.entries(resources).forEach(([key, value]) => {
process.env[key] = JSON.stringify({ value })
})
afterAll(async () => {
await server.stop(true)
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key]
return
}
process.env[key] = value
})
})
test("a fake entry keeps the visitor message, spend range, and enterprise lead source in their own fields", async () => {
const { createLead } = await import("./salesforce")
const accepted = await createLead({
name: "Fake Console Visitor",
role: "Engineering lead",
company: "Example Test Company",
email: "fake-visitor@example.test",
inferenceSpend: "$10K–$50K",
message: " We need SSO for our developers.\nPlease share the enterprise options. ",
})
expect(accepted).toBe(true)
expect(leads).toHaveLength(1)
expect(leads[0]).toEqual({
LastName: "Fake Console Visitor",
Company: "Example Test Company",
Email: "fake-visitor@example.test",
Phone: null,
Title: "Engineering lead",
Description: " We need SSO for our developers.\nPlease share the enterprise options. ",
Current_Monthly_Inference_Spend__c: "$10K–$50K",
LeadSource: "enterprise website form",
})
})
test("the local Enterprise request handler applies the updated mapping to a form submission", async () => {
const { handleEnterpriseRequest } = await import("./enterprise")
const response = await handleEnterpriseRequest(
new Request("http://localhost/api/enterprise", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Fake Local Form Visitor",
role: "Engineering lead",
company: "Example Test Company",
email: "fake-local-visitor@example.test",
inferenceSpend: "50k-100k",
message: " How are you bro!\nThis is a local form test. ",
alias: "",
}),
}),
{
salesforceSubmit: async (payload) => {
const response = await fetch(`${server.url.origin}/services/data/v59.0/sobjects/Lead`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
return response.ok
},
notifications: false,
accountLookup: async () => ({
id: "user_test",
email: "fake-local-visitor@example.test",
name: null,
createdAt: "2026-10-01T12:00:00.000Z",
}),
},
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ success: true })
expect(leads.at(-1)).toMatchObject({
LastName: "Fake Local Form Visitor",
Description: " How are you bro!\nThis is a local form test. ",
Current_Monthly_Inference_Spend__c: "$50K–$100K",
LeadSource: "enterprise website form",
Has_Console_Account__c: true,
Console_Account_ID__c: "user_test",
Console_Signup_Date__c: "2026-10-01T12:00:00.000Z",
})
})
+130 -13
View File
@@ -1,4 +1,54 @@
import { Resource } from "@opencode/console-resource"
import { z } from "zod"
import { consoleAccountFields, type ConsoleAccount } from "./console-account"
// Match provider domains exactly; business domains hosted by these providers remain business addresses.
const personalEmailDomains = new Set([
"gmail.com",
"googlemail.com",
"outlook.com",
"outlook.co.uk",
"hotmail.com",
"hotmail.co.uk",
"hotmail.fr",
"hotmail.de",
"hotmail.it",
"live.com",
"live.co.uk",
"live.ca",
"live.com.au",
"msn.com",
"yahoo.com",
"yahoo.co.uk",
"yahoo.co.in",
"yahoo.com.au",
"yahoo.ca",
"yahoo.fr",
"yahoo.de",
"yahoo.it",
"yahoo.co.jp",
"ymail.com",
"rocketmail.com",
"icloud.com",
"me.com",
"mac.com",
"proton.me",
"protonmail.com",
"pm.me",
"aol.com",
"aim.com",
"gmx.com",
"gmx.de",
"gmx.net",
"mail.com",
"fastmail.com",
"fastmail.fm",
"hey.com",
"tuta.com",
"tuta.io",
"tutanota.com",
"tutanota.de",
])
async function login() {
const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "")
@@ -46,9 +96,25 @@ export interface SalesforceLeadInput {
phone?: string
inferenceSpend?: string
message: string
consoleAccount?: ConsoleAccount | null
}
export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
export async function createLead(
input: SalesforceLeadInput,
submit?: (payload: Record<string, unknown>) => Promise<boolean>,
): Promise<boolean> {
const payload = {
LastName: input.name,
Company: input.company?.trim() || "Website",
Email: input.email,
Phone: input.phone ?? null,
Title: input.role,
Description: input.message,
Current_Monthly_Inference_Spend__c: input.inferenceSpend,
LeadSource: "enterprise website form",
...(input.consoleAccount === undefined ? {} : consoleAccountFields(input.consoleAccount)),
}
if (submit) return submit(payload)
const auth = await login()
if (!auth) return false
@@ -58,18 +124,7 @@ export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
Authorization: `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
LastName: input.name,
Company: input.company?.trim() || "Website",
Email: input.email,
Phone: input.phone ?? null,
Title: input.role,
Description: input.inferenceSpend
? `Current monthly inference spend: ${input.inferenceSpend}\n\n${input.message}`
: input.message,
Current_Monthly_Inference_Spend__c: input.inferenceSpend,
LeadSource: "Website",
}),
body: JSON.stringify(payload),
}).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
})
@@ -83,3 +138,65 @@ export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
return true
}
export type SalesforceRequest = (
path: string,
method?: "GET" | "POST" | "PATCH",
payload?: Record<string, unknown>,
) => Promise<unknown>
export async function salesforceRequest(): Promise<SalesforceRequest> {
const auth = await login()
if (!auth) throw new Error("Salesforce authentication unavailable")
return async (path, method = "GET", payload) => {
const response = await fetch(`${auth.url}${path}`, {
method,
headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
...(payload ? { body: JSON.stringify(payload) } : {}),
signal: AbortSignal.timeout(8000),
})
if (!response.ok) throw new Error(`Salesforce request failed (${response.status})`)
if (response.status === 204) return null
return response.json()
}
}
/** Update existing people without changing their acquisition source or sales notes. */
export async function syncConsoleAccount(account: ConsoleAccount, request: SalesforceRequest) {
const quote = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
const matches = async (object: "Contact" | "Lead") => {
const query = `SELECT Id FROM ${object} WHERE ${object === "Lead" ? "IsConverted = false AND " : ""}(Console_Account_ID__c = '${quote(account.id)}' OR Email = '${quote(account.email.toLowerCase())}')`
const records: { Id: string }[] = []
let path: string | undefined = `/services/data/v59.0/query?q=${encodeURIComponent(query)}`
while (path) {
const result = z
.object({
records: z.array(z.object({ Id: z.string().regex(/^[a-zA-Z0-9]{15,18}$/) })),
done: z.boolean(),
nextRecordsUrl: z.string().optional(),
})
.parse(await request(path))
records.push(...result.records)
if (result.done) break
if (!result.nextRecordsUrl?.startsWith("/services/data/")) throw new Error("Salesforce query pagination missing")
path = result.nextRecordsUrl
}
return records.map((record) => ({ object, id: record.Id }))
}
const people = [...(await matches("Contact")), ...(await matches("Lead"))]
const fields = consoleAccountFields(account)
for (const person of people)
await request(`/services/data/v59.0/sobjects/${person.object}/${person.id}`, "PATCH", fields)
if (people.length > 0) return { success: true, created: false, updated: people.length }
const domain = account.email.slice(account.email.lastIndexOf("@") + 1).toLowerCase()
const result = z.object({ success: z.literal(true), id: z.string() }).parse(
await request("/services/data/v59.0/sobjects/Lead", "POST", {
LastName: (account.name?.trim() || account.email).slice(0, 80),
Company: personalEmailDomains.has(domain) ? "Individual" : domain,
Email: account.email.toLowerCase(),
LeadSource: "console signup",
...fields,
}),
)
return { success: true, created: true, id: result.id }
}
@@ -0,0 +1,8 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode/console-resource"
import { handleConsoleSignup } from "~/lib/console-signup"
// SolidStart keeps only the method export from API routes, so the handler lives in lib.
export async function POST(event: APIEvent) {
return handleConsoleSignup(event.request, Resource.CONSOLE_CRM_TOKEN.value)
}
@@ -1,138 +1,7 @@
import type { APIEvent } from "@solidjs/start/server"
import { sendEmail } from "~/lib/aws"
import { Resource } from "@opencode/console-resource"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createLead } from "~/lib/salesforce"
import { inferenceSpendOptions } from "~/lib/inference-spend"
interface EnterpriseFormData {
name: string
role: string
company?: string
email: string
phone?: string
inferenceSpend?: string
alias?: string
message: string
}
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
function splitFullName(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter((p) => p.length > 0)
if (parts.length === 0) return { firstName: "", lastName: "" }
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
}
function subscribe(email: string, fullName: string) {
const name = splitFullName(fullName)
const fields: Record<string, string> = {}
if (name.firstName) fields.FirstName = name.firstName
if (name.lastName) fields.LastName = name.lastName
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
if (Object.keys(fields).length) payload.fields = fields
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
method: "PUT",
headers: {
Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}).then(
async (res) => {
if (!res.ok) {
console.error("EmailOctopus subscribe failed:", res.status, res.statusText, await res.text())
return false
}
return true
},
(err) => {
console.error("Failed to subscribe enterprise email:", err)
return false
},
)
}
import { handleEnterpriseRequest } from "~/lib/enterprise"
// SolidStart keeps only the method export from API routes, so the handler lives in lib.
export async function POST(event: APIEvent) {
const dict = i18n(localeFromRequest(event.request))
try {
const body = (await event.request.json()) as EnterpriseFormData
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
if (trap) {
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
if (!body.name || !body.role || !body.email || !body.message) {
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) {
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
}
const inferenceSpend = inferenceSpendOptions.find((option) => option.value === body.inferenceSpend)
if (body.inferenceSpend && !inferenceSpend) {
return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 })
}
const emailContent = `
${body.message}<br><br>
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
--<br>
${body.name}<br>
${body.role}<br>
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.trim()
const [lead, mail, octopus] = await Promise.all([
createLead({
name: body.name,
role: body.role,
company: body.company,
email: body.email,
phone: body.phone,
inferenceSpend: inferenceSpend?.salesforceValue,
message: body.message,
}).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
return false
}),
sendEmail({
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
}).then(
() => true,
(err) => {
console.error("Failed to send enterprise email:", err)
return false
},
),
subscribe(body.email, body.name),
])
if (!lead && !mail && !octopus) {
if (import.meta.env.DEV) {
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
console.error("Enterprise inquiry delivery failed", { email: body.email })
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
} catch (error) {
console.error("Error processing enterprise form:", error)
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
return handleEnterpriseRequest(event.request)
}
+1 -4
View File
@@ -35,7 +35,7 @@ const models = [
{ name: "Space Bunny", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "LongCat 2.5 Preview Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "Step 5 Preview Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "Cat Jam Free", training: "go.faq.a5.used", retention: "go.faq.a5.notZdr" },
{ name: "Cat Jam Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "Grok 4.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "Grok 4.6", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "GPT 6 Luna", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
@@ -343,9 +343,6 @@ export default function Home() {
</a>
.
</p>
<p>
<strong>Cat Jam Free:</strong> {i18n.t("go.faq.a5.catJamRetention")}
</p>
<p>
<strong>DeepSeek:</strong> {i18n.t("go.faq.a5.deepseekRetention")}
</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.28",
"version": "2.0.29",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.28",
"version": "2.0.29",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+111 -15
View File
@@ -4,13 +4,14 @@ import { define } from "@opencode/plugin/effect/plugin"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { sameDirectory } from "@opencode/util/path"
import { dirname, join } from "path"
import { Effect, PubSub, Semaphore, Stream } from "effect"
import path, { dirname, join } from "path"
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/http"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { InstructionDiscovery } from "../../instruction-discovery.js"
import { Instructions } from "../../instructions/index.js"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
type Loaded =
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
@@ -18,17 +19,19 @@ type Loaded =
export const Plugin = define({
id: "opencode.config.instruction",
effect: Effect.fn(function* () {
effect: Effect.fn(function* (ctx) {
const discovery = yield* InstructionDiscovery.Service
// Nothing this plugin watches or loads can contribute when both scopes
// are disabled; skip the resolves, the watcher fiber, and the transform.
if (!discovery.project && !discovery.global) return
// Activate even when both discovery scopes are disabled: configured `instructions`
// entries are explicit requests, not discovery.
yield* Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const changes = yield* PubSub.sliding<string>(1)
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const root = yield* fs.resolve(location.project.directory)
@@ -37,6 +40,10 @@ export const Plugin = define({
const stop = FSUtil.contains(home, start) ? home : root
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
const configured: { sources: string[]; remote: Map<string, InstructionDiscovery.File> } = {
sources: [],
remote: new Map(),
}
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
// The ancestor walk can reach the global file when the location sits
@@ -49,15 +56,15 @@ export const Plugin = define({
.filter((file) => discovery.global || file !== globalFile)
: []),
]
for (const path of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path, type: "file" })
for (const file of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path: file, type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
const content = yield* fs.readFileStringSafe(path)
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (filepath: string) {
const content = yield* fs.readFileStringSafe(filepath)
if (content !== undefined) return new InstructionDiscovery.File({ path: filepath, content })
yield* Effect.logDebug("instruction file skipped", { path: filepath, reason: "unavailable" })
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
@@ -78,6 +85,83 @@ export const Plugin = define({
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
})
// Relative patterns search upward from the location to the project root. Without
// project discovery they resolve against the global config directory instead.
const match = Effect.fn("ConfigInstructionPlugin.match")(function* (source: string) {
const expanded = source.startsWith("~/") ? path.join(global.home, source.slice(2)) : source
const options = { absolute: true, include: "file", dot: true } as const
if (path.isAbsolute(expanded))
return yield* fs
.scan(path.basename(expanded), { ...options, cwd: path.dirname(expanded) })
.pipe(Effect.orElseSucceed(() => [] as string[]))
if (project) return yield* fs.globUp(expanded, start, root)
if (!discovery.global) return []
return yield* fs
.scan(expanded, { ...options, cwd: global.config })
.pipe(Effect.orElseSucceed(() => [] as string[]))
})
const configuredSource = Effect.fn("ConfigInstructionPlugin.configuredSource")(function* () {
const files: InstructionDiscovery.File[] = []
const watched = new Set<string>()
for (const source of configured.sources) {
if (isUrl(source)) {
const file = configured.remote.get(source)
if (file) files.push(file)
continue
}
for (const item of (yield* match(source)).toSorted()) {
const resolved = yield* fs.resolve(item)
if (watched.has(resolved)) continue
watched.add(resolved)
const file = yield* read(resolved)
if (file) files.push(file)
}
}
for (const [key] of Array.from(watches)) if (!watched.has(key)) yield* FiberMap.remove(watches, key)
for (const file of watched) {
const updates = yield* watcher.subscribe({ path: file, type: "file" })
yield* FiberMap.run(watches, file, updates.pipe(Stream.runForEach(publish)), {
onlyIfMissing: true,
startImmediately: true,
})
}
return files
})
// Remote instructions are fetched only when config loads, not on every file rescan.
// A failed fetch keeps the last good copy or skips the URL, so an unreachable URL
// never makes the whole instruction set unavailable.
const reconfigure = Effect.fn("ConfigInstructionPlugin.reconfigure")(
function* () {
const entries = yield* config.entries()
const sources = Array.from(
new Set(entries.flatMap((entry) => (entry.type === "document" ? (entry.info.instructions ?? []) : []))),
)
const fetched = yield* Effect.forEach(
sources.filter(isUrl),
(url) =>
HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.text),
Effect.timeout("5 seconds"),
Effect.map((content) => new InstructionDiscovery.File({ path: url, content })),
Effect.catchCause((cause) =>
Effect.logWarning("failed to fetch instruction url", { url, cause }).pipe(
Effect.as(configured.remote.get(url)),
),
),
),
{ concurrency: 4 },
)
configured.sources = sources
configured.remote = new Map(
fetched.filter((file) => file !== undefined).map((file) => [file.path, file] as const),
)
},
(effect) => lock.withPermit(effect),
)
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchCause((cause) =>
@@ -92,10 +176,11 @@ export const Plugin = define({
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
configured: isolate("configured", configuredSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
Array.isArray(sources.global) && Array.isArray(sources.project) && Array.isArray(sources.configured)
? { type: "available", files: [...sources.global, ...sources.project, ...sources.configured] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
@@ -115,6 +200,13 @@ export const Plugin = define({
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
// Subscribe before the first config read so an update in between still triggers a reload.
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reconfigure().pipe(Effect.andThen(refresh()), Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* reconfigure()
yield* refresh()
yield* discovery.transform((editor) => {
if (loaded.current.type === "unavailable") {
@@ -141,3 +233,7 @@ function ancestorDirectories(start: string, stop: string): string[] {
if (sameDirectory(start, stop)) return [start]
return [start, ...ancestorDirectories(dirname(start), stop)]
}
function isUrl(source: string) {
return URL.canParse(source) && /^https?:$/.test(new URL(source).protocol)
}
+3 -3
View File
@@ -30,7 +30,7 @@ export const Plugin = define({
})
}
integrations.update(integrationID, (integration) => {
integration.name = provider.name ?? integration.name
integration.name = provider.name || integration.name
})
if (provider.env !== undefined) {
integrations.method.update({
@@ -66,7 +66,7 @@ export const Plugin = define({
})
provider.activation = "enabled"
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.name) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compatibility !== undefined)
provider.compatibility = { ...provider.compatibility, ...item.compatibility }
@@ -111,7 +111,7 @@ export const Plugin = define({
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
}
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.name) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
+17 -21
View File
@@ -19,7 +19,6 @@ type ParseSource =
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
@@ -56,27 +55,24 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
const filePath = token.slice("{file:".length, -1)
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
Effect.catch((error) => {
if (input.missing === "empty") return Effect.succeed("")
const fileContent = yield* fs
.readFileStringSafe(resolvedPath)
.pipe(
Effect.mapError(
(error) =>
new InvalidError({ path: configSource, message: `bad file reference: "${token}"` }, { cause: error }),
),
)
// Like a missing {env:VAR}, a missing or unreadable file substitutes empty so one stale secret
// reference cannot take down every Location that loads this config.
if (fileContent === undefined)
yield* Effect.logWarning("config file reference is missing or unreadable", {
source: configSource,
reference: token,
path: resolvedPath,
})
const message = `bad file reference: "${token}"`
return Effect.fail(
new InvalidError(
{
path: configSource,
message:
error._tag === "PlatformError" && error.reason._tag === "NotFound"
? `${message} ${resolvedPath} does not exist`
: message,
},
{ cause: error },
),
)
}),
)
out += JSON.stringify(fileContent.trim()).slice(1, -1)
out += JSON.stringify((fileContent ?? "").trim()).slice(1, -1)
cursor = index + token.length
}
+6 -6
View File
@@ -5,11 +5,11 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
import { createPatch } from "diff"
import { Bus } from "./bus.js"
import { Instructions } from "./instructions/index.js"
import { AbsolutePath } from "./schema.js"
import { State } from "./state.js"
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
path: AbsolutePath,
// An absolute file path, or the URL of a configured remote instruction.
path: Schema.String,
content: Schema.String,
}) {}
@@ -21,7 +21,7 @@ export const Event = {
}
export type Data = {
files: Map<AbsolutePath, Types.DeepMutable<File>>
files: Map<string, Types.DeepMutable<File>>
available: boolean
}
@@ -64,12 +64,12 @@ export const layer = (options?: Options) =>
list: () => Array.from(editor.files.values()),
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
update: (path, update) => {
const current = editor.files.get(AbsolutePath.make(path))
const current = editor.files.get(path)
if (!current) return
update(current)
current.path = AbsolutePath.make(path)
current.path = path
},
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
remove: (path) => editor.files.delete(path),
unavailable: () => {
editor.available = false
},
+4 -2
View File
@@ -1306,7 +1306,7 @@ describe("Config", () => {
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Ignored reference: {file:missing.txt}
// Ignored reference: {file:.}
"username": "user-{env:OPENCODE_TEST_MISSING}",
"mcp": {
"servers": {
@@ -1315,7 +1315,8 @@ describe("Config", () => {
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
"X-Token": "{file:token.txt}"
"X-Token": "{file:token.txt}",
"X-Missing": "Bearer {file:~/.opencode-test-missing/key}"
}
}
}
@@ -1335,6 +1336,7 @@ describe("Config", () => {
expect(remote.headers).toEqual({
Authorization: "Bearer secret",
"X-Token": 'file\n"token"',
"X-Missing": "Bearer ",
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
+5 -2
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { writeFileSync } from "node:fs"
import { describe, expect } from "bun:test"
import { describe, expect, setDefaultTimeout } from "bun:test"
import { Document, Event, Info } from "@opencode/schema/config"
import { Agent } from "@opencode/core/agent"
import { Command } from "@opencode/core/command"
@@ -38,6 +38,8 @@ import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
setDefaultTimeout(30_000)
const it = testEffect(
Layer.merge(
PluginTestLayer,
@@ -366,8 +368,9 @@ function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
// Watcher delivery and reloads can take seconds on loaded CI runners.
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 100; attempt++) {
for (let attempt = 0; attempt < 500; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
@@ -1,14 +1,18 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/http"
import fs from "fs/promises"
import path from "path"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Bus } from "@opencode/core/bus"
import { Config } from "@opencode/core/config"
import { ConfigInstructionPlugin } from "@opencode/core/config/plugin/instruction"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
import { Location } from "@opencode/core/location"
import { AbsolutePath } from "@opencode/core/schema"
import { Document, Info } from "@opencode/schema/config"
import { Event } from "@opencode/schema/event"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -26,13 +30,25 @@ const instructionLayer = (input: {
home?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
instructions?: string[]
http?: HttpClient.HttpClient
project?: boolean
}) => {
const watcher = Watcher.testLayer
const config = Config.testLayer(input.instructions ? [configured(input.instructions)] : [])
return Layer.mergeAll(
AppNodeBuilder.build(
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
LayerNode.group([
InstructionDiscovery.node,
Bus.node,
Config.node,
FSUtil.node,
Global.node,
Location.node,
Watcher.node,
]),
[
Config.node.replace(config),
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
Global.node.replace(
input.config || input.home
@@ -48,20 +64,34 @@ const instructionLayer = (input: {
],
),
watcher,
config,
Layer.succeed(HttpClient.HttpClient, input.http ?? respond(() => new Response(null, { status: 404 }))),
)
}
const start = Effect.fnUntraced(function* () {
yield* ConfigInstructionPlugin.Plugin.effect(host())
const start = Effect.fnUntraced(function* (ctx = host()) {
yield* ConfigInstructionPlugin.Plugin.effect(ctx)
return yield* InstructionDiscovery.Service
})
const file = (path: string, content: string) =>
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
const configured = (instructions: string[]) => new Document({ type: "document", info: new Info({ instructions }) })
const configUpdated = () => ({ id: Event.ID.create(), created: Date.now(), type: "config.updated" as const, data: {} })
const respond = (handler: (url: string) => Response) =>
HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request.url))))
const file = (path: string, content: string) => new InstructionDiscovery.File({ path, content })
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* triggerAndWait(watcher.emit(update))
})
}
function triggerAndWait<E, R>(trigger: Effect.Effect<void, E, R>) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const updated = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
@@ -69,7 +99,7 @@ function emitAndWait(update: Watcher.Update) {
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* watcher.emit(update)
yield* trigger
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(fiber)
})
@@ -250,6 +280,133 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
it.live("loads configured files, globs, home paths, and URLs alongside AGENTS.md", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const project = path.join(tmp.path, "repo")
const directory = path.join(project, "packages", "core")
const agents = path.join(project, "AGENTS.md")
const first = path.join(project, "rules", "a.md")
const second = path.join(project, "rules", "b.md")
const absolute = path.join(tmp.path, "absolute.md")
const homeFile = path.join(tmp.path, "home.md")
const remote = "https://example.test/instructions.md"
const requests: string[] = []
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(first), { recursive: true })
await fs.mkdir(directory, { recursive: true })
await Promise.all([
fs.writeFile(agents, "agents"),
fs.writeFile(first, "first"),
fs.writeFile(second, "second"),
fs.writeFile(absolute, "absolute"),
fs.writeFile(homeFile, "home"),
])
})
const discovery = yield* start()
const initialized = yield* readInitial(yield* discovery.load())
// The unreachable URL and missing file are skipped without making AGENTS.md unavailable.
expect(initialized.text).toBe(
[
`Instructions from: ${agents}\nagents`,
`Instructions from: ${first}\nfirst`,
`Instructions from: ${second}\nsecond`,
`Instructions from: ${absolute}\nabsolute`,
`Instructions from: ${homeFile}\nhome`,
`Instructions from: ${remote}\nremote`,
].join("\n\n"),
)
yield* Effect.promise(() => fs.writeFile(first, "changed"))
yield* emitAndWait({ type: "update", path: first })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
`The instructions changed:\nInstructions from: ${first}\nchanged`,
)
// File rescans reuse fetched URLs instead of requesting them again.
expect(requests).toHaveLength(2)
}).pipe(
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
home: tmp.path,
instructions: [
"rules/*.md",
absolute,
"~/home.md",
remote,
"missing.md",
"https://example.test/missing.md",
],
http: respond((url) => {
requests.push(url)
return url === remote ? new Response("remote") : new Response(null, { status: 404 })
}),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
it.live("reloads configured sources on config updates and keeps the last fetched URL", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const first = path.join(tmp.path, "first.md")
const second = path.join(tmp.path, "second.md")
const remote = "https://example.test/instructions.md"
const responses = [new Response("remote"), new Response(null, { status: 500 })]
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(first, "first")
await fs.writeFile(second, "second")
})
const events = yield* PubSub.unbounded<ReturnType<typeof configUpdated>>()
const config = yield* Config.Test
const discovery = yield* start(host({ event: { subscribe: () => Stream.fromPubSub(events) } }))
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[`Instructions from: ${first}\nfirst`, `Instructions from: ${remote}\nremote`].join("\n\n"),
)
yield* config.setEntries([configured([second, remote])])
yield* triggerAndWait(PubSub.publish(events, configUpdated()).pipe(Effect.asVoid))
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${second}\nsecond`, `Instructions from: ${remote}\nremote`].join("\n\n"),
)
}).pipe(
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
instructions: [first, remote],
http: respond(() => responses.shift() ?? new Response(null, { status: 500 })),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
)
}),
),
)
it.live("keeps an empty AGENTS.md as available context", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+4 -2
View File
@@ -103,7 +103,7 @@ describe("LocationServiceMap", () => {
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
}
if (failure === "config reference") {
yield* Effect.promise(() => fs.mkdir(directory))
yield* Effect.promise(() => fs.mkdir(path.join(directory, "username.txt"), { recursive: true }))
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
)
@@ -124,6 +124,7 @@ describe("LocationServiceMap", () => {
}
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
if (failure === "config reference") {
yield* Effect.promise(() => fs.rm(path.join(directory, "username.txt"), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
}
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
@@ -147,7 +148,7 @@ describe("LocationServiceMap", () => {
const locations = yield* LocationServiceMap.Service
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
if (failure === "config reference") {
yield* Effect.promise(() => fs.mkdir(directory))
yield* Effect.promise(() => fs.mkdir(path.join(directory, "username.txt"), { recursive: true }))
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
)
@@ -165,6 +166,7 @@ describe("LocationServiceMap", () => {
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
if (failure === "config reference") {
yield* Effect.promise(() => fs.rm(path.join(directory, "username.txt"), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
}
const repaired = yield* locations.contextEffect(ref)
+2
View File
@@ -1767,6 +1767,8 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("resources")])
expect(yield* service.tools()).toEqual([])
}),
// Spawns and connects the stdio server three times, which is slow on Windows CI.
30_000,
)
test("restores runtime MCP config when a transform is disposed", async () => {
+76 -65
View File
@@ -218,56 +218,63 @@ describe("Npm.add", () => {
expect(await fs.stat(path.join(entries.added.directory, "package.json"))).toBeTruthy()
expect(entries.cached).toEqual(entries.added)
})
}, 30_000)
test.each(["unnamed", "named"])("installs and locates %s Git packages with dependencies", async (kind) => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec =
kind === "unnamed"
? `git+file://${fixture.repository}#${fixture.commit}`
: `fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`
// Real Git installs and updates exceed Bun's default timeout on Windows. Plain tests are used because
// Windows CI has reported test.each cases timing out at the 5s default despite an explicit timeout.
for (const kind of ["unnamed", "named"]) {
test(`installs and locates ${kind} Git packages with dependencies`, async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec =
kind === "unnamed"
? `git+file://${fixture.repository}#${fixture.commit}`
: `fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
added: yield* npm.add(spec),
cached: yield* npm.add(spec),
resolved: yield* npm.resolve(spec),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
added: yield* npm.add(spec),
cached: yield* npm.add(spec),
resolved: yield* npm.resolve(spec),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name))
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
expect(entries.added.directory).toContain("node_modules")
})
expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name))
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
expect(entries.added.directory).toContain("node_modules")
}, 30_000)
}
test.each(["commit", "branch"])("installs a Git package from an npm ::path: subdirectory at a %s", async (ref) => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec = `git+file://${fixture.repository}#${ref === "commit" ? fixture.commit : "fixture-branch"}::path:packages/subdirectory-plugin`
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
return { added: yield* npm.add(spec), outdated: yield* npm.check(spec) }
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
for (const ref of ["commit", "branch"]) {
test(`installs a Git package from an npm ::path: subdirectory at a ${ref}`, async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec = `git+file://${fixture.repository}#${ref === "commit" ? fixture.commit : "fixture-branch"}::path:packages/subdirectory-plugin`
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
return { added: yield* npm.add(spec), outdated: yield* npm.check(spec) }
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(result.added.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(result.added.name).toBe("fixture-subdirectory-plugin")
expect(result.added.version).toBe(fixture.commit)
expect(await Bun.file(path.join(result.added.directory, "index.js")).text()).toContain("subdirectory: true")
expect(
await fs.stat(path.join(path.dirname(result.added.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
expect(result.outdated).toBeFalse()
})
expect(result.added.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(result.added.name).toBe("fixture-subdirectory-plugin")
expect(result.added.version).toBe(fixture.commit)
expect(await Bun.file(path.join(result.added.directory, "index.js")).text()).toContain("subdirectory: true")
expect(
await fs.stat(
path.join(path.dirname(result.added.directory), "fixture-subdirectory-dependency", "package.json"),
),
).toBeTruthy()
expect(result.outdated).toBeFalse()
}, 30_000)
}
// Several real Git installs and updates exceed Bun's default timeout on Windows.
test("checks and updates mutable Git packages without changing pinned installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
@@ -311,29 +318,33 @@ describe("Npm.add", () => {
}, 30_000)
// Symlink creation needs elevated privileges on Windows.
test.skipIf(win)("records Git revisions when the cache directory is reached through a symlink", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
await fs.mkdir(path.join(tmp.path, "cache"))
await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link"))
const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
test.skipIf(win)(
"records Git revisions when the cache directory is reached through a symlink",
async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
await fs.mkdir(path.join(tmp.path, "cache"))
await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link"))
const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const added = yield* npm.add(mutable)
const current = yield* npm.check(mutable)
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
})
return { added, current, outdated: yield* npm.check(mutable) }
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise)
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const added = yield* npm.add(mutable)
const current = yield* npm.check(mutable)
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
})
return { added, current, outdated: yield* npm.check(mutable) }
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise)
expect(result.added.version).toBe(fixture.commit)
expect(result.current).toBeFalse()
expect(result.outdated).toBeTrue()
}, 30_000)
expect(result.added.version).toBe(fixture.commit)
expect(result.current).toBeFalse()
expect(result.outdated).toBeTrue()
},
30_000,
)
})
describe("Npm.resolve", () => {
+2 -2
View File
@@ -215,7 +215,7 @@ describe("Session.move", () => {
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
if (broken)
yield* Effect.promise(() =>
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:./missing.txt}"] })),
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:.}"] })),
)
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
@@ -271,7 +271,7 @@ describe("Session.move", () => {
const source = Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "source")) })
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
yield* Effect.promise(() => Promise.all([mkdir(source.directory), mkdir(destination)]))
const config = JSON.stringify({ instructions: ["{file:./missing.txt}"] })
const config = JSON.stringify({ instructions: ["{file:.}"] })
if (!broken) yield* Effect.promise(() => Bun.write(path.join(source.directory, "opencode.json"), config))
const selectedID = Session.ID.create()
const replacements: LayerNode.Replacements = [
+1 -1
View File
@@ -324,8 +324,8 @@ describe("util.effect-flock", () => {
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
const lines = (await fs.readFile(done, "utf8"))
.split("\n")
+1 -1
View File
@@ -59,8 +59,8 @@ describe("util.flock", () => {
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
const lines = (await fs.readFile(done, "utf8"))
.split("\n")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.28",
"version": "2.0.29",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/gui-extensions",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.28",
"version": "2.0.29",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.28",
"version": "2.0.29",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-figma",
"version": "2.0.28",
"version": "2.0.29",
"description": "Figma MCP integration and official Figma skills for OpenCode",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.28",
"version": "2.0.29",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"license": "MIT",
+13
View File
@@ -36,6 +36,17 @@ type Data = {
}
}
/** Names a finished turn by its Session and the user message that opened it. */
export type TurnDiffInput = { sessionID: string; from: string }
/** The host's cache of the files each finished turn changed. */
export type TurnDiffSource = {
/** The turn's diffs, or undefined until they load. */
get: (input: TurnDiffInput) => readonly FileDiffInfo[] | undefined
/** Starts loading the turn's diffs unless they are cached or already loading. */
load: (input: TurnDiffInput) => void
}
export type NavigateToSessionFn = (sessionID: string) => void
export type SessionHrefFn = (sessionID: string) => string
@@ -48,6 +59,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sessionID?: string
shellRunning?: (id: string) => boolean
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
turnDiff?: TurnDiffSource
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
}) => {
@@ -65,6 +77,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sessionHref: props.onSessionHref,
shellRunning: props.shellRunning,
shellOutput: props.shellOutput,
turnDiff: props.turnDiff,
}
},
})
@@ -62,6 +62,7 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
},
session_diff: { [CURRENT_SESSION_ID]: props.document.diffs },
}}
turnDiff={{ get: () => props.document.diffs, load: () => {} }}
>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DataProvider>
@@ -76,6 +77,7 @@ export function CurrentSessionTimelineStory(props: {
width?: string
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
turnDiffs?: boolean
}) {
// Inline story documents rebuild their messages on every prop read, and the timeline projection keys text by identity.
const document = createMemo(() => props.document)
@@ -118,6 +120,7 @@ export function CurrentSessionTimelineStory(props: {
actions={actions}
shellToolDefaultOpen={props.shellToolDefaultOpen}
editToolDefaultOpen={props.editToolDefaultOpen}
turnDiffs={props.turnDiffs}
/>
</CurrentSessionProviders>
</div>
@@ -10,6 +10,7 @@ import {
writeFileDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import type { SessionDocument } from "../document"
import { SessionTimeline } from "./session-timeline"
import { ToolDisplay } from "../tools/tool-renderer"
@@ -198,6 +199,76 @@ const WrittenSource = {
),
}
// A finished turn: the step records which files changed, and the idle marker ends the turn.
function finishedTurnDocument(files: { file: string; before: string; after: string }[]): SessionDocument {
const document = storyDocument([
storyTool("tool_turn_shell", "shell", "completed", { command: "bun run format" }, { output: "Formatted" }),
])
const step = document.messages.at(-1)
if (step?.type !== "assistant") throw new Error("Expected the story document to end with a step")
return {
...document,
messages: [
...document.messages.slice(0, -1),
{ ...step, snapshot: { files: files.map((item) => item.file) } },
{ id: "msg_turn_idle", type: "idle", outcome: "succeeded", time: { created: step.time.created + 400 } },
],
diffs: files.map((item) => {
const lines = (text: string) => (text ? text.split("\n").length - 1 : 0)
const changes = createTwoFilesPatch(item.file, item.file, item.before, item.after)
return {
file: item.file,
patch: changes,
additions: item.before ? (item.after ? 1 : 0) : lines(item.after),
deletions: item.after ? (item.before ? 1 : 0) : lines(item.before),
status: !item.before ? "added" : !item.after ? "deleted" : "modified",
}
}),
}
}
export const TurnChangeSummary = {
render: () => (
<CurrentSessionTimelineStory
title="Turn change summary"
description="Once the turn ends, every file it changed is summarized below it, including changes made by shell commands."
document={finishedTurnDocument([
{
file: "packages/app/src/session/timeline/message-timeline.tsx",
before: "export const before = true\n",
after: "export const after = true\n",
},
{ file: "packages/app/src/session/turn-summary.ts", before: "", after: "export const summary = []\n" },
{ file: "packages/app/src/session/legacy-summary.ts", before: "export const legacy = []\n", after: "" },
])}
width="860px"
turnDiffs
/>
),
}
export const TurnChangeSummaryOverflow = {
render: () => (
<CurrentSessionTimelineStory
title="Turn change summary with many files"
description="Past five files, the rest of the turn's changes wait behind Show all."
document={finishedTurnDocument(
Array.from({ length: 14 }, (_, index) => ({
file: `src/modules/module-${index + 1}.ts`,
before: `export const value = ${index}\n`,
after: `export const value = ${index + 1}\n`,
})),
)}
width="860px"
turnDiffs
/>
),
}
const fileScenarios = {
repeated: RepeatedEdits,
streaming: EditWithStreamedSibling,
@@ -256,6 +327,7 @@ export const AppendingToolCalls = {
export const ChangingFiles = {
args: { scenario: "streaming" },
argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } },
// SAFETY: the scenario control only offers fileScenarios keys.
render: (args: { scenario: string }) => fileScenarios[args.scenario as keyof typeof fileScenarios].render(),
}
+67 -23
View File
@@ -33,6 +33,7 @@ type Entry =
| { type: "assistant"; message: SessionMessageAssistant }
| { type: "notice"; message: Notice }
| { type: "idle"; message: FailedIdle }
| { type: "diff"; message: SessionMessageIdle; from: string; files: string[] }
type Content = SessionMessageAssistant["content"][number]
@@ -79,6 +80,7 @@ export type TimelineProjectionInput = {
timelineDetail?: TimelineDetail
pendingInputIDs?: ReadonlySet<string>
queuedCompactionIDs?: readonly string[]
turnDiffs?: boolean
previousRows?: TimelineRow.TimelineRow[]
}
@@ -95,6 +97,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
undefined,
input.timelineDetail,
input.queuedCompactionIDs,
input.turnDiffs,
)
const rows = reuseTimelineRows(input.previousRows, projection.rows)
@@ -133,6 +136,8 @@ export function createReactiveTimelineProjection(input: {
timelineDetail?: Accessor<TimelineDetail>
pendingInputIDs?: Accessor<ReadonlySet<string>>
queuedCompactionIDs?: Accessor<readonly string[]>
/** Summarize each finished turn's changed files after it. */
turnDiffs?: Accessor<boolean>
}) {
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
@@ -170,6 +175,7 @@ export function createReactiveTimelineProjection(input: {
textVisible().get(content)!(),
input.timelineDetail?.(),
input.queuedCompactionIDs?.(),
input.turnDiffs?.(),
),
)
@@ -232,6 +238,7 @@ export namespace Timeline {
isRenderable = renderable,
detail?: TimelineDetail,
queuedCompactionIDs: readonly string[] = [],
turnDiffs = false,
) {
type Turn = {
id: string
@@ -245,6 +252,8 @@ export namespace Timeline {
const turnByUserID = new Map<string, Turn>()
const leading: Notice[] = []
let current: Turn | undefined
// Everything since the last idle marker is one turn, including prompts steered in meanwhile.
let execution: { opener: string; turn: Turn; files: Set<string> } | undefined
messages.forEach((message) => {
if (isNotice(message)) {
@@ -270,38 +279,32 @@ export namespace Timeline {
turnByUserID.set(message.id, turn)
current = turn
// Undelivered input has not joined the turn yet.
if (!pendingInputIDs?.has(message.id))
execution = { opener: execution?.opener ?? message.id, turn, files: execution?.files ?? new Set() }
return
}
if (isFailedIdle(message)) {
const last = current?.entries.at(-1)
if (message.type === "idle") {
appendIdle(message)
if (
last?.type === "notice" &&
last.message.type === "compaction" &&
last.message.status === "failed" &&
!isInterrupted(last.message.error)
)
return
const lastStep = current?.entries.findLast((entry) => entry.type === "assistant" || entry.type === "idle")
if (lastStep?.type === "assistant" && lastStep.message.error && !isInterrupted(lastStep.message.error)) return
if (current && !current.shell) {
current.entries.push({ type: "idle", message })
return
}
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "idle", message }] }
turns.push(turn)
current = turn
// The summary follows the turn's last prompt, after any failure the marker reports.
// Sorted like the diff route's git order, so the list keeps its order when the diffs arrive.
if (turnDiffs && execution?.files.size)
execution.turn.entries.push({
type: "diff",
message,
from: execution.opener,
files: [...execution.files].sort(),
})
execution = undefined
return
}
if (message.type !== "assistant") return
message.snapshot?.files?.forEach((file) => execution?.files.add(file))
const existing = current?.user ? current : undefined
if (existing?.user) {
@@ -322,6 +325,33 @@ export namespace Timeline {
current = turn
})
function appendIdle(message: SessionMessageIdle) {
if (!isFailedIdle(message)) return
const last = current?.entries.at(-1)
if (
last?.type === "notice" &&
last.message.type === "compaction" &&
last.message.status === "failed" &&
!isInterrupted(last.message.error)
)
return
const lastStep = current?.entries.findLast((entry) => entry.type === "assistant" || entry.type === "idle")
if (lastStep?.type === "assistant" && lastStep.message.error && !isInterrupted(lastStep.message.error)) return
if (current && !current.shell) {
current.entries.push({ type: "idle", message })
return
}
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "idle", message }] }
turns.push(turn)
current = turn
}
const activeMessageID =
turns.findLast((turn) => !pendingInputIDs?.has(turn.id) || turn.entries.some((entry) => entry.type === "idle"))
?.id ?? turns.at(-1)?.id
@@ -339,6 +369,7 @@ export namespace Timeline {
entry.type === "notice"
? visibleNotice(entry.message)
: entry.type === "idle" ||
entry.type === "diff" ||
!!entry.message.error ||
!!entry.message.retry ||
entry.message.content.some((content) => isRenderable(content, showReasoning, detail)),
@@ -556,6 +587,19 @@ export namespace Timeline {
}),
)
return
case "diff":
appendAssistantSegment(assistantSegment)
assistantSegment = []
rows.push(
new TimelineRow.TurnDiff({
userMessageID: turnID,
messageID: entry.message.id,
from: entry.from,
files: entry.files,
}),
)
return
case "notice":
if (detail?.notices.placement === "hidden" && !timelineNoticeRequired(entry.message)) return
@@ -1452,4 +1452,67 @@ describe("current session timeline rows", () => {
}),
)
})
test("summarizes a turn's changed files after the idle marker that ends it", () => {
const step = (id: string, files?: string[]) =>
({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: id }],
snapshot: files && { start: "tree_a", end: "tree_b", files },
time: { created: 1, completed: 2 },
}) satisfies SessionMessageAssistant
const idle = (id: string) => ({ id, type: "idle", outcome: "succeeded", time: { created: 1 } }) as const
const user = (id: string) => ({ id, type: "user", text: id, time: { created: 1 } }) as const
const source = [
// A steered prompt joins the running turn; the summary diffs from its opening prompt and lists
// every path its steps recorded once, sorted.
user("msg_open"),
step("msg_edit", ["src/a.ts"]),
user("msg_steer"),
step("msg_answer", ["src/c.ts", "src/a.ts"]),
idle("msg_idle_1"),
// A turn that changed nothing gets no summary.
user("msg_ask"),
step("msg_reply", []),
idle("msg_idle_2"),
// Neither does a turn still running.
user("msg_next"),
step("msg_running", ["src/b.ts"]),
] satisfies SessionMessageInfo[]
const rows = createTimelineProjection({
sessionMessages: source,
status: { type: "busy" },
reasoningMode: "compact",
turnDiffs: true,
}).rows
expect(rows.filter((row) => Predicate.isTagged(row, "TurnDiff"))).toEqual([
new TimelineRow.TurnDiff({
userMessageID: "msg_steer",
messageID: "msg_idle_1",
from: "msg_open",
files: ["src/a.ts", "src/c.ts"],
}),
])
expect(rows.map(TimelineRow.key).slice(0, 6)).toEqual([
"user-message:msg_open",
"assistant-part:part:part:msg_edit:msg_edit:text:0",
"turn-gap:msg_steer",
"user-message:msg_steer",
"assistant-part:part:part:msg_answer:msg_answer:text:0",
"turn-diff:msg_idle_1",
])
// Hosts opt in, so a timeline without the setting lists no summaries.
expect(
Timeline.constructSessionMessageRows(source, true, { type: "busy" }).rows.some((row) =>
Predicate.isTagged(row, "TurnDiff"),
),
).toBe(false)
})
})
@@ -6,7 +6,8 @@ import type {
} from "@opencode/client/promise"
import { useI18n } from "@opencode/ui/context/i18n"
import { Option, Predicate, Schema } from "effect"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import { For, Show, createEffect, createMemo, type Accessor, type JSX } from "solid-js"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Dynamic } from "solid-js/web"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
import { useData } from "../context"
@@ -25,7 +26,8 @@ import {
SessionCompactionMessage,
SessionCompactionQueued,
} from "../message/message-content"
import type { ContextGroupPart } from "../tools/tool-renderer"
import { ToolRegistry, type ContextGroupPart } from "../tools/tool-renderer"
import { GenericTool } from "../components/basic-tool"
import { SessionRetry } from "../components/session-retry"
import { SessionError } from "../components/session-error"
import { timelineCategory, type TimelineDetail } from "./detail"
@@ -41,6 +43,9 @@ import {
const emptyAssistantMessages: SessionMessageAssistant[] = []
// Past this many files, the rest of a turn's changes wait behind "Show all".
const turnDiffFileLimit = 5
const decodeMetadataFile = Schema.decodeUnknownOption(Schema.Struct({ file: Schema.String }))
type Projection = ReturnType<typeof createReactiveTimelineProjection>
@@ -704,6 +709,56 @@ export function createSessionTimelineRowRenderer(input: {
)
}
function TurnDiff(props: { row: TimelineRow.TurnDiff; onSizeChange?: () => void }) {
const render = ToolRegistry.render("patch") ?? GenericTool
// Until the diff loads, list the paths the turn's steps recorded, without change counts.
const turn = () => ({ sessionID: input.sessionID(), from: props.row.from })
const diffs = () => data.turnDiff?.get(turn())
const paths = () => diffs()?.map((diff) => diff.file) ?? props.row.files
const showAllKey = () => `turn-diff:${props.row.messageID}`
const showAll = () => input.disclosure.value(showAllKey()) === true
const limit = <T,>(items: readonly T[]) => (showAll() ? items : items.slice(0, turnDiffFileLimit))
const overflow = () => paths().length > turnDiffFileLimit
const fileKey = (path: string) => `turn-diff:${props.row.messageID}:file:${path}`
createEffect(() => data.turnDiff?.load(turn()))
return (
<Show when={data.turnDiff && paths().length > 0}>
<div data-slot="session-turn-diffs" class="flex flex-col gap-2 pt-4">
<div class="flex h-6 min-w-0 items-center gap-2">
<span class="text-14-medium text-text-strong tabular-nums">
{i18n.plural("ui.sessionTurn.diffs.changed", paths().length)}
</span>
<DiffChanges changes={[...(diffs() ?? [])]} />
<Show when={overflow()}>
<button
type="button"
class="ml-auto text-14-regular text-text-interactive-base"
onClick={() => input.disclosure.set(showAllKey(), !showAll())}
>
{showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
</button>
</Show>
</div>
<Dynamic
component={render}
tool="patch"
input={{}}
metadata={{ files: limit(diffs() ?? []) }}
pendingFiles={diffs() ? undefined : limit(props.row.files)}
status="completed"
fileOpen={(path: string) => input.disclosure.value(fileKey(path))}
onFileOpenChange={(path: string, open: boolean) => input.disclosure.set(fileKey(path), open)}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
</div>
</Show>
)
}
const render = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
if (Predicate.isTagged(row(), "TurnGap")) return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
@@ -898,6 +953,24 @@ export function createSessionTimelineRowRenderer(input: {
)
}
if (Predicate.isTagged(row(), "TurnDiff")) {
const current = () => {
const value = row()
if (!Predicate.isTagged(value, "TurnDiff")) throw new Error("Expected a turn-diff timeline row")
return value
}
return (
<Frame row={current()}>
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<TurnDiff row={current()} onSizeChange={onSizeChange} />
</div>
</Frame>
)
}
if (Predicate.isTagged(row(), "Retry")) {
const current = () => {
const value = row()
@@ -16,6 +16,7 @@ export type SessionTimelineProps = {
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
timelineDetail?: TimelineDetail
turnDiffs?: boolean
class?: string
}
@@ -27,6 +28,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
timelineDetail: props.timelineDetail && (() => props.timelineDetail!),
turnDiffs: () => props.turnDiffs ?? false,
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
@@ -72,6 +72,19 @@ export namespace TimelineRow {
inboxID: string
}> {}
/**
* The files a finished turn changed, shown once the Session goes idle. `messageID` is the
* idle marker that ended the turn; `from` is the prompt that opened it, which names the turn
* to the session diff route even when later prompts were steered in. `files` are the paths its
* steps recorded, listed before the diff loads; the diff can omit a file a later step reverted.
*/
export class TurnDiff extends Data.TaggedClass("TurnDiff")<{
userMessageID: string
messageID: string
from: string
files: readonly string[]
}> {}
/** Where the host shows the active turn's live status when undelivered input waits below it. */
export class Activity extends Data.TaggedClass("Activity")<{
userMessageID: string
@@ -88,6 +101,7 @@ export namespace TimelineRow {
| Error
| Retry
| CompactionQueued
| TurnDiff
| Activity
export const key: (row: TimelineRow) => string = Match.typeTags<TimelineRow>()({
@@ -104,6 +118,7 @@ export namespace TimelineRow {
Error: (row) => `error:${row.userMessageID}`,
Retry: (row) => `retry:${row.userMessageID}`,
CompactionQueued: (row) => `compaction-queued:${row.inboxID}`,
TurnDiff: (row) => `turn-diff:${row.messageID}`,
// One live status exists at a time; it keeps its identity when the active turn changes.
Activity: () => "activity",
})
@@ -129,5 +144,6 @@ export type TimelineRowMap = {
Retry: { userMessageID: string }
Error: { userMessageID: string; text: string }
CompactionQueued: { userMessageID: string; inboxID: string }
TurnDiff: { userMessageID: string; messageID: string; from: string; files: readonly string[] }
Activity: { userMessageID: string }
}
@@ -1132,6 +1132,8 @@ export interface ToolProps {
deferContent?: boolean
virtualizeDiff?: boolean
onContentRendered?: () => void
/** Paths the patch view lists, without change counts or diffs, instead of `metadata.files`. */
pendingFiles?: readonly string[]
forceOpen?: boolean
locked?: boolean
}
@@ -2212,7 +2214,18 @@ ToolRegistry.register({
render(props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
const files = createMemo(() => patchFileGroups(props.metadata.files))
const files = createMemo(
() =>
props.pendingFiles?.map((path) => ({
path,
type: "update" as const,
additions: 0,
deletions: 0,
views: [],
})) ?? patchFileGroups(props.metadata.files),
)
const [expanded, setExpanded] = createSignal<string[]>([])
const title = createMemo(() => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.28",
"version": "2.0.29",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.28",
"version": "2.0.29",
"private": true,
"type": "module",
"license": "MIT",
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more