Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions PLANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
306 changes: 306 additions & 0 deletions plans/session-activity-terminal-delivery.md

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions scripts/session-activity-runtime-smoke.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}

async function main(): Promise<void> {
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(' -> ') || '<none>'}`);
}
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();

19 changes: 19 additions & 0 deletions src/webui/routes/workspaces-quickchat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions src/webui/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<base62>` for opencode. This
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down
33 changes: 33 additions & 0 deletions src/workspaces/adapters/ai-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
13 changes: 7 additions & 6 deletions src/workspaces/adapters/interactive-seed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ import { shellAdapter } from './shell.js';
* pi → … <prompt> (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`
Expand Down
55 changes: 51 additions & 4 deletions src/workspaces/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, Promise<readonly Record<string, unknown>[]>>();

function readOpenCodeSessionRows(cwd: string): Promise<readonly Record<string, unknown>[]> {
Expand Down Expand Up @@ -188,7 +219,7 @@ function parseJsonRecord(raw: string | null): Record<string, unknown> | null {
}
}

async function ensureOpenCodeTuiConfigExcluded(cwd: string): Promise<void> {
async function ensureOpenCodeLocalPathsExcluded(cwd: string): Promise<void> {
// OpenAlice workspaces are Git repositories, but adapter tests and external
// callers may prepare a plain directory. Do not manufacture a partial .git.
try {
Expand All @@ -198,9 +229,23 @@ async function ensureOpenCodeTuiConfigExcluded(cwd: string): Promise<void> {
}
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<void> {
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);
}

/**
Expand All @@ -211,7 +256,7 @@ async function ensureOpenCodeTuiConfigExcluded(cwd: string): Promise<void> {
* remains user-owned.
*/
export async function syncOpenCodeWorkspaceTheme(cwd: string): Promise<boolean> {
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.
Expand Down Expand Up @@ -296,6 +341,7 @@ export const opencodeAdapter: CliAdapter = {
// `opencode --session <id>` (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'],
Expand Down Expand Up @@ -346,6 +392,7 @@ export const opencodeAdapter: CliAdapter = {
lifecycle: {
async prepareWorkspace({ cwd }): Promise<void> {
await syncOpenCodeWorkspaceTheme(cwd);
await syncOpenCodeSessionActivityPlugin(cwd);
},
},

Expand Down
Loading
Loading