@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 +11 -1
- package/apps/agent-terminal/app.css +2 -0
- package/apps/agent-terminal/app.js +24 -6
- package/apps/agent-terminal/index.html +3 -3
- package/docs/sdk/openapi.yaml +1 -1
- package/package.json +1 -1
- package/packages/client/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/core/package.json +2 -2
- package/packages/server/bin/agent-terminal-server.js +5 -4
- package/packages/server/bin/claude-workbench-server.js +9 -1
- package/packages/server/index.d.ts +1 -1
- package/packages/server/index.js +7 -4
- package/packages/server/lib/agent-registry.js +4 -1
- package/packages/server/lib/bridges/opencode-server-bridge.js +28 -13
- package/packages/server/lib/cli-session-recovery.js +28 -0
- package/packages/server/lib/drivers/cli-json-runner.js +3 -12
- package/packages/server/lib/drivers/codex-driver.js +14 -10
- package/packages/server/lib/drivers/opencode-driver.js +16 -10
- package/packages/server/lib/drivers/zcode-driver.js +83 -20
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -0
- package/packages/server/lib/json-file.js +11 -4
- package/packages/server/lib/knowledge-context.js +7 -16
- package/packages/server/lib/profile-store.js +6 -2
- package/packages/server/lib/workbench-service.js +23 -18
- package/packages/server/package.json +2 -2
- package/packages/ui/package.json +2 -2
- package/src/backend/lib/claude-cli-runner.js +10 -5
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
|
|
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
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
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"
|
|
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>
|
package/docs/sdk/openapi.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-ai-workbench/client",
|
|
3
|
-
"version": "0.13.
|
|
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.
|
|
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/core",
|
|
3
|
-
"version": "0.13.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/packages/server/index.js
CHANGED
|
@@ -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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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;
|