Merge branch 'main' into feat/gh-2634-pending-question

This commit is contained in:
Serhii Dziupin
2026-08-06 13:04:16 +03:00
58 changed files with 3797 additions and 57 deletions
@@ -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.
@@ -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<ChatMessageProps> = ({
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'));
@@ -45,6 +45,7 @@ import {
type EmbeddedSessionRuntimeBootstrap,
} from './contextPanelEmbeddedChat';
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
import {
type PreviewElementMetadata,
isPreviewElementMetadata,
@@ -2453,6 +2454,13 @@ export const ContextPanel: React.FC = () => {
return;
}
// Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode).
// ghostty-web listens in the bubble phase; stopping capture here would
// swallow the key before the terminal ever sees it (issue #2644).
if (isTerminalEventTarget(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
handleClose();
@@ -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<RailItemProps> = ({
surface,
isActive,
showActivityDot,
label,
description,
badgeCount,
badgeAriaLabel,
badgeDescription,
orderNumber,
showOrderNumber,
onSelect,
@@ -68,6 +81,8 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
id: surface.id,
});
const displayBadgeCount = badgeCount != null && badgeCount > 0 ? formatRailBadgeCount(badgeCount) : null;
return (
<div
ref={setNodeRef}
@@ -81,7 +96,7 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
{...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<RailItemProps> = ({
) : (
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
)}
{showActivityDot && !showOrderNumber ? (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
/>
) : null}
{showOrderNumber && orderNumber != null ? (
<span
aria-hidden="true"
@@ -108,6 +117,18 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
>
{orderNumber === 10 ? '0' : orderNumber}
</span>
) : displayBadgeCount ? (
<span
aria-hidden="true"
className="absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-muted px-1 text-[0.625rem] font-medium leading-none text-muted-foreground"
>
{displayBadgeCount}
</span>
) : showActivityDot ? (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
/>
) : null}
</button>
</TooltipTrigger>
@@ -115,6 +136,9 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
<div className="flex flex-col gap-0.5">
<span>{label}</span>
<span className="typography-micro text-muted-foreground">{description}</span>
{badgeDescription ? (
<span className="typography-micro text-muted-foreground">{badgeDescription}</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
@@ -258,19 +282,43 @@ export const ContextPanelRail: React.FC = () => {
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={surfaces.map((surface) => surface.id)} strategy={verticalListSortingStrategy}>
{surfaces.map((surface, index) => (
<ContextPanelRailItem
key={surface.id}
surface={surface}
isActive={activeMode === surface.mode}
showActivityDot={surface.id === 'git' && changedFilesCount > 0}
label={t(surface.labelKey)}
description={t(surface.descriptionKey)}
orderNumber={index + 1}
showOrderNumber={revealNumbers}
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
/>
))}
{surfaces.map((surface, index) => {
const label = t(surface.labelKey);
// Git shows a numeric badge instead of the old activity dot.
// Other surfaces never inherit git's changed-files signal.
const gitChangedCount = surface.id === 'git' ? changedFilesCount : 0;
const badgeCount = gitChangedCount > 0 ? gitChangedCount : null;
return (
<ContextPanelRailItem
key={surface.id}
surface={surface}
isActive={activeMode === surface.mode}
showActivityDot={false}
label={label}
description={t(surface.descriptionKey)}
badgeCount={badgeCount}
badgeAriaLabel={badgeCount !== null
? t(
badgeCount === 1
? 'contextRail.surface.git.changesCountAriaSingle'
: 'contextRail.surface.git.changesCountAriaPlural',
{ label, count: badgeCount },
)
: null}
badgeDescription={badgeCount !== null
? t(
badgeCount === 1
? 'contextRail.surface.git.changesCountTooltipSingle'
: 'contextRail.surface.git.changesCountTooltipPlural',
{ count: badgeCount },
)
: null}
orderNumber={index + 1}
showOrderNumber={revealNumbers}
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
/>
);
})}
</SortableContext>
</DndContext>
</nav>
@@ -0,0 +1,187 @@
/**
* Regression guard for https://github.com/openchamber/openchamber/issues/2644
*
* Escape while focus is inside the terminal must reach the PTY (e.g. Vim
* Normal mode). The context panel still closes on Escape when focus is on
* non-terminal panel chrome.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
const mobileWorkspaceDrawerSource = readFileSync(
join(__dirname, '..', '..', '..', 'apps', 'MobileWorkspaceDrawer.tsx'),
'utf-8',
);
describe('issue #2644: Escape in terminal must not close the context panel', () => {
test('the context panel captures Escape at the panel level', () => {
expect(contextPanelSource).toContain('onKeyDownCapture={handlePanelKeyDownCapture}');
});
test('the capture handler skips closing when the event target is inside the terminal', () => {
const start = contextPanelSource.indexOf('const handlePanelKeyDownCapture = React.useCallback(');
expect(start).toBeGreaterThan(-1);
const end = contextPanelSource.indexOf('}, [handleClose]);', start);
expect(end).toBeGreaterThan(start);
const handler = contextPanelSource.slice(start, end);
expect(handler).toContain("event.key !== 'Escape'");
expect(handler).toContain('isTerminalEventTarget(event.target)');
expect(handler).toContain('event.preventDefault()');
expect(handler).toContain('event.stopPropagation()');
expect(handler).toContain('handleClose()');
// Guard must return before preventDefault/stopPropagation so ghostty-web's
// bubble-phase keydown listener can forward Escape to the PTY.
const guardIndex = handler.indexOf('isTerminalEventTarget(event.target)');
const preventIndex = handler.indexOf('event.preventDefault()');
expect(guardIndex).toBeGreaterThan(-1);
expect(preventIndex).toBeGreaterThan(guardIndex);
});
test('ContextPanel imports the shared terminal focus helper', () => {
expect(contextPanelSource).toContain("from '@/lib/terminalFocus'");
expect(contextPanelSource).toContain('isTerminalEventTarget');
});
test('mobile drawer keeps its terminal Escape exception', () => {
const handlerStart = mobileWorkspaceDrawerSource.indexOf("if (event.key === 'Escape'");
expect(handlerStart).toBeGreaterThan(-1);
const handler = mobileWorkspaceDrawerSource.slice(handlerStart, handlerStart + 200);
expect(handler).toContain("tabRef.current !== 'terminal'");
});
});
type Listener = { capture: boolean; onEvent: (event: SimulatedEvent) => void };
type SimulatedEvent = {
type: string;
defaultPrevented: boolean;
propagationStopped: boolean;
target: SimNode;
preventDefault(): void;
stopPropagation(): void;
};
class SimNode {
readonly children: SimNode[] = [];
private listeners: Listener[] = [];
private parent: SimNode | null = null;
addListener(listener: Listener): void {
this.listeners.push(listener);
}
attach(child: SimNode): void {
child.parent = this;
this.children.push(child);
}
dispatch(type: string): SimulatedEvent {
const buildPath = (target: SimNode): SimNode[] => {
const ancestors: SimNode[] = [];
let cursor: SimNode | null = target;
while (cursor !== null) {
ancestors.push(cursor);
cursor = cursor.parent;
}
ancestors.reverse();
return ancestors;
};
const path = buildPath(this);
const event: SimulatedEvent = {
type,
defaultPrevented: false,
propagationStopped: false,
target: this,
preventDefault() {
event.defaultPrevented = true;
},
stopPropagation() {
event.propagationStopped = true;
},
};
for (let i = 0; i < path.length; i += 1) {
if (event.propagationStopped) return event;
for (const listener of path[i].listeners) {
if (!listener.capture) continue;
listener.onEvent(event);
if (event.propagationStopped) return event;
}
}
for (let i = path.length - 1; i >= 0; i -= 1) {
if (event.propagationStopped) return event;
for (const listener of path[i].listeners) {
if (listener.capture) continue;
listener.onEvent(event);
if (event.propagationStopped) return event;
}
}
return event;
}
}
describe('issue #2644: fixed Escape propagation to the terminal', () => {
test('when the panel skips terminal Escape, the terminal bubble handler receives it', () => {
const panel = new SimNode();
const terminalContainer = new SimNode();
panel.attach(terminalContainer);
const calls: string[] = [];
const panelEscapeHandler = (event: SimulatedEvent) => {
// Fixed behavior: do not close / stop when the target is the terminal.
if (event.target === terminalContainer) {
calls.push('panel-capture-skipped');
return;
}
calls.push('panel-capture-closed');
event.preventDefault();
event.stopPropagation();
};
const terminalKeydownHandler = () => {
calls.push('terminal-bubble');
};
panel.addListener({ capture: true, onEvent: panelEscapeHandler });
terminalContainer.addListener({ capture: false, onEvent: terminalKeydownHandler });
const event = terminalContainer.dispatch('keydown');
expect(calls).toEqual(['panel-capture-skipped', 'terminal-bubble']);
expect(event.propagationStopped).toBe(false);
expect(event.defaultPrevented).toBe(false);
});
test('Escape outside the terminal still closes via the capture handler', () => {
const panel = new SimNode();
const headerButton = new SimNode();
const terminalContainer = new SimNode();
panel.attach(headerButton);
panel.attach(terminalContainer);
const calls: string[] = [];
panel.addListener({
capture: true,
onEvent: (event) => {
if (event.target === terminalContainer) return;
calls.push('panel-capture-closed');
event.preventDefault();
event.stopPropagation();
},
});
terminalContainer.addListener({
capture: false,
onEvent: () => calls.push('terminal-bubble'),
});
const event = headerButton.dispatch('keydown');
expect(calls).toEqual(['panel-capture-closed']);
expect(event.propagationStopped).toBe(true);
expect(event.defaultPrevented).toBe(true);
});
});
@@ -463,6 +463,14 @@ export function ScheduledTasksDialog() {
<div className="typography-micro truncate text-muted-foreground">
{formatSchedule(task, t)}
</div>
{task.loopFile ? (
<div
className="typography-micro truncate text-muted-foreground/70"
title={task.loopFile}
>
{t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })}
</div>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
@@ -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}
>
<Checkbox
checked={task.enabled}
@@ -534,7 +545,7 @@ export function ScheduledTasksDialog() {
ariaLabel={task.enabled
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
disabled={isBusy}
disabled={isBusy || Boolean(task.loopFile)}
/>
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
</label>
@@ -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 })}
>
<Icon name="edit-2" className="h-4 w-4" /> {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 })}
>
<Icon name="delete-bin" className="h-4 w-4" />
+7
View File
@@ -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.',
@@ -2830,6 +2833,10 @@ export const dict = {
'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.',
'contextRail.surface.editor.description': 'Bearbeitungskontext',
'contextRail.surface.git.description': 'Git-Kontext',
'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} geänderte Datei',
'contextRail.surface.git.changesCountAriaPlural': '{label}, {count} geänderte Dateien',
'contextRail.surface.git.changesCountTooltipSingle': '{count} geänderte Datei',
'contextRail.surface.git.changesCountTooltipPlural': '{count} geänderte Dateien',
'contextRail.surface.terminal.description': 'Terminal-Kontext',
'contextRail.surface.diff.description': 'Diff-Kontext',
'contextPanel.mode.walkthrough': 'Walkthrough',
+7
View File
@@ -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.',
@@ -1108,6 +1111,10 @@ export const dict = {
'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.',
'contextRail.surface.editor.description': 'Edit project files',
'contextRail.surface.git.description': 'Commits, branches, and pull requests',
'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} changed file',
'contextRail.surface.git.changesCountAriaPlural': '{label}, {count} changed files',
'contextRail.surface.git.changesCountTooltipSingle': '{count} changed file',
'contextRail.surface.git.changesCountTooltipPlural': '{count} changed files',
'contextRail.surface.terminal.description': 'Built-in terminal',
'contextRail.surface.diff.description': 'Review working changes',
'contextPanel.mode.walkthrough': 'Walkthrough',
+7
View File
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
"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.",
@@ -1109,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.",
"contextRail.surface.editor.description": "Editar archivos del proyecto",
"contextRail.surface.git.description": "Commits, ramas y pull requests",
"contextRail.surface.git.changesCountAriaSingle": "{label}, {count} archivo modificado",
"contextRail.surface.git.changesCountAriaPlural": "{label}, {count} archivos modificados",
"contextRail.surface.git.changesCountTooltipSingle": "{count} archivo modificado",
"contextRail.surface.git.changesCountTooltipPlural": "{count} archivos modificados",
"contextRail.surface.terminal.description": "Terminal integrada",
"contextRail.surface.diff.description": "Revisar cambios en curso",
"contextPanel.mode.walkthrough": "Recorrido",
+7
View File
@@ -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.',
@@ -933,6 +936,10 @@ export const dict = {
'contextPanel.editorEmpty.description': 'Choisissez un fichier dans larborescence pour commencer.',
'contextRail.surface.editor.description': 'Modifier les fichiers du projet',
'contextRail.surface.git.description': 'Commits, branches et pull requests',
'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} fichier modifié',
'contextRail.surface.git.changesCountAriaPlural': '{label}, {count} fichiers modifiés',
'contextRail.surface.git.changesCountTooltipSingle': '{count} fichier modifié',
'contextRail.surface.git.changesCountTooltipPlural': '{count} fichiers modifiés',
'contextRail.surface.terminal.description': 'Terminal intégré',
'contextRail.surface.diff.description': 'Passer en revue les modifications',
'contextPanel.mode.walkthrough': 'Parcours',
+7
View File
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
'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': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。',
@@ -1105,6 +1108,10 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。',
'contextRail.surface.editor.description': 'プロジェクトのファイルを編集',
'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト',
'contextRail.surface.git.changesCountAriaSingle': '{label}、変更ファイル{count}件',
'contextRail.surface.git.changesCountAriaPlural': '{label}、変更ファイル{count}件',
'contextRail.surface.git.changesCountTooltipSingle': '変更ファイル{count}件',
'contextRail.surface.git.changesCountTooltipPlural': '変更ファイル{count}件',
'contextRail.surface.terminal.description': '内蔵ターミナル',
'contextRail.surface.diff.description': '作業中の変更をレビュー',
'contextPanel.mode.walkthrough': 'ウォークスルー',
+7
View File
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
'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': '새 세션을 만들고 프롬프트를 보내는 서버 작업을 설정합니다.',
@@ -1109,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.',
'contextRail.surface.editor.description': '프로젝트 파일 편집',
'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트',
'contextRail.surface.git.changesCountAriaSingle': '{label}, 변경된 파일 {count}개',
'contextRail.surface.git.changesCountAriaPlural': '{label}, 변경된 파일 {count}개',
'contextRail.surface.git.changesCountTooltipSingle': '변경된 파일 {count}개',
'contextRail.surface.git.changesCountTooltipPlural': '변경된 파일 {count}개',
'contextRail.surface.terminal.description': '내장 터미널',
'contextRail.surface.diff.description': '작업 중인 변경 사항 검토',
'contextPanel.mode.walkthrough': '워크스루',
+7
View File
@@ -396,6 +396,9 @@ export const dict: Record<I18nKey, string> = {
'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.',
@@ -1421,6 +1424,10 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.',
'contextRail.surface.editor.description': 'Edytuj pliki projektu',
'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty',
'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} zmieniony plik',
'contextRail.surface.git.changesCountAriaPlural': '{label}, {count} zmienionych plików',
'contextRail.surface.git.changesCountTooltipSingle': '{count} zmieniony plik',
'contextRail.surface.git.changesCountTooltipPlural': '{count} zmienionych plików',
'contextRail.surface.terminal.description': 'Wbudowany terminal',
'contextRail.surface.diff.description': 'Przeglądaj bieżące zmiany',
'contextPanel.mode.walkthrough': 'Przewodnik',
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
"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.",
@@ -1109,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.",
"contextRail.surface.editor.description": "Editar arquivos do projeto",
"contextRail.surface.git.description": "Commits, branches e pull requests",
"contextRail.surface.git.changesCountAriaSingle": "{label}, {count} arquivo modificado",
"contextRail.surface.git.changesCountAriaPlural": "{label}, {count} arquivos modificados",
"contextRail.surface.git.changesCountTooltipSingle": "{count} arquivo modificado",
"contextRail.surface.git.changesCountTooltipPlural": "{count} arquivos modificados",
"contextRail.surface.terminal.description": "Terminal integrado",
"contextRail.surface.diff.description": "Revisar alterações em andamento",
"contextPanel.mode.walkthrough": "Percurso",
+7
View File
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
"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": "Налаштувати завдання на стороні сервера, яке створює нову сесію і надсилає запит.",
@@ -1109,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.",
"contextRail.surface.editor.description": "Редагування файлів проєкту",
"contextRail.surface.git.description": "Коміти, гілки та pull request-и",
"contextRail.surface.git.changesCountAriaSingle": "{label}, {count} змінений файл",
"contextRail.surface.git.changesCountAriaPlural": "{label}, {count} змінених файлів",
"contextRail.surface.git.changesCountTooltipSingle": "{count} змінений файл",
"contextRail.surface.git.changesCountTooltipPlural": "{count} змінених файлів",
"contextRail.surface.terminal.description": "Вбудований термінал",
"contextRail.surface.diff.description": "Перегляд поточних змін",
"contextPanel.mode.walkthrough": "Розбір",
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
'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': '配置一个服务端任务,用于创建新会话并发送提示词。',
@@ -1109,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.editorEmpty.description': '从文件树中选择一个文件开始编辑。',
'contextRail.surface.editor.description': '编辑项目文件',
'contextRail.surface.git.description': '提交、分支和拉取请求',
'contextRail.surface.git.changesCountAriaSingle': '{label}{count} 个更改的文件',
'contextRail.surface.git.changesCountAriaPlural': '{label}{count} 个更改的文件',
'contextRail.surface.git.changesCountTooltipSingle': '{count} 个更改的文件',
'contextRail.surface.git.changesCountTooltipPlural': '{count} 个更改的文件',
'contextRail.surface.terminal.description': '内置终端',
'contextRail.surface.diff.description': '查看工作区更改',
'contextPanel.mode.walkthrough': '导读',
@@ -282,6 +282,9 @@ export const dict: Record<I18nKey, string> = {
'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': '設定一個伺服器端任務,用於建立新會話並傳送提示詞。',
@@ -1121,6 +1124,10 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.editorEmpty.description': '從檔案樹選擇檔案開始編輯。',
'contextRail.surface.editor.description': '編輯專案檔案',
'contextRail.surface.git.description': '提交、分支與拉取請求',
'contextRail.surface.git.changesCountAriaSingle': '{label}{count} 個變更的檔案',
'contextRail.surface.git.changesCountAriaPlural': '{label}{count} 個變更的檔案',
'contextRail.surface.git.changesCountTooltipSingle': '{count} 個變更的檔案',
'contextRail.surface.git.changesCountTooltipPlural': '{count} 個變更的檔案',
'contextRail.surface.terminal.description': '內建終端機',
'contextRail.surface.diff.description': '檢視工作區變更',
'contextPanel.mode.walkthrough': '導讀',
+3
View File
@@ -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[];
@@ -29,6 +29,20 @@ describe('useFilesViewTabsStore', () => {
expect(useFilesViewTabsStore.getState().byRoot[root]?.expandedPaths).toEqual(['/repo/src']);
});
test('rejects realpath children of workspace symlinks (issue 2627)', () => {
const root = '/workspace';
const store = useFilesViewTabsStore.getState();
store.toggleExpandedPath(root, '/workspace/pkg');
store.toggleExpandedPath(root, '/real/pkg/src');
store.toggleExpandedPath(root, '/workspace/pkg/src');
expect(useFilesViewTabsStore.getState().byRoot[root]?.expandedPaths).toEqual([
'/workspace/pkg',
'/workspace/pkg/src',
]);
});
test('removes stale expanded paths by prefix without closing files', () => {
const root = '/repo';
const store = useFilesViewTabsStore.getState();
+6
View File
@@ -204,6 +204,8 @@ Incomplete-session materialization is deduplicated by runtime, directory, and se
When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status.
When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with active tool parts and no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the parts, see openchamber#2577 / anomalyco/opencode#19023). The active parts are finalized locally as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event or refresh supersedes it while a stale `running` refresh cannot regress it.
Directory stores also own session-keyed sidecar notification channels for permissions, questions, and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission and question rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify.
@@ -255,6 +257,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`
@@ -0,0 +1,152 @@
/**
* Tests for interrupted-turn reconciliation (#2577): when a managed OpenCode
* process dies mid-turn, the persisted turn never settles the trailing
* assistant message has no time.completed and its tool parts stay running.
* Once the session is authoritatively settled, `interruptedTurnToolParts`
* finalizes the orphaned parts locally.
*/
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { interruptedTurnToolParts } from "../sync-context"
import type { DirectoryStore } from "../child-store"
import { INITIAL_STATE } from "../types"
function state(overrides: Partial<DirectoryStore> = {}): DirectoryStore {
return {
...INITIAL_STATE,
session_status: {},
message: {},
part: {},
question: {},
permission: {},
...overrides,
} as unknown as DirectoryStore
}
function runningTool(id: string, messageID: string, start = 1000): Part {
return {
id,
messageID,
sessionID: "ses_1",
type: "tool",
tool: "bash",
state: { status: "running", time: { start }, input: {} },
} as unknown as Part
}
function completedTool(id: string, messageID: string): Part {
return {
id,
messageID,
sessionID: "ses_1",
type: "tool",
tool: "bash",
state: { status: "completed", time: { start: 1000, end: 2000 }, input: {} },
} as unknown as Part
}
function pendingTool(id: string, messageID: string): Part {
return {
id,
messageID,
sessionID: "ses_1",
type: "tool",
tool: "bash",
state: { status: "pending", time: { start: 1000 }, input: {} },
} as unknown as Part
}
function unfinishedAssistantMessage(id: string): Message {
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10 } } as unknown as Message
}
function finishedAssistantMessage(id: string): Message {
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10, completed: 2000 } } as unknown as Message
}
describe("interruptedTurnToolParts (#2577)", () => {
test("settled session with unfinished message and running tool finalizes the part", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
const part = result!.parts[0] as { state: { status: string; error: string; time: { end: number } } }
expect(part.state.status).toBe("error")
expect(part.state.error).toBe("Interrupted")
expect(part.state.time.end).toBe(5000)
})
test("busy session is never marked (live work)", () => {
const store = state({
session_status: { ses_1: { type: "busy" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("absent status is unknown, not settled — never marked", () => {
const store = state({
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("finished message is not an interruption (tail refresh reconciles it)", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [finishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("pending question means the turn is waiting for input, not interrupted", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
question: { ses_1: [{ id: "q_1", sessionID: "ses_1", questions: [{ question: "?", header: "h", options: [{ label: "a", description: "" }] }] }] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("pending permission means the turn is waiting for input, not interrupted", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
permission: { ses_1: [{ id: "p_1", sessionID: "ses_1", permission: "bash", patterns: [], metadata: {}, always: [] }] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("only active parts are finalized; completed parts are untouched", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: {
msg_1: [runningTool("tool_1", "msg_1"), completedTool("tool_2", "msg_1"), pendingTool("tool_3", "msg_1")],
},
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
const statuses = result!.parts.map((part) => (part as { state: { status: string } }).state.status)
expect(statuses).toEqual(["error", "completed", "error"])
})
test("no active parts → no change", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [completedTool("tool_2", "msg_1")] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
})
@@ -167,6 +167,39 @@ describe("materializeSessionSnapshots", () => {
expect(mergedPart.state?.time?.end).toBe(2000)
})
test("does not regress a locally interrupted tool (error + end) when a stale running snapshot arrives", () => {
// The #2577 mark writes status "error" + end time; a later stale refresh
// that still reports the part as running must not undo it.
const interruptedTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "error", error: "Interrupted", time: { start: 1000, end: 5000 } },
} as unknown as Part
const staleRunningTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "running", time: { start: 1000 } },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [interruptedTool] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [staleRunningTool] }],
)
expect(result.part.msg_1[0]).toBe(interruptedTool)
expect(result.part.msg_1[0]).not.toBe(staleRunningTool)
expect((result.part.msg_1[0] as { state: { status: string } }).state.status).toBe("error")
})
test("does not regress a completed tool when a stale running snapshot arrives", () => {
const completedTool = {
id: "prt_1",
@@ -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,
+87 -4
View File
@@ -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<Record<string, unknown>>): 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<Array<{ id: string }> | undefined>) {
if (requests?.some((request) => request.id === requestId)) {
return directory
}
for (const requests of Object.values(requestMap) as Array<Array<{ id: string; sessionID?: string }> | 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
}
+106
View File
@@ -629,6 +629,19 @@ async function resyncDirectorySessionStatuses(
applySessionStatusSnapshot(store, nextStatuses, candidateSessionIds, mode)
if (mode === "authoritative") {
applyGlobalSessionStatusSnapshot(directory, nextStatuses, candidateSessionIds)
// An authoritative snapshot that settles sessions previously observed
// busy/retry can orphan running tool parts (managed process died
// mid-turn, #2577): finalize them now. The snapshot write above already
// lowered their status to explicit idle, which is the gate the helper
// requires — a session the snapshot reports busy stays untouched.
for (const sessionId of candidateSessionIds) {
const interrupted = interruptedTurnToolParts(store.getState(), sessionId)
if (interrupted) {
store.setState((state) => ({
part: { ...state.part, [interrupted.messageID]: interrupted.parts },
}))
}
}
}
return nextStatuses
}
@@ -1765,11 +1778,98 @@ function handleEvent(
messageID,
})
}
// The reducer already wrote the idle/error status into `draft`; mark the
// orphaned tools using the batched state and publish through the batch.
if (sessionID) {
const interrupted = interruptedTurnToolParts(state, sessionID)
if (interrupted) {
cloneField("part", (value) => ({ ...(value ?? {}) }))
;(draft as DirectoryStore).part[interrupted.messageID] = interrupted.parts
if (batch) {
batch.states.set(store, draft as DirectoryStore)
batch.changedStores.add(store)
} else {
store.setState({ part: { ...(store.getState().part), [interrupted.messageID]: interrupted.parts } })
}
}
}
}
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
}
// ---------------------------------------------------------------------------
// Interrupted-turn reconciliation
//
// A managed OpenCode process can die mid-turn (crash, health-check restart).
// The persisted turn then never settles: the trailing assistant message has
// no `time.completed` and its tool parts stay `pending`/`running` forever —
// the server never finalizes them (anomalyco/opencode#19023). The
// settle-triggered tail refresh above refetches the same stale records, so
// the UI would keep running tool timers and "working" styling indefinitely
// (#2577).
//
// OpenCode keeps a turn's session busy while it is genuinely alive —
// including while waiting for a question/permission reply — so once a
// session is AUTHORITATIVELY settled (a `session.idle`/`session.error`
// event, or an authoritative status snapshot that lowers a previously busy
// session) and the trailing assistant message is still unfinished with
// active tool parts and no pending question/permission, the turn is
// definitively interrupted. Finalize the orphaned parts locally as
// `error`/`Interrupted` with an end time — the same shape OpenCode itself
// writes for cancelled tools. A later terminal part event or a refresh that
// carries the true terminal state supersedes the mark; a stale refresh that
// still reports `running` is rejected by the reducer's and the materializer's
// final-status preservation.
export function interruptedTurnToolParts(
state: DirectoryStore,
sessionID: string,
now = Date.now(),
): { messageID: string; parts: Part[] } | null {
if ((state.question?.[sessionID] ?? []).length > 0) return null
if ((state.permission?.[sessionID] ?? []).length > 0) return null
const status = state.session_status?.[sessionID]
if (!status || status.type !== "idle") {
// Absent status is "unknown", not settled (the reducer maps both
// session.idle and session.error to {type:"idle"}): never judge an
// interrupted turn without an authoritative settle signal.
return null
}
const messageID = getStaleRunningToolMessageID(state, sessionID)
if (!messageID) return null
const message = (state.message[sessionID] ?? []).find((candidate) => candidate.id === messageID)
if (!message) return null
if (typeof (message as { time?: { completed?: unknown } }).time?.completed === "number") {
// The turn finished; a missed terminal tool event is the tail refresh's
// job, not an interruption.
return null
}
const current = state.part[messageID]
if (!current) return null
let changed = false
const nextParts = current.map((part) => {
if (part.type !== "tool") return part
const partState = (part as { state?: { status?: unknown; time?: { start?: number } } }).state
if (!partState) return part
if (partState.status !== "pending" && partState.status !== "running") return part
changed = true
return {
...part,
state: {
...partState,
status: "error",
error: "Interrupted",
time: { ...(partState.time ?? {}), end: now },
},
} as Part
})
return changed ? { messageID, parts: nextParts } : null
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
@@ -2267,6 +2367,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) {
+1 -1
View File
@@ -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 |
+4
View File
@@ -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,
+39
View File
@@ -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;
+2 -2
View File
@@ -593,7 +593,7 @@ OPTIONS:
--lan Bind to 0.0.0.0 for LAN access
--server <url> 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)
+30
View File
@@ -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,
};
+33 -5
View File
@@ -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');
+13
View File
@@ -1079,6 +1079,19 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
}
return [...new Set(directories)];
},
// A managed restart can move OpenCode to a NEW port (the old one may stay
// occupied by an orphaned process, e.g. killProcessOnPort is a no-op on
// Windows). Rebind the message-stream upstream readers to the current port
// so the UI keeps receiving events instead of staying pinned to the old
// process (#2638). The runtime is created later by the startup pipeline;
// by the time any restart runs, it is assigned.
onOpenCodeRestarted: () => {
try {
messageStreamRuntime?.rebindUpstream();
} catch (error) {
console.warn('Failed to rebind message stream after OpenCode restart:', error?.message ?? error);
}
},
getManagedOpenCodeEnv: async () => {
const settings = await readSettingsFromDiskMigrated().catch(() => null);
const managedEnv = settings?.agentControlToolEnabled === false
@@ -0,0 +1,186 @@
import { EventEmitter } from 'node:events';
import { describe, expect, it, vi } from 'vitest';
import { createGlobalMessageStreamHub } from './global-hub.js';
import { createMessageStreamWsRuntime } from './runtime.js';
class FakeSocket extends EventEmitter {
constructor() {
super();
this.readyState = 1;
this.sent = [];
this.closeCalls = [];
}
send(payload) {
this.sent.push(JSON.parse(payload));
}
ping() {
void 0;
}
close(code, reason) {
if (this.readyState === 3) {
return;
}
this.readyState = 3;
this.closeCalls.push({ code, reason });
this.emit('close');
}
}
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
const encoder = new TextEncoder();
let index = 0;
return {
ok: true,
body: {
getReader() {
return {
async read() {
if (index < blocks.length) {
const next = blocks[index++];
return { value: encoder.encode(next), done: false };
}
if (!holdOpen) {
return { value: undefined, done: true };
}
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
};
signal.addEventListener('abort', onAbort, { once: true });
});
},
};
},
},
};
}
describe('rebindUpstream (#2638)', () => {
it('restarts the shared hub upstream so a connected client resumes receiving events on the new port', async () => {
const server = new EventEmitter();
const wsClients = new Set();
let port = 4096;
let fetchCalls = 0;
// Port changes after a managed restart: buildOpenCodeUrl resolves the
// CURRENT port on every attempt, exactly like production network-runtime.
const buildOpenCodeUrl = vi.fn(() => `http://127.0.0.1:${port}/global/event`);
const fetchImpl = vi.fn(async (_url, options) => {
fetchCalls += 1;
if (fetchCalls === 1) {
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: ['id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n'],
});
}
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: ['id: evt-2\ndata: {"type":"session.updated","properties":{"sessionID":"ses_1"}}\n\n'],
});
});
const globalHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders: () => ({}),
fetchImpl,
upstreamReconnectDelayMs: 0,
});
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
globalEventHub: globalHub,
buildOpenCodeUrl,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
heartbeatIntervalMs: 5000,
upstreamReconnectDelayMs: 0,
fetchImpl,
});
const socket = new FakeSocket();
runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fetchCalls).toBe(1);
expect(socket.sent.some((frame) => frame.type === 'event' && frame.eventId === 'evt-1')).toBe(true);
// The managed process was restarted onto a new port while the old
// process's SSE stream stays open (orphaned survivor).
port = 5000;
runtime.rebindUpstream();
await new Promise((resolve) => setTimeout(resolve, 20));
// The hub dialed the new port and the connected client received events
// from the new upstream without reconnecting its own socket.
expect(fetchCalls).toBe(2);
expect(fetchImpl.mock.calls[1][0]).toContain(':5000/global/event');
expect(socket.sent.some((frame) => frame.type === 'event' && frame.eventId === 'evt-2')).toBe(true);
socket.close();
await runtime.close();
});
it('closes directory-scoped sockets so their pinned readers reconnect to the new port', async () => {
const server = new EventEmitter();
const wsClients = new Set();
let port = 4096;
let fetchCalls = 0;
const buildOpenCodeUrl = vi.fn(() => `http://127.0.0.1:${port}/event`);
const fetchImpl = vi.fn(async (_url, options) => {
fetchCalls += 1;
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: ['id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n'],
});
});
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
buildOpenCodeUrl,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
heartbeatIntervalMs: 5000,
upstreamReconnectDelayMs: 0,
fetchImpl,
});
const directorySocket = new FakeSocket();
runtime.wsServer.emit('connection', directorySocket, { url: '/api/event/ws?directory=%2Fproj' });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fetchCalls).toBe(1);
port = 5000;
runtime.rebindUpstream();
expect(directorySocket.readyState).toBe(3);
expect(directorySocket.closeCalls.length).toBeGreaterThan(0);
directorySocket.close();
await runtime.close();
});
});
@@ -70,6 +70,12 @@ export function createMessageStreamWsRuntime({
noServer: true,
});
// Directory-scoped streams create one upstream reader per client
// connection. Track those sockets so a managed OpenCode restart can close
// them: each reader is pinned to the port it connected at and would
// otherwise keep streaming from an orphaned process on the old port (#2638).
const directorySockets = new Set();
const ownsGlobalHub = !globalEventHub;
const globalHub = globalEventHub ?? createGlobalMessageStreamHub({
buildOpenCodeUrl,
@@ -103,6 +109,11 @@ export function createMessageStreamWsRuntime({
return;
}
directorySockets.add(socket);
socket.on('close', () => {
directorySockets.delete(socket);
});
acceptDirectoryMessageStreamWsConnection({
socket,
requestedLastEventId,
@@ -156,6 +167,27 @@ export function createMessageStreamWsRuntime({
return {
wsServer,
/**
* Rebind all upstream readers to the current OpenCode port. Called after
* a managed process restart: the restart can land on a NEW port while
* the old process (or an orphaned survivor of it) still holds the
* previous one, and a healthy-but-pinned SSE connection never notices
* so the UI would stop receiving events until the app restarts (#2638).
* Restarting the shared hub re-dials `buildOpenCodeUrl` (which reads the
* current port) on its next attempt; directory-scoped readers are
* rebuilt by closing their client sockets, which reconnect with
* `Last-Event-ID` and re-establish the stream against the new port.
*/
rebindUpstream() {
globalHub.stop();
globalHub.start();
for (const socket of Array.from(directorySockets)) {
try {
socket.close(1012, 'OpenCode upstream restarted');
} catch {
}
}
},
async close() {
server.off('upgrade', upgradeHandler);
globalBridge.close();
@@ -35,3 +35,4 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
## Notes for contributors
- Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root.
- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
- `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks.
+19 -9
View File
@@ -454,7 +454,7 @@ export const registerFsRoutes = (app, dependencies) => {
// Non-cacheable commands always execute and are never stored.
const runCommandWithGitReadCache = async ({ shell, shellFlag, command, resolvedCwd }) => {
const cacheable = gitReadCacheTtlMs > 0 && isCacheableGitReadCommand(command);
const cacheKey = cacheable ? `${resolvedCwd}${normalizeCommand(command)}` : null;
const cacheKey = cacheable ? `${resolvedCwd}${normalizeCommand(command)}` : null;
if (cacheKey) {
const cached = gitReadCache.get(cacheKey);
@@ -1296,6 +1296,11 @@ export const registerFsRoutes = (app, dependencies) => {
? req.query.path.trim()
: os.homedir();
const respectGitignore = req.query.respectGitignore === 'true';
// Logical (requested) path stays in the caller's path space. Realpath is
// only used to read directory contents — returning real paths for entries
// breaks file-tree expansion when listing through a symlink, because the
// UI rejects expanded paths that fall outside the workspace root.
let requestedPath = '';
let resolvedPath = '';
const isPlansDirectory = (value) => {
@@ -1305,7 +1310,8 @@ export const registerFsRoutes = (app, dependencies) => {
};
try {
resolvedPath = await realpathCache.resolve(path.resolve(normalizeDirectoryPath(rawPath)));
requestedPath = path.resolve(normalizeDirectoryPath(rawPath));
resolvedPath = await realpathCache.resolve(requestedPath);
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isDirectory()) {
@@ -1364,8 +1370,8 @@ export const registerFsRoutes = (app, dependencies) => {
const entries = await Promise.all(
dirents.map(async (dirent) => {
const entryPath = path.join(resolvedPath, dirent.name);
if (respectGitignore && ignoredPaths.has(entryPath)) {
const physicalEntryPath = path.join(resolvedPath, dirent.name);
if (respectGitignore && ignoredPaths.has(physicalEntryPath)) {
return null;
}
@@ -1374,7 +1380,7 @@ export const registerFsRoutes = (app, dependencies) => {
if (!isDirectory && isSymbolicLink) {
try {
const linkStats = await fsPromises.stat(entryPath);
const linkStats = await fsPromises.stat(physicalEntryPath);
isDirectory = linkStats.isDirectory();
} catch {
isDirectory = false;
@@ -1383,7 +1389,7 @@ export const registerFsRoutes = (app, dependencies) => {
return {
name: dirent.name,
path: entryPath,
path: path.join(requestedPath, dirent.name),
isDirectory,
isFile: dirent.isFile(),
isSymbolicLink,
@@ -1392,19 +1398,23 @@ export const registerFsRoutes = (app, dependencies) => {
);
return res.json({
path: resolvedPath,
path: requestedPath,
entries: entries.filter(Boolean),
});
} catch (error) {
const err = error;
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath));
const isPlansPath = code === 'ENOENT' && (
isPlansDirectory(resolvedPath)
|| isPlansDirectory(requestedPath)
|| isPlansDirectory(rawPath)
);
if (code !== 'ENOENT') {
console.error('Failed to list directory:', error);
}
if (code === 'ENOENT') {
if (isPlansPath) {
return res.json({ path: resolvedPath || rawPath, entries: [] });
return res.json({ path: requestedPath || resolvedPath || rawPath, entries: [] });
}
return res.status(404).json({ error: 'Directory not found' });
}
+75
View File
@@ -635,3 +635,78 @@ describe('fs raw download Content-Disposition', () => {
expect(cd).toContain("filename*=UTF-8''readme.txt");
});
});
describe('fs list symlink path space (issue 2627)', () => {
const registerList = (fsPromises) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
...fsPromises,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/workspace' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('GET', '/api/fs/list');
};
const callList = async (handler, query) => {
const res = createMockResponse();
await handler({ query }, res);
return res;
};
it('keeps entry paths in the requested path space when listing through a symlink', async () => {
const dirents = [
{
name: 'src',
isDirectory: () => true,
isSymbolicLink: () => false,
isFile: () => false,
},
{
name: 'README.md',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true,
},
];
const fsPromises = {
realpath: vi.fn(async (targetPath) => (
targetPath === '/workspace/pkg' ? '/real/pkg' : targetPath
)),
stat: vi.fn(async () => ({ isDirectory: () => true })),
readdir: vi.fn(async () => dirents),
};
const handler = registerList(fsPromises);
const res = await callList(handler, { path: '/workspace/pkg' });
expect(res.statusCode).toBe(200);
expect(res.body.path).toBe('/workspace/pkg');
expect(res.body.entries).toEqual([
{
name: 'src',
path: '/workspace/pkg/src',
isDirectory: true,
isFile: false,
isSymbolicLink: false,
},
{
name: 'README.md',
path: '/workspace/pkg/README.md',
isDirectory: false,
isFile: true,
isSymbolicLink: false,
},
]);
expect(fsPromises.readdir).toHaveBeenCalledWith('/real/pkg', { withFileTypes: true });
});
});
@@ -112,7 +112,7 @@ This module provides OpenCode server integration utilities for the web server ru
The runtime maintains active-session count incrementally from idempotent activity phase transitions. Upstream stall-timeout and lifecycle health checks read it in O(1); the hourly cleanup removes activity phases older than 24 hours without broadcasting synthetic state transitions. Snapshot generation remains reserved for the session-activity API.
## Public exports (lifecycle.js)
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration.
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart; `index.js` wires it to `messageStreamRuntime.rebindUpstream()` so event-stream readers rebind to the possibly-new port (a restart can land on a new port while an orphaned process keeps the old one, which would otherwise leave the chat UI silent — issue #2638).
- Returned API:
- `startOpenCode()`
- `restartOpenCode()`
@@ -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;
})();
@@ -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);
});
});
@@ -48,6 +48,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
getActiveSessionCount = () => 0,
reapManagedOrphanedProcesses = reapOrphanedProcesses,
getWarmupDirectories = async () => [],
onOpenCodeRestarted = null,
now = Date.now,
} = deps;
@@ -694,6 +695,17 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
setupProxy(state.expressApp);
ensureOpenCodeApiPrefix();
}
// The restart may have landed on a NEW port (the old one can remain
// occupied by an orphaned process, e.g. Windows killProcessOnPort is a
// no-op). Upstream event readers pinned to the old process would keep
// the UI silent forever, so rebind them to the current port. Best
// effort: a failure here must not fail the restart itself.
try {
onOpenCodeRestarted?.();
} catch (error) {
console.warn('Failed to rebind event stream after OpenCode restart:', error?.message ?? error);
}
})();
try {
@@ -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(),
@@ -286,6 +287,70 @@ describe('OpenCode lifecycle', () => {
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('calls onOpenCodeRestarted after a successful managed restart', async () => {
const close = vi.fn(async () => {});
const replacement = createMockChild();
const onOpenCodeRestarted = vi.fn();
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return replacement;
});
const runtime = createRuntime({ onOpenCodeRestarted }, {
openCodePort: 45678,
openCodeProcess: {
pid: null,
exitCode: 1,
signalCode: null,
close,
},
});
await runtime.triggerHealthCheck();
expect(close).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(1);
// The restart completed on a (possibly new) port — the event-stream
// upstreams must rebind so the UI keeps receiving events (#2638).
expect(onOpenCodeRestarted).toHaveBeenCalledTimes(1);
});
it('does not call onOpenCodeRestarted when a managed restart fails', async () => {
const close = vi.fn(async () => {});
const onOpenCodeRestarted = vi.fn();
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
spawnMock.mockImplementation(() => {
const child = createMockChild();
queueMicrotask(() => {
child.emit('error', new Error('spawn failed'));
});
return child;
});
const runtime = createRuntime({ onOpenCodeRestarted }, {
openCodePort: 45678,
openCodeProcess: {
pid: null,
exitCode: 1,
signalCode: null,
close,
},
});
// triggerHealthCheck logs instead of rethrowing; call restartOpenCode
// directly to observe the failure result.
await expect(runtime.restartOpenCode()).rejects.toThrow();
expect(onOpenCodeRestarted).not.toHaveBeenCalled();
});
it('launches managed OpenCode with the managed PATH', async () => {
delete process.env.OPENCODE_BINARY;
const child = createMockChild();
@@ -312,6 +377,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;
@@ -565,6 +565,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;
}
@@ -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();
+37 -4
View File
@@ -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();
@@ -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.');
});
});
@@ -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;
@@ -313,6 +313,11 @@ const normalizeTaskForStorage = (value, options) => {
const schedule = normalizeSchedule(value.schedule, existingTask?.schedule);
const execution = normalizeExecution(value.execution);
// Loop provenance: absolute path of the `.agents/loops/*.md` file driving
// this task, when any. Preserved on every write so the scheduler can detect
// removed loop files across restarts. Unknown to the UI model.
const loopFile = asNonEmptyString(value.loopFile) ?? asNonEmptyString(existingTask?.loopFile);
const nowMs = Math.max(0, Math.round(now));
const baseState = normalizeState(value.state, existingTask?.state);
const state = {
@@ -328,6 +333,7 @@ const normalizeTaskForStorage = (value, options) => {
schedule,
execution,
state,
...(loopFile ? { loopFile } : {}),
};
};
@@ -559,11 +565,141 @@ export const createProjectConfigRuntime = (deps) => {
});
};
/**
* Reconcile discovered `.agents/loops` definitions with the persisted JSON
* task list.
*
* Rules (documented in scheduled-tasks/DOCUMENTATION.md):
* - For loop-owned tasks (carrying the `loopFile` marker) identity is the
* LOOP FILE PATH: a loop takes its task over regardless of the task's
* current name, so renaming the loop (`name` field or a UI edit) renames
* the task in place instead of leaving a stale duplicate behind.
* - A loop whose name matches a JSON task (no `loopFile`) takes that task
* over: its schedule/execution/enabled are overwritten from the file while
* its id and runtime state are preserved (markdown wins on conflict).
* Execution fields the file format does not define (goalEnabled,
* goalTokenBudget, permissionAutoAccept, variant) are preserved.
* - A task whose loopFile no longer matches any discovered loop file is
* unscheduled (removed). JSON-configured tasks (no loopFile) are never
* removed.
* - A task whose loop file still exists but is currently unparseable is
* KEPT with its last good definition: only a genuinely removed file
* unschedules a task, so transiently malformed files (mid-edit, bad
* merge) never delete tasks or their runtime state.
* - Loops with no matching task are created under a deterministic
* `loop:<scope>:<name>` id, so runtime state survives restarts.
* - Malformed definitions are skipped with a warning and never block valid
* loops; the scheduler passes them as `definition: null` entries, and
* normalization failures here are isolated per loop.
*/
const reconcileLoopTasks = async (projectID, loops) => {
return withProjectWriteLock(projectID, async () => {
const now = Date.now();
const current = await readProjectConfigFromDisk(projectID);
const tasks = current.scheduledTasks;
const activeLoopFilePaths = new Set();
const pendingLoops = new Map();
const loopsByPath = new Map();
for (const loop of loops) {
if (!loop || typeof loop.filePath !== 'string' || !loop.filePath) {
continue;
}
activeLoopFilePaths.add(loop.filePath);
if (loop.definition && typeof loop.definition === 'object') {
pendingLoops.set(loop.definition.name, loop);
loopsByPath.set(loop.filePath, loop);
}
}
const consumedLoopPaths = new Set();
const nextTasks = [];
for (const task of tasks) {
if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) {
// The driving loop file was removed (or renamed) — unschedule.
continue;
}
// Loop-owned tasks adopt by file path (covers renames of the `name`
// field); JSON tasks adopt by name.
const loop = task.loopFile
? loopsByPath.get(task.loopFile) || null
: pendingLoops.get(task.name) || null;
if (loop) {
try {
const adopted = normalizeTaskForStorage(
{
...task,
...loop.definition,
// File-defined execution fields win; UI-only fields the file
// format does not define are preserved from the task.
execution: { ...task.execution, ...loop.definition.execution },
loopFile: loop.filePath,
},
{
now,
createId: taskIDFactory,
existingTask: task,
allowCreate: false,
refreshUpdatedAt: false,
},
);
nextTasks.push(adopted);
pendingLoops.delete(loop.definition.name);
if (task.loopFile) {
consumedLoopPaths.add(task.loopFile);
loopsByPath.delete(task.loopFile);
}
} catch (error) {
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath} for task "${task.name}":`, error?.message ?? error);
nextTasks.push(task);
}
continue;
}
if (task.loopFile && consumedLoopPaths.has(task.loopFile)) {
// Orphan duplicate: another task already adopted this loop file
// (left over from a rename) — unschedule it.
continue;
}
nextTasks.push(task);
}
for (const loop of pendingLoops.values()) {
try {
const id = `loop:${loop.scope}:${loop.definition.name}`;
const created = normalizeTaskForStorage(
{ id, ...loop.definition, loopFile: loop.filePath },
{
now,
createId: taskIDFactory,
existingTask: null,
allowCreate: true,
refreshUpdatedAt: false,
},
);
nextTasks.push(created);
} catch (error) {
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error);
}
}
await writeProjectConfigToDisk(projectID, {
version: PROJECT_CONFIG_VERSION,
scheduledTasks: nextTasks,
});
return nextTasks;
});
};
return {
listScheduledTasks,
upsertScheduledTask,
deleteScheduledTask,
updateScheduledTaskState,
reconcileLoopTasks,
resolveProjectConfigPath,
};
};
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
@@ -173,3 +173,302 @@ describe('project-config runtime', () => {
}
});
});
describe('project-config loop reconciliation', () => {
const loop = (name, overrides = {}) => ({
scope: 'project',
filePath: `/repo/.agents/loops/${name}.md`,
definition: {
name,
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
execution: {
prompt: `Loop prompt for ${name}`,
providerID: 'openai',
modelID: 'gpt-4.1',
},
...overrides,
},
});
it('creates tasks for discovered loops with deterministic ids', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const tasks = await runtime.reconcileLoopTasks('project-test', [
loop('daily-digest'),
loop('weekly-report'),
]);
expect(tasks).toHaveLength(2);
const digest = tasks.find((task) => task.name === 'daily-digest');
expect(digest.id).toBe('loop:project:daily-digest');
expect(digest.schedule.cron).toBe('0 9 * * *');
expect(digest.execution.providerID).toBe('openai');
expect(digest.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
const reloaded = await runtime.listScheduledTasks('project-test');
expect(reloaded).toHaveLength(2);
expect(reloaded[0].state.createdAt).toBeGreaterThan(0);
} finally {
await cleanup();
}
});
it('adopts an existing task by name, preserving id and state, and persists state across reconciles', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const adopted = first.find((task) => task.id === created.task.id);
expect(adopted).toBeDefined();
expect(adopted.id).toBe(created.task.id);
expect(adopted.name).toBe('daily-digest');
expect(adopted.schedule.kind).toBe('cron');
expect(adopted.schedule.cron).toBe('0 9 * * *');
expect(adopted.execution.prompt).toBe('Loop prompt for daily-digest');
expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
const state = adopted.state;
await runtime.updateScheduledTaskState('project-test', adopted.id, {
nextRunAt: 123456,
lastRunAt: 111,
lastStatus: 'success',
});
const second = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const again = second.find((task) => task.id === created.task.id);
expect(again.id).toBe(created.task.id);
expect(again.state.nextRunAt).toBe(123456);
expect(again.state.lastRunAt).toBe(111);
expect(again.state.lastStatus).toBe('success');
expect(again.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
} finally {
await cleanup();
}
});
it('unschedules a loop-sourced task when its file is removed', async () => {
const { runtime, cleanup } = await createRuntime();
try {
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const tasks = await runtime.reconcileLoopTasks('project-test', []);
expect(tasks).toHaveLength(0);
expect(await runtime.listScheduledTasks('project-test')).toHaveLength(0);
} finally {
await cleanup();
}
});
it('leaves JSON-configured tasks untouched when no loop matches', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'json-only',
enabled: true,
schedule: { kind: 'daily', time: '08:00', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const tasks = await runtime.reconcileLoopTasks('project-test', [loop('loop-only')]);
expect(tasks).toHaveLength(2);
expect(tasks.find((task) => task.id === created.task.id)).toBeDefined();
expect(tasks.find((task) => task.name === 'loop-only')).toBeDefined();
} finally {
await cleanup();
}
});
it('does not remove a JSON task that merely shares a loop name after the loop is gone... keeps it when never adopted', async () => {
// A JSON task that was never driven by a loop file (no loopFile marker)
// must survive reconciles even when a loop with the same name existed
// only in a previous reconcile round — but once a loop adopted it, the
// file is authoritative and removing the file unschedules the task.
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
// First reconcile adopts the task (loopFile marker set).
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
// Loop file removed -> task unscheduled.
const afterRemoval = await runtime.reconcileLoopTasks('project-test', []);
expect(afterRemoval.find((task) => task.id === created.task.id)).toBeUndefined();
} finally {
await cleanup();
}
});
it('skips invalid loop definitions without blocking valid ones', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const tasks = await runtime.reconcileLoopTasks('project-test', [
loop('bad-loop', { schedule: { kind: 'cron', cron: 'not a cron', timezone: 'UTC' } }),
loop('good-loop'),
]);
expect(tasks.map((task) => task.name)).toEqual(['good-loop']);
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
it('renames a loop-sourced task in place when the loop name changes but the file stays', async () => {
// Identity for loop-owned tasks is the loop file path: changing the `name`
// field (or renaming via the UI) must not leave a stale duplicate running.
const { runtime, cleanup } = await createRuntime();
try {
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const original = first.find((task) => task.name === 'daily-digest');
const renamed = await runtime.reconcileLoopTasks('project-test', [{
scope: 'project',
filePath: '/repo/.agents/loops/daily-digest.md',
definition: {
name: 'digest',
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
execution: { prompt: 'Loop prompt for digest', providerID: 'openai', modelID: 'gpt-4.1' },
},
}]);
expect(renamed).toHaveLength(1);
const adopted = renamed[0];
expect(adopted.id).toBe(original.id);
expect(adopted.name).toBe('digest');
expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
expect(adopted.execution.prompt).toBe('Loop prompt for digest');
} finally {
await cleanup();
}
});
it('reverts a UI rename of a loop task back to the loop name on reconcile', async () => {
const { runtime, cleanup } = await createRuntime();
try {
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const created = (await runtime.listScheduledTasks('project-test'))[0];
// The UI editor renamed the task; loopFile survives the write.
await runtime.upsertScheduledTask('project-test', {
id: created.id,
name: 'renamed-by-ui',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'UI prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const after = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
expect(after).toHaveLength(1);
expect(after[0].id).toBe(created.id);
expect(after[0].name).toBe('daily-digest');
expect(after[0].execution.prompt).toBe('Loop prompt for daily-digest');
} finally {
await cleanup();
}
});
it('keeps a loop-sourced task while its file exists but is currently unparseable', async () => {
// A transiently malformed file (mid-edit, bad merge) must not delete the
// task or its runtime state — only a genuinely removed file unschedules.
const { runtime, cleanup } = await createRuntime();
try {
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const original = first[0];
await runtime.updateScheduledTaskState('project-test', original.id, {
nextRunAt: 123456,
lastRunAt: 111,
lastStatus: 'success',
});
const after = await runtime.reconcileLoopTasks('project-test', [{
scope: 'project',
filePath: '/repo/.agents/loops/daily-digest.md',
definition: null,
}]);
expect(after).toHaveLength(1);
expect(after[0].id).toBe(original.id);
expect(after[0].name).toBe('daily-digest');
expect(after[0].loopFile).toBe('/repo/.agents/loops/daily-digest.md');
expect(after[0].schedule.cron).toBe('0 9 * * *');
expect(after[0].state.nextRunAt).toBe(123456);
expect(after[0].state.lastStatus).toBe('success');
} finally {
await cleanup();
}
});
it('unschedules orphan duplicates of the same loop file', async () => {
// Zombie cleanup: two tasks driving one file (e.g. left over from a
// rename under the old name-identity rules) — the later one is removed.
const { runtime, cleanup } = await createRuntime();
try {
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const original = first[0];
await runtime.upsertScheduledTask('project-test', {
id: 'zombie-copy',
name: 'daily-digest-copy',
enabled: true,
loopFile: '/repo/.agents/loops/daily-digest.md',
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'Stale copy', providerID: 'openai', modelID: 'gpt-4.1' },
});
const after = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
expect(after).toHaveLength(1);
expect(after[0].id).toBe(original.id);
expect(after.find((task) => task.id === 'zombie-copy')).toBeUndefined();
} finally {
await cleanup();
}
});
it('preserves UI-only execution fields when adopting a JSON task', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: {
prompt: 'JSON prompt',
providerID: 'openai',
modelID: 'gpt-4.1',
variant: 'fast',
goalEnabled: true,
goalTokenBudget: 20000,
permissionAutoAccept: true,
},
});
const adopted = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const task = adopted.find((entry) => entry.id === created.task.id);
expect(task.execution.prompt).toBe('Loop prompt for daily-digest');
expect(task.execution.variant).toBe('fast');
expect(task.execution.goalEnabled).toBe(true);
expect(task.execution.goalTokenBudget).toBe(20000);
expect(task.execution.permissionAutoAccept).toBe(true);
} finally {
await cleanup();
}
});
});
@@ -5,7 +5,8 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
## Scope
- Per-project scheduled task persistence is owned by `packages/web/server/lib/projects/project-config.js`.
- Runtime orchestration and execution is owned by this module.
- Markdown loop discovery/parsing is owned by `packages/web/server/lib/scheduled-tasks/loops.js`.
- Runtime orchestration and execution is owned by `packages/web/server/lib/scheduled-tasks/runtime.js`.
- This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
## Files
@@ -17,11 +18,90 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
- Session create + prompt_async execution
- Emits OpenChamber task-run events
- `packages/web/server/lib/scheduled-tasks/loops.js`
- Discovery of `.agents/loops/*.md` (project scope, ancestors up to the worktree root) and `~/.agents/loops/*.md` (user scope)
- Frontmatter parsing into scheduled-task definitions
- `syncProject` reconciles discovered loops with the persisted task list on every project sync (startup, task save/delete)
- `packages/web/server/lib/scheduled-tasks/routes.js`
- Scheduled task CRUD endpoints
- Manual run endpoint
- OpenChamber events SSE stream endpoint
## Loop file format
Portable, git-commit-able scheduled-task definitions:
```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.
```
Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`):
| Frontmatter | Task field |
|---|---|
| `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) |
| `agent` | `execution.agent` (optional) |
| `timezone` | `schedule.timezone` (optional, IANA; defaults to the server zone) |
| body | `execution.prompt` (required) |
`thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the portable
format (UI/JSON-only today); `daily`/`weekly`/`once` schedules remain UI/JSON-only.
Runtime state (`lastRunAt`, `nextRunAt`, `lastStatus`, `lastError`, `lastSessionId`,
`lastDurationMs`) is never written to the markdown file — it continues to live in
the project config state store.
## Loop reconciliation rules
`projectConfigRuntime.reconcileLoopTasks(projectID, loops)` runs inside the
project write lock on every `syncProject` when the project path is known:
- **Identity.** For loop-owned tasks (carrying the `loopFile` marker) identity
is the loop file path: a loop takes its task over regardless of the task's
current name, so renaming the loop (the `name` field, or a UI rename) renames
the task in place instead of leaving a stale duplicate behind. A loop whose
name matches a JSON task (no `loopFile`) takes that task over instead: its
schedule/execution/enabled are overwritten from the file while the task's
`id` and runtime `state` are preserved (markdown wins on conflict).
- **UI-only fields survive adoption.** Execution fields the file format does
not define (`goalEnabled`, `goalTokenBudget`, `permissionAutoAccept`,
`variant`) are preserved from the task when a loop adopts it; only fields the
file defines are re-applied.
- **Deletion.** A task carrying the `loopFile` marker whose loop file is no
longer discovered (removed or renamed) is unscheduled (removed from the
config). The marker is persisted in the config file, so removal is detected
across restarts. JSON-configured tasks without the marker are never removed.
A task whose loop file still exists but is currently unparseable is KEPT with
its last good definition — a transiently malformed file (mid-edit, bad merge)
never deletes a task or its runtime state.
- **Creation.** Loops without a matching task are created under a deterministic
`loop:<scope>:<name>` id so runtime state survives restarts. At most one task
is driven per loop file; orphan duplicates of the same file are unscheduled.
- **Scope precedence.** Project-scope loops shadow user-scope loops with the
same name; among project files the nearest ancestor wins.
- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
unreadable) are reported to the scheduler as `definition: null` entries and
warned about; they never block valid loops in the same or other scopes.
- **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
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)
- `createScheduledTasksRuntime(dependencies)`
@@ -0,0 +1,209 @@
/**
* Markdown loops portable scheduled-task definitions.
*
* Loops are git-commit-able markdown files with YAML frontmatter, discovered
* from `.agents/loops/*.md` (project scope, including ancestor directories up
* to the worktree root) and `~/.agents/loops/*.md` (user scope), mirroring the
* skills discovery pattern (`packages/web/server/lib/opencode/skills.js`).
*
* File format:
*
* ---
* 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.
*
* Field mapping (see packages/ui/src/lib/scheduledTasksApi.ts):
* name -> task.name
* schedule -> task.schedule.kind "cron" + task.schedule.cron
* enabled -> task.enabled (default false loops only run when the file
* explicitly enables them, so discovery never auto-executes
* repository content)
* model -> split into task.execution.providerID / task.execution.modelID
* agent -> task.execution.agent (optional)
* timezone -> task.schedule.timezone (optional, defaults to the server zone)
* body -> task.execution.prompt
*
* `thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the
* portable format (they are UI-only today); editing them in the file has no
* effect and they remain JSON/UI-only.
*
* Runtime state (lastRunAt, nextRunAt, lastStatus, ...) is never written to
* the markdown file; it continues to live in the project config/state store.
*/
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);
const asNonEmptyString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
/**
* Split a `provider/model` string into its two parts. Splits on the first `/`
* so model ids containing a slash (e.g. `openai/gpt-5`) still resolve.
*/
const splitProviderModel = (value) => {
const raw = asNonEmptyString(value);
if (!raw) {
return null;
}
const separator = raw.indexOf('/');
if (separator <= 0 || separator === raw.length - 1) {
return null;
}
return {
providerId: raw.slice(0, separator).trim(),
modelId: raw.slice(separator + 1).trim(),
};
};
/**
* Parse one loop markdown file into a scheduled-task definition, or return
* null when the file is malformed. Malformed files are skipped with a warning
* and never prevent valid files from loading.
*/
export const parseLoopDefinition = (filePath) => {
let parsed;
try {
parsed = parseMdFile(filePath);
} catch (error) {
console.warn(`[loops] skipped malformed loop file ${filePath}:`, error?.message ?? error);
return null;
}
const frontmatter = parsed.frontmatter && typeof parsed.frontmatter === 'object'
? parsed.frontmatter
: {};
const name = asNonEmptyString(frontmatter.name);
if (!name) {
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) {
console.warn(`[loops] skipped ${filePath}: frontmatter "schedule" (cron expression) is required`);
return null;
}
const prompt = asNonEmptyString(parsed.body);
if (!prompt) {
console.warn(`[loops] skipped ${filePath}: markdown body (the execution prompt) is required`);
return null;
}
const providerModel = splitProviderModel(frontmatter.model);
if (!providerModel) {
console.warn(`[loops] skipped ${filePath}: frontmatter "model" must be "provider/model"`);
return null;
}
const timezone = asNonEmptyString(frontmatter.timezone);
const agent = asNonEmptyString(frontmatter.agent);
return {
name,
enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : false,
schedule: {
kind: 'cron',
cron,
...(timezone ? { timezone } : {}),
},
execution: {
prompt,
providerID: providerModel.providerId,
modelID: providerModel.modelId,
...(agent ? { agent } : {}),
},
};
};
const walkLoopMdFiles = (rootDir) => {
if (!rootDir || !fs.existsSync(rootDir)) {
return [];
}
try {
return fs.readdirSync(rootDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => path.join(rootDir, entry.name))
.sort();
} catch {
return [];
}
};
/**
* Discover loop files for a project: `~/.agents/loops/*.md` (user scope) plus
* `.agents/loops/*.md` in every ancestor of the project path up to the
* worktree root (project scope).
*/
export const discoverLoopFiles = (projectPath) => {
const files = [];
for (const filePath of walkLoopMdFiles(USER_LOOP_ROOT())) {
files.push({ filePath, scope: 'user' });
}
if (projectPath) {
const worktreeRoot = findWorktreeRoot(projectPath) || path.resolve(projectPath);
for (const ancestor of getAncestors(projectPath, worktreeRoot)) {
const root = path.join(ancestor, '.agents', LOOP_DIR_NAME);
for (const filePath of walkLoopMdFiles(root)) {
files.push({ filePath, scope: 'project' });
}
}
}
return files;
};
/**
* Discover and parse all loops for a project. Project-scope loops shadow
* user-scope loops with the same name; among project files the nearest
* ancestor wins.
*
* Unparseable files are reported as `{ scope, filePath, definition: null }`
* entries instead of being dropped: the scheduler must distinguish "file is
* gone" (unschedule its task) from "file exists but is currently malformed"
* (keep its task with the last good definition until the file is fixed).
* Malformed files never block valid ones in the same or other scopes.
*/
export const discoverLoops = (projectPath) => {
const byName = new Map();
const loops = [];
for (const { filePath, scope } of discoverLoopFiles(projectPath)) {
const definition = parseLoopDefinition(filePath);
if (!definition) {
loops.push({ scope, filePath, definition: null });
continue;
}
const existing = byName.get(definition.name);
if (existing && (existing.scope === 'project' || scope === 'user')) {
continue;
}
byName.set(definition.name, { scope, filePath, definition });
}
for (const entry of byName.values()) {
loops.push(entry);
}
return loops;
};
@@ -0,0 +1,389 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises';
import { parseLoopDefinition, discoverLoops, discoverLoopFiles } from './loops.js';
const createProject = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-'));
const projectPath = path.join(tempRoot, 'repo');
await mkdir(projectPath, { recursive: true });
await mkdir(path.join(projectPath, '.git'), { recursive: true });
return {
projectPath,
cleanup: async () => {
await rm(tempRoot, { recursive: true, force: true });
},
};
};
const writeLoop = async (projectPath, fileName, content) => {
const dir = path.join(projectPath, '.agents', 'loops');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, fileName), content, 'utf8');
};
describe('parseLoopDefinition', () => {
it('maps frontmatter and body to the scheduled-task definition shape', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'digest.md', `---
name: daily-digest
schedule: "0 9 * * *"
enabled: true
model: anthropic/claude-sonnet-4-5
agent: plan
timezone: Europe/Kyiv
---
Summarize repository changes since yesterday.
`);
const definition = parseLoopDefinition(path.join(projectPath, '.agents', 'loops', 'digest.md'));
expect(definition).toEqual({
name: 'daily-digest',
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'Europe/Kyiv' },
execution: {
prompt: 'Summarize repository changes since yesterday.',
providerID: 'anthropic',
modelID: 'claude-sonnet-4-5',
agent: 'plan',
},
});
} finally {
await cleanup();
}
});
it('splits model ids containing a slash on the first separator', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'loop.md');
await writeFile(filePath, `---
name: nested-model
schedule: "0 8 * * 1"
model: openai/gpt-5
---
Run weekly checks.
`, 'utf8');
const definition = parseLoopDefinition(filePath);
expect(definition.execution.providerID).toBe('openai');
expect(definition.execution.modelID).toBe('gpt-5');
expect(definition.enabled).toBe(false);
} finally {
await cleanup();
}
});
it('defaults enabled to false and omits optional fields', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'loop.md');
await writeFile(filePath, `---
name: minimal
schedule: "*/30 * * * *"
model: openai/gpt-5
---
Run every half hour.
`, 'utf8');
const definition = parseLoopDefinition(filePath);
// Loops only run when the file explicitly enables them: discovery of
// repository content must never auto-execute scheduled sessions.
expect(definition.enabled).toBe(false);
expect(definition.schedule).toEqual({ kind: 'cron', cron: '*/30 * * * *' });
expect(definition.execution.agent).toBeUndefined();
} finally {
await cleanup();
}
});
it('honors an explicit enabled: true in the frontmatter', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'loop.md');
await writeFile(filePath, `---
name: explicit-enabled
schedule: "*/30 * * * *"
model: openai/gpt-5
enabled: true
---
Run every half hour.
`, 'utf8');
expect(parseLoopDefinition(filePath).enabled).toBe(true);
} finally {
await cleanup();
}
});
it('returns null for files missing required frontmatter fields', async () => {
const { projectPath, cleanup } = await createProject();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const noName = path.join(projectPath, 'noname.md');
await writeFile(noName, `---
schedule: "0 9 * * *"
model: openai/gpt-5
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noName)).toBeNull();
const noSchedule = path.join(projectPath, 'noschedule.md');
await writeFile(noSchedule, `---
name: no-schedule
model: openai/gpt-5
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noSchedule)).toBeNull();
const noModel = path.join(projectPath, 'nomodel.md');
await writeFile(noModel, `---
name: no-model
schedule: "0 9 * * *"
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noModel)).toBeNull();
const malformed = path.join(projectPath, 'malformed.md');
await writeFile(malformed, 'not a markdown frontmatter file at all', 'utf8');
expect(parseLoopDefinition(malformed)).toBeNull();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
it('treats a missing body as an invalid loop', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'empty-body.md');
await writeFile(filePath, `---
name: empty-body
schedule: "0 9 * * *"
model: openai/gpt-5
---
`, 'utf8');
expect(parseLoopDefinition(filePath)).toBeNull();
} finally {
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', () => {
it('discovers project loops and parses them', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'digest.md', `---
name: daily-digest
schedule: "0 9 * * *"
model: openai/gpt-5
---
Summarize.
`);
const loops = discoverLoops(projectPath);
expect(loops).toHaveLength(1);
expect(loops[0].scope).toBe('project');
expect(loops[0].definition.name).toBe('daily-digest');
expect(loops[0].filePath.endsWith(path.join('.agents', 'loops', 'digest.md'))).toBe(true);
} finally {
await cleanup();
}
});
it('scans ancestor directories up to the worktree root', async () => {
const { projectPath, cleanup } = await createProject();
try {
// Worktree root contains the loop; the project directory is nested.
const nested = path.join(projectPath, 'src', 'nested');
await mkdir(nested, { recursive: true });
await writeLoop(projectPath, 'root-loop.md', `---
name: root-loop
schedule: "0 9 * * *"
model: openai/gpt-5
---
From the root.
`);
const loops = discoverLoops(nested);
expect(loops.map((loop) => loop.definition.name)).toEqual(['root-loop']);
expect(loops[0].scope).toBe('project');
} finally {
await cleanup();
}
});
it('discovers user-scope loops from ~/.agents/loops', async () => {
const { projectPath, cleanup } = await createProject();
const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-'));
const userDir = path.join(home, '.agents', 'loops');
await mkdir(userDir, { recursive: true });
await writeFile(path.join(userDir, 'user-loop.md'), `---
name: user-loop
schedule: "0 7 * * *"
model: openai/gpt-5
---
User scope.
`, 'utf8');
const originalHome = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(home);
try {
const loops = discoverLoops(projectPath);
expect(loops.map((loop) => loop.definition.name)).toEqual(['user-loop']);
expect(loops[0].scope).toBe('user');
} finally {
os.homedir = originalHome;
await rm(home, { recursive: true, force: true });
await cleanup();
}
});
it('lets project scope shadow user scope on name collision', async () => {
const { projectPath, cleanup } = await createProject();
const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-'));
const userDir = path.join(home, '.agents', 'loops');
await mkdir(userDir, { recursive: true });
await writeFile(path.join(userDir, 'same-name.md'), `---
name: shared
schedule: "0 7 * * *"
model: openai/gpt-5
---
User version.
`, 'utf8');
await writeLoop(projectPath, 'same-name.md', `---
name: shared
schedule: "0 8 * * *"
model: anthropic/claude-sonnet-4-5
---
Project version.
`);
const originalHome = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(home);
try {
const loops = discoverLoops(projectPath);
expect(loops).toHaveLength(1);
expect(loops[0].scope).toBe('project');
expect(loops[0].definition.execution.providerID).toBe('anthropic');
expect(loops[0].definition.schedule.cron).toBe('0 8 * * *');
} finally {
os.homedir = originalHome;
await rm(home, { recursive: true, force: true });
await cleanup();
}
});
it('reports malformed files as unparsed entries without blocking valid ones', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'bad.md', `---
name: bad
schedule: "0 9 * * *"
---
No model.
`);
await writeLoop(projectPath, 'good.md', `---
name: good
schedule: "0 9 * * *"
model: openai/gpt-5
---
Valid.
`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const loops = discoverLoops(projectPath);
// The malformed file stays visible as a `definition: null` entry so
// the scheduler can keep its task alive while the file is fixed.
const bad = loops.find((loop) => loop.filePath.endsWith(path.join('.agents', 'loops', 'bad.md')));
expect(bad.definition).toBeNull();
expect(bad.scope).toBe('project');
const good = loops.find((loop) => loop.filePath.endsWith(path.join('.agents', 'loops', 'good.md')));
expect(good.definition.name).toBe('good');
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
it('returns an empty list when nothing exists', async () => {
const { projectPath, cleanup } = await createProject();
try {
expect(discoverLoops(projectPath)).toEqual([]);
} finally {
await cleanup();
}
});
it('lists raw loop files per scope without parsing', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'one.md', `---
name: one
schedule: "0 9 * * *"
model: openai/gpt-5
---
One.
`);
await writeFile(path.join(projectPath, 'not-a-loop.txt'), 'ignore me', 'utf8');
const files = discoverLoopFiles(projectPath);
expect(files).toHaveLength(1);
expect(files[0].scope).toBe('project');
expect(files[0].filePath.endsWith(path.join('.agents', 'loops', 'one.md'))).toBe(true);
} finally {
await cleanup();
}
});
});
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
import parser from 'cron-parser';
import { expandSnippets } from '../opencode/snippets.js';
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
import { discoverLoops } from './loops.js';
const DEFAULT_GLOBAL_CONCURRENCY = 4;
const DEFAULT_PROJECT_CONCURRENCY = 2;
@@ -382,8 +383,19 @@ export const createScheduledTasksRuntime = (deps) => {
const syncProject = async (projectID) => {
await ensureProjectPath(projectID);
const projectPath = projectPathByID.get(projectID) || null;
let tasks;
if (projectPath) {
// Reconcile `.agents/loops` definitions with the persisted task list:
// loop files are authoritative while present, removed files unschedule
// their task, and runtime state is preserved (see loops.js).
const loops = await discoverLoops(projectPath);
tasks = await projectConfigRuntime.reconcileLoopTasks(projectID, loops);
} else {
tasks = await projectConfigRuntime.listScheduledTasks(projectID);
}
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
setProjectTasks(projectID, tasks);
for (const task of tasks) {
@@ -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();
}
});
});
@@ -1,3 +1,4 @@
import fs from 'node:fs';
import path from 'node:path';
import { OpenChamberControlError } from '../openchamber-control/error.js';
@@ -78,6 +79,19 @@ export const createScheduledTaskService = (dependencies) => {
await findProjectByID(projectID);
const normalizedTaskID = asNonEmptyString(taskID);
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 && 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 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,
);
}
const result = await projectConfigRuntime.deleteScheduledTask(projectID, normalizedTaskID);
if (!result.deleted) throw new OpenChamberControlError('Task not found', 404);
await scheduledTasksRuntime.syncProject(projectID);
@@ -0,0 +1,99 @@
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 = {}) => {
const projectConfigRuntime = {
listScheduledTasks: vi.fn(async () => []),
deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })),
...(overrides.projectConfigRuntime || {}),
};
const scheduledTasksRuntime = {
syncProject: vi.fn(async () => []),
...(overrides.scheduledTasksRuntime || {}),
};
const service = createScheduledTaskService({
readSettingsFromDiskMigrated: async () => ({
projects: [{ id: 'project-test', path: '/repo' }],
}),
sanitizeProjects: (projects) => projects,
projectConfigRuntime,
scheduledTasksRuntime,
});
return { service, projectConfigRuntime, scheduledTasksRuntime };
};
const loopTask = {
id: 'loop:project:daily-digest',
name: 'daily-digest',
enabled: true,
loopFile: '/repo/.agents/loops/daily-digest.md',
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
execution: { prompt: 'digest', providerID: 'openai', modelID: 'gpt-4.1' },
};
describe('scheduled-task service remove', () => {
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');
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 () => {
const jsonTask = { ...loopTask, id: 'json-task', loopFile: undefined };
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
projectConfigRuntime: {
listScheduledTasks: vi.fn(async () => [jsonTask]),
deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })),
},
});
const tasks = await service.remove('project-test', jsonTask.id);
expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', jsonTask.id);
expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled();
expect(Array.isArray(tasks)).toBe(true);
});
});
+47
View File
@@ -0,0 +1,47 @@
# Reproduction: Chat UI stops updating until the desktop app is restarted (#2638)
Reproduces https://github.com/openchamber/openchamber/issues/2638 using the
real server modules (`lifecycle.js`, `global-hub.js`, `network-runtime.js`),
real processes, and real ports — no mocks.
## Run
```sh
# Windows-orphan scenario (the reported bug):
node scripts/repro/issue-2638/reproduce-2638.mjs
# Control: healthy restart on Linux (hub reconnects, UI keeps updating):
node scripts/repro/issue-2638/reproduce-2638.mjs --baseline
```
Requires `lsof` (used only for cleanup). The default run simulates Windows
(`process.platform` is temporarily overridden to `win32`) because the bug is
specific to the Windows process-lifecycle path.
## What it demonstrates
1. A managed OpenCode process starts; the global message-stream hub connects to
its `/global/event` SSE stream and chat events flow to the UI.
2. The managed process "exits" but the actual server process survives on the
old port (on Windows `killProcessOnPort` is a no-op and `taskkill` cannot
reach the orphaned tree — the report shows leftover `opencode.exe serve`
processes on historical ports).
3. `restartOpenCode()` gives up after 5 s — logs
`Timed out waiting for OpenCode port <old> to be released` — and spawns a
fresh server on a NEW port, leaving the orphaned process running.
4. HTTP/proxy traffic follows `state.openCodePort` to the new server, but the
hub's upstream SSE reader stays pinned to the OLD server's `/global/event`
stream (that connection never closed), so events emitted by the new server
never reach the UI: the chat UI goes stale while the new server keeps
persisting session data — visible only after restarting the app.
The `--baseline` control proves the reconnect logic itself is fine: when the
old process dies and the port is properly released, the hub reconnects to the
new port and events are delivered.
## Files
- `reproduce-2638.mjs` — the reproduction driver (assertions + summary).
- `fake-opencode-serve.mjs` — a fake `opencode serve` binary whose launcher
spawns a detached server core that survives the launcher's death
(Windows-style orphan), plus an in-process mode for the baseline control.
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
// Fake `opencode serve` used to reproduce https://github.com/openchamber/openchamber/issues/2638
//
// Modes (controlled by env):
// FAKE_OPENCODE_CORE=1 server-core mode: binds the port, serves
// /global/health + SSE /global/event, ignores
// SIGTERM so it survives its launcher's death
// (Windows-style orphaned server process).
// FAKE_OPENCODE_BASELINE=1 in-process mode: the server runs inside the
// managed process and dies with it (normal
// Linux behavior used as a control).
// default (launcher) spawns a detached core grandchild, waits for
// it to bind, prints the `opencode server
// listening on ...` line the lifecycle greps
// for, stays alive, and on SIGTERM exits WITHOUT
// killing the core (mimics opencode.exe dying
// while its server child survives).
import http from 'node:http';
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
const args = process.argv.slice(2);
const portIndex = args.indexOf('--port');
const hostnameIndex = args.indexOf('--hostname');
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 0;
const hostname = hostnameIndex >= 0 ? args[hostnameIndex + 1] : '127.0.0.1';
const pidDir = process.env.FAKE_OPENCODE_PID_DIR || null;
function writePidFile(label) {
if (!pidDir) return;
try {
fs.mkdirSync(pidDir, { recursive: true });
fs.writeFileSync(path.join(pidDir, `${label}-${port}.pid`), String(process.pid));
} catch {
// best effort
}
}
function createServer() {
const clients = new Set();
const emitted = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${hostname}:${port}`);
if (url.pathname === '/global/health') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ healthy: true }));
return;
}
if (url.pathname === '/global/event') {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
});
clients.add(res);
req.on('close', () => clients.delete(res));
// SSE keep-alive comments — exactly what a real OpenCode server sends,
// which prevents the upstream reader's 20s stall timer from firing.
const keepalive = setInterval(() => {
res.write(': keepalive\n\n');
}, 1000);
req.on('close', () => clearInterval(keepalive));
return;
}
if (url.pathname === '/emit') {
const type = url.searchParams.get('type') || 'session.updated';
const id = url.searchParams.get('id') || `evt-${Date.now()}`;
emitted.push({ id, type });
const block = `id: ${id}\ndata: ${JSON.stringify({ type, id })}\n\n`;
for (const client of clients) client.write(block);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, id }));
return;
}
if (url.pathname === '/events') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(emitted));
return;
}
res.writeHead(404);
res.end('not found');
});
return { server };
}
const runServer = (label) => {
createServer().server.listen(port, hostname, () => {
console.log(`opencode server listening on http://${hostname}:${port}`);
});
writePidFile(label);
setInterval(() => {}, 1 << 30);
};
const main = async () => {
// Server-core mode: the orphaned server. Survives its launcher's death.
if (process.env.FAKE_OPENCODE_CORE === '1') {
runServer('core');
process.on('SIGTERM', () => {});
process.on('SIGINT', () => {});
return;
}
// Baseline in-process mode (control): dies with the managed process, so the
// port is properly released on restart.
if (process.env.FAKE_OPENCODE_BASELINE === '1') {
runServer('baseline');
return;
}
// Launcher mode: spawn a detached core grandchild, wait for it to bind,
// print the listening line, and on SIGTERM exit leaving the core running.
const core = spawn(process.execPath, [process.argv[1], ...args], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env, FAKE_OPENCODE_CORE: '1' },
});
core.unref();
for (let i = 0; i < 200; i += 1) {
if (core.exitCode !== null) throw new Error('core exited early');
const ok = await new Promise((resolve) => {
const socket = net.connect({ port, host: hostname });
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, 200);
socket.once('connect', () => {
clearTimeout(timer);
socket.destroy();
resolve(true);
});
socket.once('error', () => {
clearTimeout(timer);
resolve(false);
});
});
if (ok) break;
await new Promise((r) => setTimeout(r, 50));
}
writePidFile('launcher');
console.log(`opencode server listening on http://${hostname}:${port}`);
// On SIGTERM exit ourselves, leaving the detached core running.
process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
setInterval(() => {}, 1 << 30);
};
await main();
+376
View File
@@ -0,0 +1,376 @@
// Reproduction for https://github.com/openchamber/openchamber/issues/2638
// "[Bug] Chat UI stops updating until the desktop app is restarted"
//
// Run with: node reproduce-2638.mjs (Windows-orphan scenario)
// node reproduce-2638.mjs --baseline (control: healthy restart)
//
// What it wires up (real repo modules, real processes, real ports):
// - createOpenCodeLifecycleRuntime (packages/web/server/lib/opencode/lifecycle.js)
// - createGlobalMessageStreamHub (packages/web/server/lib/event-stream/global-hub.js)
// - createOpenCodeNetworkRuntime (packages/web/server/lib/opencode/network-runtime.js)
// - a fake `opencode serve` binary (fake-opencode-serve.mjs)
//
// Scenario (issue #2638):
// 1. OpenCode starts; the global message-stream hub connects to its
// /global/event SSE stream. Chat UI updates flow (baseline event e1
// reaches the hub).
// 2. The managed OpenCode process "exits" but the actual server process
// survives on the old port (on Windows killProcessOnPort is a no-op and
// taskkill cannot reach the orphaned tree — the report shows leftover
// `opencode.exe serve` processes on historical ports).
// 3. restartOpenCode() gives up after 5 s
// ("Timed out waiting for OpenCode port <old> to be released") and
// spawns a fresh server on a NEW port.
// 4. HTTP/proxy traffic follows state.openCodePort to the NEW server, but
// the hub's upstream SSE reader is still pinned to the OLD server's
// /global/event stream (that connection never closed), so events from
// the new server never reach the UI. Chat UI goes stale while the new
// server keeps persisting session data — visible only after restarting
// the app, exactly as reported.
import { spawnSync } from 'node:child_process';
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Repository root (override with REPO=/path/to/openchamber if needed). Default:
// walk up from this script until we find the repo root (AGENTS.md + package.json).
const findRepoRoot = () => {
let dir = __dirname;
for (let i = 0; i < 8; i += 1) {
if (fs.existsSync(path.join(dir, 'AGENTS.md')) && fs.existsSync(path.join(dir, 'package.json'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return '/home/runner/work/openchamber/openchamber';
};
const REPO = process.env.REPO || findRepoRoot();
const BASELINE = process.argv.includes('--baseline');
// Simulate the Windows behavior reported in #2638 (process.platform is read
// at call time inside lifecycle.js: killProcessOnPort no-ops on win32 and
// terminateChildProcess takes the taskkill path, which cannot exist here).
if (!BASELINE) {
Object.defineProperty(process, 'platform', { value: 'win32' });
}
const { createOpenCodeLifecycleRuntime } = await import(
path.join(REPO, 'packages/web/server/lib/opencode/lifecycle.js')
);
// --- shared state + real network runtime -----------------------------------
const state = {
openCodeWorkingDirectory: '/tmp',
openCodeProcess: null,
openCodePort: null,
openCodeBaseUrl: null,
currentRestartPromise: null,
isRestartingOpenCode: false,
openCodeApiPrefix: '',
openCodeApiPrefixDetected: false,
openCodeApiDetectionTimer: null,
lastOpenCodeError: null,
isOpenCodeReady: false,
openCodeNotReadySince: 0,
isExternalOpenCode: false,
isShuttingDown: false,
healthCheckInterval: null,
expressApp: null,
useWslForOpencode: false,
resolvedWslBinary: null,
resolvedWslOpencodePath: null,
resolvedWslDistro: null,
lastOpenCodeLaunchDiagnostics: null,
};
const { createOpenCodeNetworkRuntime } = await import(
path.join(REPO, 'packages/web/server/lib/opencode/network-runtime.js')
);
const networkRuntime = createOpenCodeNetworkRuntime({
state,
getOpenCodeAuthHeaders: () => ({}),
configuredOpenCodeHostname: '127.0.0.1',
});
const fakeBinary = path.join(__dirname, 'fake-opencode-serve.mjs');
const pidDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-repro-2638-'));
process.env.OPENCODE_BINARY = fakeBinary;
process.env.FAKE_OPENCODE_PID_DIR = pidDir;
if (BASELINE) process.env.FAKE_OPENCODE_BASELINE = '1';
const lifecycle = createOpenCodeLifecycleRuntime({
state,
env: {
ENV_CONFIGURED_OPENCODE_PORT: 0,
ENV_CONFIGURED_OPENCODE_HOST: null,
ENV_EFFECTIVE_PORT: 0,
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
ENV_SKIP_OPENCODE_START: false,
},
syncToHmrState: () => {},
syncFromHmrState: () => {},
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (...args) => networkRuntime.buildOpenCodeUrl(...args),
waitForReady: (...args) => networkRuntime.waitForReady(...args),
normalizeApiPrefix: (...args) => networkRuntime.normalizeApiPrefix(...args),
applyOpencodeBinaryFromSettings: async () => {},
ensureOpencodeCliEnv: () => {},
ensureLocalOpenCodeServerPassword: async () => 'password',
resolveManagedOpenCodeLaunchSpec: (binary) => ({ binary, args: [], wrapperType: null }),
setOpenCodePort: (port) => { state.openCodePort = port; },
setDetectedOpenCodeApiPrefix: () => {},
setupProxy: () => {},
ensureOpenCodeApiPrefix: () => {},
clearResolvedOpenCodeBinary: () => {},
buildAugmentedPath: () => process.env.PATH,
buildManagedOpenCodePath: () => process.env.PATH,
getManagedOpenCodeShellEnvSnapshot: async () => ({}),
getManagedOpenCodeEnv: async () => ({}),
reapManagedOrphanedProcesses: async () => ({ reaped: 0 }),
getWarmupDirectories: async () => [],
// Production index.js wires this to the message-stream runtime's
// rebindUpstream(); mirror it here so the harness exercises the fix.
onOpenCodeRestarted: () => {
try {
rebindHub?.();
} catch {
}
},
});
const { createGlobalMessageStreamHub } = await import(
path.join(REPO, 'packages/web/server/lib/event-stream/global-hub.js')
);
// The hub represents the server→OpenCode SSE push pipeline that feeds the
// renderer (both the server-side PushWatcher and the browser WS bridge).
const received = [];
const statuses = [];
let rebindHub = null;
const hub = createGlobalMessageStreamHub({
buildOpenCodeUrl: (p) => networkRuntime.buildOpenCodeUrl(p, ''),
getOpenCodeAuthHeaders: () => ({}),
upstreamStallTimeoutMs: 20000,
upstreamReconnectDelayMs: 250,
});
hub.subscribeEvent(({ eventId, payload }) => {
received.push({ eventId, type: payload?.type, id: payload?.id });
});
hub.subscribeStatus((status) => statuses.push(status));
rebindHub = () => {
hub.stop();
hub.start();
};
// --- helpers ----------------------------------------------------------------
const warnLog = [];
const origWarn = console.warn;
console.warn = (...args) => {
warnLog.push(args.map(String).join(' '));
origWarn(...args);
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(fn, timeoutMs, what) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await fn()) return true;
await sleep(50);
}
throw new Error(`timeout waiting for ${what}`);
}
const emitEvent = async (port, id, type = 'session.updated') => {
const res = await fetch(`http://127.0.0.1:${port}/emit?type=${type}&id=${id}`);
if (!res.ok) throw new Error(`emit ${id} failed on port ${port}`);
return res.json();
};
const persistedEvents = async (port) => {
const res = await fetch(`http://127.0.0.1:${port}/events`);
return res.ok ? res.json() : [];
};
const portOpen = (port) => new Promise((resolve) => {
const socket = net.connect({ port, host: '127.0.0.1' });
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 300);
socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once('error', () => { clearTimeout(timer); resolve(false); });
});
const pidFilePids = () => {
const out = [];
for (const file of fs.readdirSync(pidDir)) {
if (!file.endsWith('.pid')) continue;
try {
out.push({ label: file.replace(/\.pid$/, ''), pid: Number(fs.readFileSync(path.join(pidDir, file), 'utf8')) });
} catch {
// ignore
}
}
return out;
};
const killPortPids = (port) => {
try {
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8' });
const pids = String(result.stdout || '').trim().split(/\s+/).map(Number).filter(Boolean);
for (const pid of pids) {
if (pid === process.pid) continue; // never kill ourselves (TIME_WAIT client sockets)
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
}
} catch { /* lsof unavailable */ }
};
const cleanup = async () => {
for (const { label, pid } of pidFilePids()) {
if (label.startsWith('launcher') || label.startsWith('baseline')) {
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
} else {
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
}
}
if (state.openCodePort) killPortPids(state.openCodePort);
// Belt and braces: kill any surviving fake-opencode processes from this run.
try {
const result = spawnSync('pgrep', ['-f', 'fake-opencode-serve.mjs'], { encoding: 'utf8' });
const pids = String(result.stdout || '').trim().split(/\s+/).map(Number).filter(Boolean);
for (const pid of pids) {
if (pid === process.pid) continue;
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
}
} catch { /* pgrep unavailable */ }
await sleep(300);
try { fs.rmSync(pidDir, { recursive: true, force: true }); } catch { /* ignore */ }
console.warn = origWarn;
};
// --- run ---------------------------------------------------------------------
console.log(`\n=== reproduce-2638 (${BASELINE ? 'BASELINE control' : 'Windows-orphan scenario'}) ===\n`);
let failures = 0;
const check = (label, ok, detail = '') => {
console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}${detail ? `${detail}` : ''}`);
if (!ok) failures += 1;
};
try {
// 1. Bootstrapping starts the managed OpenCode (launcher + server core) on P1.
await lifecycle.bootstrapOpenCodeAtStartup();
const p1 = state.openCodePort;
console.log(`[1] bootstrap OK — managed OpenCode listening on port ${p1} (pid ${state.openCodeProcess?.pid})`);
// 2. Connect the message-stream hub (server→OpenCode SSE push pipeline).
hub.start();
await waitFor(() => statuses.some((s) => s.type === 'connect'), 10000, 'hub connect to P1');
console.log('[2] message-stream hub connected to /global/event');
// 3. Baseline delivery: an event emitted by P1 reaches the UI pipeline.
await emitEvent(p1, 'evt-before-restart');
await waitFor(() => received.some((r) => r.eventId === 'evt-before-restart'), 5000, 'event delivery');
check('events flow to the UI before the restart (baseline)', received.some((r) => r.eventId === 'evt-before-restart'));
// 4. The managed process "exits" while the actual server survives on P1
// (simulates the Windows orphan: launcher dies, server core keeps the
// port and the SSE stream). In baseline mode the server runs in-process
// and dies with the managed process instead.
const launcherPid = state.openCodeProcess.pid;
process.kill(launcherPid, 'SIGTERM');
await waitFor(async () => {
try { process.kill(launcherPid, 0); return false; } catch { return true; }
}, 5000, 'launcher exit');
console.log(`[4] managed process (pid ${launcherPid}) exited; ${BASELINE ? 'server process died with it' : `orphaned server core still listening on ${p1}`}`);
// 5. Trigger the reported restart path ("Refreshing OpenCode after manual
// configuration reload" / periodic health check).
console.log('[5] triggering restart (refreshOpenCodeAfterConfigChange)...');
await lifecycle.refreshOpenCodeAfterConfigChange('manual configuration reload');
const p2 = state.openCodePort;
console.log(`[5] restarted — new managed OpenCode listening on port ${p2}`);
// 6. Assert the reported log line: the old port was never released. In the
// baseline control the port IS released, so the warning must be absent.
const timeoutWarn = warnLog.find((line) => line.includes('Timed out waiting for OpenCode port') && line.includes(String(p1)));
if (BASELINE) {
check(`no "Timed out waiting for OpenCode port ${p1}" warning in baseline control`, !timeoutWarn);
} else {
check(`"Timed out waiting for OpenCode port ${p1} to be released" is logged`, Boolean(timeoutWarn), timeoutWarn || '');
}
check('new port differs from old port (leaked process pinned the old one)', p2 !== p1, `p1=${p1} p2=${p2}`);
// 7. Assert the orphaned old server is still running (process pile-up from
// the report: "six opencode.exe serve processes were still running").
// In baseline mode we instead expect the port to be properly released.
const oldCoreStillUp = await portOpen(p1);
const pids = pidFilePids();
const orphanPids = pids.filter(({ label }) => label.startsWith('core')).map(({ pid }) => pid);
const orphanAlive = orphanPids.length > 0 && orphanPids.every((pid) => {
try { process.kill(pid, 0); return true; } catch { return false; }
});
if (BASELINE) {
check('old port properly released (no orphan in baseline control)', !oldCoreStillUp,
`old port ${p1} ${oldCoreStillUp ? 'still open' : 'released'}`);
} else {
check('orphaned server process still running on the old port', oldCoreStillUp && orphanAlive,
`old port ${p1} still open; orphan core pids ${orphanPids.join(', ')}`);
}
// 8. The stale-UI reproduction: the new server persists events, but in the
// orphan scenario they never reach the UI because the hub is still pinned
// to the old SSE stream. In baseline mode the hub must reconnect to the
// new port and deliver them (wait for the reconnect before emitting —
// the upstream reader only learns about the new port on its next attempt).
const connectsBefore = statuses.filter((s) => s.type === 'connect').length;
if (!BASELINE) {
await sleep(1000);
} else {
await waitFor(() => statuses.filter((s) => s.type === 'connect').length >= connectsBefore + 1, 15000, 'hub reconnect to new port');
}
await emitEvent(p2, 'evt-after-restart');
console.log(`[8] emitted evt-after-restart on new port ${p2} — waiting to see if the UI receives it...`);
await sleep(2500);
const deliveredAfter = received.filter((r) => r.eventId === 'evt-after-restart').length;
if (BASELINE) {
check('NEW server event IS delivered to the UI (hub reconnected in baseline)', deliveredAfter === 1,
`delivered=${deliveredAfter}, total hub events=${received.length}`);
} else {
// Fixed: the lifecycle hook rebinds the hub after the managed restart,
// so the UI receives events from the new port even when the old port is
// orphaned. The wait mirrors the baseline branch (the reader re-dials on
// its next attempt after the rebind).
await waitFor(() => statuses.filter((s) => s.type === 'connect').length >= connectsBefore + 1, 15000, 'hub reconnect to new port after rebind');
check('NEW server event IS delivered to the UI (rebound after restart)', deliveredAfter === 1,
`delivered=${deliveredAfter}, total hub events=${received.length}`);
}
const persistedOnNew = await persistedEvents(p2);
check('NEW server persisted the event (data survives, UI does not show it)', persistedOnNew.some((e) => e.id === 'evt-after-restart'),
`persisted on port ${p2}: ${JSON.stringify(persistedOnNew)}`);
// 9. Orphan scenario: with the rebind the hub is no longer pinned to the
// old server — events emitted by the old (zombie) server must NOT reach
// the UI anymore (it left the previous upstream behind).
if (!BASELINE) {
await emitEvent(p1, 'evt-zombie-old-server');
await sleep(2000);
check('OLD zombie server events no longer reach the UI (hub rebound to new upstream)', !received.some((r) => r.eventId === 'evt-zombie-old-server'));
}
console.log(`\n=== ${failures === 0 ? 'REPRODUCED (all checks passed)' : `${failures} check(s) FAILED`} ===\n`);
console.log(`hub statuses observed: ${JSON.stringify(statuses)}`);
} catch (error) {
console.error('\nReproduction script error:', error);
failures += 1;
} finally {
await cleanup();
}
process.exit(failures === 0 ? 0 : 1);