From 94d88a4b6d3c61bbaa7435d3b7b727d4d8c238b0 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:10:38 +0800 Subject: [PATCH 01/13] feat(workspaces): prototype native session activity state --- PLANS.md | 3 + plans/session-activity-terminal-delivery.md | 200 ++++++++++++++++++ src/webui/routes/workspaces.ts | 15 ++ src/workspaces/adapters/ai-config.spec.ts | 33 +++ src/workspaces/adapters/opencode.ts | 55 ++++- src/workspaces/adapters/pi.ts | 19 +- src/workspaces/cli-adapter.ts | 2 + src/workspaces/cli/bin/pi-session-provider.ts | 30 ++- .../headless-terminal-snapshot.spec.ts | 20 ++ src/workspaces/headless-terminal-snapshot.ts | 7 + src/workspaces/persistent-session.spec.ts | 36 ++++ src/workspaces/persistent-session.ts | 38 ++++ src/workspaces/protocol.ts | 9 + src/workspaces/service.ts | 3 + src/workspaces/session-activity.spec.ts | 37 ++++ src/workspaces/session-activity.ts | 77 +++++++ src/workspaces/session-pool.ts | 3 + .../session-runtime-binding.spec.ts | 8 +- .../workspace/OverviewCard.spec.tsx | 8 +- ui/src/components/workspace/OverviewCard.tsx | 15 +- ui/src/components/workspace/Sidebar.tsx | 33 ++- ui/src/components/workspace/Terminal.tsx | 25 ++- .../workspace/WorkspaceNavigationDialogs.tsx | 17 +- ui/src/components/workspace/WorkspaceView.tsx | 14 +- ui/src/components/workspace/api.ts | 6 + ui/src/components/workspace/protocol.spec.ts | 41 ++++ ui/src/components/workspace/protocol.ts | 40 ++++ .../workspace/session-activity-ui.spec.ts | 40 ++++ .../workspace/session-activity-ui.ts | 56 +++++ ui/src/components/workspace/workspaces.css | 12 +- ui/src/contexts/WorkspacesContext.tsx | 5 + ui/src/i18n/locales/en.ts | 8 + ui/src/i18n/locales/ja.ts | 8 + ui/src/i18n/locales/zh-Hant.ts | 8 + ui/src/i18n/locales/zh.ts | 8 + 35 files changed, 894 insertions(+), 45 deletions(-) create mode 100644 plans/session-activity-terminal-delivery.md create mode 100644 src/workspaces/session-activity.spec.ts create mode 100644 src/workspaces/session-activity.ts create mode 100644 ui/src/components/workspace/protocol.spec.ts create mode 100644 ui/src/components/workspace/session-activity-ui.spec.ts create mode 100644 ui/src/components/workspace/session-activity-ui.ts diff --git a/PLANS.md b/PLANS.md index 074b2533e..3a7ea24bf 100644 --- a/PLANS.md +++ b/PLANS.md @@ -21,6 +21,9 @@ the durable truth after it changes. ## Active +- [[plans/session-activity-terminal-delivery.md]] — Separates persistent PTY + lifecycle from native Agent activity, makes finished OpenCode/Pi turns visibly + idle, and preserves the first Quick Chat response across attach/reconnect. - [[plans/electron-runtime-browser-handoff.md]] — Lets Electron detect a healthy dev/CLI Runtime already owning the selected data location and hand the user to its verified browser UI without takeover. diff --git a/plans/session-activity-terminal-delivery.md b/plans/session-activity-terminal-delivery.md new file mode 100644 index 000000000..812adfba4 --- /dev/null +++ b/plans/session-activity-terminal-delivery.md @@ -0,0 +1,200 @@ +# Session Activity and Terminal Delivery + +Status: Draft proposal — do not merge pending design review + +Related reports: + +- [TraderAlice discussion #718](https://github.com/orgs/TraderAlice/discussions/718) +- Local OpenCode Quick Chat launch failure reproduced on 2026-08-09 + +Owner guides: + +- [[../docs/managed-workspace-runtime.md]] +- [[../docs/model-semantics-and-runtime-injection.md]] +- [[../docs/conversation-provenance.md]] +- [[../docs/ui-interaction-and-motion.md]] + +## Topic + +Make a persistent native Agent Session report its real work state and deliver +its terminal output reliably without treating a live TUI process as evidence +that the Agent is still generating. + +## Problem + +OpenAlice currently projects a live `PersistentSession` PTY as `running`. That +is a process-lifecycle fact: an interactive OpenCode or Pi TUI is intentionally +kept alive after one turn completes. Product surfaces can therefore leave the +Session looking busy forever even when the native Agent has returned to its +prompt. The final `transcript.session.captured` event only records the native +conversation identity and is not a completion signal. + +The same boundary owns a second reliability concern. Quick Chat can submit its +first prompt before a browser terminal attaches, so the PTY mirror and replay +path must preserve the visible initial reply and recover it after a WebSocket +disconnect. A successful model request is not sufficient if the user-facing +terminal never receives the response. + +A local prerequisite was also reproduced for OpenCode: its interactive TUI is +currently launched with a `--variant` option that only `opencode run` accepts, +so the child exits before activity or output behavior can be exercised. +That immediate startup defect is deliberately isolated in Draft PR #1037 and +is not part of the lifecycle proposal's acceptance decision. + +## Open Design Questions + +1. Should product Session state remain a two-axis model (`running/paused` PTY + lifecycle plus transient Agent activity), or should the public contract + expose one richer state machine? +2. Is a native runtime hook the minimum acceptable source of activity truth, + or should OpenAlice offer a documented lower-confidence fallback for + runtimes that expose no events? +3. Should the last activity observation disappear on process restart, as this + prototype does, or become durable Session metadata with staleness rules? +4. Is a Workspace-local managed OpenCode/Pi hook an acceptable ownership + boundary, or should adapters integrate through a non-file registration + surface when the runtime provides one? + +## Acceptance Criteria + +1. Session lifecycle and Agent activity are separate public concepts. A live + PTY remains resumable/running while activity can be starting, working, + waiting, unavailable, failed, or stopped. +2. OpenCode and Pi publish native turn transitions. Completing a turn while the + TUI stays alive moves the Session to waiting/idle instead of leaving it + visibly working. +3. A real native turn start is visibly working. Adapters without activity + support degrade to an explicit unavailable/terminal-ready state and never + fabricate progress from process liveness or output quietness. +4. Quick Chat's first visible response survives a late terminal attach in + source dev and Docker-shaped HTTP mode. A WebSocket reconnect replays the + authoritative current terminal screen plus current activity state. +5. `transcript.session.captured` remains solely a native Session identity event; + no UI or runtime code interprets it as turn completion. +6. OpenCode interactive launch uses only arguments accepted by its TUI while + headless `opencode run` retains supported model/variant injection. +7. Tests cover OpenCode and Pi start/settle transitions, live-process idle + behavior, late attach, reconnect/replay, stale or malformed activity input, + and the resulting UI presentation. +8. The real Chat route is verified in the browser, and the matching Electron / + PTY smoke proves the desktop transport still launches and reconnects. + +## Design Alternatives + +### A. Infer completion from terminal output quietness + +Observe PTY writes and mark a Session idle after a debounce window. + +- Advantage: no native runtime integration. +- Rejected because TUI redraws, terminal queries, resize/focus events, streaming + pauses, and long tool calls make silence neither necessary nor sufficient for + completion. It would create another timing heuristic at the exact boundary + this topic is meant to make reliable. + +### B. Treat process exit as completion or replace the TUI with headless runs + +End the child after each prompt, or route Quick Chat through a one-shot API. + +- Advantage: lifecycle and work state become superficially identical. +- Rejected because persistent native TUI Sessions are a product contract. This + changes resume behavior and removes the visible TUI handoff that teaches the + user how OpenAlice controls a native runtime. + +### C. Bridge native Agent activity through the terminal transport + +Install OpenAlice-owned, Workspace-local OpenCode/Pi activity hooks. The hooks +emit a versioned private terminal control sequence keyed to `AQ_SESSION_ID`. +The headless terminal mirror consumes it, `PersistentSession` stores the latest +activity, and REST/WebSocket projections deliver lifecycle and activity as +separate facts. + +- Advantage: native lifecycle truth, no polling, works before browser attach, + and preserves the existing PTY/TUI architecture. +- Cost: each adapter must own and test a small native hook, with an explicit + unavailable fallback when a runtime version cannot provide the events. +- Selected because it is the only option that is both semantically accurate and + compatible with a long-lived interactive terminal. + +## Interaction Model + +- Running/paused remains a Session lifecycle control used for opening, + resuming, and stopping the PTY. +- Activity is a secondary status: starting, working, waiting for the user, + unavailable, failed, or stopped. It must not reuse the primary lifecycle + label or make an idle live TUI appear dead. +- On reconnect, the client receives the activity snapshot in the same attach + handshake as terminal cursor/screen state, before relying on future events. +- Reduced-motion behavior is unchanged; status changes use existing shared + feedback primitives and do not add continuous animation. +- Runtime-specific event capture belongs to adapters. Framing, validation, + replay, and public protocol ownership belong to the shared terminal layer. + +## Non-goals + +- Replacing native TUI Sessions with an in-process Agent loop. +- Making `transcript.session.captured` a generic lifecycle event. +- Promising exact activity for third-party adapters that expose no native + lifecycle hooks. +- Redesigning the paused/open-TUI transition screen beyond the status facts + required by this topic. +- General transcript parsing, billing state, or token-progress estimation. + +## Work + +### 1. Baseline and contract + +- [ ] Preserve source-dev, Docker-shaped HTTP, and Electron/PT​​Y reproduction + evidence for initial output, reconnect, and completed-turn behavior. +- [x] Define the versioned activity phases and private adapter-to-terminal + framing, including identity validation and unsupported fallback. +- [x] Remove the unsupported OpenCode interactive `--variant` argument while + preserving its headless projection. + +### 2. Native adapter activity + +- [x] Add a managed Pi extension that emits start/settled activity only for the + interactive Session path. +- [ ] Decide whether Pi exposes a trustworthy native failure event or whether + child-exit failure is the only supported failure boundary. +- [x] Add a reversible Workspace-local OpenCode plugin that emits + working/idle/error activity without overwriting user-owned plugins. +- [x] Prove managed files are locally excluded, conflict-safe, and carry no + credential or prompt content. + +### 3. Terminal and public protocol + +- [x] Parse and validate private activity frames in the shared headless terminal + layer without exposing control bytes as product content. +- [x] Store the latest activity in `PersistentSession` and include it in attach, + reconnect, and lifecycle projections. +- [ ] Extend REST, WebSocket, Electron IPC, demo, and UI types without changing + the meaning of `SessionRecord.state`. + +### 4. Product behavior + +- [x] Present live-process waiting separately from working in the Session row + and paused/open-TUI surface. +- [x] Keep unknown adapters honest and preserve accessible status semantics. +- [ ] Verify late attach and repeated reconnect show the same final response and + current activity without a reload. + +### 5. Verification and delivery + +- [x] Add focused adapter, terminal snapshot, PersistentSession, route/protocol, + and UI regression tests. +- [ ] Run root/UI typechecks and the monorepo test suite. +- [ ] Walk the real Chat route in `pnpm dev` and a Docker-shaped HTTP launch. +- [ ] Run the relevant Electron/PT​​Y smoke and record platform-only residual + risk without invoking release signing. +- [ ] Open one labeled Draft PR targeting `dev`; keep it unmerged pending topic + acceptance. + +## Completion Criteria + +- A completed OpenCode or Pi turn with a live TUI is waiting, not working. +- A new turn transitions to working from native runtime evidence. +- Quick Chat output is visible after late attach and after WebSocket reconnect. +- Public Session projections never conflate activity with PTY lifecycle. +- The unsupported OpenCode interactive option no longer prevents launch. +- Focused tests, required typechecks/tests, browser verification, and the + Electron/PT​​Y acceptance lane are recorded on the Draft PR. diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index afcd31ac0..c588398fa 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -85,6 +85,7 @@ import { managerTerminalPrompt, managerSkillPath, } from '../../workspaces/manager-workspace.js'; +import type { SessionAgentActivity } from '../../workspaces/session-activity.js'; // The spawn body's `resume` value is an AGENT-side session id, whose shape is // adapter-native: uuid for claude/codex/pi, `ses_` for opencode. This @@ -191,6 +192,7 @@ interface PublicSessionBody { readonly startedAt: number | null; readonly title: string | null; readonly sourceRunId: string | null; + readonly activity: SessionAgentActivity; readonly runtime?: { readonly credentialSource: 'native' | 'vault' | 'workspace'; readonly credentialSlug?: string; @@ -472,6 +474,7 @@ export function createWorkspaceRoutes( const terminal = svc.pool.get(record.id); const browser = svc.webPi?.get(record.id) ?? null; const binding = svc.resumeRegistry.get(record.resumeId)?.runtimeBinding; + const fallbackObservedAt = Date.parse(record.lastActiveAt); return { id: record.id, wsId: record.wsId, @@ -486,6 +489,18 @@ export function createWorkspaceRoutes( startedAt: terminal?.startedAt ?? browser?.startedAt ?? null, title: sessionPreferredTitle(record) ?? null, sourceRunId: record.sourceRunId ?? null, + activity: terminal?.agentActivity ?? { + phase: browser + ? browser.phase === 'idle' ? 'waiting' + : browser.phase === 'failed' ? 'failed' + : browser.phase === 'stopped' ? 'stopped' + : browser.phase === 'starting' ? 'starting' + : 'working' + : 'stopped', + observedAt: terminal?.startedAt + ?? browser?.startedAt + ?? (Number.isFinite(fallbackObservedAt) ? fallbackObservedAt : 0), + }, ...(binding ? { runtime: { diff --git a/src/workspaces/adapters/ai-config.spec.ts b/src/workspaces/adapters/ai-config.spec.ts index 8f35c95ec..e8cafe55f 100644 --- a/src/workspaces/adapters/ai-config.spec.ts +++ b/src/workspaces/adapters/ai-config.spec.ts @@ -524,6 +524,39 @@ describe('opencodeAdapter AI-config', () => { expect(await read('tui.jsonc')).toBe('{ // user-owned\n "scroll_speed": 2\n}\n'); }); + it('installs the managed OpenCode activity plugin without tracking it', async () => { + await mkdir(join(dir, '.git/info'), { recursive: true }); + + await prepareAgentRuntimeWorkspace(opencodeAdapter, { + wsId: 'ws-abc', + cwd: dir, + launcherRepoRoot: '/repo', + }); + + const plugin = await read('.opencode/plugins/openalice-session-activity.js'); + expect(plugin).toContain('// @openalice-managed session-activity v1'); + expect(plugin).toContain("event.type === 'session.idle'"); + expect(await read('.git/info/exclude')).toContain( + '.opencode/plugins/openalice-session-activity.js\n', + ); + }); + + it('preserves a same-name user-owned OpenCode plugin', async () => { + await mkdir(join(dir, '.opencode/plugins'), { recursive: true }); + await writeFile( + join(dir, '.opencode/plugins/openalice-session-activity.js'), + '// user-owned\n', + ); + + await prepareAgentRuntimeWorkspace(opencodeAdapter, { + wsId: 'ws-abc', + cwd: dir, + launcherRepoRoot: '/repo', + }); + + expect(await read('.opencode/plugins/openalice-session-activity.js')).toBe('// user-owned\n'); + }); + it('keeps OpenAlice MCP out of opencode env even when an MCP URL is present', () => { const env = opencodeAdapter.composeEnv!({ cwd: dir, env: mcpEnv }); expect(env['OPENCODE_DISABLE_MODELS_FETCH']).toBe('1'); diff --git a/src/workspaces/adapters/opencode.ts b/src/workspaces/adapters/opencode.ts index 462d13fe1..0de18d253 100644 --- a/src/workspaces/adapters/opencode.ts +++ b/src/workspaces/adapters/opencode.ts @@ -25,6 +25,7 @@ const OPENCODE_CONFIG_PATH = 'opencode.json'; const OPENCODE_TUI_CONFIG_PATH = 'tui.json'; const OPENCODE_TUI_CONFIGC_PATH = 'tui.jsonc'; const OPENCODE_BINDING_STATE_PATH = '.opencode/openalice-provider.json'; +const OPENCODE_ACTIVITY_PLUGIN_PATH = '.opencode/plugins/openalice-session-activity.js'; const OPENCODE_PROVIDER_NAME = 'workspace'; const OPENCODE_SESSION_PROVIDER_NAME = 'openalice-session'; const OPENCODE_SYSTEM_THEME = 'system'; @@ -35,6 +36,36 @@ const OPENCODE_OWNED_PATHS = [ ] as const; const DEFAULT_OUTPUT_TOKENS = 16_384; +const OPENCODE_ACTIVITY_PLUGIN_SOURCE = `// @openalice-managed session-activity v1 +const OSC = 6973 + +function emitActivity(phase) { + const sessionId = process.env.AQ_SESSION_ID + if (!sessionId || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return + process.stdout.write(\`\\x1b]\${OSC};openalice-session-activity;v=1;session=\${sessionId};phase=\${phase}\\x1b\\\\\`) +} + +export const OpenAliceSessionActivity = async () => { + emitActivity('waiting') + return { + event: async ({ event }) => { + if (event.type === 'session.idle') { + emitActivity('waiting') + return + } + if (event.type === 'session.error') { + emitActivity('failed') + return + } + if (event.type !== 'session.status') return + const status = event.properties?.status?.type + if (status === 'busy' || status === 'retry') emitActivity('working') + if (status === 'idle') emitActivity('waiting') + }, + } +} +`; + const openCodeSessionRowsInFlight = new Map[]>>(); function readOpenCodeSessionRows(cwd: string): Promise[]> { @@ -188,7 +219,7 @@ function parseJsonRecord(raw: string | null): Record | null { } } -async function ensureOpenCodeTuiConfigExcluded(cwd: string): Promise { +async function ensureOpenCodeLocalPathsExcluded(cwd: string): Promise { // OpenAlice workspaces are Git repositories, but adapter tests and external // callers may prepare a plain directory. Do not manufacture a partial .git. try { @@ -198,9 +229,23 @@ async function ensureOpenCodeTuiConfigExcluded(cwd: string): Promise { } const path = '.git/info/exclude'; const current = await readWorkspaceFile(cwd, path) ?? ''; - if (current.split(/\r?\n/).includes(OPENCODE_TUI_CONFIG_PATH)) return; + const lines = current.split(/\r?\n/); + const additions = [OPENCODE_TUI_CONFIG_PATH, OPENCODE_ACTIVITY_PLUGIN_PATH] + .filter((entry) => !lines.includes(entry)); + if (additions.length === 0) return; const separator = current.length === 0 || current.endsWith('\n') ? '' : '\n'; - await writeWorkspaceFile(cwd, path, `${current}${separator}${OPENCODE_TUI_CONFIG_PATH}\n`); + await writeWorkspaceFile(cwd, path, `${current}${separator}${additions.join('\n')}\n`); +} + +async function syncOpenCodeSessionActivityPlugin(cwd: string): Promise { + const current = await readWorkspaceFile(cwd, OPENCODE_ACTIVITY_PLUGIN_PATH); + if (current !== null && !current.startsWith('// @openalice-managed session-activity ')) { + // A same-name user plugin is extraordinarily unlikely, but still belongs + // to the user. Preserve it instead of claiming the path. + return; + } + if (current === OPENCODE_ACTIVITY_PLUGIN_SOURCE) return; + await writeWorkspaceFile(cwd, OPENCODE_ACTIVITY_PLUGIN_PATH, OPENCODE_ACTIVITY_PLUGIN_SOURCE); } /** @@ -211,7 +256,7 @@ async function ensureOpenCodeTuiConfigExcluded(cwd: string): Promise { * remains user-owned. */ export async function syncOpenCodeWorkspaceTheme(cwd: string): Promise { - await ensureOpenCodeTuiConfigExcluded(cwd); + await ensureOpenCodeLocalPathsExcluded(cwd); // A JSONC project file is user-owned. Avoid creating a competing tui.json // because OpenCode accepts both and their same-directory ordering is native. @@ -296,6 +341,7 @@ export const opencodeAdapter: CliAdapter = { // `opencode --session ` (composeCommand) resumes by id. transcriptDiscovery: 'subprocess', headless: true, + interactiveActivity: 'terminal-osc-v1', aiProvider: { credentialSource: 'runtime-or-workspace', wirePreference: ['google-generative-ai', 'openai-chat', 'anthropic', 'openai-responses'], @@ -343,6 +389,7 @@ export const opencodeAdapter: CliAdapter = { lifecycle: { async prepareWorkspace({ cwd }): Promise { await syncOpenCodeWorkspaceTheme(cwd); + await syncOpenCodeSessionActivityPlugin(cwd); }, }, diff --git a/src/workspaces/adapters/pi.ts b/src/workspaces/adapters/pi.ts index a9791699b..38a4b1908 100644 --- a/src/workspaces/adapters/pi.ts +++ b/src/workspaces/adapters/pi.ts @@ -231,6 +231,7 @@ export const piAdapter: CliAdapter = { // immune to pi's lazy transcript write. assignsSessionId: true, headless: true, + interactiveActivity: 'terminal-osc-v1', aiProvider: { credentialSource: 'runtime-or-workspace', wirePreference: ['google-generative-ai', 'openai-chat', 'anthropic', 'openai-responses'], @@ -253,11 +254,9 @@ export const piAdapter: CliAdapter = { const ai = runtime.ai; const customProvider = !!ai && !!(ai.apiKey || ai.baseUrl); const model = runtime.binding.model; - const args = [ + const selectionArgs = [ ...(customProvider ? [ - '--extension', - join(cliBinPath(), 'pi-session-provider.ts'), '--provider', PI_SESSION_PROVIDER_ID, ] : []), @@ -266,6 +265,11 @@ export const piAdapter: CliAdapter = { ? ['--thinking', runtime.binding.reasoningEffort === 'none' ? 'off' : runtime.binding.reasoningEffort] : []), ]; + const interactiveArgs = [ + '--extension', + join(cliBinPath(), 'pi-session-provider.ts'), + ...selectionArgs, + ]; const env: Record = {}; if (customProvider && ai) { env[PI_SESSION_PROVIDER_ENV] = JSON.stringify({ @@ -273,7 +277,14 @@ export const piAdapter: CliAdapter = { provider: buildPiProvider(ctx.cwd, ai), }); } - return { env, interactiveArgs: args, headlessArgs: args, webArgs: args }; + // Activity OSC frames belong only to the terminal TUI. JSON headless and + // WebPi RPC surfaces keep their machine-readable stdout uncontaminated. + return { + env, + interactiveArgs, + headlessArgs: selectionArgs, + webArgs: selectionArgs, + }; }, }, diff --git a/src/workspaces/cli-adapter.ts b/src/workspaces/cli-adapter.ts index ad395ec1f..86ab768f0 100644 --- a/src/workspaces/cli-adapter.ts +++ b/src/workspaces/cli-adapter.ts @@ -289,6 +289,8 @@ export interface CliAdapter { * set this; `shell` does not (no agent-turn concept). */ readonly headless?: boolean; + /** Native interactive turns publish private terminal activity frames. */ + readonly interactiveActivity?: 'terminal-osc-v1'; /** * Native AI-provider projection contract. Shared credential/model logic * consumes this declaration instead of branching on adapter ids. Omit for diff --git a/src/workspaces/cli/bin/pi-session-provider.ts b/src/workspaces/cli/bin/pi-session-provider.ts index 4c33d5ef8..9c071d136 100644 --- a/src/workspaces/cli/bin/pi-session-provider.ts +++ b/src/workspaces/cli/bin/pi-session-provider.ts @@ -1,11 +1,29 @@ -// OpenAlice process-local Pi provider projection. -// Loaded explicitly with `pi --extension`; the secret-bearing provider payload -// arrives only through the child environment and is never written to argv or a -// product Session record. +// OpenAlice process-local Pi integration. +// Loaded explicitly with `pi --extension`; the optional secret-bearing provider +// payload arrives only through the child environment and is never written to +// argv or a product Session record. Interactive launches also publish native +// Agent activity through a private terminal OSC frame consumed by OpenAlice. -export default function openAliceSessionProvider(pi: { +const ACTIVITY_OSC = 6973 + +type PiExtension = { registerProvider(providerId: string, provider: Record): void -}): void { + on(event: 'agent_start' | 'agent_settled', handler: () => void | Promise): void +} + +function emitActivity(phase: 'working' | 'waiting'): void { + const sessionId = process.env['AQ_SESSION_ID'] + if (!sessionId || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return + process.stdout.write( + `\x1b]${ACTIVITY_OSC};openalice-session-activity;v=1;session=${sessionId};phase=${phase}\x1b\\`, + ) +} + +export default function openAliceSessionProvider(pi: PiExtension): void { + emitActivity('waiting') + pi.on('agent_start', () => emitActivity('working')) + pi.on('agent_settled', () => emitActivity('waiting')) + const raw = process.env['OPENALICE_PI_SESSION_PROVIDER'] if (!raw) return const value = JSON.parse(raw) as Record diff --git a/src/workspaces/headless-terminal-snapshot.spec.ts b/src/workspaces/headless-terminal-snapshot.spec.ts index 3a218738d..188be4e60 100644 --- a/src/workspaces/headless-terminal-snapshot.spec.ts +++ b/src/workspaces/headless-terminal-snapshot.spec.ts @@ -143,4 +143,24 @@ describe('HeadlessTerminalSnapshot', () => { terminal.dispose() } }) + + it('consumes private Session activity frames without rendering them', () => { + const activity: string[] = [] + const terminal = new HeadlessTerminalSnapshot({ + cols: 80, + rows: 24, + onSessionActivity: (payload) => activity.push(payload), + }) + try { + terminal.write('before\x1b]6973;openalice-session-activity;v=1;session=rec-1;phase=working\x1b\\after') + + expect(activity).toEqual([ + 'openalice-session-activity;v=1;session=rec-1;phase=working', + ]) + expect(terminal.snapshot()).toContain('beforeafter') + expect(terminal.snapshot()).not.toContain('openalice-session-activity') + } finally { + terminal.dispose() + } + }) }) diff --git a/src/workspaces/headless-terminal-snapshot.ts b/src/workspaces/headless-terminal-snapshot.ts index 3b308ea7e..347830491 100644 --- a/src/workspaces/headless-terminal-snapshot.ts +++ b/src/workspaces/headless-terminal-snapshot.ts @@ -9,6 +9,7 @@ import { type TerminalViewAttributeResponder, } from './terminal-view-attribute-responder.js' import type { TerminalViewAttributes } from './terminal-view-attributes.js' +import { SESSION_ACTIVITY_OSC } from './session-activity.js' // This beta is published as CommonJS even though its declaration file exposes // named exports. Native Node ESM therefore sees the package as one default @@ -30,6 +31,8 @@ export interface HeadlessTerminalSnapshotOptions { readonly rows: number readonly scrollbackRows?: number readonly onQueryReply?: (reply: string) => void + /** Receive OpenAlice-private adapter activity frames while consuming them. */ + readonly onSessionActivity?: (payload: string) => void } export interface HeadlessTerminalWriteOptions { @@ -89,6 +92,10 @@ export class HeadlessTerminalSnapshot { }, }) this.installColorSchemeUpdateTracking() + this.terminal.parser.registerOscHandler(SESSION_ACTIVITY_OSC, (payload) => { + options.onSessionActivity?.(payload) + return true + }) } write(data: string | Uint8Array, options: HeadlessTerminalWriteOptions = {}): void { diff --git a/src/workspaces/persistent-session.spec.ts b/src/workspaces/persistent-session.spec.ts index 7b2007be3..499918924 100644 --- a/src/workspaces/persistent-session.spec.ts +++ b/src/workspaces/persistent-session.spec.ts @@ -19,6 +19,7 @@ import * as pty from 'node-pty'; import { PersistentSession, type PersistentSessionOptions } from './persistent-session.js'; import type { Logger } from './logger.js'; import type { TerminalViewAttributes } from './terminal-view-attributes.js'; +import { encodeSessionActivityOsc } from './session-activity.js'; vi.mock('node-pty', () => ({ spawn: vi.fn() })); @@ -271,6 +272,41 @@ describe('PersistentSession backpressure / socket-drop deadlock', () => { session.dispose('test'); }); + + it('publishes native activity separately and replays the latest state on reconnect', () => { + const session = new PersistentSession(makeOptions({ initialAgentActivity: 'starting' })); + const first = new FakeWs(); + session.attach(first as never, 80, 24, undefined); + + expect(JSON.parse(first.send.mock.calls.at(-1)?.[0] as string)).toMatchObject({ + type: 'attached', + activity: { phase: 'starting' }, + }); + + term.emitData(Buffer.from(encodeSessionActivityOsc('rec-1', 'working'))); + expect(session.agentActivity.phase).toBe('working'); + expect(first.send.mock.calls.map(([data]) => data).filter((data) => typeof data === 'string')) + .toContainEqual(expect.stringContaining('"type":"activity"')); + + first.emit('close'); + term.emitData(Buffer.from(encodeSessionActivityOsc('rec-1', 'waiting'))); + const second = new FakeWs(); + session.attach(second as never, 80, 24, undefined); + + expect(JSON.parse(second.send.mock.calls.at(-1)?.[0] as string)).toMatchObject({ + type: 'attached', + activity: { phase: 'waiting' }, + }); + session.dispose('test'); + }); + + it('ignores activity frames authored for another Session', () => { + const session = new PersistentSession(makeOptions({ initialAgentActivity: 'starting' })); + term.emitData(Buffer.from(encodeSessionActivityOsc('other-session', 'waiting'))); + + expect(session.agentActivity.phase).toBe('starting'); + session.dispose('test'); + }); }); describe('PersistentSession controller lease', () => { diff --git a/src/workspaces/persistent-session.ts b/src/workspaces/persistent-session.ts index 4fe90d4a3..bc02891f9 100644 --- a/src/workspaces/persistent-session.ts +++ b/src/workspaces/persistent-session.ts @@ -13,6 +13,11 @@ import { terminalColorSchemeUpdateSequence, type TerminalViewAttributes, } from './terminal-view-attributes.js'; +import { + parseSessionActivityOsc, + type SessionAgentActivity, + type SessionAgentActivityPhase, +} from './session-activity.js'; export interface PersistentSessionOptions { /** The workspace this session belongs to (for routing, logging, cwd context). */ @@ -33,6 +38,8 @@ export interface PersistentSessionOptions { readonly onDisposed: () => void; readonly initialTerminalViewAttributes?: TerminalViewAttributes; readonly onTerminalViewAttributes?: (attributes: TerminalViewAttributes) => void; + /** Initial transient work state. Never persisted in SessionRegistry. */ + readonly initialAgentActivity?: SessionAgentActivityPhase; /** * V3.S5 — bytes prepended to the ReplayBuffer before the PTY spawns. Used * by shell resume: the prior session's scrollback is pushed back into the @@ -120,6 +127,7 @@ export class PersistentSession { */ private firstExit: { code: number; signal: number | null } | null = null; private exitWaiters: Set<(info: { code: number; signal: number | null }) => void> = new Set(); + private _agentActivity: SessionAgentActivity; constructor(opts: PersistentSessionOptions) { this.opts = opts; @@ -136,11 +144,16 @@ export class PersistentSession { } this.currentCols = clamp(opts.initialCols, 1, MAX_DIM); this.currentRows = clamp(opts.initialRows, 1, MAX_DIM); + this._agentActivity = { + phase: opts.initialAgentActivity ?? 'unavailable', + observedAt: Date.now(), + }; this.headless = new HeadlessTerminalSnapshot({ cols: this.currentCols, rows: this.currentRows, onQueryReply: (reply) => this.onHeadlessQueryReply(reply), + onSessionActivity: (payload) => this.onSessionActivity(payload), }); if (opts.initialTerminalViewAttributes) { this.terminalViewAttributes = opts.initialTerminalViewAttributes; @@ -219,6 +232,7 @@ export class PersistentSession { code: exitCode, signal, }); + this.setAgentActivity(exitCode === 0 ? 'stopped' : 'failed'); const now = Date.now(); this.respawnTimes = this.respawnTimes.filter((t) => now - t < RESPAWN_WINDOW_MS); @@ -242,6 +256,7 @@ export class PersistentSession { if (this.disposed) return; try { this.term = this.spawnChild(); + this.setAgentActivity('starting'); this.log.info('session.respawned', { pid: this.term.pid }); this.sendControl({ type: 'lifecycle', kind: 'child-respawn', pid: this.term.pid }); } catch (err) { @@ -283,6 +298,10 @@ export class PersistentSession { return this._startedAt; } + get agentActivity(): SessionAgentActivity { + return this._agentActivity; + } + /** * Resolve when the FIRST PTY child exits, or null when `timeoutMs` elapses * with the child still alive. Lets the REST spawn/resume handlers report @@ -421,6 +440,7 @@ export class PersistentSession { scrollbackTruncated, kittyKeyboardFlags: this.headless.getKittyKeyboardFlags(), colorSchemeUpdatesSubscribed: this.headless.getColorSchemeUpdatesSubscribed(), + activity: this._agentActivity, }; ws.send(JSON.stringify(attached)); this.lastCursorSeq = slice.tailSeq; @@ -461,6 +481,7 @@ export class PersistentSession { dispose(reason: string): void { if (this.disposed) return; + this.setAgentActivity('stopped'); this.disposed = true; if (this.cursorTimer) { clearInterval(this.cursorTimer); @@ -557,6 +578,23 @@ export class PersistentSession { } } + private onSessionActivity(payload: string): void { + if (this.disposed) return; + const phase = parseSessionActivityOsc(payload, this.opts.recordId); + if (!phase) { + this.log.warn('session.activity_frame_ignored'); + return; + } + this.setAgentActivity(phase); + } + + private setAgentActivity(phase: SessionAgentActivityPhase): void { + if (this._agentActivity.phase === phase) return; + this._agentActivity = { phase, observedAt: Date.now() }; + this.log.event('session.activity_changed', { phase }); + this.sendControl({ type: 'activity', activity: this._agentActivity }); + } + private onWsMessage(ws: WebSocket, raw: unknown, isBinary: boolean): void { if (this.disposed) return; if (this.ws !== ws) return; // stale (this ws was kicked) diff --git a/src/workspaces/protocol.ts b/src/workspaces/protocol.ts index 052009798..de9b731d5 100644 --- a/src/workspaces/protocol.ts +++ b/src/workspaces/protocol.ts @@ -17,6 +17,7 @@ import { validateTerminalViewAttributes, type TerminalViewAttributes, } from './terminal-view-attributes.js'; +import type { SessionAgentActivity } from './session-activity.js'; // ── client → server ───────────────────────────────────────────────────────── @@ -63,6 +64,8 @@ export interface AttachedMessage { readonly kittyKeyboardFlags: number; /** Whether the live TUI subscribed to Contour/Kitty color-scheme updates. */ readonly colorSchemeUpdatesSubscribed: boolean; + /** Latest native Agent work state, independent from PTY lifecycle. */ + readonly activity: SessionAgentActivity; } export interface CursorMessage { @@ -93,9 +96,15 @@ export interface ExitMessage { readonly signal: number | null; } +export interface ActivityMessage { + readonly type: 'activity'; + readonly activity: SessionAgentActivity; +} + export type ServerControlMessage = | AttachedMessage | CursorMessage + | ActivityMessage | LifecycleMessage | ExitMessage; diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index 2c1db0699..d5f5e888e 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -2142,6 +2142,9 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions replayBufferBytes: config.replayBufferBytes, highWatermarkBytes: config.bpHighWatermarkBytes, lowWatermarkBytes: config.bpLowWatermarkBytes, + initialAgentActivity: adapter.capabilities.interactiveActivity + ? isFresh && !!ctx.initialPrompt ? 'starting' : 'waiting' + : 'unavailable', ...(ctx.initialReplayBytes ? { initialReplayBytes: ctx.initialReplayBytes } : {}), }, adapter, diff --git a/src/workspaces/session-activity.spec.ts b/src/workspaces/session-activity.spec.ts new file mode 100644 index 000000000..a00792151 --- /dev/null +++ b/src/workspaces/session-activity.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { + encodeSessionActivityOsc, + parseSessionActivityOsc, +} from './session-activity.js' + +describe('Session activity OSC protocol', () => { + it('round-trips a scoped native activity frame', () => { + const frame = encodeSessionActivityOsc('rec-1', 'working') + const payload = frame.slice(frame.indexOf(';') + 1, -2) + + expect(frame).toBe( + '\x1b]6973;openalice-session-activity;v=1;session=rec-1;phase=working\x1b\\', + ) + expect(parseSessionActivityOsc(payload, 'rec-1')).toBe('working') + }) + + it('ignores malformed, cross-Session, and future-version frames', () => { + expect(parseSessionActivityOsc( + 'openalice-session-activity;v=1;session=rec-2;phase=waiting', + 'rec-1', + )).toBeNull() + expect(parseSessionActivityOsc( + 'openalice-session-activity;v=2;session=rec-1;phase=waiting', + 'rec-1', + )).toBeNull() + expect(parseSessionActivityOsc( + 'openalice-session-activity;v=1;session=rec-1;phase=teleporting', + 'rec-1', + )).toBeNull() + expect(parseSessionActivityOsc( + 'openalice-session-activity;v=1;v=1;session=rec-1;phase=working', + 'rec-1', + )).toBeNull() + }) +}) diff --git a/src/workspaces/session-activity.ts b/src/workspaces/session-activity.ts new file mode 100644 index 000000000..dbed70500 --- /dev/null +++ b/src/workspaces/session-activity.ts @@ -0,0 +1,77 @@ +/** + * Transient Agent work state for one live interactive Session. + * + * This is deliberately separate from SessionRecord.state: a native TUI can + * stay alive and resumable while the Agent is waiting for the next prompt. + * Activity is not persisted; adapter hooks publish a fresh snapshot whenever + * the native process starts or a turn changes state. + */ +export const SESSION_ACTIVITY_OSC = 6973 +export const SESSION_ACTIVITY_PROTOCOL_VERSION = 1 + +export type SessionAgentActivityPhase = + | 'starting' + | 'working' + | 'waiting' + | 'unavailable' + | 'failed' + | 'stopped' + +export interface SessionAgentActivity { + readonly phase: SessionAgentActivityPhase + readonly observedAt: number +} + +const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/ +const FRAME_PREFIX = 'openalice-session-activity' +const PHASES = new Set([ + 'starting', + 'working', + 'waiting', + 'unavailable', + 'failed', + 'stopped', +]) + +/** Encode a private OSC frame that is invisible to ordinary terminal output. */ +export function encodeSessionActivityOsc( + sessionId: string, + phase: SessionAgentActivityPhase, +): string { + if (!SESSION_ID_RE.test(sessionId)) throw new Error('invalid Session activity id') + const payload = [ + FRAME_PREFIX, + `v=${SESSION_ACTIVITY_PROTOCOL_VERSION}`, + `session=${sessionId}`, + `phase=${phase}`, + ].join(';') + return `\x1b]${SESSION_ACTIVITY_OSC};${payload}\x1b\\` +} + +/** + * Validate an adapter-authored OSC payload for the PTY that emitted it. + * Malformed, stale, cross-Session, or future-version frames are ignored. + */ +export function parseSessionActivityOsc( + payload: string, + expectedSessionId: string, +): SessionAgentActivityPhase | null { + if (!SESSION_ID_RE.test(expectedSessionId) || payload.length > 512) return null + const [prefix, ...parts] = payload.split(';') + if (prefix !== FRAME_PREFIX) return null + const fields = new Map() + for (const part of parts) { + const separator = part.indexOf('=') + if (separator <= 0 || separator === part.length - 1) return null + const key = part.slice(0, separator) + if (fields.has(key)) return null + fields.set(key, part.slice(separator + 1)) + } + if (fields.size !== 3) return null + if (fields.get('v') !== String(SESSION_ACTIVITY_PROTOCOL_VERSION)) return null + if (fields.get('session') !== expectedSessionId) return null + const phase = fields.get('phase') + return phase && PHASES.has(phase as SessionAgentActivityPhase) + ? phase as SessionAgentActivityPhase + : null +} diff --git a/src/workspaces/session-pool.ts b/src/workspaces/session-pool.ts index 2c79a2b5c..e0735674e 100644 --- a/src/workspaces/session-pool.ts +++ b/src/workspaces/session-pool.ts @@ -13,6 +13,7 @@ import { terminalViewAttributesEqual, type TerminalViewAttributes, } from './terminal-view-attributes.js'; +import type { SessionAgentActivity } from './session-activity.js'; /** * Per-attach context the factory uses to compose a fresh PersistentSession. @@ -66,6 +67,7 @@ export interface LiveSessionInfo { readonly startedAt: number; readonly agent: string; readonly agentSessionId: string | null; + readonly activity: SessionAgentActivity; } /** @@ -162,6 +164,7 @@ export class SessionPool { startedAt: s.startedAt, agent: adapter?.id ?? 'unknown', agentSessionId: s.agentSessionId, + activity: s.agentActivity, }); } return out; diff --git a/src/workspaces/session-runtime-binding.spec.ts b/src/workspaces/session-runtime-binding.spec.ts index 4db14b078..73df9b260 100644 --- a/src/workspaces/session-runtime-binding.spec.ts +++ b/src/workspaces/session-runtime-binding.spec.ts @@ -258,14 +258,18 @@ describe('built-in Agent Session runtime projection', () => { }, ) - it('projects the native model and effort flags on every launch surface', () => { + it('projects each native override only onto runtime surfaces that support it', () => { expect(claudeAdapter.sessionRuntime!.project(ctx, runtime).interactiveArgs) .toEqual(['--model', 'session-model', '--effort', 'high']) expect(codexAdapter.sessionRuntime!.project(ctx, runtime).headlessArgs) .toContain('model_reasoning_effort="high"') expect(opencodeAdapter.sessionRuntime!.project(ctx, runtime).headlessArgs) .toContain('--variant') - expect(piAdapter.sessionRuntime!.project(ctx, runtime).webArgs) + expect(piAdapter.sessionRuntime!.project(ctx, runtime).interactiveArgs) .toContain('--extension') + expect(piAdapter.sessionRuntime!.project(ctx, runtime).headlessArgs) + .not.toContain('--extension') + expect(piAdapter.sessionRuntime!.project(ctx, runtime).webArgs) + .not.toContain('--extension') }) }) diff --git a/ui/src/components/workspace/OverviewCard.spec.tsx b/ui/src/components/workspace/OverviewCard.spec.tsx index e576f030a..daa7b1c53 100644 --- a/ui/src/components/workspace/OverviewCard.spec.tsx +++ b/ui/src/components/workspace/OverviewCard.spec.tsx @@ -63,7 +63,7 @@ describe('OverviewCard', () => { ) const workspaceButton = screen.getByRole('button', { name: 'Research desk' }) - const sessionButton = screen.getByRole('button', { name: 'x1 running' }) + const sessionButton = screen.getByRole('button', { name: 'x1 Live' }) expect(workspaceButton.tagName).toBe('BUTTON') expect(sessionButton.tagName).toBe('BUTTON') workspaceButton.focus() @@ -107,10 +107,10 @@ describe('OverviewCard', () => { />, ) - expect(screen.getAllByRole('button', { name: / (running|paused)$/ })).toHaveLength(5) + expect(screen.getAllByRole('button', { name: / (Live|paused)$/ })).toHaveLength(5) expect(screen.getByRole('button', { name: 'x5 paused' })).toBeTruthy() expect(screen.queryByRole('button', { name: 'x6 paused' })).toBeNull() - expect(screen.getByRole('button', { name: 'x1 running' }).className).toContain('min-h-10') + expect(screen.getByRole('button', { name: 'x1 Live' }).className).toContain('min-h-10') expect(screen.getByRole('button', { name: 'x3 paused' }).closest('li')?.className) .toContain('hidden sm:list-item') @@ -146,7 +146,7 @@ describe('OverviewCard', () => { const viewAll = screen.getByRole('button', { name: 'View all 3 sessions' }) expect(viewAll.closest('li')?.className).toContain('sm:hidden') expect(viewAll.textContent).toContain('+1') - expect(screen.getByRole('button', { name: 'x3 running' }).closest('li')?.className) + expect(screen.getByRole('button', { name: 'x3 Live' }).closest('li')?.className) .toContain('hidden sm:list-item') }) }) diff --git a/ui/src/components/workspace/OverviewCard.tsx b/ui/src/components/workspace/OverviewCard.tsx index 96cc62a90..aaf9a009e 100644 --- a/ui/src/components/workspace/OverviewCard.tsx +++ b/ui/src/components/workspace/OverviewCard.tsx @@ -4,6 +4,11 @@ import { ArrowUpCircle, Bot, ChevronRight, Code, Cpu, GitBranch, ScrollText, Set import { useTranslation } from 'react-i18next' import type { GitLogEntry, Workspace } from './api' import { workspaceDisplayName, workspaceDisplayTitle } from './display' +import { + sessionActivityLabelKey, + sessionActivityTone, + sessionPresentationPhase, +} from './session-activity-ui' /** * Single-workspace card for the Workspaces Overview dashboard. Variant B @@ -140,7 +145,7 @@ export function OverviewCard({ > {/* Right-aligned, always-visible state-as-action: a running session shows STOP (■, click to pause it); a paused one shows PLAY (▶, click to diff --git a/ui/src/components/workspace/Terminal.tsx b/ui/src/components/workspace/Terminal.tsx index fe4865316..c007fa5c7 100644 --- a/ui/src/components/workspace/Terminal.tsx +++ b/ui/src/components/workspace/Terminal.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import type { ReactElement, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; @@ -9,6 +10,7 @@ import '@xterm/xterm/css/xterm.css'; import { parseServerControl, type ClientControlMessage, + type SessionAgentActivity, } from './protocol'; import { attachWebglRenderer } from './renderer'; import { @@ -19,6 +21,7 @@ import { installTerminalKeyboardController, } from './terminal-keyboard-controller'; import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker'; +import { sessionActivityDot, sessionActivityLabelKey } from './session-activity-ui'; import { colorSchemeUpdateSequence, terminalThemesEqual, @@ -189,6 +192,7 @@ export interface TerminalViewProps { * Fires once per WS lifetime when the server's `attached` message lands. */ readonly onAttached?: (sessionId: string) => void; + readonly onActivity?: (activity: SessionAgentActivity) => void; /** * Fires when the WS closes with 4404 — server doesn't recognize the * sessionId (record paused-since-poll-lag, server restarted, …). The @@ -206,7 +210,12 @@ export function TerminalView(props: TerminalViewProps): ReactElement { ); } const containerRef = useRef(null); + const { t } = useTranslation(); const [status, setStatus] = useState('connecting'); + const [agentActivity, setAgentActivity] = useState({ + phase: 'unavailable', + observedAt: 0, + }); const [pid, setPid] = useState(null); const [scrollbackTruncated, setScrollbackTruncated] = useState(false); const [exitInfo, setExitInfo] = useState(null); @@ -222,6 +231,8 @@ export function TerminalView(props: TerminalViewProps): ReactElement { const onAttachedRef = useRef(props.onAttached); onAttachedRef.current = props.onAttached; + const onActivityRef = useRef(props.onActivity); + onActivityRef.current = props.onActivity; const onSessionLostRef = useRef(props.onSessionLost); onSessionLostRef.current = props.onSessionLost; @@ -239,6 +250,7 @@ export function TerminalView(props: TerminalViewProps): ReactElement { if (!container) return undefined; setStatus('connecting'); + setAgentActivity({ phase: 'unavailable', observedAt: 0 }); setPid(null); setScrollbackTruncated(false); setExitInfo(null); @@ -543,6 +555,12 @@ export function TerminalView(props: TerminalViewProps): ReactElement { attachedColorSchemeSubscription = msg.colorSchemeUpdatesSubscribed; maybeFinishReplay(); onAttachedRef.current?.(msg.sessionId); + setAgentActivity(msg.activity); + onActivityRef.current?.(msg.activity); + break; + case 'activity': + setAgentActivity(msg.activity); + onActivityRef.current?.(msg.activity); break; case 'cursor': // No-op for now — see comment above on the URL `since` removal. @@ -666,7 +684,12 @@ export function TerminalView(props: TerminalViewProps): ReactElement { )} - {pid !== null ? `pid ${pid}` : ''} +