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..2a0c075a6 --- /dev/null +++ b/plans/session-activity-terminal-delivery.md @@ -0,0 +1,306 @@ +# 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 was +launched with a `--variant` option that only `opencode run` accepts, so the +child exited before activity or output behavior could be exercised. That +immediate startup defect was deliberately isolated in PR #1037, merged to +`dev` as `a49133a1`, and is not part of the lifecycle proposal's acceptance +decision. This Draft branch now includes that `dev` baseline. + +## 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? +5. Should Pi keep provider registration and activity reporting in one extension + with an explicit interactive-only activity guard, or split them into two + independently loaded extensions? The current prototype incorrectly removes + the provider extension from headless/WebPi while retaining + `--provider openalice-session`. + +## 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. + +## Pi Surface Ownership (Recommended, Not Yet Approved) + +The three Pi launch surfaces have different transports and must not share one +implicit stdout behavior: + +| Surface | Process transport | OpenAlice identity | Extension ownership | +|---|---|---|---| +| Terminal TUI | `node-pty` | `AQ_SESSION_ID` | activity always; provider only for injected access | +| Headless JSON | `child_process` pipes | `AQ_RUN_ID` | provider only for injected access | +| WebPi RPC | `child_process` pipes | `AQ_SESSION_ID` | provider only for injected access; activity comes from RPC state | + +Three repair designs were considered after package acceptance exposed the +missing provider registration: + +1. Reload the combined extension everywhere and gate activity with + `process.stdout.isTTY`. This is the smallest patch, but it makes semantic + ownership depend on a transport heuristic and is vulnerable to wrappers. +2. Add per-surface environment maps to `AgentSessionRuntimeProjection`. This is + explicit, but expands a shared adapter contract merely to separate two Pi + responsibilities that already have distinct files and argv groups. +3. Restore `pi-session-provider.ts` to provider registration only, add a + separate `pi-session-activity.ts`, and compose the two repeatable + `--extension` flags per surface. Pi 0.83 documents repeated extension flags, + and this keeps JSON/RPC stdout structurally incapable of receiving activity + OSC frames. + +Option 3 is the current recommendation. It is recorded for design review and +has not been implemented. The activity extension should remember the final +assistant `stopReason` observed at `agent_end`, then emit `failed` versus +`waiting` only at `agent_settled`. Pi deliberately omits `willRetry` from the +extension-facing `agent_end`, while `agent_settled` fires only after automatic +retry, compaction, and queued continuation have all finished. Emitting failure +earlier would therefore display a transient failure during a successful retry. + +## 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 + +- [x] 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. +- [ ] Restore process-local Pi provider registration for headless and WebPi + launches without allowing activity OSC frames onto machine-readable + stdout. +- [ ] 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. +- [x] 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. +- [x] 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. +- [x] Run root/UI typechecks and the monorepo test suite. +- [ ] Walk the real Chat route in `pnpm dev` and a Docker-shaped HTTP launch. +- [x] Run the relevant Electron/PT​​Y smoke and record platform-only residual + risk without invoking release signing. +- [x] Open one labeled Draft PR targeting `dev`; keep it unmerged pending topic + acceptance. + +## Verification Evidence + +Recorded on 2026-08-09 against the proposal branch: + +- `pnpm exec vitest run src/workspaces/persistent-session.spec.ts` — 15 tests + passed, including a first reply emitted before attach, output emitted while + disconnected, cold reconnect replay, current activity replay, and identity / + activity separation. +- The Quick Chat delivery chain is now covered at each ownership boundary: + `workspaces-quickchat.spec.ts` proves the normalized prompt enters + `SessionFactoryContext.initialPrompt` before any renderer attach; + `interactive-seed.spec.ts` proves each native adapter places it in the + correct interactive argv; and `persistent-session.spec.ts` proves the first + resulting reply survives late attach and reconnect. The focused chain passes + 61 tests across those three files. +- Component-level UI regressions now render the Sidebar Session row, Workspace + Session library, and Workspace overview card with a live PTY whose native + Agent activity is `waiting`; all three assert `Ready` rather than `Working` + while preserving the running/lifecycle controls. The focused UI set passes + 18 tests across four files. +- The ordinary Workspace list projection now includes the same activity + snapshot as manager/resume routes. A shared projection function gives native + terminal activity precedence, maps every WebPi RPC phase, and marks records + without a live process stopped; its focused REST/projection/replay set passes + 76 tests. Demo Workspace fixtures and handlers also expose waiting, working, + and starting snapshots, so `dev:demo` no longer silently exercises only the + rolling-upgrade `Live` fallback. +- A repository-wide consumer audit found `transcript.session.captured` only at + its structured-log emission site in `transcript-watcher.ts`. Its downstream + path stores `agentSessionId` for resume identity; no UI, terminal, or activity + transition subscribes to that log event. `persistent-session.spec.ts` also + proves `setAgentSessionId()` leaves the current Agent activity unchanged. +- `npx tsc --noEmit`, `cd ui && npx tsc -b`, and + `npx tsc -p apps/desktop/tsconfig.json --noEmit` — passed. +- `pnpm electron:build` — passed as an unsigned development build. +- `CSC_IDENTITY_AUTO_DISCOVERY=false pnpm electron:smoke:pty --skip-build` — + passed with a real Electron IPC PTY attach and CLI socket round trip. +- `pnpm docker:smoke` — passed after building an isolated image, opening the + HTTP Workspace PTY WebSocket, executing the injected `alice` CLI, and + offboarding the temporary Workspace. No AI credential or broker was loaded. +- `pnpm exec tsx scripts/session-activity-runtime-smoke.ts --agent ` + now provides an opt-in native/global-login acceptance path. In a disposable + checkout combining this proposal with the then-isolated OpenCode argv fix + from #1037, real OpenCode and Pi turns both emitted + `waiting -> working -> waiting`, and both PTY processes remained alive after + settling. Before #1037 merged, running the configured-effort OpenCode check + on this proposal alone reproduced the pre-plugin argv failure, preserving + the dependency boundary instead of folding that fix into this PR. The Draft + branch was synchronized with `dev` after #1037 merged. +- GitHub Desktop Package Smoke currently fails on macOS and Windows because the + proposal leaves `--provider openalice-session` in Pi headless arguments after + removing the extension that registers that provider. The acceptance process + reports `Unknown provider "openalice-session"`; this is a proposal regression, + not an infrastructure-only failure. A follow-on design decision must either + guard activity emission while loading the shared extension on every required + surface, or split provider registration from interactive activity reporting. + +The real Chat route remains an explicit manual acceptance item. The in-app +browser automation surface rejected interaction with the existing localhost +tab under its URL security policy. The Vercel preview was also protected by the +team login gate, so this proposal does not claim visual browser acceptance from +either route. + +## 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/scripts/session-activity-runtime-smoke.ts b/scripts/session-activity-runtime-smoke.ts new file mode 100644 index 000000000..5556acbaf --- /dev/null +++ b/scripts/session-activity-runtime-smoke.ts @@ -0,0 +1,158 @@ +#!/usr/bin/env tsx +/** + * Opt-in native TUI acceptance for the Session activity bridge. + * + * This intentionally uses the runtime's existing native/global login. It does + * not read, print, or copy OpenAlice credentials. A successful run proves that + * a real interactive turn emits waiting -> working -> waiting while the PTY + * process remains alive for another prompt. + * + * Usage: + * pnpm exec tsx scripts/session-activity-runtime-smoke.ts --agent opencode + * pnpm exec tsx scripts/session-activity-runtime-smoke.ts --agent pi + */ + +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import * as pty from 'node-pty'; + +import type { + CliAdapter, + ResolvedSessionRuntimeBinding, + SpawnContext, +} from '../src/workspaces/cli-adapter.js'; +import { opencodeAdapter } from '../src/workspaces/adapters/opencode.js'; +import { piAdapter } from '../src/workspaces/adapters/pi.js'; + +type AgentId = 'opencode' | 'pi'; +type ActivityPhase = 'starting' | 'working' | 'waiting' | 'unavailable' | 'failed' | 'stopped'; + +const repoRoot = resolve(import.meta.dirname, '..'); +const prompt = 'Reply with exactly OPENALICE_ACTIVITY_SMOKE, then wait for another message. Do not use tools.'; +const timeoutMs = 180_000; + +function parseAgent(argv: readonly string[]): AgentId { + const index = argv.indexOf('--agent'); + const value = index >= 0 ? argv[index + 1] : undefined; + if (value === 'opencode' || value === 'pi') return value; + throw new Error('Usage: --agent opencode|pi'); +} + +function adapterFor(agent: AgentId): CliAdapter { + return agent === 'opencode' ? opencodeAdapter : piAdapter; +} + +function runtimeFor(agent: AgentId): ResolvedSessionRuntimeBinding { + return { + binding: { + version: 1, + credential: { source: 'native' }, + model: agent === 'opencode' + ? 'opencode/deepseek-v4-flash-free' + : 'deepseek/deepseek-v4-flash', + reasoningEffort: 'low', + }, + ai: null, + }; +} + +function hasSequence(phases: readonly ActivityPhase[]): boolean { + const expected: readonly ActivityPhase[] = ['waiting', 'working', 'waiting']; + let cursor = 0; + for (const phase of phases) { + if (phase === expected[cursor]) cursor += 1; + if (cursor === expected.length) return true; + } + return false; +} + +async function wait(ms: number): Promise { + await new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +async function main(): Promise { + const agent = parseAgent(process.argv.slice(2)); + const adapter = adapterFor(agent); + const cwd = await mkdtemp(join(tmpdir(), `openalice-${agent}-activity-smoke-`)); + const sessionId = `smoke-${agent}-${randomUUID()}`; + const phases: ActivityPhase[] = []; + let exited = false; + let terminal = ''; + + try { + await writeFile(join(cwd, 'README.md'), '# OpenAlice activity smoke\n', 'utf8'); + await adapter.lifecycle?.prepareWorkspace?.({ + wsId: `smoke-${agent}`, + cwd, + launcherRepoRoot: repoRoot, + }); + + if (!adapter.sessionRuntime) throw new Error(`${agent} has no Session runtime projection`); + const baseEnv = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + const projection = adapter.sessionRuntime.project({ cwd, env: baseEnv }, runtimeFor(agent)); + const env = { + ...baseEnv, + ...projection.env, + AQ_SESSION_ID: sessionId, + OPENCODE_DISABLE_AUTOUPDATE: '1', + OPENCODE_DISABLE_LSP_DOWNLOAD: '1', + TERM: 'xterm-256color', + }; + const context: SpawnContext = { + cwd, + env, + initialPrompt: prompt, + sessionRuntime: projection, + ...(agent === 'pi' ? { resume: { sessionId: randomUUID() }, approveProject: true } : {}), + }; + const command = adapter.composeCommand([adapter.binary ?? agent], context); + const [binary, ...args] = command; + if (!binary) throw new Error('adapter composed an empty command'); + + console.log(`[activity-smoke] ${agent}: launching native TUI`); + const term = pty.spawn(binary, args, { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd, + env, + encoding: null, + }); + term.onExit(() => { exited = true; }); + term.onData((chunk) => { + terminal += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk; + if (terminal.length > 1_000_000) terminal = terminal.slice(-1_000_000); + const pattern = /\x1b\]6973;openalice-session-activity;v=1;session=([^;]+);phase=([^\x1b]+)\x1b\\/g; + let match: RegExpExecArray | null; + const observed: ActivityPhase[] = []; + while ((match = pattern.exec(terminal)) !== null) { + if (match[1] !== sessionId) continue; + const phase = match[2] as ActivityPhase; + if (!observed.includes(phase) || observed.at(-1) !== phase) observed.push(phase); + } + phases.length = 0; + phases.push(...observed); + }); + + const deadline = Date.now() + timeoutMs; + while (!hasSequence(phases) && !exited && Date.now() < deadline) await wait(100); + if (!hasSequence(phases)) { + throw new Error(`${agent} did not emit waiting -> working -> waiting; observed: ${phases.join(' -> ') || ''}`); + } + await wait(1_000); + if (exited) throw new Error(`${agent} exited after settling instead of keeping its TUI alive`); + + console.log(`[activity-smoke] ${agent}: ${phases.join(' -> ')}; PTY pid ${term.pid} remains alive`); + term.kill(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +} + +await main(); + diff --git a/src/webui/routes/workspaces-quickchat.spec.ts b/src/webui/routes/workspaces-quickchat.spec.ts index 9437e616c..2b38c31fc 100644 --- a/src/webui/routes/workspaces-quickchat.spec.ts +++ b/src/webui/routes/workspaces-quickchat.spec.ts @@ -496,6 +496,25 @@ describe('GET /credentials — Quick Chat launch metadata', () => { }); describe('POST /quick-chat — native auth and explicit credential overrides', () => { + it('hands the normalized seed prompt to the Session pool before any terminal attach', async () => { + vi.mocked(readCredentials).mockResolvedValue({}); + const { app, spawn } = build(); + + const result = await quickChat(app, { + prompt: ' preserve the leading and trailing context ', + agent: 'opencode', + }); + + expect(result.status).toBe(201); + expect(spawn).toHaveBeenCalledOnce(); + expect((spawn.mock.calls[0] as any[])[1]).toMatchObject({ + agentId: 'opencode', + initialPrompt: 'preserve the leading and trailing context', + recordId: expect.any(String), + recordName: 'o1', + }); + }); + it('opencode + empty vault → native launch without injection', async () => { vi.mocked(readCredentials).mockResolvedValue({}); const { app, opencode, spawn } = build(); diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index afcd31ac0..fbeb1b550 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -85,6 +85,10 @@ import { managerTerminalPrompt, managerSkillPath, } from '../../workspaces/manager-workspace.js'; +import { + projectSessionAgentActivity, + 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 +195,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; @@ -486,6 +491,11 @@ export function createWorkspaceRoutes( startedAt: terminal?.startedAt ?? browser?.startedAt ?? null, title: sessionPreferredTitle(record) ?? null, sourceRunId: record.sourceRunId ?? null, + activity: projectSessionAgentActivity({ + terminal: terminal?.agentActivity, + browser, + lastActiveAt: record.lastActiveAt, + }), ...(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/interactive-seed.spec.ts b/src/workspaces/adapters/interactive-seed.spec.ts index 34fde9ee1..1b9555862 100644 --- a/src/workspaces/adapters/interactive-seed.spec.ts +++ b/src/workspaces/adapters/interactive-seed.spec.ts @@ -17,12 +17,13 @@ import { shellAdapter } from './shell.js'; * pi → … (bare trailing positional; pi REJECTS `--`) * shell → ignored (no agent to receive a prompt) * - * Scope note: this exercises `composeCommand` in isolation, NOT the launcher - * integration (the pool factory / `composeSpawnInputs`) nor platform resolution - * (`win-command.ts`). Two contracts live UPSTREAM of composeCommand and are NOT - * covered here: - * - FRESH-ONLY gating: the route + factory only ever set `initialPrompt` on a - * fresh spawn. claude/codex/opencode ALSO self-gate on `resume === undefined` + * Scope note: this exercises `composeCommand` in isolation, NOT the pool factory + * / `composeSpawnInputs` nor platform resolution (`win-command.ts`). The + * `/quick-chat` route handoff into `SessionFactoryContext.initialPrompt` is + * covered in `workspaces-quickchat.spec.ts`. Two contracts live UPSTREAM of + * composeCommand: + * - FRESH-ONLY gating: the route + factory only set `initialPrompt` on a fresh + * spawn. claude/codex/opencode ALSO self-gate on `resume === undefined` * (asserted below); pi does NOT (it appends the seed alongside its assigned * `--session-id`, because pi mints its id at spawn — see the pi case below). * - win32 shim safety: opencode/pi are `.cmd` shims, so `composeSpawnInputs` diff --git a/src/workspaces/adapters/opencode.ts b/src/workspaces/adapters/opencode.ts index 3806e922f..5f1c612c1 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'], @@ -346,6 +392,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..ca6174172 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,85 @@ describe('PersistentSession backpressure / socket-drop deadlock', () => { session.dispose('test'); }); + + it('preserves a Quick Chat first reply across late attach and WebSocket reconnect', () => { + const session = new PersistentSession(makeOptions()); + + // Quick Chat can submit its seed prompt before the terminal renderer is + // mounted. The first assistant response therefore arrives with no socket. + term.emitData(Buffer.from('Assistant: first reply\r\n')); + + const first = new FakeWs(); + session.attach(first as never, 80, 24, undefined); + const firstReplay = first.send.mock.calls + .map(([data]) => data) + .filter((data): data is Buffer => Buffer.isBuffer(data)) + .map((data) => data.toString('utf8')) + .join(''); + expect(firstReplay).toContain('Assistant: first reply'); + + // Output produced after the renderer disconnects must be reflected in the + // authoritative headless screen restored by a fresh cold attach. + first.emit('close'); + term.emitData(Buffer.from('Assistant: follow-up while disconnected\r\n')); + + const second = new FakeWs(); + session.attach(second as never, 80, 24, undefined); + const reconnectReplay = second.send.mock.calls + .map(([data]) => data) + .filter((data): data is Buffer => Buffer.isBuffer(data)) + .map((data) => data.toString('utf8')) + .join(''); + expect(reconnectReplay).toContain('Assistant: first reply'); + expect(reconnectReplay).toContain('Assistant: follow-up while disconnected'); + + 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'); + }); + + it('keeps transcript identity capture independent from Agent activity', () => { + const session = new PersistentSession(makeOptions({ initialAgentActivity: 'working' })); + + session.setAgentSessionId('native-conversation-id'); + + expect(session.agentSessionId).toBe('native-conversation-id'); + expect(session.agentActivity.phase).toBe('working'); + 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..71e0667a3 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -305,6 +305,7 @@ export function launchEnvironmentDisclosure( return out; } import { ScrollbackStore } from './scrollback-store.js'; +import { projectSessionAgentActivity } from './session-activity.js'; import { SessionPool, type SessionFactoryContext } from './session-pool.js'; import { SessionRegistry, @@ -2142,6 +2143,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, @@ -2346,6 +2350,11 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions startedAt: terminal?.startedAt ?? browser?.startedAt ?? null, title: sessionPreferredTitle(r) ?? null, sourceRunId: r.sourceRunId ?? null, + activity: projectSessionAgentActivity({ + terminal: terminal?.agentActivity, + browser, + lastActiveAt: r.lastActiveAt, + }), }; }); // Workspace AI provider override signals — read by the Overview diff --git a/src/workspaces/session-activity.spec.ts b/src/workspaces/session-activity.spec.ts new file mode 100644 index 000000000..6fe829ba4 --- /dev/null +++ b/src/workspaces/session-activity.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import { + encodeSessionActivityOsc, + parseSessionActivityOsc, + projectSessionAgentActivity, +} 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() + }) +}) + +describe('public Session activity projection', () => { + it('preserves the terminal-native snapshot verbatim', () => { + const terminal = { phase: 'waiting' as const, observedAt: 42 } + expect(projectSessionAgentActivity({ + terminal, + browser: { phase: 'working', startedAt: 7 }, + lastActiveAt: '2026-08-09T00:00:00.000Z', + })).toBe(terminal) + }) + + it.each([ + ['starting', 'starting'], + ['idle', 'waiting'], + ['working', 'working'], + ['compacting', 'working'], + ['retrying', 'working'], + ['failed', 'failed'], + ['stopped', 'stopped'], + ] as const)('maps WebPi %s to %s', (browserPhase, expectedPhase) => { + expect(projectSessionAgentActivity({ + browser: { phase: browserPhase, startedAt: 77 }, + lastActiveAt: '2026-08-09T00:00:00.000Z', + })).toEqual({ phase: expectedPhase, observedAt: 77 }) + }) + + it('marks a record without a live process stopped using a safe timestamp', () => { + expect(projectSessionAgentActivity({ + lastActiveAt: '2026-08-09T00:00:00.000Z', + })).toEqual({ phase: 'stopped', observedAt: Date.parse('2026-08-09T00:00:00.000Z') }) + expect(projectSessionAgentActivity({ lastActiveAt: 'not-a-date' })) + .toEqual({ phase: 'stopped', observedAt: 0 }) + }) +}) diff --git a/src/workspaces/session-activity.ts b/src/workspaces/session-activity.ts new file mode 100644 index 000000000..2431acfb0 --- /dev/null +++ b/src/workspaces/session-activity.ts @@ -0,0 +1,117 @@ +/** + * 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 +} + +type WebPiActivityPhase = + | 'starting' + | 'idle' + | 'working' + | 'compacting' + | 'retrying' + | 'stopped' + | 'failed' + +/** + * Project the shared public activity snapshot from the two interactive + * transports. Terminal sessions own an explicit native snapshot; WebPi owns + * an RPC state machine; a record with no live process is stopped. Keeping this + * mapping here prevents REST surfaces from drifting on lifecycle semantics. + */ +export function projectSessionAgentActivity(input: { + readonly terminal?: SessionAgentActivity | null + readonly browser?: { + readonly phase: WebPiActivityPhase + readonly startedAt: number + } | null + readonly lastActiveAt: string +}): SessionAgentActivity { + if (input.terminal) return input.terminal + if (input.browser) { + const phase: SessionAgentActivityPhase = + input.browser.phase === 'idle' ? 'waiting' + : input.browser.phase === 'failed' ? 'failed' + : input.browser.phase === 'stopped' ? 'stopped' + : input.browser.phase === 'starting' ? 'starting' + : 'working' + return { phase, observedAt: input.browser.startedAt } + } + const fallbackObservedAt = Date.parse(input.lastActiveAt) + return { + phase: 'stopped', + observedAt: Number.isFinite(fallbackObservedAt) ? fallbackObservedAt : 0, + } +} + +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..8be00cecb 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,28 @@ 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') }) + + it('labels a live waiting Session as ready instead of working', () => { + render( + undefined} + onOpenSession={() => undefined} + />, + ) + + const sessionButton = screen.getByRole('button', { name: 'x1 Ready' }) + expect(screen.getByText('Ready').className).toContain('text-success') + expect(sessionButton).toBeTruthy() + }) }) 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}` : ''} +