@sanqianx/claude-ai-workbench 0.13.1 → 0.13.2-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,10 +8,16 @@
8
8
 
9
9
  外壳使用真实 Agent Terminal 页面和 REST/SSE,不重新实现消息流。`embed=1` 与可配置 `apiBase` 隐藏重复品牌/项目管理,并接受同源父窗口的项目、主题与知识范围消息;独立产品行为保持不变。
10
10
 
11
+ 重复收到当前项目的嵌入通知不会取消正在创建的会话;真正切换项目时仍隔离迟到的响应和草稿。项目查找尚未完成时,以最新的选择为准。
12
+
11
13
  受信任本地外壳通过 `KB_WORKBENCH_RUNTIME_TOKEN`、`KB_EVENT_BRIDGE_URL` 和引擎地址连接后台服务。原生工作台捕获原始用户输入及成功最终回答,通过持久化 outbox 投递到 bridge 的鉴权入口;不捕获系统提示词、注入的知识、工具输出或供应商凭据。`/api/health` 中 `conversationCapture` 展示启用状态、待投递数和错误;已有用户显式关闭的捕获设置不会被覆盖。桥接不可用时保留待投递记录,恢复或重启后继续,不伪造会话。
12
14
 
13
15
  工作台是产品供应商与凭据的活动配置源。后台维护默认模型持久化,不随会话临时模型切换;解析后的凭据只交给鉴权本地服务,不进入浏览器消息。SDK 会话从当前项目登记的工作区恢复,也兼容旧登记文件。
14
16
 
17
+ 四客户端的会话历史使用同目录临时文件原子替换。磁盘写满、临时写入失败或 Windows 拒绝替换时保留上一次完整记录,不截断或删除原历史;空间恢复后新会话仍可正常持久化。已经被旧版截断的历史不能凭空恢复。
18
+
19
+ MiniMax 向量模型使用独立的 `/v1/embeddings` 接口,不复用聊天的 `/anthropic` 路由。官方域名的已知接口后缀在运行期规范化为服务根地址,现有配置文件不被迁移或改写;自定义网关路径保持原样。
20
+
15
21
  Windows 上 Claude Code 探测与实际启动使用同一解析结果:失效的 `CLAUDE_CODE_EXECPATH` 不遮住有效安装,npm/CMD/PowerShell 启动脚本解析成实际文件,JavaScript 入口用当前 Node 探测版本。`/system/doctor` 返回实际启动路径、被跳过的失效路径及版本探测错误。发现和会话失败使用 `log/v2`;受外壳守护时写入外壳统一诊断,独立运行保存在工作台数据目录的 `diagnostics.jsonl`,不记录凭据或原始提示词。
16
22
 
17
23
  ## 仓库结构
@@ -59,10 +65,14 @@ npm run start:agent-terminal
59
65
  - 页面:`http://127.0.0.1:5760/agent-terminal/`
60
66
  - 数据目录:`~/.agent-terminal`(`AGENT_TERMINAL_DATA_DIR` 覆盖;端口 `AGENT_TERMINAL_PORT`)
61
67
  - 首次使用:设置 → 模型供应商 → 添加供应商(Base URL / API Key / 模型列表),然后添加本地项目文件夹即可开聊。
62
- - Agent 发现自动扫描本机:Claude Code(Agent SDK,支持 Anthropic 兼容端点如 MiniMax)、Codex(`codex exec --json`,使用自身登录)、OpenCode(`opencode run --format json`)、ZCode(已安装但暂无 headless CLI,如实标注不可用)。
68
+ - Agent 发现自动扫描本机:Claude Code(Agent SDK,支持 Anthropic 兼容端点如 MiniMax)、Codex(app-server,保留 CLI 回退)、OpenCode(serve + SSE,保留 CLI 回退)、ZCode(桌面内置 app-server)。版本探测成功不等于认证或任务执行成功,实际运行失败另写诊断日志。
69
+ - OpenCode 启动健康检查的期限同时约束连接、响应头与 JSON 响应体,超时释放连接及未就绪子进程并按现有策略回退 CLI;不会等待操作系统的长连接超时再把任务误报为模块不可达。
70
+ - ZCode 原生认证使用已有桌面供应商配置,保留显式环境变量覆盖及个人配置;共享 Workbench 模型通过临时本机连接接入,上游 API Key 只在内存中,临时文件仅保存本机访问令牌,关闭时清理。不改写桌面安装资源或用户模型配置。同时支持旧版 runtimeModel 与新版 modelSelection 协议,失败和完成事件均结束当前任务。
63
71
  - 零点击模型检测:填好 Base URL 与 API Key 后自动探测 `/v1/models` 并合并 models.dev 能力数据(上下文窗口、模态、推理),保存时自动填充模型列表与上下文大小。
64
72
  - 会话内模型切换:切换供应商/模型不换会话,历史跨供应商延续(Claude 保留 resume,CLI 保留线程参数)。
65
73
  - 运行时体验对齐:中断不再误报退出码错误、stderr 聚合为单个折叠、上下文用量按轮取最大值而非累计求和。
74
+ - 交互会话不设默认整任务时限,原生 Agent 可以连续工作数小时;任务仍会在正常完成、无法恢复的请求或进程错误、手动中止或服务关闭时结束,不保证永远运行。
75
+ - `profile.timeoutMs` 仍只是单次模型请求超时(Claude 环境变量 `API_TIMEOUT_MS`),不作为任务总时限;知识引擎的后台维护超时不受此改动影响。
66
76
 
67
77
  ## 启动开发服务器
68
78
 
@@ -40,6 +40,8 @@ button { cursor:pointer; color:inherit; }
40
40
  .fin:focus-visible, .pop-search:focus-visible, .composer textarea:focus-visible { outline:none; }
41
41
  .view { display:none; height:100vh; }
42
42
  .view.active { display:flex; }
43
+ .view[inert] { cursor:wait; }
44
+ .view[inert] button, .view[inert] textarea { opacity:.55; }
43
45
  .linklike { border:0; color:var(--blue); background:transparent; padding:0; font-size:inherit; }
44
46
  .linklike:hover { text-decoration:underline; }
45
47
 
@@ -5,9 +5,15 @@ const embedParams = new URLSearchParams(location.search);
5
5
  const embedded = embedParams.get('embed') === '1';
6
6
  const configuredApiBase = embedParams.get('apiBase');
7
7
  const apiBase = configuredApiBase && /^\/api\/[a-z0-9/-]+$/i.test(configuredApiBase) ? configuredApiBase.replace(/\/$/, '') : '/api/claude-workbench/v1';
8
+ function abortStopLabel(reason) {
9
+ // Historic events carry no reason; user aborts keep the neutral wording.
10
+ if (reason === 'runtime-timeout') return '运行超时,已停止';
11
+ return '已停止';
12
+ }
8
13
  let selectionEpoch = 0;
9
14
  let sessionEpoch = 0;
10
15
  let sessionCreationEpoch = 0;
16
+ let pendingEmbedSelection = null;
11
17
  const projectDrafts = new Map();
12
18
  function savedProjectDraft(projectId, text) {
13
19
  try {
@@ -158,6 +164,10 @@ async function init() {
158
164
  wireComposer();
159
165
  wireSettings();
160
166
  wireProjectModal();
167
+ $('view-terminal').inert = false;
168
+ $('view-terminal').setAttribute('aria-busy', 'false');
169
+ $('view-settings').inert = false;
170
+ if (!state.activeSessionId) $('session-title').textContent = '选择或创建一个会话';
161
171
  // Ready is the usable-page boundary: the host may immediately select a
162
172
  // project or open settings, so data and handlers must both be available.
163
173
  if (window.parent !== window) window.parent.postMessage({ type: 'kb:ready', app: 'agent-terminal' }, '*');
@@ -1103,7 +1113,7 @@ function renderEvent(event) {
1103
1113
  endTurnVisuals();
1104
1114
  {
1105
1115
  const stopNote = el('div', 'turn-note');
1106
- stopNote.append(icon('stop', 12), el('span', null, ' 已停止'));
1116
+ stopNote.append(icon('stop', 12), el('span', null, ` ${abortStopLabel(event.reason)}`));
1107
1117
  col.append(stopNote);
1108
1118
  }
1109
1119
  {
@@ -2407,11 +2417,19 @@ window.addEventListener('message', async ev => {
2407
2417
  if (data.type === 'kb:open-settings') { openSettings('providers'); return; }
2408
2418
  if (data.type === 'kb:sidebar') setEmbedSidebarVisible(!!data.visible);
2409
2419
  else if (data.type === 'kb:select-project') {
2410
- const epoch = ++selectionEpoch;
2411
- if (!embedFindProject(data.project)) await refreshProjects();
2412
- if (epoch !== selectionEpoch) return;
2413
- reply({ type: 'kb:projects', ok: embedSelectProject(data.project), activeProjectId: state.activeProjectId,
2414
- projects: state.projects.map(p => ({ id: p.id, name: p.name, rootPath: p.rootPath })) });
2420
+ const project = embedFindProject(data.project);
2421
+ // Repeated context receipts are not a workspace change. Invalidating a
2422
+ // creation here leaves a real backend session orphaned in the sidebar.
2423
+ const changed = !project || project.id !== state.activeProjectId || !!pendingEmbedSelection;
2424
+ const epoch = changed ? ++selectionEpoch : selectionEpoch;
2425
+ const pending = changed ? { epoch } : null;
2426
+ if (pending) pendingEmbedSelection = pending;
2427
+ try {
2428
+ if (!project) await refreshProjects();
2429
+ if (epoch !== selectionEpoch) return;
2430
+ reply({ type: 'kb:projects', ok: embedSelectProject(data.project), activeProjectId: state.activeProjectId,
2431
+ projects: state.projects.map(p => ({ id: p.id, name: p.name, rootPath: p.rootPath })) });
2432
+ } finally { if (pendingEmbedSelection === pending) pendingEmbedSelection = null; }
2415
2433
  } else if (data.type === 'kb:list-sessions') {
2416
2434
  const project = embedFindProject(data.project);
2417
2435
  if (!project) { reply({ type: 'kb:sessions', ok: false, project: data.project, sessions: [] }); return; }
@@ -9,7 +9,7 @@
9
9
  <body>
10
10
 
11
11
  <!-- ============ 终端视图 ============ -->
12
- <div id="view-terminal" class="view active">
12
+ <div id="view-terminal" class="view active" inert aria-busy="true">
13
13
  <aside class="sidebar">
14
14
  <div class="brand"><h1><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 8 4 4-4 4"/><path d="M13 16h6"/></svg>Agent Terminal</h1><span class="ver" id="app-version"></span></div>
15
15
  <button class="new-session" id="btn-new-session"><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span>新会话</span></button>
@@ -33,7 +33,7 @@
33
33
 
34
34
  <div class="main">
35
35
  <div class="top-strip">
36
- <span class="t" id="session-title">选择或创建一个会话</span>
36
+ <span class="t" id="session-title">正在加载工作台…</span>
37
37
  <span class="session-provider" id="session-provider"></span>
38
38
  <span class="st" id="session-status" hidden><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l1.9 5.8L20 12l-6.1 3.2L12 21l-1.9-5.8L4 12l6.1-3.2z"/></svg><span id="session-status-text"></span></span>
39
39
  <span style="flex:1"></span>
@@ -84,7 +84,7 @@
84
84
  </div>
85
85
 
86
86
  <!-- ============ 设置视图 ============ -->
87
- <div id="view-settings" class="view">
87
+ <div id="view-settings" class="view" inert>
88
88
  <div class="settings-wrap">
89
89
  <nav class="settings-nav">
90
90
  <button class="ghost settings-back" id="settings-back"><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12H4"/><path d="m11 5-7 7 7 7"/></svg><span>返回</span></button>
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: Claude AI Workbench API
4
- version: 0.13.1
4
+ version: 0.13.2-rc.10
5
5
  description: Versioned local API for AI Profile, credential and Claude Code session management.
6
6
  servers:
7
7
  - url: http://127.0.0.1:5760/api/claude-workbench/v1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/claude-ai-workbench",
3
- "version": "0.13.1",
3
+ "version": "0.13.2-rc.10",
4
4
  "description": "Shared Claude Code terminal and Anthropic-compatible AI profile modules for local developer tools.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/client",
3
- "version": "0.13.1",
3
+ "version": "0.13.2-rc.10",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "browser": "index.js",
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@claude-ai-workbench/contracts": "0.13.1"
23
+ "@claude-ai-workbench/contracts": "0.13.2-rc.10"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/contracts",
3
- "version": "0.13.1",
3
+ "version": "0.13.2-rc.10",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/core",
3
- "version": "0.13.1",
3
+ "version": "0.13.2-rc.10",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "browser": "index.js",
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@claude-ai-workbench/contracts": "0.13.1"
23
+ "@claude-ai-workbench/contracts": "0.13.2-rc.10"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -29,12 +29,13 @@ app.server.listen(port, host, () => {
29
29
  });
30
30
 
31
31
  let stopping = false;
32
- function shutdown() {
32
+ async function shutdown() {
33
33
  if (stopping) return; stopping = true;
34
- app.service.destroy();
34
+ setTimeout(() => process.exit(1), 10000).unref();
35
+ try { await app.service.destroy(); }
36
+ catch { process.stderr.write('SHUTDOWN_FAILED: 客户端运行环境未能完整关闭。\n'); process.exitCode = 1; }
35
37
  app.server.closeAllConnections?.();
36
- app.server.close(() => process.exit(0));
37
- setTimeout(() => process.exit(0), 3000).unref();
38
+ app.server.close(() => process.exit(process.exitCode || 0));
38
39
  }
39
40
  process.on('SIGINT', shutdown);
40
41
  process.on('SIGTERM', shutdown);
@@ -41,6 +41,14 @@ app.server.listen(port, host, () => {
41
41
  warmFolderPicker({ title: '选择项目文件夹' }).catch(() => {});
42
42
  });
43
43
 
44
- function shutdown() { app.server.close(() => process.exit(0)); }
44
+ let stopping = false;
45
+ async function shutdown() {
46
+ if (stopping) return; stopping = true;
47
+ setTimeout(() => process.exit(1), 10000).unref();
48
+ try { await app.service.destroy(); }
49
+ catch { process.stderr.write('SHUTDOWN_FAILED: 客户端运行环境未能完整关闭。\n'); process.exitCode = 1; }
50
+ app.server.closeAllConnections?.();
51
+ app.server.close(() => process.exit(process.exitCode || 0));
52
+ }
45
53
  process.on('SIGINT', shutdown);
46
54
  process.on('SIGTERM', shutdown);
@@ -11,7 +11,7 @@ export interface WorkbenchRunner {
11
11
  sendInput(sessionId: string, text: string, profile: Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown>;
12
12
  listSupportedCommands?(sessionId: string, profile: Record<string, unknown>): Promise<SlashCommand[]>;
13
13
  resolvePermission(sessionId: string, requestId: string, decision: Record<string, unknown>): unknown;
14
- abort(sessionId: string): void;
14
+ abort(sessionId: string, reason?: string): void;
15
15
  deleteSession(sessionId: string): unknown;
16
16
  findClaudeExecutableForSdk(): unknown;
17
17
  }
@@ -30,6 +30,7 @@ function createWorkbenchServer(options = {}) {
30
30
  const apiPrefix = options.apiPrefix || DEFAULT_API_PREFIX;
31
31
  const testRoot = path.join(dataDir, 'test-workspaces');
32
32
  const projects = options.projects || new ProjectStore({ dataDir });
33
+ require('./lib/cli-session-recovery').recoverOwnedCliSessions({ dataDir, projects });
33
34
  const sessionArchive = options.sessionArchive || new SessionArchiveStore({ dataDir });
34
35
  const workspaces = {
35
36
  'test-workspace-a': path.join(testRoot, 'context-a'),
@@ -47,6 +48,8 @@ function createWorkbenchServer(options = {}) {
47
48
  const attachments = options.attachments || new AttachmentStore({ root: path.join(dataDir, 'attachments') });
48
49
  const contexts = options.contexts || new ContextResolver({ workspaces, createMissing: options.createMissingWorkspaces !== false });
49
50
  const claudeDriver = options.runner || loadLegacyRuntime('claude-cli-runner');
51
+ const knowledgeContextProvided = Boolean(options.knowledge || options.engineUrl || process.env.KB_KNOWLEDGE_ENGINE_URL);
52
+ claudeDriver.setKnowledgeContextProvided?.(knowledgeContextProvided);
50
53
  // Bridge capture scope: terminal sessions are exempt from Development
51
54
  // Conversation capture unless the 会话捕获 setting says otherwise. Applied to
52
55
  // the driver at boot and re-applied on every settings save.
@@ -62,9 +65,9 @@ function createWorkbenchServer(options = {}) {
62
65
  drivers: {
63
66
  'claude-code': claudeDriver,
64
67
  ...(options.drivers || {
65
- codex: createCodexDriver(),
66
- opencode: createOpenCodeDriver(),
67
- zcode: createZCodeDriver(),
68
+ codex: createCodexDriver({ knowledgeContextProvided, conversationCaptureProvided: Boolean(conversationCapture), persistence: { dir: path.join(dataDir, 'cli-sessions') } }),
69
+ opencode: createOpenCodeDriver({ persistence: { dir: path.join(dataDir, 'cli-sessions') } }),
70
+ zcode: createZCodeDriver({ knowledgeContextProvided, conversationCaptureProvided: Boolean(conversationCapture), persistence: { dir: path.join(dataDir, 'cli-sessions') } }),
68
71
  }),
69
72
  },
70
73
  });
@@ -136,7 +139,7 @@ function createWorkbenchServer(options = {}) {
136
139
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
137
140
  res.end(JSON.stringify({ ok: false, error: 'not found' }));
138
141
  });
139
- server.on('close', () => service.destroy());
142
+ server.on('close', () => { Promise.resolve(service.destroy()).catch(error => diagnostics.error('agent.shutdown.failed', '客户端运行环境关闭失败。', { phase: 'shutdown', error: { code: error.code || 'SHUTDOWN_FAILED', message: error.message } })); });
140
143
  return { server, service, profiles, contexts, attachments, projects, sessionArchive, registry, agentDiscovery, apiPrefix, dataDir };
141
144
  }
142
145
 
@@ -46,6 +46,9 @@ class AgentRegistry {
46
46
  asRunner() {
47
47
  const registry = this;
48
48
  return {
49
+ async destroy() {
50
+ await Promise.all([...registry.drivers.values()].map(driver => typeof driver.destroy === 'function' ? driver.destroy() : undefined));
51
+ },
49
52
  startChatSession(args = {}) {
50
53
  const agentId = normalizeAgentId(args.agentId);
51
54
  const driver = registry.driverFor(agentId);
@@ -82,7 +85,7 @@ class AgentRegistry {
82
85
  return session;
83
86
  },
84
87
  resolvePermission(id, requestId, decision) { return registry._route(id).resolvePermission(id, requestId, decision); },
85
- abort(id) { return registry._route(id).abort(id); },
88
+ abort(id, reason) { return registry._route(id).abort(id, reason); },
86
89
  deleteSession(id) { return registry._route(id).deleteSession(id); },
87
90
  listSupportedCommands(id, profile) { return registry._route(id).listSupportedCommands(id, profile); },
88
91
  findClaudeExecutableForSdk() {
@@ -86,6 +86,7 @@ function createOpenCodeServerBridge(config = {}) {
86
86
  let generation = 0; // bumped on every successful (re)launch
87
87
  let bootPromise = null;
88
88
  let bootSettle = null; // { resolve, reject } of the in-flight launch
89
+ let healthAbort = null;
89
90
  let restartTimer = null;
90
91
  let idleTimer = null;
91
92
  let restartsUsed = 0;
@@ -296,21 +297,34 @@ function createOpenCodeServerBridge(config = {}) {
296
297
  }
297
298
 
298
299
  async function waitForHealth(deadline) {
299
- while (Date.now() < deadline) {
300
- if (!child) throw bridgeError('opencode serve child exited during startup', 503, 'BRIDGE_SPAWN_FAILED');
301
- try {
302
- const response = await fetchFn(`${base()}/global/health`);
303
- if (response.ok) {
304
- const parsed = await response.json().catch(() => null);
305
- if (!parsed || parsed.healthy !== true) throw new Error('health endpoint reported unhealthy');
306
- return parsed;
300
+ const controller = new AbortController();
301
+ healthAbort = controller;
302
+ // A loop deadline alone cannot bound a connection stalled at headers or
303
+ // JSON body. The same budget must cancel the in-flight health request.
304
+ const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
305
+ try {
306
+ while (Date.now() < deadline && !controller.signal.aborted) {
307
+ if (!child) throw bridgeError('opencode serve child exited during startup', 503, 'BRIDGE_SPAWN_FAILED');
308
+ try {
309
+ const response = await fetchFn(`${base()}/global/health`, { signal: controller.signal });
310
+ if (response.ok) {
311
+ const parsed = await response.json().catch(() => null);
312
+ if (!parsed || parsed.healthy !== true) throw new Error('health endpoint reported unhealthy');
313
+ return parsed;
314
+ }
315
+ await response.body?.cancel?.();
316
+ } catch (error) {
317
+ if (closed) throw bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED');
318
+ if (!child) throw bridgeError('opencode serve child exited during startup', 503, 'BRIDGE_SPAWN_FAILED', { cause: error });
307
319
  }
308
- } catch (error) {
309
- if (!child) throw bridgeError('opencode serve child exited during startup', 503, 'BRIDGE_SPAWN_FAILED', { cause: error });
320
+ if (!controller.signal.aborted) await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now()))));
310
321
  }
311
- await new Promise(resolve => setTimeout(resolve, 100));
322
+ throw bridgeError(`opencode serve did not become healthy within ${opts.healthTimeoutMs}ms`, 504, 'BRIDGE_HEALTH_TIMEOUT');
323
+ } finally {
324
+ clearTimeout(timer);
325
+ controller.abort();
326
+ if (healthAbort === controller) healthAbort = null;
312
327
  }
313
- throw bridgeError(`opencode serve did not become healthy within ${opts.healthTimeoutMs}ms`, 504, 'BRIDGE_HEALTH_TIMEOUT');
314
328
  }
315
329
 
316
330
  function wire(childInstance) {
@@ -441,7 +455,7 @@ function createOpenCodeServerBridge(config = {}) {
441
455
  intentionalStop = true;
442
456
  stopSse();
443
457
  killChild();
444
- state = 'idle';
458
+ state = closed ? 'closed' : 'idle';
445
459
  port = null;
446
460
  });
447
461
  }));
@@ -462,6 +476,7 @@ function createOpenCodeServerBridge(config = {}) {
462
476
  clearTimeout(restartTimer);
463
477
  clearTimeout(idleTimer);
464
478
  intentionalStop = true;
479
+ healthAbort?.abort();
465
480
  stopSse();
466
481
  killChild();
467
482
  if (bootSettle) {
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const SCHEMAS = new Set(['cli-json-runner-session/v1', 'codex-appserver-session/v1', 'opencode-serve-session/v1', 'zcode-appserver-session/v1']);
6
+
7
+ function recoverOwnedCliSessions({ dataDir, projects, sourceDir = path.join(os.homedir(), '.agent-terminal/cli-sessions') }) {
8
+ const targetDir = path.join(dataDir, 'cli-sessions');
9
+ if (path.resolve(sourceDir) === path.resolve(targetDir) || !fs.existsSync(sourceDir)) return;
10
+ const owners = new Map(projects.list().map(project => [project.id, path.resolve(project.rootPath)]));
11
+ // Earlier v5 wiring put CLI records in the standalone directory. Preserve
12
+ // only records whose project ID AND code directory belong to this product.
13
+ for (const name of fs.readdirSync(sourceDir)) {
14
+ if (!/^[\w.-]+\.json$/.test(name)) continue;
15
+ const source = path.join(sourceDir, name); const target = path.join(targetDir, name);
16
+ let record;
17
+ try {
18
+ const stat = fs.lstatSync(source);
19
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8 * 1024 * 1024 || fs.existsSync(target)) continue;
20
+ record = JSON.parse(fs.readFileSync(source, 'utf8'));
21
+ } catch { continue; }
22
+ if (!SCHEMAS.has(record.schema) || !owners.has(record.projectSlug) || typeof record.projectPath !== 'string' || path.resolve(record.projectPath) !== owners.get(record.projectSlug) || `${record.sessionId}.json` !== name) continue;
23
+ fs.mkdirSync(targetDir, { recursive: true });
24
+ try { fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); }
25
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
26
+ }
27
+ }
28
+ module.exports = { recoverOwnedCliSessions };
@@ -292,16 +292,7 @@ function createCliJsonRunner(config) {
292
292
  try {
293
293
  fs.mkdirSync(path.dirname(file), { recursive: true });
294
294
  fs.writeFileSync(tmp, payload, 'utf8');
295
- try {
296
- fs.renameSync(tmp, file); // same-volume atomic replace
297
- } catch {
298
- // Windows can refuse to replace a destination an AV scanner or
299
- // indexer still holds open: drop the old copy and retry the move.
300
- // The temp file always carries the complete new version, so the
301
- // destination is only ever missing for one rename call.
302
- try { fs.unlinkSync(file); } catch { /* nothing to drop */ }
303
- fs.renameSync(tmp, file);
304
- }
295
+ fs.renameSync(tmp, file); // failed replacement keeps the old history
305
296
  persistFailures = 0;
306
297
  if (diskIndex) diskIndex.set(session.sessionId, { file, summary: summary(session) });
307
298
  return;
@@ -619,7 +610,7 @@ function createCliJsonRunner(config) {
619
610
  return summary(session);
620
611
  },
621
612
 
622
- abort(id) {
613
+ abort(id, reason) {
623
614
  const session = resolveSession(id);
624
615
  if (!session) return;
625
616
  // A user-initiated kill is not a failure: the nonzero exit code it
@@ -628,7 +619,7 @@ function createCliJsonRunner(config) {
628
619
  killChild(session);
629
620
  if (!['idle', 'ended'].includes(session.state)) {
630
621
  session.state = 'aborted';
631
- emit(session, { type: 'claude/aborted' });
622
+ emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
632
623
  setState(session, 'idle');
633
624
  }
634
625
  flushPersist(session);
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const crypto = require('crypto');
7
+ const { writeJson } = require('../json-file');
7
8
  const { createCliJsonRunner, resolveCliInvocation } = require('./cli-json-runner');
8
9
  const { createAppServerBridge } = require('../bridges/app-server-bridge');
9
10
  const {
@@ -74,7 +75,7 @@ function createCliCodexRunner(options = {}) {
74
75
  sandboxFor(permissionMode) { return SANDBOX_FOR_PERMISSION[permissionMode] || 'read-only'; },
75
76
 
76
77
  envFor(profile) {
77
- return isInjected(profile) ? { [PROVIDER_KEY_ENV]: profile.apiKey } : {};
78
+ return { ...(isInjected(profile) ? { [PROVIDER_KEY_ENV]: profile.apiKey } : {}), ...ownedContextEnvironment(options) };
78
79
  },
79
80
 
80
81
  spawnArgs({ projectPath, model, sandbox, threadId, profile, imagePaths, effort }) {
@@ -122,6 +123,10 @@ function injectedProviderArgs(profile) {
122
123
  return args;
123
124
  }
124
125
 
126
+ function ownedContextEnvironment(options) {
127
+ return { ...(options.knowledgeContextProvided ? { KB_KNOWLEDGE_CONTEXT: '0' } : {}), ...(options.conversationCaptureProvided ? { AI_CODING_EVENT_BRIDGE_CAPTURE: '0' } : {}) };
128
+ }
129
+
125
130
  function parseCodexLine(event, session, helpers) {
126
131
  const out = [];
127
132
  const type = event && event.type;
@@ -335,8 +340,7 @@ function createAppServerCodexDriver(options = {}) {
335
340
  if (!appServerPersist.enabled) return;
336
341
  try {
337
342
  const file = appServerRecordFile(session.sessionId);
338
- fs.mkdirSync(path.dirname(file), { recursive: true });
339
- fs.writeFileSync(file, `${JSON.stringify({
343
+ writeJson(file, {
340
344
  schema: APP_SERVER_PERSIST_SCHEMA,
341
345
  agentId: 'codex',
342
346
  transport: 'app-server',
@@ -360,7 +364,7 @@ function createAppServerCodexDriver(options = {}) {
360
364
  peakTurnTokens: session.peakTurnTokens || 0,
361
365
  events: session.outputBuffer.slice(-MAX_APP_SERVER_PERSISTED_EVENTS),
362
366
  updatedAt: new Date().toISOString(),
363
- })}\n`, 'utf8');
367
+ });
364
368
  if (appServerDiskIndex) appServerDiskIndex.set(session.sessionId, { file: appServerRecordFile(session.sessionId), summary: summary(session) });
365
369
  } catch {
366
370
  // Read-only home or full disk degrades to memory-only behavior.
@@ -690,9 +694,9 @@ function createAppServerCodexDriver(options = {}) {
690
694
  return summary(session);
691
695
  },
692
696
 
693
- abort(id) {
697
+ abort(id, reason) {
694
698
  const session = sessions.get(id);
695
- if (!session) return cliRunner.abort(id); // disk-restored sessions restore as idle
699
+ if (!session) return cliRunner.abort(id, reason); // disk-restored sessions restore as idle
696
700
  session.abortedByUser = true;
697
701
  denyPendingApproval(session, 'aborted');
698
702
  const currentTurn = session.currentTurn;
@@ -704,10 +708,10 @@ function createAppServerCodexDriver(options = {}) {
704
708
  bridge.request('turn/interrupt', { threadId: session.threadId, turnId: currentTurn.turnId }, { timeoutMs: 5000 })
705
709
  .catch(() => recycleBridge('interrupt failed'));
706
710
  }
707
- if (session.cliMirrorId) cliRunner.abort(session.cliMirrorId);
711
+ if (session.cliMirrorId) cliRunner.abort(session.cliMirrorId, reason);
708
712
  if (!['idle', 'ended'].includes(session.state)) {
709
713
  session.state = 'aborted';
710
- emit(session, { type: 'claude/aborted' });
714
+ emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
711
715
  setState(session, 'idle');
712
716
  }
713
717
  if (!session.cliMirrorId) persistAppServerSession(session);
@@ -745,12 +749,12 @@ function createAppServerCodexDriver(options = {}) {
745
749
  // -- bridge lifecycle ----------------------------------------------------
746
750
 
747
751
  function bridgeSpecFor(profile) {
748
- if (!isInjected(profile)) return { key: 'agent-owned', args: ['app-server'], env: {} };
752
+ if (!isInjected(profile)) return { key: 'agent-owned', args: ['app-server'], env: ownedContextEnvironment(options) };
749
753
  const providerArgs = injectedProviderArgs(profile); // throws without a base URL
750
754
  return {
751
755
  key: `injected:${resolveOpenAIBaseUrl(profile)}`,
752
756
  args: ['app-server', ...providerArgs],
753
- env: { [PROVIDER_KEY_ENV]: profile.apiKey },
757
+ env: { [PROVIDER_KEY_ENV]: profile.apiKey, ...ownedContextEnvironment(options) },
754
758
  };
755
759
  }
756
760
 
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const { writeJson } = require('../json-file');
4
5
  const os = require('os');
5
6
  const path = require('path');
6
7
  const crypto = require('crypto');
@@ -249,8 +250,7 @@ function createServeOpenCodeDriver(options = {}) {
249
250
  if (!persist.enabled) return;
250
251
  try {
251
252
  const file = serveRecordFile(session.sessionId);
252
- fs.mkdirSync(path.dirname(file), { recursive: true });
253
- fs.writeFileSync(file, `${JSON.stringify({
253
+ writeJson(file, {
254
254
  schema: SERVE_PERSIST_SCHEMA,
255
255
  agentId: 'opencode',
256
256
  transport: 'serve',
@@ -275,7 +275,7 @@ function createServeOpenCodeDriver(options = {}) {
275
275
  peakTurnTokens: session.peakTurnTokens || 0,
276
276
  events: session.outputBuffer.slice(-MAX_SERVE_PERSISTED_EVENTS),
277
277
  updatedAt: new Date().toISOString(),
278
- })}\n`, 'utf8');
278
+ });
279
279
  if (serveDiskIndex) serveDiskIndex.set(session.sessionId, { file: serveRecordFile(session.sessionId), summary: summary(session) });
280
280
  } catch {
281
281
  // Read-only home or full disk degrades to memory-only behavior.
@@ -493,6 +493,7 @@ function createServeOpenCodeDriver(options = {}) {
493
493
  session.lastResult = '';
494
494
  session.currentTurn = {
495
495
  assistantMessageID: null,
496
+ assistantMessageIDs: new Set(),
496
497
  userMessageIDs: new Set(), // echoed prompts never stream as output
497
498
  parts: new Map(), // partID -> tracking state
498
499
  sawEvent: false,
@@ -570,9 +571,9 @@ function createServeOpenCodeDriver(options = {}) {
570
571
  return summary(session);
571
572
  },
572
573
 
573
- abort(id) {
574
+ abort(id, reason) {
574
575
  const session = sessions.get(id) || restoreServeSession(id);
575
- if (!session) return cliRunner.abort(id);
576
+ if (!session) return cliRunner.abort(id, reason);
576
577
  session.abortedByUser = true;
577
578
  if (mode === 'serve' && session.currentTurn && session.opencodeSessionId && bridge && bridge.state === 'ready') {
578
579
  // Preferred path: per-session abort on the shared serve instance
@@ -582,13 +583,13 @@ function createServeOpenCodeDriver(options = {}) {
582
583
  }
583
584
  if (session.currentTurn) {
584
585
  session.state = 'aborted';
585
- emit(session, { type: 'claude/aborted' });
586
+ emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
586
587
  finishServeTurn(session, {});
587
588
  }
588
- if (session.cliMirrorId) cliRunner.abort(session.cliMirrorId);
589
+ if (session.cliMirrorId) cliRunner.abort(session.cliMirrorId, reason);
589
590
  if (!['idle', 'ended'].includes(session.state)) {
590
591
  session.state = 'aborted';
591
- emit(session, { type: 'claude/aborted' });
592
+ emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
592
593
  setState(session, 'idle');
593
594
  }
594
595
  if (!session.cliMirrorId) persistServeSession(session);
@@ -849,8 +850,11 @@ function createServeOpenCodeDriver(options = {}) {
849
850
  return;
850
851
  }
851
852
  if (info.role !== 'assistant') return;
853
+ const turn = session.currentTurn;
854
+ if (info.id && !turn.assistantMessageIDs.has(info.id) && info.time?.created && info.time.created < turn.startedAt - 5000) return;
852
855
  markActivity(session);
853
- if (!session.currentTurn.assistantMessageID && info.id) session.currentTurn.assistantMessageID = info.id;
856
+ // Tool rounds and the final answer have distinct assistant message IDs.
857
+ if (info.id) { turn.assistantMessageIDs.add(info.id); turn.assistantMessageID = info.id; }
854
858
  // Completed assistant messages carry the final token totals; they back
855
859
  // up the per-step-finish usage when a turn had no step-finish parts.
856
860
  if (info.time && info.time.completed && info.tokens && info.tokens.total != null) {
@@ -863,6 +867,7 @@ function createServeOpenCodeDriver(options = {}) {
863
867
  // the assistant message this turn belongs to.
864
868
  function partBelongsToTurn(turn, messageID) {
865
869
  if (messageID && turn.userMessageIDs.has(messageID)) return false;
870
+ if (messageID && turn.assistantMessageIDs.has(messageID)) return true;
866
871
  if (turn.assistantMessageID && messageID && messageID !== turn.assistantMessageID) return false;
867
872
  return true;
868
873
  }
@@ -872,6 +877,7 @@ function createServeOpenCodeDriver(options = {}) {
872
877
  if (!partBelongsToTurn(session.currentTurn, part.messageID)) return;
873
878
  if (!session.currentTurn.assistantMessageID && part.messageID) {
874
879
  session.currentTurn.assistantMessageID = part.messageID;
880
+ session.currentTurn.assistantMessageIDs.add(part.messageID);
875
881
  }
876
882
  markActivity(session);
877
883
  if (part.type === 'step-start') {
@@ -961,7 +967,7 @@ function createServeOpenCodeDriver(options = {}) {
961
967
  const turn = session.currentTurn;
962
968
  if (!turn || field !== 'text' || !partID) return;
963
969
  if (!partBelongsToTurn(turn, messageID)) return;
964
- if (!turn.assistantMessageID && messageID) turn.assistantMessageID = messageID;
970
+ if (!turn.assistantMessageID && messageID) { turn.assistantMessageID = messageID; turn.assistantMessageIDs.add(messageID); }
965
971
  markActivity(session);
966
972
  const tracker = partTracker(session, partID);
967
973
  if (!tracker) return;