@co0ontty/wand 4.46.0 → 4.47.0
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/dist/build-info.json +3 -3
- package/dist/distribution-manager.d.ts +17 -0
- package/dist/distribution-manager.js +80 -17
- package/dist/server-update-routes.d.ts +5 -0
- package/dist/server-update-routes.js +15 -0
- package/dist/server.js +8 -2
- package/dist/structured-claude-adapter.d.ts +3 -1
- package/dist/structured-claude-adapter.js +48 -93
- package/dist/structured-codex-adapter.d.ts +5 -0
- package/dist/structured-codex-adapter.js +40 -78
- package/dist/structured-exec-host.d.ts +76 -0
- package/dist/structured-exec-host.js +117 -0
- package/dist/structured-exec-pump.d.ts +37 -0
- package/dist/structured-exec-pump.js +127 -0
- package/dist/structured-grok-adapter.d.ts +3 -1
- package/dist/structured-grok-adapter.js +28 -67
- package/dist/structured-opencode-adapter.d.ts +3 -1
- package/dist/structured-opencode-adapter.js +37 -79
- package/dist/structured-pi-adapter.d.ts +3 -1
- package/dist/structured-pi-adapter.js +27 -39
- package/dist/structured-qoder-adapter.d.ts +3 -1
- package/dist/structured-qoder-adapter.js +49 -87
- package/dist/structured-session-manager.d.ts +9 -1
- package/dist/structured-session-manager.js +399 -12
- package/dist/terminal-daemon-client.d.ts +15 -1
- package/dist/terminal-daemon-client.js +261 -0
- package/dist/terminal-daemon-protocol.d.ts +10 -3
- package/dist/terminal-daemon-protocol.js +1 -1
- package/dist/terminal-daemon-server.js +178 -0
- package/dist/web-ui/content/scripts.js +57 -57
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +3 -3
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-08-
|
|
4
|
-
"version": "4.
|
|
2
|
+
"commit": "8f7762a1d3d16afbb54650544c054f7e26df7c07",
|
|
3
|
+
"builtAt": "2026-08-23T03:26:15.182Z",
|
|
4
|
+
"version": "4.47.0",
|
|
5
5
|
"channel": "stable"
|
|
6
6
|
}
|
|
@@ -45,6 +45,23 @@ export declare class DistributionManager {
|
|
|
45
45
|
constructor(options: DistributionManagerOptions);
|
|
46
46
|
resolveLatestApk(channel: ApkUpdateChannel): Promise<ResolvedDistributionAsset | null>;
|
|
47
47
|
resolveAndroidDownload(channel?: ApkUpdateChannel): Promise<LocalDistributionAsset | null>;
|
|
48
|
+
/**
|
|
49
|
+
* 清理 Android 分发目录,只保留最新的一个 APK。在设备开始下载 Beta 包时
|
|
50
|
+
* 触发(见 /android/download 路由):debug 构建每次都带独立时间戳版本号,
|
|
51
|
+
* 不清理会按构建次数无限堆积。
|
|
52
|
+
*
|
|
53
|
+
* 永不删除两类文件:
|
|
54
|
+
* - keepFileName:本次正在下发的文件(理论上就是最新的,防御性保留);
|
|
55
|
+
* - config.android.currentApkFile 固定指向的文件(stable 通道可能仍在用)。
|
|
56
|
+
*
|
|
57
|
+
* 「最新」的判定与 resolveLocalAsset 的选择规则一致(见
|
|
58
|
+
* compareLocalAssetCandidates)。返回删除的文件名与释放字节数;目录不存在、
|
|
59
|
+
* 只剩一个包等情形都是空操作。单个文件删除失败不阻断其余清理。
|
|
60
|
+
*/
|
|
61
|
+
pruneAndroidApkDirectory(keepFileName?: string): Promise<{
|
|
62
|
+
deleted: string[];
|
|
63
|
+
freedBytes: number;
|
|
64
|
+
}>;
|
|
48
65
|
resolveLatestDmg(): Promise<ResolvedDistributionAsset | null>;
|
|
49
66
|
resolveMacosDownload(): Promise<LocalDistributionAsset | null>;
|
|
50
67
|
resolveLatestIpa(): Promise<ResolvedDistributionAsset | null>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
|
-
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { compareApkInstallOrder, compareSemver, extractSemver } from "./version-utils.js";
|
|
6
6
|
const GITHUB_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
@@ -24,6 +24,27 @@ function resolveConfiguredDir(configDir, configuredDir, fallback) {
|
|
|
24
24
|
function extractArtifactVersion(fileName, extension) {
|
|
25
25
|
return extractSemver(fileName.replace(new RegExp(`\\${extension}$`, "i"), ""));
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* 本地分发候选的统一排序:版本新者优先(调用方提供比较器),解析不出版本的
|
|
29
|
+
* 文件排在有版本的后面,同版本按修改时间新者优先。resolveLocalAsset 的挑选
|
|
30
|
+
* 和 pruneAndroidApkDirectory 的「保最新」判定共用这一份规则。
|
|
31
|
+
*/
|
|
32
|
+
function compareLocalAssetCandidates(a, b, extension, compareVersions) {
|
|
33
|
+
const aVersion = extractArtifactVersion(a.name, extension);
|
|
34
|
+
const bVersion = extractArtifactVersion(b.name, extension);
|
|
35
|
+
if (aVersion && bVersion) {
|
|
36
|
+
const comparison = compareVersions(bVersion, aVersion);
|
|
37
|
+
if (comparison !== 0)
|
|
38
|
+
return comparison;
|
|
39
|
+
}
|
|
40
|
+
else if (aVersion) {
|
|
41
|
+
return -1;
|
|
42
|
+
}
|
|
43
|
+
else if (bVersion) {
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
return b.mtimeMs - a.mtimeMs;
|
|
47
|
+
}
|
|
27
48
|
function isPrerelease(version) {
|
|
28
49
|
return !!version && version.includes("-");
|
|
29
50
|
}
|
|
@@ -87,6 +108,63 @@ export class DistributionManager {
|
|
|
87
108
|
acceptVersion: channel === "stable" ? (version) => !isPrerelease(version) : undefined,
|
|
88
109
|
});
|
|
89
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* 清理 Android 分发目录,只保留最新的一个 APK。在设备开始下载 Beta 包时
|
|
113
|
+
* 触发(见 /android/download 路由):debug 构建每次都带独立时间戳版本号,
|
|
114
|
+
* 不清理会按构建次数无限堆积。
|
|
115
|
+
*
|
|
116
|
+
* 永不删除两类文件:
|
|
117
|
+
* - keepFileName:本次正在下发的文件(理论上就是最新的,防御性保留);
|
|
118
|
+
* - config.android.currentApkFile 固定指向的文件(stable 通道可能仍在用)。
|
|
119
|
+
*
|
|
120
|
+
* 「最新」的判定与 resolveLocalAsset 的选择规则一致(见
|
|
121
|
+
* compareLocalAssetCandidates)。返回删除的文件名与释放字节数;目录不存在、
|
|
122
|
+
* 只剩一个包等情形都是空操作。单个文件删除失败不阻断其余清理。
|
|
123
|
+
*/
|
|
124
|
+
async pruneAndroidApkDirectory(keepFileName) {
|
|
125
|
+
await this.refreshConfig();
|
|
126
|
+
const { config, configDir } = this.options;
|
|
127
|
+
if (config.android?.enabled !== true)
|
|
128
|
+
return { deleted: [], freedBytes: 0 };
|
|
129
|
+
const directory = resolveConfiguredDir(configDir, config.android.apkDir, "android");
|
|
130
|
+
let entries;
|
|
131
|
+
try {
|
|
132
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { deleted: [], freedBytes: 0 };
|
|
136
|
+
}
|
|
137
|
+
const apks = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".apk"));
|
|
138
|
+
if (apks.length === 0)
|
|
139
|
+
return { deleted: [], freedBytes: 0 };
|
|
140
|
+
const candidates = await Promise.all(apks.map(async (entry) => ({
|
|
141
|
+
name: entry.name,
|
|
142
|
+
filePath: path.join(directory, entry.name),
|
|
143
|
+
fileStat: await stat(path.join(directory, entry.name)),
|
|
144
|
+
})));
|
|
145
|
+
const sorted = [...candidates].sort((a, b) => compareLocalAssetCandidates({ name: a.name, mtimeMs: a.fileStat.mtimeMs }, { name: b.name, mtimeMs: b.fileStat.mtimeMs }, ".apk", compareApkInstallOrder));
|
|
146
|
+
const keepNames = new Set([sorted[0].name]);
|
|
147
|
+
const pinned = config.android.currentApkFile?.trim();
|
|
148
|
+
if (pinned)
|
|
149
|
+
keepNames.add(path.basename(pinned));
|
|
150
|
+
if (keepFileName)
|
|
151
|
+
keepNames.add(keepFileName);
|
|
152
|
+
const deleted = [];
|
|
153
|
+
let freedBytes = 0;
|
|
154
|
+
for (const candidate of sorted) {
|
|
155
|
+
if (keepNames.has(candidate.name))
|
|
156
|
+
continue;
|
|
157
|
+
try {
|
|
158
|
+
await unlink(candidate.filePath);
|
|
159
|
+
deleted.push(candidate.name);
|
|
160
|
+
freedBytes += candidate.fileStat.size;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// 清理是尽力而为;删不掉(如正被另一个下载流读取)留到下次触发。
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { deleted, freedBytes };
|
|
167
|
+
}
|
|
90
168
|
async resolveLatestDmg() {
|
|
91
169
|
const localDmg = await this.resolveMacosDownload();
|
|
92
170
|
if (localDmg?.version) {
|
|
@@ -252,22 +330,7 @@ export class DistributionManager {
|
|
|
252
330
|
}))).filter(({ entry }) => options.acceptVersion?.(extractArtifactVersion(entry.name, options.extension)) ?? true);
|
|
253
331
|
if (candidates.length === 0)
|
|
254
332
|
return null;
|
|
255
|
-
candidates.sort((a, b) => {
|
|
256
|
-
const aVersion = extractArtifactVersion(a.entry.name, options.extension);
|
|
257
|
-
const bVersion = extractArtifactVersion(b.entry.name, options.extension);
|
|
258
|
-
if (aVersion && bVersion) {
|
|
259
|
-
const comparison = options.compareVersions(bVersion, aVersion);
|
|
260
|
-
if (comparison !== 0)
|
|
261
|
-
return comparison;
|
|
262
|
-
}
|
|
263
|
-
else if (aVersion) {
|
|
264
|
-
return -1;
|
|
265
|
-
}
|
|
266
|
-
else if (bVersion) {
|
|
267
|
-
return 1;
|
|
268
|
-
}
|
|
269
|
-
return b.fileStat.mtimeMs - a.fileStat.mtimeMs;
|
|
270
|
-
});
|
|
333
|
+
candidates.sort((a, b) => compareLocalAssetCandidates({ name: a.entry.name, mtimeMs: a.fileStat.mtimeMs }, { name: b.entry.name, mtimeMs: b.fileStat.mtimeMs }, options.extension, options.compareVersions));
|
|
271
334
|
const selected = candidates[0];
|
|
272
335
|
return {
|
|
273
336
|
fileName: selected.entry.name,
|
|
@@ -21,6 +21,11 @@ interface ResolvedUpdateAsset {
|
|
|
21
21
|
export interface PublicUpdateRoutesDependencies {
|
|
22
22
|
resolveLatestApk(channel: "stable" | "beta"): Promise<ResolvedUpdateAsset | null>;
|
|
23
23
|
resolveAndroidDownload(channel: "stable" | "beta"): Promise<DownloadAsset | null>;
|
|
24
|
+
/** Beta 包下发时清理分发目录,只保留最新一个 APK(可选,测试 stub 可不实现)。 */
|
|
25
|
+
pruneAndroidApkDirectory?(keepFileName?: string): Promise<{
|
|
26
|
+
deleted: string[];
|
|
27
|
+
freedBytes: number;
|
|
28
|
+
}>;
|
|
24
29
|
/** 计算本地分发文件的 SHA-256(hex);失败返回 null,下载响应不带哈希头。 */
|
|
25
30
|
computeAssetSha256(asset: DownloadAsset): Promise<string | null>;
|
|
26
31
|
resolveLatestDmg(): Promise<ResolvedUpdateAsset | null>;
|
|
@@ -42,6 +42,21 @@ export function registerPublicUpdateRoutes(app, deps) {
|
|
|
42
42
|
res.status(404).json({ error: "当前没有可下载的 APK 文件。" });
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
|
+
// Beta 包下发时顺手清理分发目录:debug 构建每次都带独立时间戳版本号,
|
|
46
|
+
// 不清理会按构建次数无限堆积。只保留最新一个;正在下发的文件与
|
|
47
|
+
// currentApkFile 固定指向的文件不会被删。清理失败不阻断本次下载。
|
|
48
|
+
if (channel === "beta" && deps.pruneAndroidApkDirectory) {
|
|
49
|
+
try {
|
|
50
|
+
const pruned = await deps.pruneAndroidApkDirectory(asset.fileName);
|
|
51
|
+
if (pruned.deleted.length > 0) {
|
|
52
|
+
process.stdout.write(`[wand] 已清理旧版安装包:${pruned.deleted.join(", ")}`
|
|
53
|
+
+ `(释放 ${(pruned.freedBytes / 1024 / 1024).toFixed(1)} MB)\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
process.stdout.write(`[wand] 清理旧版安装包失败:${getErrorMessage(error)}\n`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
45
60
|
// 响应头携带实际发送字节的 SHA-256:check 与 download 之间 APK 目录若被
|
|
46
61
|
// 重新构建/覆盖,客户端以响应头为准,避免用过期 check 快照误报校验失败。
|
|
47
62
|
const headers = {};
|
package/dist/server.js
CHANGED
|
@@ -48,7 +48,7 @@ import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
|
|
|
48
48
|
import { EMBEDDED_WEB_ASSETS } from "./web-ui/embedded-assets.js";
|
|
49
49
|
import { renderApp } from "./web-ui/index.js";
|
|
50
50
|
import { WsBroadcastManager } from "./ws-broadcast.js";
|
|
51
|
-
import { createTerminalHost } from "./terminal-daemon-client.js";
|
|
51
|
+
import { createTerminalHost, TerminalDaemonClient } from "./terminal-daemon-client.js";
|
|
52
52
|
import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
|
|
53
53
|
import { updateProviderClis, verifyProviderCliUpdateResults, } from "./provider-cli-updater.js";
|
|
54
54
|
const SERVER_MODULE_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
@@ -515,7 +515,10 @@ export async function startServer(config, configPath, options = {}) {
|
|
|
515
515
|
const terminalHost = options.terminalHost ?? await createTerminalHost(configPath);
|
|
516
516
|
const processes = new ProcessManager(config, storage, configDir, terminalHost);
|
|
517
517
|
const structuredLogger = new SessionLogger(configDir, config.shortcutLogMaxBytes);
|
|
518
|
-
|
|
518
|
+
// Only the daemon-backed host keeps structured CLI runs alive across restarts;
|
|
519
|
+
// the in-process fallback keeps the legacy die-with-server lifecycle.
|
|
520
|
+
const structuredExecHost = terminalHost instanceof TerminalDaemonClient ? terminalHost : undefined;
|
|
521
|
+
const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger, undefined, {}, structuredExecHost);
|
|
519
522
|
const sessionRegistry = new SessionRegistry(processes, structuredSessions, storage);
|
|
520
523
|
const missions = new Missions(storage, structuredSessions, sessionRegistry);
|
|
521
524
|
const updateState = new ServerUpdateState();
|
|
@@ -1154,6 +1157,9 @@ export async function startServer(config, configPath, options = {}) {
|
|
|
1154
1157
|
missions.ingest(event);
|
|
1155
1158
|
wsManager.emitEvent(event);
|
|
1156
1159
|
});
|
|
1160
|
+
// Re-attach structured CLI runs that kept going inside terminald while the
|
|
1161
|
+
// previous web process was down; fire-and-forget, failures are logged inside.
|
|
1162
|
+
void structuredSessions.recoverDetachedRuns();
|
|
1157
1163
|
// ── Restart endpoint (needs server + wss in scope) ──
|
|
1158
1164
|
function safeServiceInstalled() {
|
|
1159
1165
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StructuredExecHost } from "./structured-exec-host.js";
|
|
1
2
|
import type { StructuredRunnerAdapter, StructuredRunnerContext, StructuredRunnerExecution, StructuredRunnerObserver } from "./structured-runner.js";
|
|
2
3
|
import type { ExecutionMode, SessionSnapshot } from "./types.js";
|
|
3
4
|
type WandPermissionMode = "default" | "acceptEdits" | "bypassPermissions";
|
|
@@ -24,7 +25,8 @@ export interface ClaudeCliRunnerOptions {
|
|
|
24
25
|
/** Owns the Claude print-mode process and its stream-json protocol. */
|
|
25
26
|
export declare class ClaudeCliRunner implements StructuredRunnerAdapter {
|
|
26
27
|
private readonly options;
|
|
27
|
-
|
|
28
|
+
private readonly execHost?;
|
|
29
|
+
constructor(options?: ClaudeCliRunnerOptions, execHost?: StructuredExecHost | undefined);
|
|
28
30
|
start(context: StructuredRunnerContext, observer: StructuredRunnerObserver): StructuredRunnerExecution;
|
|
29
31
|
}
|
|
30
32
|
export {};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { readFileSync, statSync } from "node:fs";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { isRunningAsRoot } from "./env-utils.js";
|
|
5
|
+
import { startStructuredCli } from "./structured-exec-pump.js";
|
|
6
6
|
import { buildLanguageDirective, buildManagedAutonomyDirective } from "./language-prompt.js";
|
|
7
7
|
import { thinkingEffortToClaudeCliEffort, thinkingEffortToSdkBudget } from "./structured-provider-common.js";
|
|
8
8
|
import { ClaudeCliProtocolReducer } from "./structured-claude-protocol.js";
|
|
@@ -120,8 +120,10 @@ export function buildClaudeSdkThinking(effort) {
|
|
|
120
120
|
/** Owns the Claude print-mode process and its stream-json protocol. */
|
|
121
121
|
export class ClaudeCliRunner {
|
|
122
122
|
options;
|
|
123
|
-
|
|
123
|
+
execHost;
|
|
124
|
+
constructor(options = {}, execHost) {
|
|
124
125
|
this.options = options;
|
|
126
|
+
this.execHost = execHost;
|
|
125
127
|
}
|
|
126
128
|
start(context, observer) {
|
|
127
129
|
const permissionPolicy = derivePermissionPolicy(context.session.mode, context.session.autoApprovePermissions ?? false, context.session.cwd);
|
|
@@ -129,105 +131,58 @@ export class ClaudeCliRunner {
|
|
|
129
131
|
permissionPolicy,
|
|
130
132
|
systemPromptParts: buildAppendSystemPromptParts(this.options.language?.(), context.session.mode),
|
|
131
133
|
});
|
|
132
|
-
const spawnedAt = new Date().toISOString();
|
|
133
|
-
const child = spawn("claude", args, {
|
|
134
|
-
cwd: context.session.cwd,
|
|
135
|
-
env: context.env,
|
|
136
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
137
|
-
});
|
|
138
|
-
child.stdin?.end(context.prompt);
|
|
139
134
|
const reducer = new ClaudeCliProtocolReducer(context.session);
|
|
140
|
-
let lineBuffer = "";
|
|
141
|
-
let stderr = "";
|
|
142
|
-
let stdoutTail = "";
|
|
143
|
-
let settled = false;
|
|
144
135
|
let killedForQuestion = false;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
event = JSON.parse(trimmed);
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (event && typeof event === "object" && !Array.isArray(event)) {
|
|
169
|
-
observer.onEvent?.(event);
|
|
170
|
-
}
|
|
171
|
-
if (reducer.apply(event, context.session.mode === "managed")) {
|
|
172
|
-
observer.onUpdate(reducer.state);
|
|
173
|
-
}
|
|
174
|
-
if (reducer.askUserQuestionDetected && !killedForQuestion) {
|
|
175
|
-
killedForQuestion = true;
|
|
136
|
+
let stdoutTail = "";
|
|
137
|
+
return startStructuredCli({
|
|
138
|
+
sessionId: context.session.id,
|
|
139
|
+
file: "claude",
|
|
140
|
+
args,
|
|
141
|
+
cwd: context.session.cwd,
|
|
142
|
+
env: context.env,
|
|
143
|
+
stdinData: context.prompt,
|
|
144
|
+
observer,
|
|
145
|
+
execHost: this.execHost,
|
|
146
|
+
createState: () => reducer.state,
|
|
147
|
+
processLine: (line, ctx) => {
|
|
148
|
+
if (!observer.isActive())
|
|
149
|
+
return;
|
|
150
|
+
const trimmed = line.trim();
|
|
151
|
+
if (!trimmed)
|
|
152
|
+
return;
|
|
153
|
+
let event;
|
|
176
154
|
try {
|
|
177
|
-
|
|
155
|
+
event = JSON.parse(trimmed);
|
|
178
156
|
}
|
|
179
|
-
catch {
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
const completion = new Promise((resolve) => {
|
|
183
|
-
child.stdout?.on("data", (chunk) => {
|
|
184
|
-
if (!observer.isActive())
|
|
157
|
+
catch {
|
|
185
158
|
return;
|
|
186
|
-
|
|
187
|
-
|
|
159
|
+
}
|
|
160
|
+
if (event && typeof event === "object" && !Array.isArray(event)) {
|
|
161
|
+
observer.onEvent?.(event);
|
|
162
|
+
}
|
|
163
|
+
if (reducer.apply(event, context.session.mode === "managed")) {
|
|
164
|
+
observer.onUpdate(reducer.state);
|
|
165
|
+
}
|
|
166
|
+
if (reducer.askUserQuestionDetected && !killedForQuestion) {
|
|
167
|
+
killedForQuestion = true;
|
|
168
|
+
ctx.requestStop();
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
onStdoutText: (text) => {
|
|
188
172
|
const trimmed = text.trim();
|
|
189
173
|
if (trimmed)
|
|
190
174
|
stdoutTail = trimmed.slice(-1024);
|
|
191
|
-
lineBuffer += text;
|
|
192
|
-
const lines = lineBuffer.split("\n");
|
|
193
|
-
lineBuffer = lines.pop() ?? "";
|
|
194
|
-
for (const line of lines)
|
|
195
|
-
processLine(line);
|
|
196
|
-
});
|
|
197
|
-
child.stderr?.on("data", (chunk) => {
|
|
198
|
-
if (!observer.isActive())
|
|
199
|
-
return;
|
|
200
|
-
const text = chunk.toString();
|
|
201
|
-
observer.onStderr?.(text);
|
|
202
|
-
stderr += text;
|
|
203
|
-
});
|
|
204
|
-
child.on("error", (error) => {
|
|
205
|
-
if (settled)
|
|
206
|
-
return;
|
|
207
|
-
settled = true;
|
|
208
|
-
resolve(result(null, null, error));
|
|
209
|
-
});
|
|
210
|
-
child.on("close", (exitCode, signal) => {
|
|
211
|
-
if (settled)
|
|
212
|
-
return;
|
|
213
|
-
settled = true;
|
|
214
|
-
if (lineBuffer.trim())
|
|
215
|
-
processLine(lineBuffer);
|
|
216
|
-
lineBuffer = "";
|
|
217
|
-
resolve(result(exitCode, signal));
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
return {
|
|
221
|
-
args,
|
|
222
|
-
spawnedAt,
|
|
223
|
-
pid: child.pid ?? null,
|
|
224
|
-
completion,
|
|
225
|
-
interrupt: () => {
|
|
226
|
-
try {
|
|
227
|
-
child.kill("SIGTERM");
|
|
228
|
-
}
|
|
229
|
-
catch { /* best effort */ }
|
|
230
175
|
},
|
|
231
|
-
|
|
176
|
+
finalize: (ctx, exitCode, signal, spawnError) => ({
|
|
177
|
+
state: reducer.state,
|
|
178
|
+
exitCode,
|
|
179
|
+
signal,
|
|
180
|
+
stderr: ctx.stderr,
|
|
181
|
+
stdoutTail,
|
|
182
|
+
primaryError: null,
|
|
183
|
+
stopReason: killedForQuestion ? "ask-user-question" : undefined,
|
|
184
|
+
spawnError,
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
232
187
|
}
|
|
233
188
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { StructuredExecHost } from "./structured-exec-host.js";
|
|
1
3
|
import type { SessionSnapshot } from "./types.js";
|
|
2
4
|
import type { StructuredRunnerAdapter, StructuredRunnerContext, StructuredRunnerExecution, StructuredRunnerObserver } from "./structured-runner.js";
|
|
3
5
|
/** Build the stable CLI contract for a structured Codex turn. */
|
|
4
6
|
export declare function buildCodexArgs(session: SessionSnapshot): string[];
|
|
5
7
|
/** Owns the Codex CLI process and translates its NDJSON protocol into runner-neutral state. */
|
|
6
8
|
export declare class CodexRunner implements StructuredRunnerAdapter {
|
|
9
|
+
private readonly spawnProcess;
|
|
10
|
+
private readonly execHost?;
|
|
11
|
+
constructor(spawnProcess?: typeof spawn, execHost?: StructuredExecHost | undefined);
|
|
7
12
|
start(context: StructuredRunnerContext, observer: StructuredRunnerObserver): StructuredRunnerExecution;
|
|
8
13
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { startStructuredCli } from "./structured-exec-pump.js";
|
|
2
3
|
import { CodexProtocolReducer } from "./structured-codex-protocol.js";
|
|
3
4
|
import { thinkingEffortToCodexReasoningEffort } from "./structured-provider-common.js";
|
|
4
5
|
/** Build the stable CLI contract for a structured Codex turn. */
|
|
@@ -31,93 +32,54 @@ export function buildCodexArgs(session) {
|
|
|
31
32
|
}
|
|
32
33
|
/** Owns the Codex CLI process and translates its NDJSON protocol into runner-neutral state. */
|
|
33
34
|
export class CodexRunner {
|
|
35
|
+
spawnProcess;
|
|
36
|
+
execHost;
|
|
37
|
+
constructor(spawnProcess = spawn, execHost) {
|
|
38
|
+
this.spawnProcess = spawnProcess;
|
|
39
|
+
this.execHost = execHost;
|
|
40
|
+
}
|
|
34
41
|
start(context, observer) {
|
|
35
42
|
const args = buildCodexArgs(context.session);
|
|
36
|
-
const
|
|
37
|
-
|
|
43
|
+
const reducer = new CodexProtocolReducer(context.session);
|
|
44
|
+
return startStructuredCli({
|
|
45
|
+
sessionId: context.session.id,
|
|
46
|
+
file: "codex",
|
|
47
|
+
args,
|
|
38
48
|
cwd: context.session.cwd,
|
|
39
49
|
env: context.env,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
let settled = false;
|
|
47
|
-
const result = (exitCode, signal, spawnError) => ({
|
|
48
|
-
state: reducer.state,
|
|
49
|
-
exitCode,
|
|
50
|
-
signal,
|
|
51
|
-
stderr,
|
|
52
|
-
primaryError: reducer.primaryError,
|
|
53
|
-
errors: reducer.errors,
|
|
54
|
-
spawnError,
|
|
55
|
-
});
|
|
56
|
-
const processLine = (line) => {
|
|
57
|
-
if (!observer.isActive())
|
|
58
|
-
return;
|
|
59
|
-
const trimmed = line.trim();
|
|
60
|
-
if (!trimmed)
|
|
61
|
-
return;
|
|
62
|
-
let event;
|
|
63
|
-
try {
|
|
64
|
-
event = JSON.parse(trimmed);
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
if (event && typeof event === "object" && !Array.isArray(event)) {
|
|
70
|
-
observer.onEvent?.(event);
|
|
71
|
-
}
|
|
72
|
-
if (reducer.apply(event))
|
|
73
|
-
observer.onUpdate(reducer.state);
|
|
74
|
-
};
|
|
75
|
-
const completion = new Promise((resolve) => {
|
|
76
|
-
child.stdout?.on("data", (chunk) => {
|
|
77
|
-
if (!observer.isActive())
|
|
78
|
-
return;
|
|
79
|
-
const text = chunk.toString();
|
|
80
|
-
observer.onStdout?.(text);
|
|
81
|
-
lineBuffer += text;
|
|
82
|
-
const lines = lineBuffer.split("\n");
|
|
83
|
-
lineBuffer = lines.pop() ?? "";
|
|
84
|
-
for (const line of lines)
|
|
85
|
-
processLine(line);
|
|
86
|
-
});
|
|
87
|
-
child.stderr?.on("data", (chunk) => {
|
|
50
|
+
stdinData: context.prompt,
|
|
51
|
+
observer,
|
|
52
|
+
execHost: this.execHost,
|
|
53
|
+
spawnProcess: this.spawnProcess,
|
|
54
|
+
createState: () => reducer.state,
|
|
55
|
+
processLine: (line) => {
|
|
88
56
|
if (!observer.isActive())
|
|
89
57
|
return;
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
stderr += text;
|
|
93
|
-
});
|
|
94
|
-
child.on("error", (error) => {
|
|
95
|
-
if (settled)
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed)
|
|
96
60
|
return;
|
|
97
|
-
|
|
98
|
-
resolve(result(null, null, error));
|
|
99
|
-
});
|
|
100
|
-
child.on("close", (exitCode, signal) => {
|
|
101
|
-
if (settled)
|
|
102
|
-
return;
|
|
103
|
-
settled = true;
|
|
104
|
-
if (lineBuffer.trim())
|
|
105
|
-
processLine(lineBuffer);
|
|
106
|
-
lineBuffer = "";
|
|
107
|
-
resolve(result(exitCode, signal));
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
return {
|
|
111
|
-
args,
|
|
112
|
-
spawnedAt,
|
|
113
|
-
pid: child.pid ?? null,
|
|
114
|
-
completion,
|
|
115
|
-
interrupt: () => {
|
|
61
|
+
let event;
|
|
116
62
|
try {
|
|
117
|
-
|
|
63
|
+
event = JSON.parse(trimmed);
|
|
118
64
|
}
|
|
119
|
-
catch {
|
|
65
|
+
catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (event && typeof event === "object" && !Array.isArray(event)) {
|
|
69
|
+
observer.onEvent?.(event);
|
|
70
|
+
}
|
|
71
|
+
if (reducer.apply(event))
|
|
72
|
+
observer.onUpdate(reducer.state);
|
|
120
73
|
},
|
|
121
|
-
|
|
74
|
+
finalize: (ctx, exitCode, signal, spawnError) => ({
|
|
75
|
+
state: reducer.state,
|
|
76
|
+
exitCode,
|
|
77
|
+
signal,
|
|
78
|
+
stderr: ctx.stderr,
|
|
79
|
+
primaryError: reducer.primaryError,
|
|
80
|
+
errors: reducer.errors,
|
|
81
|
+
spawnError,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
122
84
|
}
|
|
123
85
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership seam for structured CLI runs, mirroring TerminalHost for PTYs.
|
|
3
|
+
* Persistent adapters (terminald) outlive web restarts; the in-process adapter
|
|
4
|
+
* deliberately keeps the legacy "die with the server" lifecycle.
|
|
5
|
+
*/
|
|
6
|
+
/** Per-stream replay log cap inside the daemon. Reducers need full history. */
|
|
7
|
+
export declare const STRUCTURED_RUN_LOG_MAX_CHARS: number;
|
|
8
|
+
export interface StructuredSpawnRequest {
|
|
9
|
+
/** Stable across restarts: `structured:<sessionId>`. One run per session. */
|
|
10
|
+
runId: string;
|
|
11
|
+
file: string;
|
|
12
|
+
args: string[];
|
|
13
|
+
cwd: string;
|
|
14
|
+
env: NodeJS.ProcessEnv;
|
|
15
|
+
/** Written to stdin once, then stdin is closed. Omit for stdio-ignore CLIs. */
|
|
16
|
+
stdinData?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface StructuredRunState {
|
|
19
|
+
runId: string;
|
|
20
|
+
incarnationId: string;
|
|
21
|
+
pid: number;
|
|
22
|
+
status: "running" | "exited";
|
|
23
|
+
exitCode: number | null;
|
|
24
|
+
signal: number | null;
|
|
25
|
+
stdoutSeq: number;
|
|
26
|
+
stderrSeq: number;
|
|
27
|
+
stdoutLog: string;
|
|
28
|
+
stderrLog: string;
|
|
29
|
+
stdoutTruncated: boolean;
|
|
30
|
+
stderrTruncated: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface StructuredStreamEvent {
|
|
33
|
+
stream: "stdout" | "stderr";
|
|
34
|
+
data: string;
|
|
35
|
+
seq: number;
|
|
36
|
+
}
|
|
37
|
+
export interface StructuredExitEvent {
|
|
38
|
+
exitCode: number | null;
|
|
39
|
+
signal: number | null;
|
|
40
|
+
}
|
|
41
|
+
export interface StructuredExecProcess {
|
|
42
|
+
readonly runId: string;
|
|
43
|
+
readonly incarnationId: string;
|
|
44
|
+
readonly pid: number;
|
|
45
|
+
interrupt(signal?: string): void;
|
|
46
|
+
onStream(listener: (event: StructuredStreamEvent) => void): {
|
|
47
|
+
dispose(): void;
|
|
48
|
+
};
|
|
49
|
+
onExit(listener: (event: StructuredExitEvent) => void): {
|
|
50
|
+
dispose(): void;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export interface StructuredExecHost {
|
|
54
|
+
readonly persistent: boolean;
|
|
55
|
+
spawnStructured(request: StructuredSpawnRequest): Promise<StructuredExecProcess>;
|
|
56
|
+
/** Full state incl. replay logs; null when the host does not know the run. */
|
|
57
|
+
attachRun(runId: string): Promise<StructuredRunState | null>;
|
|
58
|
+
/** Subscribe to a known running run without spawning anything. */
|
|
59
|
+
adoptRun(runId: string): Promise<StructuredExecProcess | null>;
|
|
60
|
+
listRuns(): Promise<StructuredRunState[]>;
|
|
61
|
+
forgetRun(runId: string): void;
|
|
62
|
+
}
|
|
63
|
+
/** Resolve a stable daemon-side key for a session's active structured run. */
|
|
64
|
+
export declare function structuredRunId(sessionId: string): string;
|
|
65
|
+
/** Legacy/local adapter and deterministic test adapter for the structured seam. */
|
|
66
|
+
export declare class InProcessStructuredExecHost implements StructuredExecHost {
|
|
67
|
+
readonly persistent = false;
|
|
68
|
+
private readonly processes;
|
|
69
|
+
spawnStructured(request: StructuredSpawnRequest): Promise<StructuredExecProcess>;
|
|
70
|
+
attachRun(): Promise<StructuredRunState | null>;
|
|
71
|
+
adoptRun(): Promise<StructuredExecProcess | null>;
|
|
72
|
+
listRuns(): Promise<StructuredRunState[]>;
|
|
73
|
+
forgetRun(runId: string): void;
|
|
74
|
+
}
|
|
75
|
+
/** Map a NodeJS.Signals name to the numeric value used by waitpid-style APIs. */
|
|
76
|
+
export declare function osSignalNumber(signal: NodeJS.Signals): number;
|