Merge branch 'main' into feat/gh-2634-pending-question
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’arborescence 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',
|
||||
|
||||
@@ -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': 'ウォークスルー',
|
||||
|
||||
@@ -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': '워크스루',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '導讀',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} | ||||