From 483ac6875ef47e9911aa14108b98a7511ca812e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Jul 2026 17:53:19 +0200 Subject: [PATCH 01/18] fix(settings): persist collapsed message preference --- packages/web/server/lib/opencode/settings-helpers.js | 3 +++ packages/web/server/lib/opencode/settings-helpers.test.js | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index b95e1145..82aefd82 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -552,6 +552,9 @@ export const createSettingsHelpers = (dependencies) => { result.userMessageRenderingMode = mode; } } + if (typeof candidate.collapsibleUserMessages === 'boolean') { + result.collapsibleUserMessages = candidate.collapsibleUserMessages; + } if (typeof candidate.stickyUserHeader === 'boolean') { result.stickyUserHeader = candidate.stickyUserHeader; } diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 20387b55..9ac08512 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -74,6 +74,14 @@ describe('settings helpers', () => { expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: 'true' })).toEqual({}); }); + it('accepts only booleans for collapsible user messages', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: true })).toEqual({ collapsibleUserMessages: true }); + expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: false })).toEqual({ collapsibleUserMessages: false }); + expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: 'true' })).toEqual({}); + }); + it('accepts messageStreamTransport as a persisted shared setting', () => { const helpers = createTestHelpers(); From 41a2e3781d1e95620abb72497c0b9e41d1445157 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 11:24:10 +0300 Subject: [PATCH 02/18] fix(cli): generate a UI password for bare --ui-password in daemon/serve mode The grand tunnel restructuring removed the CLI's auto-generated UI password, so `openchamber -d --ui-password` (no value) silently started an unauthenticated server instead of creating a password as in 1.8.1. Restore generation for an explicit --ui-password flag without a value: the password is generated before either launch path, passed to the daemon/foreground process via OPENCHAMBER_UI_PASSWORD, persisted in the instance state file, and surfaced once in human/quiet/json output. Refs OPE-216 --- packages/web/bin/cli.js | 4 +++ packages/web/bin/cli.test.js | 39 ++++++++++++++++++++++++++ packages/web/bin/lib/cli-args.js | 4 +-- packages/web/bin/lib/cli-network.js | 30 ++++++++++++++++++++ packages/web/bin/lib/commands-serve.js | 38 +++++++++++++++++++++---- 5 files changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 36058032..098e9005 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -9,6 +9,8 @@ import { EXIT_CODE, TunnelCliError } from './lib/cli-errors.js'; import { resolveServeHost, hasUiPasswordConfigured, + generateUiPassword, + resolveServeUiPassword, assertAuthenticatedNetworkExposure, } from './lib/cli-network.js'; import { @@ -428,6 +430,8 @@ export { assertAuthenticatedNetworkExposure, resolveServeHost, hasUiPasswordConfigured, + generateUiPassword, + resolveServeUiPassword, shouldDisplayTunnelQr, isValidTunnelDoctorResponse, readDesktopLocalPortFromSettings, diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index 7cc00660..5c2adbf4 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -34,12 +34,14 @@ import { discoverRunningInstances, discoverUnconfirmedRegistryInstanceOnPort, ensureTunnelProfilesMigrated, + generateUiPassword, getInstanceFilePath, getPidFilePath, isOpenchamberCmdline, isOpenchamberProcessRunning, parseArgs, resolveServeHost, + resolveServeUiPassword, } from './cli.js'; async function withTempOpenChamberDataDir(fn) { @@ -692,6 +694,43 @@ describe('network-exposed auth validation', () => { }); }); +describe('serve UI password resolution', () => { + it('keeps a configured password untouched', () => { + expect(resolveServeUiPassword({ uiPassword: 'secret', explicitUiPassword: true })) + .toEqual({ password: 'secret', generated: false }); + }); + + it('generates a password for an explicit --ui-password flag without a value', () => { + const resolved = resolveServeUiPassword({ uiPassword: '', explicitUiPassword: true }); + expect(resolved.generated).toBe(true); + expect(typeof resolved.password).toBe('string'); + expect(resolved.password.length).toBe(16); + }); + + it('does not generate a password when the flag is absent', () => { + expect(resolveServeUiPassword({ uiPassword: undefined, explicitUiPassword: false })) + .toEqual({ password: undefined, generated: false }); + }); + + it('generates passwords from an ambiguity-free charset', () => { + const resolved = resolveServeUiPassword({ uiPassword: '', explicitUiPassword: true }); + expect(resolved.password).toMatch(/^[A-HJ-NP-Za-km-z2-9]{16}$/); + expect(resolved.password).not.toMatch(/[0O1Il]/); + }); + + it('generates distinct passwords on repeated calls', () => { + const a = generateUiPassword(); + const b = generateUiPassword(); + expect(a).not.toBe(b); + }); + + it('parses --ui-password without a value as explicit but empty', () => { + const parsed = parseArgs(['serve', '--ui-password']); + expect(parsed.options.explicitUiPassword).toBe(true); + expect(parsed.options.uiPassword).toBe(''); + }); +}); + describe('serve host resolution', () => { it('uses OPENCHAMBER_HOST when --host is not provided', () => { const previous = process.env.OPENCHAMBER_HOST; diff --git a/packages/web/bin/lib/cli-args.js b/packages/web/bin/lib/cli-args.js index deeccb94..1bc9c387 100644 --- a/packages/web/bin/lib/cli-args.js +++ b/packages/web/bin/lib/cli-args.js @@ -593,7 +593,7 @@ OPTIONS: --lan Bind to 0.0.0.0 for LAN access --server Public/server URL for connect-url links --relay connect-url: also include the end-to-end-encrypted relay transport - --ui-password Protect browser UI with single password + --ui-password [password] Protect browser UI with a password (generates one when omitted) --api-only Start API routes only, without serving browser UI assets --foreground Run server in foreground (use with systemd/process managers) --no-daemon Alias for --foreground @@ -752,7 +752,7 @@ COMMON OPTIONS: -p, --port Target OpenChamber instance port --host Bind address when auto-starting an instance --lan Bind to 0.0.0.0 when auto-starting an instance - --ui-password Protect browser UI when auto-starting an instance + --ui-password [password] Protect browser UI when auto-starting an instance (generates one when omitted) --api-only Start API routes only when auto-starting an instance --json Output machine-readable JSON --all Apply to all running instances (doctor default, stop) diff --git a/packages/web/bin/lib/cli-network.js b/packages/web/bin/lib/cli-network.js index 7e0e666a..992a9e94 100644 --- a/packages/web/bin/lib/cli-network.js +++ b/packages/web/bin/lib/cli-network.js @@ -1,5 +1,6 @@ import dgram from 'dgram'; import os from 'os'; +import { randomInt } from 'node:crypto'; import { EXIT_CODE, TunnelCliError } from './cli-errors.js'; import { getUnauthenticatedLanErrorMessage, @@ -125,6 +126,33 @@ function hasUiPasswordConfigured(password) { return typeof password === 'string' && password.trim().length > 0; } +// Ambiguous-character-free alphabet so the printed password is easy to type +// from a phone or another machine. Mirrors the pre-refactor CLI alphabet. +const UI_PASSWORD_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + +function generateUiPassword(length = 16) { + let password = ''; + for (let i = 0; i < length; i++) { + password += UI_PASSWORD_CHARSET[randomInt(UI_PASSWORD_CHARSET.length)]; + } + return password; +} + +// Resolves the effective UI password for a serve: a configured password wins; +// an explicit `--ui-password` flag without a value gets a freshly generated +// password so daemon/foreground serves never silently drop the requested +// protection. The caller must surface `generated` passwords to the user once +// and persist them in the instance state file the server-side reads. +function resolveServeUiPassword({ uiPassword, explicitUiPassword }) { + if (hasUiPasswordConfigured(uiPassword)) { + return { password: uiPassword, generated: false }; + } + if (explicitUiPassword === true) { + return { password: generateUiPassword(), generated: true }; + } + return { password: undefined, generated: false }; +} + function assertAuthenticatedNetworkExposure({ host, uiPassword }) { const bindHost = resolveConfiguredBindHost(host); if (hasUiPasswordConfigured(uiPassword)) { @@ -150,5 +178,7 @@ export { detectLanIPv4Address, assertSafeBrowserPort, hasUiPasswordConfigured, + generateUiPassword, + resolveServeUiPassword, assertAuthenticatedNetworkExposure, }; diff --git a/packages/web/bin/lib/commands-serve.js b/packages/web/bin/lib/commands-serve.js index 1475018a..48e0a6bc 100644 --- a/packages/web/bin/lib/commands-serve.js +++ b/packages/web/bin/lib/commands-serve.js @@ -2,7 +2,7 @@ import fs from 'fs'; import { pathToFileURL } from 'url'; import { spawn } from 'child_process'; import { EXIT_CODE, TunnelCliError } from './cli-errors.js'; -import { buildLocalUrl, resolveServeHost, assertSafeBrowserPort, hasUiPasswordConfigured, assertAuthenticatedNetworkExposure } from './cli-network.js'; +import { buildLocalUrl, resolveServeHost, assertSafeBrowserPort, resolveServeUiPassword, assertAuthenticatedNetworkExposure } from './cli-network.js'; import { fetchSystemInfoFromPort } from './cli-http.js'; import { isPortAvailable, resolveAvailablePort } from './cli-ports.js'; import { ensureLogsDir, getLogFilePath } from './cli-paths.js'; @@ -110,7 +110,13 @@ async function serveCommand(options) { rotateLogFile(initialLogPath); const logFd = fs.openSync(initialLogPath, 'a'); - const effectiveUiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined; + // Resolve the effective UI password before either launch path so a + // password generated for `--ui-password` (no value) is set in the + // daemon/foreground environment before spawning and persisted in the + // instance state file the server and restart/status flows read. + const resolvedUiPassword = resolveServeUiPassword(options); + const effectiveUiPassword = resolvedUiPassword.password; + const autoGeneratedUiPassword = resolvedUiPassword.generated === true; assertAuthenticatedNetworkExposure({ host: effectiveHost, uiPassword: effectiveUiPassword, @@ -214,8 +220,15 @@ async function serveCommand(options) { if (isQuietMode(options)) { if (!options.suppressQuietOutput) { - realStdoutWrite(`${resolvedPort}\n`); + realStdoutWrite( + autoGeneratedUiPassword + ? `${resolvedPort} pass:${effectiveUiPassword}\n` + : `${resolvedPort}\n` + ); } + } else if (autoGeneratedUiPassword && showOutput && !options.suppressStartupSummary) { + console.log(`Generated UI password: ${effectiveUiPassword}`); + console.log('Save this password — it is not shown again.'); } // Clean up PID / instance files. @@ -365,7 +378,11 @@ async function serveCommand(options) { }; if (isJsonMode(options)) { - printJson({ ...serveResult, messages: jsonMessages }); + printJson({ + ...serveResult, + messages: jsonMessages, + ...(autoGeneratedUiPassword ? { password: effectiveUiPassword } : {}), + }); return resolvedPort; } @@ -373,7 +390,14 @@ async function serveCommand(options) { if (options.suppressQuietOutput) { return resolvedPort; } - process.stdout.write(`${resolvedPort}\n`); + // A generated password is essential result data for scripts: include it + // in the same compact `pass:` token form `openchamber status --quiet` + // already emits. Configured passwords are never echoed. + process.stdout.write( + autoGeneratedUiPassword + ? `${resolvedPort} pass:${effectiveUiPassword}\n` + : `${resolvedPort}\n` + ); return resolvedPort; } @@ -382,6 +406,10 @@ async function serveCommand(options) { if (!options.suppressStartupSummary && showOutput) { clackIntro('OpenChamber Started'); logStatus('success', `port ${serveResult.port} (PID: ${serveResult.pid})`); + if (autoGeneratedUiPassword) { + logStatus('success', 'UI password', effectiveUiPassword); + logStatus('warning', 'save this password', 'it is not shown again'); + } logStatus('info', `visit: ${serveResult.url}`); logStatus('info', `logs: ${serveResult.logs}`); clackOutro('daemon running'); From ddae6f2545272a957329a4463d039539b59338db Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 11:24:14 +0300 Subject: [PATCH 03/18] feat(server): validate OPENCHAMBER_OPENCODE_HOSTNAME bind hostname The env var was already read and passed to the managed OpenCode server spawn, but any non-empty string was accepted. Reject values that are not a valid IP (IPv4/IPv6, brackets allowed) or DNS-style hostname with a clear [config] error and fall back to the secure loopback default so a typo can never silently bind a non-loopback address. Refs OPE-231 --- packages/web/README.md | 2 +- .../web/server/lib/opencode/env-config.js | 31 ++++++ .../server/lib/opencode/env-config.test.js | 97 +++++++++++++++++++ .../web/server/lib/opencode/lifecycle.test.js | 24 ++++- 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 packages/web/server/lib/opencode/env-config.test.js diff --git a/packages/web/README.md b/packages/web/README.md index 784b9a81..03e9e752 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -110,7 +110,7 @@ OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber | `OPENCODE_HOST` | Full base URL of external server (overrides `OPENCODE_PORT`) | | `OPENCODE_PORT` | Port of external server | | `OPENCODE_SKIP_START` | Skip starting embedded OpenCode server | -| `OPENCHAMBER_OPENCODE_HOSTNAME` | Bind hostname for managed OpenCode server (default: `127.0.0.1`, use `0.0.0.0` for LAN/remote access — trusted networks only) | +| `OPENCHAMBER_OPENCODE_HOSTNAME` | Bind hostname for managed OpenCode server (default: `127.0.0.1`, use `0.0.0.0` for LAN/remote access — trusted networks only). Invalid values are rejected with an error and fall back to loopback | | `OPENCHAMBER_HOST` | Bind hostname for the OpenChamber web server (default: `127.0.0.1`; use `0.0.0.0` for LAN/remote access — trusted networks only) | | `OPENCHAMBER_VERBOSE_REQUEST_LOGS` | Set to `true` to log every HTTP request; disabled by default to keep user logs small | | `OPENCHAMBER_SKIP_API_COMPRESSION` | Set to `true` to disable gzip compression for `/api/*` responses | diff --git a/packages/web/server/lib/opencode/env-config.js b/packages/web/server/lib/opencode/env-config.js index fc493f0b..42abd0a2 100644 --- a/packages/web/server/lib/opencode/env-config.js +++ b/packages/web/server/lib/opencode/env-config.js @@ -1,3 +1,26 @@ +import { isIP } from 'node:net'; + +const MAX_HOSTNAME_LENGTH = 253; +const HOSTNAME_LABEL_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/; +// All-numeric dotted values must be a real IPv4 address; otherwise typo'd IPs +// like "0.0.0.0.0" would slip through as (technically valid) hostnames. +const ALL_NUMERIC_DOTTED_RE = /^\d+(?:\.\d+)*$/; + +// Valid bind hostnames for the managed OpenCode server: IPv4, IPv6 (with or +// without brackets), or a DNS-style hostname. Everything else (URLs, ports, +// paths, whitespace, underscores) is rejected. +export const isValidOpenCodeHostname = (value) => { + if (typeof value !== 'string') return false; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_HOSTNAME_LENGTH) return false; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return isIP(trimmed.slice(1, -1)) === 6; + } + if (isIP(trimmed) !== 0) return true; + if (ALL_NUMERIC_DOTTED_RE.test(trimmed)) return false; + return trimmed.split('.').every((label) => HOSTNAME_LABEL_RE.test(label)); +}; + export const resolveOpenCodeEnvConfig = (options = {}) => { const env = options.env && typeof options.env === 'object' ? options.env : {}; const logger = options.logger ?? console; @@ -60,6 +83,14 @@ export const resolveOpenCodeEnvConfig = (options = {}) => { ); return '127.0.0.1'; } + if (!isValidOpenCodeHostname(trimmed)) { + logger.error( + `[config] Rejecting OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: ` + + 'must be a valid hostname or IP address (for example 127.0.0.1, 0.0.0.0, localhost, [::1]); ' + + 'falling back to 127.0.0.1 (loopback only)', + ); + return '127.0.0.1'; + } return trimmed; })(); diff --git a/packages/web/server/lib/opencode/env-config.test.js b/packages/web/server/lib/opencode/env-config.test.js new file mode 100644 index 00000000..4b5f8bb0 --- /dev/null +++ b/packages/web/server/lib/opencode/env-config.test.js @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isValidOpenCodeHostname, resolveOpenCodeEnvConfig } from './env-config.js'; + +describe('isValidOpenCodeHostname', () => { + it('accepts IPv4 addresses', () => { + expect(isValidOpenCodeHostname('127.0.0.1')).toBe(true); + expect(isValidOpenCodeHostname('0.0.0.0')).toBe(true); + expect(isValidOpenCodeHostname('192.168.1.10')).toBe(true); + }); + + it('accepts IPv6 addresses with and without brackets', () => { + expect(isValidOpenCodeHostname('::1')).toBe(true); + expect(isValidOpenCodeHostname('[::1]')).toBe(true); + expect(isValidOpenCodeHostname('::')).toBe(true); + expect(isValidOpenCodeHostname('[::]')).toBe(true); + }); + + it('accepts DNS-style hostnames', () => { + expect(isValidOpenCodeHostname('localhost')).toBe(true); + expect(isValidOpenCodeHostname('tailscale-host')).toBe(true); + expect(isValidOpenCodeHostname('my.host.example')).toBe(true); + }); + + it('rejects malformed values', () => { + const invalid = [ + '', + ' ', + 'http://localhost', + 'https://host:4096', + 'host:4096', + 'host/path', + 'bad host', + 'bad_host', + '0.0.0.0.0', + '999.999.999.999', + '[::1', + '::1]', + 'a'.repeat(254), + '1.2.3.4.5.6.7.8.9', + ]; + for (const value of invalid) { + expect(isValidOpenCodeHostname(value), JSON.stringify(value)).toBe(false); + } + }); + + it('rejects non-string values', () => { + expect(isValidOpenCodeHostname(undefined)).toBe(false); + expect(isValidOpenCodeHostname(null)).toBe(false); + expect(isValidOpenCodeHostname(42)).toBe(false); + }); +}); + +describe('resolveOpenCodeEnvConfig hostname', () => { + it('defaults to loopback when the env var is absent', () => { + expect(resolveOpenCodeEnvConfig({ env: {} }).configuredOpenCodeHostname).toBe('127.0.0.1'); + }); + + it('reads OPENCHAMBER_OPENCODE_HOSTNAME', () => { + const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0' } }); + expect(result.configuredOpenCodeHostname).toBe('0.0.0.0'); + }); + + it('trims surrounding whitespace', () => { + const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' tailscale-host ' } }); + expect(result.configuredOpenCodeHostname).toBe('tailscale-host'); + }); + + it('warns and falls back for an empty value', () => { + const logger = { warn: vi.fn(), error: vi.fn() }; + const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' ' }, logger }); + expect(result.configuredOpenCodeHostname).toBe('127.0.0.1'); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty after trimming')); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('rejects invalid values with a clear error and falls back to loopback', () => { + const logger = { warn: vi.fn(), error: vi.fn() }; + const result = resolveOpenCodeEnvConfig({ + env: { OPENCHAMBER_OPENCODE_HOSTNAME: 'http://nope:4096' }, + logger, + }); + expect(result.configuredOpenCodeHostname).toBe('127.0.0.1'); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Rejecting OPENCHAMBER_OPENCODE_HOSTNAME'), + ); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('127.0.0.1')); + }); + + it('keeps other env config intact when the hostname is validated', () => { + const result = resolveOpenCodeEnvConfig({ + env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0', OPENCODE_PORT: '4096' }, + }); + expect(result.configuredOpenCodeHostname).toBe('0.0.0.0'); + expect(result.configuredOpenCodePort).toBe(4096); + expect(result.effectivePort).toBe(4096); + }); +}); diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index fb57c3f2..ee820972 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -50,7 +50,7 @@ const createMockChild = () => { return child; }; -const createRuntime = (overrides = {}, stateOverrides = {}) => { +const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) => { const state = { openCodeWorkingDirectory: '/tmp/project', openCodeProcess: null, @@ -83,6 +83,7 @@ const createRuntime = (overrides = {}, stateOverrides = {}) => { ENV_EFFECTIVE_PORT: 3001, ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1', ENV_SKIP_OPENCODE_START: false, + ...envOverrides, }, syncToHmrState: vi.fn(), syncFromHmrState: vi.fn(), @@ -312,6 +313,27 @@ describe('OpenCode lifecycle', () => { expect(server.signalCode).toBe('SIGTERM'); }); + it('launches managed OpenCode on the configured bind hostname', async () => { + delete process.env.OPENCODE_BINARY; + const child = createMockChild(); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + child.stdout.emit('data', 'opencode server listening on http://0.0.0.0:45678\n'); + }); + return child; + }); + + const runtime = createRuntime({}, {}, { ENV_CONFIGURED_OPENCODE_HOSTNAME: '0.0.0.0' }); + const server = await runtime.startOpenCode(); + const [binary, args] = spawnMock.mock.calls[0]; + + expect(binary).toBe('opencode'); + expect(args).toEqual(['serve', '--hostname', '0.0.0.0', '--port', '45678']); + + await server.close(); + expect(server.signalCode).toBe('SIGTERM'); + }); + it('strips AppImage ARGV0 from managed OpenCode launch env', async () => { delete process.env.OPENCODE_BINARY; const previousArgv0 = process.env.ARGV0; From 0116739111422b8201162ab037fc9d7bf1f53ff8 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 11:41:40 +0300 Subject: [PATCH 04/18] fix(sync): route question/permission replies by the request's own session directory Answering a question tool (or a permission prompt) could leave the session permanently stuck on "asking question": resolveDirectoryForBlockingRequest returned the containing child-store key, which only proves containment. For a worktree session (or any session whose record is grouped under a parent project store), the reply was addressed to the parent directory's OpenCode instance, where the pending request does not exist - the server answered QuestionNotFoundError, the local request was removed, and the trailing question-tool part stayed running with no recovery until Stop. Resolve the directory from the request's own session record (server- confirmed ownership: session.directory, then project.worktree) before falling back to the containing store key. When a reply/reject comes back not-found, also enqueue the settled-running-tool tail materialization so the tool part converges to the server's actual state instead of leaving the UI stuck. Refs OPE-236 --- packages/ui/src/sync/DOCUMENTATION.md | 4 + packages/ui/src/sync/session-actions.test.ts | 159 +++++++++++++++++++ packages/ui/src/sync/session-actions.ts | 91 ++++++++++- packages/ui/src/sync/sync-context.tsx | 6 + 4 files changed, 256 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 5699fc93..55b0afb9 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -255,6 +255,10 @@ Examples of global-store updates performed in `session-actions.ts`: - `deleteSession()` / `deleteSessions()` -> wait for server confirmation or `404`, then remove the session and its persisted state - `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index +### Blocking-request (question/permission) reply routing + +`respondToQuestion`, `rejectQuestion`, `respondToPermission`, and `dismissPermission` route the reply through `resolveDirectoryForBlockingRequest`. The directory chosen decides which OpenCode instance resolves the pending request, so it must be the **session record's own server-confirmed directory** (ownership), never the containing child-store key (containment): a project store legitimately holds its worktree sessions, and a reply addressed to the parent instance makes the server answer `QuestionNotFoundError` while the question stays pending in the worktree instance — the session is then stuck on the running question tool with no recovery. When a reply/reject comes back not-found, the stale request is removed locally and a `settled-running-tool` tail materialization is enqueued so the trailing tool part converges to the server's actual state instead of leaving the UI on "asking question" forever. + ### Restore (unarchive) contract The OpenCode server cannot clear `time.archived` over HTTP: `session.update` diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 84c276f8..d815b6dd 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -1461,6 +1461,165 @@ describe("rejectQuestion passes directory", () => { }) }) +describe("blocking request reply routing and stale recovery (issue OPE-236)", () => { + const materializationCalls: Array<{ directory: string; sessionID: string; messageID: string }> = [] + const enqueueMaterialization = (directory: string, sessionID: string, messageID: string) => { + materializationCalls.push({ directory, sessionID, messageID }) + } + + beforeEach(() => { + replyCalls.length = 0 + scopedClientDirectories.length = 0 + questionReplyError = null + questionRejectError = null + materializationCalls.length = 0 + }) + + test("routes the question reply by the request's own session directory, not the containing store key", async () => { + // The question was asked by a worktree session whose record lives in the + // parent store (containment). The reply must be addressed to the session's + // own server-confirmed directory — otherwise the server resolves the + // parent instance, does not find the pending question, and answers + // QuestionNotFoundError, leaving the session stuck on "asking question". + const question = buildQuestion("q-wt", "session-wt") + const store = createStore({}, { + session: [{ id: "session-wt", directory: "/test/project/wt" } as Session], + question: { "session-wt": [question] }, + }) + const childStores = createChildStores([["/test/project", store]]) + + const { setActionRefs, respondToQuestion } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization) + + await respondToQuestion("session-wt", "q-wt", [["Yes"]]) + + expect(scopedClientDirectories).toEqual(["/test/project/wt"]) + expect(replyCalls[0]?.params.directory).toBe("/test/project/wt") + expect(replyCalls[0]?.params.requestID).toBe("q-wt") + }) + + test("routes permission replies by the request's own session directory", async () => { + const permission = buildPermission("perm-wt", "session-wt") + const store = createStore( + { "session-wt": [permission] }, + { + session: [{ id: "session-wt", directory: "/test/project/wt" } as Session], + }, + ) + const childStores = createChildStores([["/test/project", store]]) + + const { setActionRefs, respondToPermission } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization) + + await respondToPermission("session-wt", "perm-wt", "once") + + expect(scopedClientDirectories).toEqual(["/test/project/wt"]) + expect(replyCalls[0]?.params.directory).toBe("/test/project/wt") + expect(replyCalls[0]?.params.requestID).toBe("perm-wt") + }) + + test("falls back to the containing store key when the session record carries no directory", async () => { + const question = buildQuestion("q-1", "session-a") + const store = createStore({}, { + session: [{ id: "session-a" } as Session], + question: { "session-a": [question] }, + }) + const childStores = createChildStores([["/test/project", store]]) + + const { setActionRefs, respondToQuestion } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization) + + await respondToQuestion("session-a", "q-1", [["Yes"]]) + + expect(scopedClientDirectories).toEqual(["/test/project"]) + expect(replyCalls[0]?.params.directory).toBe("/test/project") + }) + + test("enqueues settled-running-tool tail recovery when the question reply is not found", async () => { + const question = buildQuestion("q-stale", "session-a") + const store = createStore({}, { + session: [{ id: "session-a" } as Session], + question: { "session-a": [question] }, + message: { + "session-a": [{ id: "msg-1", sessionID: "session-a", role: "assistant", time: { created: 1 } } as Message], + }, + part: { + "msg-1": [{ + id: "prt-1", + messageID: "msg-1", + sessionID: "session-a", + type: "tool", + tool: "question", + state: { status: "running" }, + } as Part], + }, + }) + const childStores = createChildStores([["/test/project", store]]) + questionReplyError = Object.assign(new Error("question.reply failed (404): QuestionNotFoundError"), { status: 404 }) + + const { setActionRefs, respondToQuestion } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization) + + let thrown: unknown + try { + await respondToQuestion("session-a", "q-stale", [["Yes"]]) + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(Error) + // The stale request is gone from the store and the trailing running tool + // part is reconciled instead of leaving the UI stuck on "asking question". + expect(store.getState().question["session-a"]).toBe(undefined) + expect(materializationCalls).toEqual([{ directory: "/test/project", sessionID: "session-a", messageID: "msg-1" }]) + }) + + test("enqueues tail recovery on reject not-found but not on success", async () => { + const question = buildQuestion("q-1", "session-a") + const store = createStore({}, { + session: [{ id: "session-a" } as Session], + question: { "session-a": [question] }, + message: { + "session-a": [{ id: "msg-1", sessionID: "session-a", role: "assistant", time: { created: 1 } } as Message], + }, + part: { + "msg-1": [{ + id: "prt-1", + messageID: "msg-1", + sessionID: "session-a", + type: "tool", + tool: "question", + state: { status: "running" }, + } as Part], + }, + }) + const childStores = createChildStores([["/test/project", store]]) + + const { setActionRefs, rejectQuestion } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization) + + // Success: no recovery enqueued — the normal question.rejected event flow clears state. + await rejectQuestion("session-a", "q-1") + expect(materializationCalls).toEqual([]) + + // Not-found: the request is stale server-side; the tail must be reconciled. + questionRejectError = Object.assign(new Error("question.reject failed (404): QuestionNotFoundError"), { status: 404 }) + const stale = buildQuestion("q-stale", "session-a") + store.setState({ question: { "session-a": [stale] } }) + + let thrown: unknown + try { + await rejectQuestion("session-a", "q-stale") + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(Error) + expect(store.getState().question["session-a"]).toBe(undefined) + expect(materializationCalls).toEqual([{ directory: "/test/project", sessionID: "session-a", messageID: "msg-1" }]) + }) +}) + function buildQuestion(id: string, sessionId: string): QuestionRequest { return { id, diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 1b695891..aa8cdb10 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -30,6 +30,8 @@ import { getImperativeSessionMessageLoader } from "./session-message-loader" import { cleanupPersistedSessionState } from "./session-deletion-cleanup" import { getRuntimeKey } from "@/lib/runtime-switch" import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error" +import { getStaleRunningToolMessageID } from "./materialization" +import { normalizePath } from "@/lib/pathNormalization" const MESSAGE_REFETCH_LIMIT = 100 const SEND_CONFIRMATION_REFETCH_LIMIT = 30 @@ -52,6 +54,10 @@ const UNREVERT_REFETCH_RETRY_MS = 150 let _sdk: OpencodeClient | null = null let _childStores: ChildStoreManager | null = null let _getDirectory: () => string = () => "" +// Optional ref into the sync layer's session-tail materialization queue. Used +// to reconcile a trailing running tool part after a blocking request is +// confirmed stale server-side (see recoverStaleBlockingRequest). +let _enqueueSessionMaterialization: ((directory: string, sessionID: string, messageID: string) => void) | null = null type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] } type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string } type OptimisticConfirmInput = OptimisticRemoveInput @@ -139,10 +145,12 @@ export function setActionRefs( sdk: OpencodeClient, childStores: ChildStoreManager, getDirectory: () => string, + enqueueSessionMaterialization?: (directory: string, sessionID: string, messageID: string) => void, ) { _sdk = sdk _childStores = childStores _getDirectory = getDirectory + _enqueueSessionMaterialization = enqueueSessionMaterialization ?? null } export function setOptimisticRefs( @@ -480,6 +488,29 @@ function restoreFilePartsToInput(fileParts: Array>): voi } } +/** + * Server-confirmed directory that owns a session, from the session record + * (`directory`, then `project.worktree`). Mirrors the authoritative source in + * session-directory-resolution: holding a session in a child store proves + * containment, not ownership — a project's session list legitimately includes + * the sessions of its worktrees so the sidebar can group them — so reading + * ownership from the containing store reports the parent for a session that + * lives in a worktree, and every fetch is then addressed to a directory that + * does not own it. + */ +function resolveSessionOwnedDirectory(session: Session): string | null { + const record = session as Session & { + directory?: string | null + project?: { worktree?: string | null } | null + } + const raw = typeof record.directory === "string" && record.directory.trim().length > 0 + ? record.directory + : typeof record.project?.worktree === "string" && record.project.worktree.trim().length > 0 + ? record.project.worktree + : null + return raw ? normalizePath(raw) : null +} + function resolveDirectoryForBlockingRequest( type: "permission" | "question", sessionId: string, @@ -493,10 +524,28 @@ function resolveDirectoryForBlockingRequest( for (const [directory, store] of stores.children) { const state = store.getState() const requestMap = type === "permission" ? state.permission : state.question - for (const requests of Object.values(requestMap) as Array | undefined>) { - if (requests?.some((request) => request.id === requestId)) { - return directory - } + for (const requests of Object.values(requestMap) as Array | undefined>) { + const request = requests?.find((candidate) => candidate.id === requestId) + if (!request) continue + + // Ownership beats containment. The request belongs to one specific + // session, and the reply must reach the instance that actually tracks + // it — the directory the session record's server-confirmed `directory` + // names. The containing store's key only proves containment: a project + // store holds its worktree sessions too, and a reply addressed to the + // parent instance makes the server answer QuestionNotFoundError while + // the question stays pending in the worktree instance, leaving the + // session stuck on the running question tool. Fall back to the store + // key only when the session record carries no directory. + const requestSessionID = typeof request.sessionID === "string" && request.sessionID.length > 0 + ? request.sessionID + : sessionId + const sessionRecord = requestSessionID + ? state.session.find((s) => s.id === requestSessionID) + : undefined + const ownedDirectory = sessionRecord ? resolveSessionOwnedDirectory(sessionRecord) : null + if (ownedDirectory) return ownedDirectory + return directory } } @@ -537,6 +586,38 @@ export function isQuestionRequestNotFoundError(error: unknown): boolean { return /Question(?:\.)?NotFoundError|Question request not found/i.test(message) } +/** + * Reconcile the trailing assistant tool part after a blocking request turned + * out to be stale server-side (reply/reject answered with not-found). The + * local request is removed (the server no longer tracks it), but the + * question/permission tool part can remain `running` with the session busy — + * the UI would stay on "asking question" with no recovery until the user + * stops the run. Enqueue the sync layer's settled-running-tool tail + * materialization so the part converges to the server's actual state. + */ +function recoverStaleBlockingRequest(sessionId: string): void { + const stores = _childStores + const enqueue = _enqueueSessionMaterialization + if (!stores || !enqueue || !sessionId) return + + for (const [directory, store] of stores.children) { + const state = store.getState() + if ( + !state.session.some((session) => session.id === sessionId) + && !Object.prototype.hasOwnProperty.call(state.message, sessionId) + && !Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId) + && !Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId) + ) { + continue + } + const messageID = getStaleRunningToolMessageID(state, sessionId) + if (messageID) { + enqueue(directory, sessionId, messageID) + } + return + } +} + function removeQuestionRequestFromChildStores(sessionId: string, requestId: string): boolean { const stores = _childStores if (!stores || !requestId) return false @@ -1581,6 +1662,7 @@ export async function respondToQuestion( } catch (error) { if (isQuestionRequestNotFoundError(error)) { removeQuestionRequestFromChildStores(sessionId, requestId) + recoverStaleBlockingRequest(sessionId) } throw error } @@ -1605,6 +1687,7 @@ export async function rejectQuestion( } catch (error) { if (isQuestionRequestNotFoundError(error)) { removeQuestionRequestFromChildStores(sessionId, requestId) + recoverStaleBlockingRequest(sessionId) } throw error } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index b4ff6c19..f3fb1610 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -2260,6 +2260,12 @@ export function SyncProvider(props: { props.sdk, childStores, () => opencodeClient.getDirectory() || props.directory, + (directory, sessionID, messageID) => { + enqueueSessionMaterialization(directory, sessionID, childStores, { + reason: "settled-running-tool", + messageID, + }) + }, ) return () => { if (getImperativeSessionMessageLoader() === messageLoader) { From ff5814a7313b794e4410f6c3dedaa340cb48a861 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 11:48:31 +0300 Subject: [PATCH 05/18] fix(web): parse agent frontmatter as leniently as OpenCode parseMdFile now matches gray-matter (used by OpenCode) for file shapes OpenChamber previously failed to parse: frontmatter whose closing '---' sits at end-of-file without a trailing newline, a UTF-8 BOM prefix, and YAML with unquoted colons in scalar values (via the same sanitizer OpenCode applies). OpenCode parses these files, so OpenChamber must too: otherwise the whole file was treated as the prompt body and a save rewrote the existing YAML block into the body, prepending a duplicate frontmatter block. Refs OPE-178 --- packages/web/server/lib/opencode/shared.js | 41 +++- .../web/server/lib/opencode/shared.test.js | 202 ++++++++++++++++++ 2 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 packages/web/server/lib/opencode/shared.test.js diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 6df1faea..062d3d0f 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -49,9 +49,36 @@ function ensureDirs() { // ============== MARKDOWN FILE OPERATIONS ============== +// Mirror of OpenCode's markdown frontmatter sanitizer (packages/opencode/src/ +// config/markdown.ts): other coding agents accept unquoted colons in YAML +// values (e.g. `description: Build agent: creates builds`), which strict YAML +// rejects. Rewrite those values as block scalars and retry the parse, so files +// OpenCode accepts are parsed identically here. +function sanitizeFrontmatter(frontmatter) { + return frontmatter + .split(/\r?\n/) + .flatMap((line) => { + if (line.trim().startsWith('#') || line.trim() === '' || /^\s+/.test(line)) return [line]; + const entry = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/); + if (!entry) return [line]; + const value = entry[2].trim(); + if (value === '' || value === '>' || value === '|' || value.startsWith('"') || value.startsWith("'")) return [line]; + if (!value.includes(':')) return [line]; + return [`${entry[1]}: |-`, ` ${value}`]; + }) + .join('\n'); +} + function parseMdFile(filePath) { - const content = fs.readFileSync(filePath, 'utf8'); - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + const rawContent = fs.readFileSync(filePath, 'utf8'); + // Strip a UTF-8 BOM so frontmatter is recognized regardless of the editor + // that saved the file. + const content = rawContent.charCodeAt(0) === 0xfeff ? rawContent.slice(1) : rawContent; + // The closing `---` may sit at end-of-file without a trailing newline. + // gray-matter (used by OpenCode) accepts that, so we must too: otherwise the + // whole file is treated as the prompt body and a later save rewrites the + // existing YAML block into the body, duplicating the frontmatter. + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/); if (!match) { return { frontmatter: {}, body: content.trim() }; @@ -61,8 +88,14 @@ function parseMdFile(filePath) { try { frontmatter = yaml.parse(match[1]) || {}; } catch (error) { - console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error); - frontmatter = {}; + // Lenient fallback for frontmatter that strict YAML rejects but OpenCode + // still accepts (unquoted colons in scalar values). + try { + frontmatter = yaml.parse(sanitizeFrontmatter(match[1])) || {}; + } catch { + console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error); + frontmatter = {}; + } } const body = match[2].trim(); diff --git a/packages/web/server/lib/opencode/shared.test.js b/packages/web/server/lib/opencode/shared.test.js new file mode 100644 index 00000000..e131aa0c --- /dev/null +++ b/packages/web/server/lib/opencode/shared.test.js @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { parseMdFile, writeMdFile } from './shared.js'; +import { updateAgent } from './agents.js'; + +const FIXTURE_DIR = path.join(os.tmpdir(), `openchamber-shared-test-${process.pid}`); + +const STANDARD_MD = [ + '---', + 'description: My build agent', + 'model: anthropic/claude-sonnet-4', + 'mode: primary', + '---', + '', + 'This is the prompt body.', + '', +].join('\n'); + +const writeFixture = (name, content) => { + const filePath = path.join(FIXTURE_DIR, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); + return filePath; +}; + +describe('parseMdFile', () => { + beforeEach(() => { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); + }); + + it('parses standard YAML frontmatter', () => { + const file = writeFixture('standard.md', STANDARD_MD); + const { frontmatter, body } = parseMdFile(file); + expect(frontmatter).toEqual({ + description: 'My build agent', + model: 'anthropic/claude-sonnet-4', + mode: 'primary', + }); + expect(body).toBe('This is the prompt body.'); + }); + + it('parses frontmatter whose closing --- is at end-of-file without a trailing newline', () => { + // gray-matter (used by OpenCode) accepts this shape; OpenChamber must too, + // otherwise a later save duplicates the YAML block. + const file = writeFixture('eof-close.md', [ + '---', + 'description: My build agent', + 'model: anthropic/claude-sonnet-4', + '---', + ].join('\n')); + const { frontmatter, body } = parseMdFile(file); + expect(frontmatter).toEqual({ + description: 'My build agent', + model: 'anthropic/claude-sonnet-4', + }); + expect(body).toBe(''); + }); + + it('parses frontmatter with CRLF line endings', () => { + const file = writeFixture('crlf.md', STANDARD_MD.replace(/\n/g, '\r\n')); + const { frontmatter, body } = parseMdFile(file); + expect(frontmatter.model).toBe('anthropic/claude-sonnet-4'); + expect(body).toBe('This is the prompt body.'); + }); + + it('parses frontmatter preceded by a UTF-8 BOM', () => { + const file = writeFixture('bom.md', `\uFEFF${STANDARD_MD}`); + const { frontmatter, body } = parseMdFile(file); + expect(frontmatter.description).toBe('My build agent'); + expect(body).toBe('This is the prompt body.'); + }); + + it('falls back to lenient YAML for unquoted colons in values, matching OpenCode', () => { + const file = writeFixture('colon.md', [ + '---', + 'description: Build agent: creates builds', + 'model: anthropic/claude-sonnet-4', + '---', + '', + 'Body', + '', + ].join('\n')); + const { frontmatter, body } = parseMdFile(file); + expect(frontmatter).toEqual({ + description: 'Build agent: creates builds', + model: 'anthropic/claude-sonnet-4', + }); + expect(body).toBe('Body'); + }); + + it('treats files without frontmatter as a plain body', () => { + const file = writeFixture('plain.md', 'Just a prompt body.'); + const { frontmatter, body } = parseMdFile(file); + expect(frontmatter).toEqual({}); + expect(body).toBe('Just a prompt body.'); + }); +}); + +describe('writeMdFile', () => { + beforeEach(() => { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); + }); + + it('round-trips a canonical single frontmatter block', () => { + const file = writeFixture('roundtrip.md', STANDARD_MD); + const parsed = parseMdFile(file); + parsed.frontmatter.model = 'openai/gpt-5'; + writeMdFile(file, parsed.frontmatter, parsed.body); + + const content = fs.readFileSync(file, 'utf8'); + // Exactly one frontmatter block. + expect(content.match(/^---\r?\n/g)).toHaveLength(1); + + const reparsed = parseMdFile(file); + expect(reparsed.frontmatter).toEqual({ + description: 'My build agent', + model: 'openai/gpt-5', + mode: 'primary', + }); + expect(reparsed.body).toBe('This is the prompt body.'); + }); +}); + +describe('updateAgent frontmatter preservation', () => { + beforeEach(() => { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); + }); + + it('updates the model in place without duplicating YAML for a file with EOF-closed frontmatter', () => { + // Repro of OPE-178: the file's closing --- sits at EOF (no trailing + // newline). OpenCode parses it; OpenChamber previously treated the whole + // file as the prompt body and prepended a second frontmatter block on save. + const projectDir = path.join(FIXTURE_DIR, 'project'); + const agentPath = path.join(projectDir, '.opencode', 'agents', 'strateg.md'); + writeFixture(path.join('project', '.opencode', 'agents', 'strateg.md'), [ + '---', + 'description: Strategy agent', + 'model: anthropic/claude-sonnet-4', + 'temperature: 0.7', + '---', + ].join('\n')); + + updateAgent('strateg', { model: 'openai/gpt-5' }, projectDir); + + const content = fs.readFileSync(agentPath, 'utf8'); + expect(content.match(/^---\r?\n/g)).toHaveLength(1); + + const parsed = parseMdFile(agentPath); + expect(parsed.frontmatter).toEqual({ + description: 'Strategy agent', + model: 'openai/gpt-5', + temperature: 0.7, + }); + expect(parsed.body).toBe(''); + }); + + it('preserves unrelated frontmatter fields when saving one field', () => { + const projectDir = path.join(FIXTURE_DIR, 'project'); + const agentPath = path.join(projectDir, '.opencode', 'agents', 'strateg.md'); + writeFixture(path.join('project', '.opencode', 'agents', 'strateg.md'), [ + '---', + 'description: Strategy agent', + 'mode: primary', + 'temperature: 0.7', + '---', + '', + 'Body of strateg.', + '', + ].join('\n')); + + updateAgent('strateg', { description: 'Updated strategy agent' }, projectDir); + + const content = fs.readFileSync(agentPath, 'utf8'); + expect(content.match(/^---\r?\n/g)).toHaveLength(1); + + const parsed = parseMdFile(agentPath); + expect(parsed.frontmatter).toEqual({ + description: 'Updated strategy agent', + mode: 'primary', + temperature: 0.7, + }); + expect(parsed.body).toBe('Body of strateg.'); + }); +}); From 654e9cdb64e56f3a05108cdaabd2a7e12c164687 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:37:20 +0300 Subject: [PATCH 06/18] feat(chat): refocus composer after adding message to context After the add-to-context (context pin) action completes successfully, move focus back to the chat input so the user can keep typing immediately. Uses the existing focusChatInput helper and the requestAnimationFrame refocus pattern already used by the model/agent selectors. Fixes #2447 --- packages/ui/src/components/chat/ChatMessage.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 5b45b4b3..5ec9a8ac 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -37,6 +37,7 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages'; import { setContextObligatoryMessage } from '@/sync/session-actions'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { focusChatInput } from './composer/editor/dom'; const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog')); @@ -416,6 +417,10 @@ const ChatMessage: React.FC = ({ createdAt: messageCreatedAt, role: isUser ? 'user' : 'assistant', }, !isPinnedIntoContext); + // Return focus to the composer so the user can keep typing right + // after adding the message to context (matches the refocus pattern + // used by the model/agent selectors). + requestAnimationFrame(focusChatInput); } catch (error) { console.error('[chat-message] failed to update context pin', error); toast.error(t('chat.messageBody.actions.contextPinFailed')); From 9b7c03252436881fbe849662a36e9cb46a9af817 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:37:46 +0300 Subject: [PATCH 07/18] feat(ui): show the changed-files count badge on the Git rail surface Replaces the plain activity dot on the context panel rail's Git button with a numeric badge of the changed-files count from the git store status, so the count is visible at a glance without opening the Git surface. Large counts cap at 99+ to keep the pill within the 36px button. The badge is reflected in the button's accessible label and the hover tooltip. Fixes #2364 --- .../components/layout/ContextPanelRail.tsx | 78 ++++++++++++++----- packages/ui/src/lib/i18n/messages/de.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 + 12 files changed, 80 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 43692cbb..54b5d78c 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -49,17 +49,30 @@ type RailItemProps = { showActivityDot: boolean; label: string; description: string; + /** Numeric badge (e.g. the Git changed-files count); takes precedence over the activity dot. */ + badgeCount?: number | null; + /** Accessible label that includes the badge count; falls back to `label`. */ + badgeAriaLabel?: string | null; + /** Extra tooltip line describing the badge; rendered under the description. */ + badgeDescription?: string | null; orderNumber?: number | null; showOrderNumber?: boolean; onSelect: (surface: ContextSurfaceDescriptor) => void; }; +// The badge corner is 16px tall; cap large counts so the pill stays compact +// on the 36px rail button (matching the order-number badge's footprint). +const formatRailBadgeCount = (count: number): string => (count > 99 ? '99+' : String(count)); + const ContextPanelRailItem: React.FC = ({ surface, isActive, showActivityDot, label, description, + badgeCount, + badgeAriaLabel, + badgeDescription, orderNumber, showOrderNumber, onSelect, @@ -68,6 +81,8 @@ const ContextPanelRailItem: React.FC = ({ id: surface.id, }); + const displayBadgeCount = badgeCount != null && badgeCount > 0 ? formatRailBadgeCount(badgeCount) : null; + return (
= ({ {...attributes} {...listeners} onClick={() => onSelect(surface)} - aria-label={label} + aria-label={badgeAriaLabel ?? label} aria-pressed={isActive} className={cn( 'flex h-9 w-9 touch-none select-none items-center justify-center rounded-md transition-colors', @@ -95,12 +110,6 @@ const ContextPanelRailItem: React.FC = ({ ) : ( )} - {showActivityDot && !showOrderNumber ? ( -
@@ -525,8 +533,11 @@ export function ScheduledTasksDialog() { className={cn( 'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium', task.enabled ? 'text-foreground' : 'text-muted-foreground', - isBusy && 'cursor-not-allowed opacity-50', + (isBusy || task.loopFile) && 'cursor-not-allowed opacity-50', )} + title={task.loopFile + ? t('sessions.scheduledTasks.dialog.loopFile.toggleDisabled') + : undefined} > {task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')} @@ -555,7 +566,10 @@ export function ScheduledTasksDialog() { setEditorTask(task); setEditorOpen(true); }} - disabled={isBusy} + disabled={isBusy || Boolean(task.loopFile)} + title={task.loopFile + ? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled') + : undefined} aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })} > {t('sessions.scheduledTasks.dialog.actions.edit')} @@ -564,7 +578,10 @@ export function ScheduledTasksDialog() { variant="destructive" size="sm" onClick={() => void handleDeleteTask(task)} - disabled={isBusy} + disabled={isBusy || Boolean(task.loopFile)} + title={task.loopFile + ? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled') + : undefined} aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })} > diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 57a22de2..0190f560 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -248,6 +248,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} pausieren', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Aktiviert', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Pausiert', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Von Loop-Datei verwaltet {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Aktiviert wird durch die Loop-Datei gesteuert; setze enabled im Markdown-Frontmatter', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop-Aufgaben werden in ihrer .agents/loops-Markdown-Datei konfiguriert', 'sessions.scheduledTasks.editor.title.edit': 'Geplante Aufgabe bearbeiten', 'sessions.scheduledTasks.editor.title.new': 'Neue geplante Aufgabe', 'sessions.scheduledTasks.editor.description': 'Konfigurieren Sie eine serverseitige Aufgabe, die eine neue Sitzung erstellt und eine Eingabeaufforderung sendet.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index c77d6a07..5d0742a0 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -268,6 +268,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Enabled', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Paused', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Managed by loop file {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Enabled is controlled by the loop file; set enabled in the markdown frontmatter', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop tasks are configured in their .agents/loops markdown file', 'sessions.scheduledTasks.editor.title.edit': 'Edit scheduled task', 'sessions.scheduledTasks.editor.title.new': 'New scheduled task', 'sessions.scheduledTasks.editor.description': 'Configure a server-side task that creates a new session and sends a prompt.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 72985914..9d97a182 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Habilitado", "sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gestionada por el archivo de bucle {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'La activación la controla el archivo de bucle; establece enabled en el frontmatter de Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Las tareas de bucle se configuran en su archivo Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Editar tarea programada", "sessions.scheduledTasks.editor.title.new": "Nueva tarea programada", "sessions.scheduledTasks.editor.description": "Configura una tarea del lado del servidor que crea una nueva sesión y envía un prompt.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 986a4cd4..5d40c830 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -105,6 +105,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Activé', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'En pause', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gérée par le fichier de boucle {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': "L'activation est contrôlée par le fichier de boucle ; définissez enabled dans le frontmatter Markdown", + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Les tâches de boucle sont configurées dans leur fichier Markdown .agents/loops', 'sessions.scheduledTasks.editor.title.edit': 'Modifier une tâche planifiée', 'sessions.scheduledTasks.editor.title.new': 'Nouvelle tâche planifiée', 'sessions.scheduledTasks.editor.description': 'Configurez une tâche côté serveur qui crée une nouvelle session et envoie un prompt.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 0f4da4e7..352c6727 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName}を一時停止', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '有効', 'sessions.scheduledTasks.dialog.taskToggle.paused': '一時停止中', + 'sessions.scheduledTasks.dialog.loopFile.note': 'ループファイル {file} によって管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '有効状態はループファイルが制御します。Markdown フロントマターで enabled を設定してください', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'ループタスクは .agents/loops の Markdown ファイルで設定します', 'sessions.scheduledTasks.editor.title.edit': 'スケジュールタスクを編集', 'sessions.scheduledTasks.editor.title.new': '新しいスケジュールタスク', 'sessions.scheduledTasks.editor.description': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0f09fba2..5ce56da8 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} 일시 중지', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '활성화됨', 'sessions.scheduledTasks.dialog.taskToggle.paused': '일시 중지됨', + 'sessions.scheduledTasks.dialog.loopFile.note': '루프 파일에서 관리됨: {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '활성화 여부는 루프 파일이 제어합니다. Markdown frontmatter에서 enabled를 설정하세요', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '루프 작업은 .agents/loops Markdown 파일에서 구성합니다', 'sessions.scheduledTasks.editor.title.edit': '예약 작업 편집', 'sessions.scheduledTasks.editor.title.new': '새 예약 작업', 'sessions.scheduledTasks.editor.description': '새 세션을 만들고 프롬프트를 보내는 서버 작업을 설정합니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 12f727b1..06c5aa7f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -396,6 +396,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Wstrzymaj {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Włączone', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Wstrzymane', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Zarządzane przez plik pętli {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Włączenie jest kontrolowane przez plik pętli; ustaw enabled w frontmatterze Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Zadania pętli są konfigurowane w pliku Markdown .agents/loops', 'sessions.scheduledTasks.editor.title.edit': 'Edytuj zaplanowane zadanie', 'sessions.scheduledTasks.editor.title.new': 'Nowe zaplanowane zadanie', 'sessions.scheduledTasks.editor.description': 'Skonfiguruj zadanie po stronie serwera, które tworzy nową sesję i wysyła prompt.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 10dd7709..b33b7976 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Ativado", "sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gerenciada pelo arquivo de loop {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'A ativação é controlada pelo arquivo de loop; defina enabled no frontmatter Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Tarefas de loop são configuradas no arquivo Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Editar tarefa agendada", "sessions.scheduledTasks.editor.title.new": "Nova tarefa agendada", "sessions.scheduledTasks.editor.description": "Configure uma tarefa do lado do servidor que cria uma nova sessão e envia um prompt.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index dd52bcb9..4b389c8d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Призупинити {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Увімкнено", "sessions.scheduledTasks.dialog.taskToggle.paused": "Призупинено", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Керується файлом циклу {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Активність контролюється файлом циклу; встановіть enabled у frontmatter Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Завдання циклів налаштовуються у файлі Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Редагувати заплановане завдання", "sessions.scheduledTasks.editor.title.new": "Нове заплановане завдання", "sessions.scheduledTasks.editor.description": "Налаштувати завдання на стороні сервера, яке створює нову сесію і надсилає запит.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 35de2c9c..057e6637 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '暂停 {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '已启用', 'sessions.scheduledTasks.dialog.taskToggle.paused': '已暂停', + 'sessions.scheduledTasks.dialog.loopFile.note': '由循环文件 {file} 管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '启用状态由循环文件控制;请在 Markdown frontmatter 中设置 enabled', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '循环任务在其 .agents/loops Markdown 文件中配置', 'sessions.scheduledTasks.editor.title.edit': '编辑计划任务', 'sessions.scheduledTasks.editor.title.new': '新建计划任务', 'sessions.scheduledTasks.editor.description': '配置一个服务端任务,用于创建新会话并发送提示词。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 6641606b..7c98d160 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -282,6 +282,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '暫停 {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '已啟用', 'sessions.scheduledTasks.dialog.taskToggle.paused': '已暫停', + 'sessions.scheduledTasks.dialog.loopFile.note': '由迴圈檔案 {file} 管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '啟用狀態由迴圈檔案控制;請在 Markdown frontmatter 中設定 enabled', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '迴圈任務在其 .agents/loops Markdown 檔案中設定', 'sessions.scheduledTasks.editor.title.edit': '編輯排程任務', 'sessions.scheduledTasks.editor.title.new': '新增排程任務', 'sessions.scheduledTasks.editor.description': '設定一個伺服器端任務,用於建立新會話並傳送提示詞。', diff --git a/packages/ui/src/lib/scheduledTasksApi.ts b/packages/ui/src/lib/scheduledTasksApi.ts index 8b215d64..c7c4aeb1 100644 --- a/packages/ui/src/lib/scheduledTasksApi.ts +++ b/packages/ui/src/lib/scheduledTasksApi.ts @@ -6,6 +6,9 @@ export type ScheduledTask = { id: string; name: string; enabled: boolean; + /** Absolute path of the `.agents/loops/*.md` file driving this task, when + * any. Present only for loop-sourced tasks; unknown to older clients. */ + loopFile?: string; schedule: { kind: 'daily' | 'weekly' | 'once' | 'cron'; times?: string[]; diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index 54166695..ab92383e 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -2,7 +2,7 @@ import { DateTime, IANAZone } from 'luxon'; import parser from 'cron-parser'; const PROJECT_CONFIG_VERSION = 1; -const MAX_TASK_NAME_LENGTH = 80; +export const MAX_TASK_NAME_LENGTH = 80; const MAX_TASK_PROMPT_LENGTH = 20_000; const MAX_CRON_LENGTH = 200; const MAX_LAST_ERROR_LENGTH = 2_000; diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 5bdba849..4de46ab5 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -48,7 +48,7 @@ Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`): | Frontmatter | Task field | |---|---| -| `name` | `name` (required) | +| `name` | `name` (required, max 80 characters — longer names are rejected as malformed) | | `schedule` | `schedule.kind: "cron"` + `schedule.cron` (required, cron-only in the portable format) | | `enabled` | `enabled` (default `false` — a loop only runs when the file explicitly enables it; add `enabled: true` to activate) | | `model` | split on the first `/` into `execution.providerID` / `execution.modelID` (required) | @@ -97,7 +97,9 @@ project write lock on every `syncProject` when the project path is known: file remains authoritative: the next reconciliation re-applies the file's definition (including `enabled`). Use `enabled: false` in the file to disable. Deleting a loop-sourced task through the API is rejected with a 400 — - the loop file is the removal surface. + the loop file is the removal surface. The scheduled-tasks UI marks loop tasks + as file-managed and disables their edit/enable/delete actions for the same + reason; `run now` remains available. ## Public exports (runtime.js) diff --git a/packages/web/server/lib/scheduled-tasks/loops.js b/packages/web/server/lib/scheduled-tasks/loops.js index 1fd71be7..7d15e671 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.js +++ b/packages/web/server/lib/scheduled-tasks/loops.js @@ -41,6 +41,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js'; +import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js'; const LOOP_DIR_NAME = 'loops'; const USER_LOOP_ROOT = () => path.join(os.homedir(), '.agents', LOOP_DIR_NAME); @@ -94,6 +95,12 @@ export const parseLoopDefinition = (filePath) => { console.warn(`[loops] skipped ${filePath}: frontmatter "name" is required`); return null; } + if (name.length > MAX_TASK_NAME_LENGTH) { + // Reject instead of clamping: task names are clamped to this length at + // storage time, so identity keys must match the stored value exactly. + console.warn(`[loops] skipped ${filePath}: frontmatter "name" exceeds ${MAX_TASK_NAME_LENGTH} characters`); + return null; + } const cron = asNonEmptyString(frontmatter.schedule); if (!cron) { diff --git a/packages/web/server/lib/scheduled-tasks/loops.test.js b/packages/web/server/lib/scheduled-tasks/loops.test.js index 7281adda..e05ef980 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.test.js +++ b/packages/web/server/lib/scheduled-tasks/loops.test.js @@ -181,6 +181,33 @@ model: openai/gpt-5 await cleanup(); } }); + + it('rejects names longer than the storage limit', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const filePath = path.join(projectPath, 'long-name.md'); + await writeFile(filePath, `--- +name: ${'x'.repeat(81)} +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Run. +`, 'utf8'); + + // Task names are clamped to 80 chars at storage time; a raw name that + // exceeds it could never match the stored task, so the file is treated + // as malformed rather than creating an unreachable definition. + expect(parseLoopDefinition(filePath)).toBeNull(); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); }); describe('discoverLoops', () => { From 9b6b90504ceb3e9a5d9f3bd596be78c3551f37fc Mon Sep 17 00:00:00 2001 From: makeittech Date: Thu, 6 Aug 2026 09:49:31 +0300 Subject: [PATCH 17/18] fix(tasks): cover syncProject wiring and allow deleting orphans after file removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: - runtime.test.js: add syncProject wiring tests with a real temp-dir project and real project-config runtime — asserts reconcileLoopTasks is driven with the discovered loops when the project path is known (task created, nextRunAt computed) and that plain listing is used when the path cannot be resolved (reconcile not called). - service.js: DELETE on a loop-owned task is rejected with a 400 only while its loop file still exists on disk; once the file is gone the orphan task can be deleted directly instead of waiting for the next reconcile. Tests use real temp files for both branches. - DOCUMENTATION.md: delete semantics updated accordingly. - PR description refreshed for the final HEAD (test counts, reconciliation contract, evidence wording). --- .../lib/scheduled-tasks/DOCUMENTATION.md | 9 +- .../lib/scheduled-tasks/runtime.test.js | 101 +++++++++++++++++- .../web/server/lib/scheduled-tasks/service.js | 9 +- .../lib/scheduled-tasks/service.test.js | 58 +++++++--- 4 files changed, 156 insertions(+), 21 deletions(-) diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 4de46ab5..4be03824 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -96,10 +96,11 @@ project write lock on every `syncProject` when the project path is known: - **UI edits** to a loop-sourced task are preserved in the config but the loop file remains authoritative: the next reconciliation re-applies the file's definition (including `enabled`). Use `enabled: false` in the file to - disable. Deleting a loop-sourced task through the API is rejected with a 400 — - the loop file is the removal surface. The scheduled-tasks UI marks loop tasks - as file-managed and disables their edit/enable/delete actions for the same - reason; `run now` remains available. + disable. Deleting a loop-sourced task through the API is rejected with a 400 + while its loop file still exists on disk — the loop file is the removal + surface; once the file is gone, deleting the orphan task is allowed. The + scheduled-tasks UI marks loop tasks as file-managed and disables their + edit/enable/delete actions for the same reason; `run now` remains available. ## Public exports (runtime.js) diff --git a/packages/web/server/lib/scheduled-tasks/runtime.test.js b/packages/web/server/lib/scheduled-tasks/runtime.test.js index 7dafaca9..3a59b19f 100644 --- a/packages/web/server/lib/scheduled-tasks/runtime.test.js +++ b/packages/web/server/lib/scheduled-tasks/runtime.test.js @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vitest'; -import { computeNextRunAt, expandCommandGoalObjective, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js'; +import { describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; +import { + computeNextRunAt, + expandCommandGoalObjective, + formatScheduledSessionTitle, + parseScheduledCommandPrompt, + createScheduledTasksRuntime, +} from './runtime.js'; +import { createProjectConfigRuntime } from '../projects/project-config.js'; describe('scheduled-tasks runtime helpers', () => { it('computes next daily run in timezone', () => { @@ -109,3 +119,90 @@ describe('scheduled-tasks runtime helpers', () => { .toBe('Review the requested scope.\n\nauth module'); }); }); + +describe('scheduled-tasks runtime syncProject wiring', () => { + const createTempProject = async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-runtime-loop-')); + const repoPath = path.join(tempRoot, 'repo'); + await mkdir(path.join(repoPath, '.agents', 'loops'), { recursive: true }); + return { + tempRoot, + repoPath, + cleanup: async () => { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; + }; + + const createProjectConfig = async (tempRoot) => createProjectConfigRuntime({ + fsPromises: await import('fs/promises'), + path, + projectsDirPath: path.join(tempRoot, 'config'), + createTaskID: () => 'task-fixed-id', + }); + + const createRuntimeDeps = (overrides = {}) => ({ + buildOpenCodeUrl: () => 'http://localhost', + getOpenCodeAuthHeaders: () => ({}), + waitForOpenCodeReady: async () => {}, + ...overrides, + }); + + it('reconciles discovered loops when the project path is known', async () => { + const { tempRoot, repoPath, cleanup } = await createTempProject(); + try { + await writeFile(path.join(repoPath, '.agents', 'loops', 'daily.md'), `--- +name: daily +schedule: "0 9 * * *" +enabled: true +model: openai/gpt-5 +--- +Run daily. +`, 'utf8'); + + const projectConfigRuntime = await createProjectConfig(tempRoot); + const runtime = createScheduledTasksRuntime({ + ...createRuntimeDeps(), + projectConfigRuntime, + listProjects: async () => [{ id: 'proj', path: repoPath }], + }); + + await runtime.syncProject('proj'); + + const tasks = await projectConfigRuntime.listScheduledTasks('proj'); + expect(tasks).toHaveLength(1); + expect(tasks[0].id).toBe('loop:project:daily'); + expect(tasks[0].loopFile).toBe(path.join(repoPath, '.agents', 'loops', 'daily.md')); + // syncTaskSchedule computed and persisted the next run for the enabled task. + expect(tasks[0].state.nextRunAt).toBeGreaterThan(0); + } finally { + await cleanup(); + } + }); + + it('falls back to plain listing when the project path cannot be resolved', async () => { + const { tempRoot, cleanup } = await createTempProject(); + try { + const projectConfigRuntime = await createProjectConfig(tempRoot); + const reconcileSpy = vi.spyOn(projectConfigRuntime, 'reconcileLoopTasks'); + const listSpy = vi.spyOn(projectConfigRuntime, 'listScheduledTasks'); + + const runtime = createScheduledTasksRuntime({ + ...createRuntimeDeps(), + projectConfigRuntime, + // Project not registered -> ensureProjectPath cannot resolve a path. + listProjects: async () => [], + }); + + await runtime.syncProject('proj'); + + expect(reconcileSpy).not.toHaveBeenCalled(); + expect(listSpy).toHaveBeenCalledWith('proj'); + expect(await projectConfigRuntime.listScheduledTasks('proj')).toEqual([]); + reconcileSpy.mockRestore(); + listSpy.mockRestore(); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/web/server/lib/scheduled-tasks/service.js b/packages/web/server/lib/scheduled-tasks/service.js index 33d69f7d..4d94251a 100644 --- a/packages/web/server/lib/scheduled-tasks/service.js +++ b/packages/web/server/lib/scheduled-tasks/service.js @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import { OpenChamberControlError } from '../openchamber-control/error.js'; @@ -80,10 +81,12 @@ export const createScheduledTaskService = (dependencies) => { if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400); const current = await projectConfigRuntime.listScheduledTasks(projectID); const existing = current.find((task) => task.id === normalizedTaskID) || null; - if (existing?.loopFile) { + if (existing?.loopFile && fs.existsSync(existing.loopFile)) { // Loop tasks are owned by their `.agents/loops` markdown file: deleting - // the JSON row would be silently undone by the next reconcile. The file - // itself is the removal surface. + // the JSON row would be silently undone by the next reconcile while the + // file exists. The file itself is the removal surface. Once the file is + // gone (the task is an orphan that the next sync would remove anyway), + // deleting the row is safe and allowed. throw new OpenChamberControlError( 'Loop task is managed by its .agents/loops markdown file; delete the file to remove the task', 400, diff --git a/packages/web/server/lib/scheduled-tasks/service.test.js b/packages/web/server/lib/scheduled-tasks/service.test.js index 07d6bf70..a90c1b64 100644 --- a/packages/web/server/lib/scheduled-tasks/service.test.js +++ b/packages/web/server/lib/scheduled-tasks/service.test.js @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; import { createScheduledTaskService } from './service.js'; const createService = (overrides = {}) => { @@ -32,19 +35,50 @@ const loopTask = { }; describe('scheduled-task service remove', () => { - it('rejects deleting a loop-sourced task without touching storage', async () => { - const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ - projectConfigRuntime: { - listScheduledTasks: vi.fn(async () => [loopTask]), - }, - }); + it('rejects deleting a loop-sourced task while its loop file still exists', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-')); + try { + const loopFilePath = path.join(tempRoot, 'daily.md'); + await writeFile(loopFilePath, '---\nname: daily-digest\n---\nRun.\n', 'utf8'); - await expect(service.remove('project-test', loopTask.id)).rejects.toMatchObject({ - statusCode: 400, - message: expect.stringContaining('delete the file to remove the task'), - }); - expect(projectConfigRuntime.deleteScheduledTask).not.toHaveBeenCalled(); - expect(scheduledTasksRuntime.syncProject).not.toHaveBeenCalled(); + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]), + }, + }); + + await expect(service.remove('project-test', loopTask.id)).rejects.toMatchObject({ + statusCode: 400, + message: expect.stringContaining('delete the file to remove the task'), + }); + expect(projectConfigRuntime.deleteScheduledTask).not.toHaveBeenCalled(); + expect(scheduledTasksRuntime.syncProject).not.toHaveBeenCalled(); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('allows deleting a loop-sourced task once its loop file is gone', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-')); + try { + // The loop file was removed from disk; the orphan task is allowed to be + // deleted directly instead of waiting for the next reconcile. + const loopFilePath = path.join(tempRoot, 'gone.md'); + + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]), + }, + }); + + const tasks = await service.remove('project-test', loopTask.id); + + expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', loopTask.id); + expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled(); + expect(Array.isArray(tasks)).toBe(true); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } }); it('deletes JSON-configured tasks normally', async () => { From 0a4fd7c5fb93a2e082555956e0f535d252abfd47 Mon Sep 17 00:00:00 2001 From: makeittech Date: Thu, 6 Aug 2026 09:56:11 +0300 Subject: [PATCH 18/18] docs(tasks): add loops quick-start to the scheduled-tasks page User-facing onboarding for markdown loop tasks: where .agents/loops files live (project + user scope), a copy-paste sample file, the frontmatter field table, and the behavior contract (file authoritative, off by default, rename/malformed semantics, run-now still available). Also lists the cron schedule type in the UI task creation steps, which the page previously omitted. --- .../docs/content/docs/scheduled-tasks.mdx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/docs/content/docs/scheduled-tasks.mdx b/packages/docs/content/docs/scheduled-tasks.mdx index e7753190..4f5a6554 100644 --- a/packages/docs/content/docs/scheduled-tasks.mdx +++ b/packages/docs/content/docs/scheduled-tasks.mdx @@ -15,6 +15,7 @@ A scheduled task runs a prompt for you on a schedule — for example, a daily "s - **daily** — at one or more times each day - **weekly** — on chosen weekdays and times - **once** — a single date and time + - **cron** — an arbitrary cron expression 4. Set what it does: the prompt to send, and the provider, model, and agent to use. The prompt can be a slash command, like `/review`. 5. Save, and make sure the task is enabled. @@ -22,6 +23,48 @@ You can run any task immediately with **run now** to check it does what you expe Check **Run as goal** to make the run pursue its prompt to completion instead of stopping after one reply — see [Session Goals](/session-goals/). +## Loops: scheduled tasks as markdown files + +A **loop** is a scheduled task defined as a portable markdown file you can commit to your repo. Drop a file into `.agents/loops/` and the task appears on the next sync — no dialog needed: + +```markdown +--- +name: daily-digest +schedule: "0 9 * * *" +enabled: true +model: anthropic/claude-sonnet-4-5 +agent: plan +timezone: Europe/Kyiv +--- +Summarize repository changes since yesterday and post the digest. +``` + +### Where files live + +- **Project scope** — `.agents/loops/*.md` in the project directory or any ancestor directory up to the git worktree root. +- **User scope** — `~/.agents/loops/*.md` applies to every project you open. + +If a project loop and a user loop share a name, the project loop wins. + +### Fields + +| Field | Meaning | +|---|---| +| `name` | Task name (required, max 80 characters). | +| `schedule` | Cron expression (required) — loop files are cron-only. | +| `enabled` | Set `true` to run. Loops are **off by default**, so committing a file never starts running a task on its own. | +| `model` | `provider/model` (required), e.g. `anthropic/claude-sonnet-4-5`. | +| `agent` | Agent to use (optional). | +| `timezone` | IANA timezone (optional, defaults to the server zone). | +| body | The execution prompt (required). Can be a slash command, like `/review src/`. | + +### How loops behave + +- The **file is authoritative** while it exists: edits made in the UI are reverted on the next sync. The scheduled-tasks dialog marks loop tasks and disables their edit/enable/delete actions — **run now** still works. To stop a loop, delete the file (or set `enabled: false`). +- Runtime state (last run, next run, status) lives in the project config and is never written back into the markdown file. +- Renaming the `name` field renames the task in place. If a loop file temporarily fails to parse (mid-edit, merge conflict), its task is kept with the last good definition until the file is fixed. +- `daily`/`weekly`/`once` schedules and goal settings remain UI-only; loop files are always cron. + ## What success looks like After a run, the task shows when it last ran, whether it succeeded, and a link to the session it created. If a run fails, the error is shown there too.