@steerable/agent-shell 0.6.15
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/LICENSE +91 -0
- package/contracts/tool-contract.json +326 -0
- package/dist/attachments.d.ts +41 -0
- package/dist/attachments.js +147 -0
- package/dist/brand.d.ts +24 -0
- package/dist/brand.js +92 -0
- package/dist/host/http-routes.d.ts +21 -0
- package/dist/host/http-routes.js +55 -0
- package/dist/host/ipc.d.ts +11 -0
- package/dist/host/ipc.js +20 -0
- package/dist/host/pack-assembly.d.ts +86 -0
- package/dist/host/pack-assembly.js +32 -0
- package/dist/host/runtime.d.ts +69 -0
- package/dist/host/runtime.js +207 -0
- package/dist/host/visible-terminal-exec.d.ts +21 -0
- package/dist/host/visible-terminal-exec.js +151 -0
- package/dist/hosted-web-search.d.ts +13 -0
- package/dist/hosted-web-search.js +82 -0
- package/dist/image-attachment.d.ts +39 -0
- package/dist/image-attachment.js +133 -0
- package/dist/insights/flush.d.ts +10 -0
- package/dist/insights/flush.js +139 -0
- package/dist/insights/record.d.ts +17 -0
- package/dist/insights/record.js +44 -0
- package/dist/json-store.d.ts +9 -0
- package/dist/json-store.js +22 -0
- package/dist/llm/index.d.ts +23 -0
- package/dist/llm/index.js +105 -0
- package/dist/llm/ollama.d.ts +23 -0
- package/dist/llm/ollama.js +242 -0
- package/dist/llm/openai-compat.d.ts +20 -0
- package/dist/llm/openai-compat.js +199 -0
- package/dist/llm/sidecar-provider.d.ts +37 -0
- package/dist/llm/sidecar-provider.js +163 -0
- package/dist/llm/tool-choice.d.ts +35 -0
- package/dist/llm/tool-choice.js +85 -0
- package/dist/llm/types.d.ts +122 -0
- package/dist/llm/types.js +1 -0
- package/dist/local-backend/agent-capability.d.ts +101 -0
- package/dist/local-backend/agent-capability.js +174 -0
- package/dist/local-backend/ai-title.d.ts +43 -0
- package/dist/local-backend/ai-title.js +173 -0
- package/dist/local-backend/auto-continue-helper.d.ts +80 -0
- package/dist/local-backend/auto-continue-helper.js +83 -0
- package/dist/local-backend/branch-helper.d.ts +24 -0
- package/dist/local-backend/branch-helper.js +27 -0
- package/dist/local-backend/context-compactor.d.ts +81 -0
- package/dist/local-backend/context-compactor.js +213 -0
- package/dist/local-backend/coreloop-stream.d.ts +245 -0
- package/dist/local-backend/coreloop-stream.js +277 -0
- package/dist/local-backend/deferred-detector.d.ts +15 -0
- package/dist/local-backend/deferred-detector.js +124 -0
- package/dist/local-backend/history-helper.d.ts +30 -0
- package/dist/local-backend/history-helper.js +34 -0
- package/dist/local-backend/interrupted-helper.d.ts +29 -0
- package/dist/local-backend/interrupted-helper.js +25 -0
- package/dist/local-backend/live-stream.d.ts +36 -0
- package/dist/local-backend/live-stream.js +23 -0
- package/dist/local-backend/llm-diagnose.d.ts +37 -0
- package/dist/local-backend/llm-diagnose.js +284 -0
- package/dist/local-backend/message-triggers.d.ts +27 -0
- package/dist/local-backend/message-triggers.js +67 -0
- package/dist/local-backend/pack-backend-routes.d.ts +31 -0
- package/dist/local-backend/pack-backend-routes.js +62 -0
- package/dist/local-backend/pack-turn-hooks.d.ts +37 -0
- package/dist/local-backend/pack-turn-hooks.js +67 -0
- package/dist/local-backend/prompt-builder.d.ts +101 -0
- package/dist/local-backend/prompt-builder.js +246 -0
- package/dist/local-backend/regenerate-helper.d.ts +62 -0
- package/dist/local-backend/regenerate-helper.js +75 -0
- package/dist/local-backend/router.d.ts +175 -0
- package/dist/local-backend/router.js +3139 -0
- package/dist/local-backend/skill-install.d.ts +19 -0
- package/dist/local-backend/skill-install.js +71 -0
- package/dist/local-backend/skill-loader.d.ts +92 -0
- package/dist/local-backend/skill-loader.js +146 -0
- package/dist/local-backend/skills/00-identity/SKILL.md +32 -0
- package/dist/local-backend/skills/10-goal/SKILL.md +59 -0
- package/dist/local-backend/skills/11-loop/SKILL.md +71 -0
- package/dist/local-backend/skills/12-create-skill/SKILL.md +88 -0
- package/dist/local-backend/skills/70-plan-mode/SKILL.md +58 -0
- package/dist/local-backend/skills/80-tool-usage/SKILL.md +70 -0
- package/dist/local-backend/skills/81-anti-deferred/SKILL.md +53 -0
- package/dist/local-backend/skills/82-data-grounding/SKILL.md +56 -0
- package/dist/local-backend/skills/85-local-exec/SKILL.md +86 -0
- package/dist/local-backend/skills/86-proactive-coding/SKILL.md +51 -0
- package/dist/local-backend/subagent-profiles.d.ts +30 -0
- package/dist/local-backend/subagent-profiles.js +74 -0
- package/dist/local-backend/task-process.d.ts +12 -0
- package/dist/local-backend/task-process.js +176 -0
- package/dist/local-backend/task-service.d.ts +135 -0
- package/dist/local-backend/task-service.js +565 -0
- package/dist/local-backend/turn-duration.d.ts +2 -0
- package/dist/local-backend/turn-duration.js +9 -0
- package/dist/local-backend/turn-timeline.d.ts +16 -0
- package/dist/local-backend/turn-timeline.js +42 -0
- package/dist/local-backend/worktree-service.d.ts +84 -0
- package/dist/local-backend/worktree-service.js +243 -0
- package/dist/local-edit.d.ts +48 -0
- package/dist/local-edit.js +44 -0
- package/dist/local-executor.d.ts +255 -0
- package/dist/local-executor.js +881 -0
- package/dist/local-script-registry.d.ts +28 -0
- package/dist/local-script-registry.js +63 -0
- package/dist/log.d.ts +13 -0
- package/dist/log.js +12 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +855 -0
- package/dist/mcp-executor.d.ts +45 -0
- package/dist/mcp-executor.js +241 -0
- package/dist/mcp-server-registry.d.ts +104 -0
- package/dist/mcp-server-registry.js +234 -0
- package/dist/preload-default.d.ts +1 -0
- package/dist/preload-default.js +9 -0
- package/dist/preload.cjs +395 -0
- package/dist/preload.d.ts +20 -0
- package/dist/preload.js +411 -0
- package/dist/product-config.d.ts +43 -0
- package/dist/product-config.js +26 -0
- package/dist/project-registry.d.ts +55 -0
- package/dist/project-registry.js +106 -0
- package/dist/project-rules.d.ts +15 -0
- package/dist/project-rules.js +102 -0
- package/dist/runtime.d.ts +62 -0
- package/dist/runtime.js +217 -0
- package/dist/scenario/pack.d.ts +8 -0
- package/dist/scenario/pack.js +1 -0
- package/dist/scenario/registry.d.ts +24 -0
- package/dist/scenario/registry.js +31 -0
- package/dist/server/http-server.d.ts +39 -0
- package/dist/server/http-server.js +361 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +107 -0
- package/dist/server/sse-bus.d.ts +14 -0
- package/dist/server/sse-bus.js +31 -0
- package/dist/shell-adapt.d.ts +21 -0
- package/dist/shell-adapt.js +104 -0
- package/dist/sidecar/boot.d.ts +36 -0
- package/dist/sidecar/boot.js +343 -0
- package/dist/sidecar/egress-hint.d.ts +15 -0
- package/dist/sidecar/egress-hint.js +46 -0
- package/dist/sidecar/egress-proxy.d.ts +183 -0
- package/dist/sidecar/egress-proxy.js +419 -0
- package/dist/sidecar/errors.d.ts +22 -0
- package/dist/sidecar/errors.js +38 -0
- package/dist/sidecar/exec-sandbox.d.ts +48 -0
- package/dist/sidecar/exec-sandbox.js +94 -0
- package/dist/sidecar/handle.d.ts +32 -0
- package/dist/sidecar/handle.js +53 -0
- package/dist/sidecar/index.d.ts +14 -0
- package/dist/sidecar/index.js +13 -0
- package/dist/sidecar/proxy-detect.d.ts +50 -0
- package/dist/sidecar/proxy-detect.js +182 -0
- package/dist/sidecar/reverse-approval.d.ts +55 -0
- package/dist/sidecar/reverse-approval.js +86 -0
- package/dist/sidecar/reverse-ask-user.d.ts +34 -0
- package/dist/sidecar/reverse-ask-user.js +59 -0
- package/dist/sidecar/reverse-spawn.d.ts +19 -0
- package/dist/sidecar/reverse-spawn.js +161 -0
- package/dist/sidecar/reverse-tools.d.ts +29 -0
- package/dist/sidecar/reverse-tools.js +106 -0
- package/dist/sidecar/safety-patterns.d.ts +41 -0
- package/dist/sidecar/safety-patterns.js +157 -0
- package/dist/sidecar/storage-path.d.ts +14 -0
- package/dist/sidecar/storage-path.js +35 -0
- package/dist/sidecar/supervisor.d.ts +218 -0
- package/dist/sidecar/supervisor.js +932 -0
- package/dist/sidecar/types.d.ts +601 -0
- package/dist/sidecar/types.js +1 -0
- package/dist/single-instance.d.ts +11 -0
- package/dist/single-instance.js +21 -0
- package/dist/storage/empty-chats.d.ts +9 -0
- package/dist/storage/empty-chats.js +16 -0
- package/dist/storage/index.d.ts +373 -0
- package/dist/storage/index.js +1158 -0
- package/dist/storage/insights-redact.d.ts +2 -0
- package/dist/storage/insights-redact.js +30 -0
- package/dist/storage/insights-settings.d.ts +53 -0
- package/dist/storage/insights-settings.js +92 -0
- package/dist/storage/llm-settings.d.ts +120 -0
- package/dist/storage/llm-settings.js +233 -0
- package/dist/storage/local-store-singleton.d.ts +28 -0
- package/dist/storage/local-store-singleton.js +38 -0
- package/dist/storage/message-order.d.ts +25 -0
- package/dist/storage/message-order.js +27 -0
- package/dist/storage/pack-migrations.d.ts +22 -0
- package/dist/storage/pack-migrations.js +24 -0
- package/dist/storage/pack-seeds.d.ts +36 -0
- package/dist/storage/pack-seeds.js +42 -0
- package/dist/storage/telemetry-settings.d.ts +38 -0
- package/dist/storage/telemetry-settings.js +59 -0
- package/dist/storage/usage-summary.d.ts +55 -0
- package/dist/storage/usage-summary.js +38 -0
- package/dist/storage/web-search-settings.d.ts +38 -0
- package/dist/storage/web-search-settings.js +74 -0
- package/dist/storage/write-lease.d.ts +26 -0
- package/dist/storage/write-lease.js +74 -0
- package/dist/terminal-manager.d.ts +83 -0
- package/dist/terminal-manager.js +506 -0
- package/dist/tool-router.d.ts +228 -0
- package/dist/tool-router.js +930 -0
- package/dist/tool-search-rank.d.ts +42 -0
- package/dist/tool-search-rank.js +96 -0
- package/package.json +67 -0
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supervises the steerable-sidecar Python subprocess.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - locate the bundled portable Python runtime (or accept an override),
|
|
6
|
+
* - spawn the sidecar with stdin/stdout/stderr pipes,
|
|
7
|
+
* - wait for the `__SIDECAR_READY__` marker on stderr before resolving start(),
|
|
8
|
+
* - parse JSON-RPC frames and dispatch them to pending requests / handlers,
|
|
9
|
+
* - run a periodic `system.ping` health-check and auto-restart on failure,
|
|
10
|
+
* - kill the process when the Electron app quits.
|
|
11
|
+
*/
|
|
12
|
+
import { execFile, spawn } from 'node:child_process';
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { promisify } from 'node:util';
|
|
19
|
+
import { onAppWillQuit, offAppWillQuit } from '../runtime.js';
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
const __dirname = dirname(__filename);
|
|
22
|
+
import { resolveWinSpawnHelperPath } from './reverse-spawn.js';
|
|
23
|
+
import { SidecarBootError, SidecarMethodError, SidecarSandboxUnavailableError, SidecarShutdownError, } from './errors.js';
|
|
24
|
+
const READY_PREFIX = '__SIDECAR_READY__:';
|
|
25
|
+
const DEFAULT_BOOT_TIMEOUT_MS = 15_000;
|
|
26
|
+
const DEFAULT_HEALTH_INTERVAL_MS = 5_000;
|
|
27
|
+
const DEFAULT_RESTART_AFTER_FAILED_PINGS = 3;
|
|
28
|
+
// Only /usr/bin/sandbox-exec is trusted — a PATH-relative lookup could
|
|
29
|
+
// resolve to an attacker-planted binary (codex's rule).
|
|
30
|
+
const SEATBELT_EXECUTABLE = '/usr/bin/sandbox-exec';
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
/**
|
|
33
|
+
* Tool names carried by a `tool.list` reply.
|
|
34
|
+
*
|
|
35
|
+
* The sidecar answers with OpenAI function-call descriptors —
|
|
36
|
+
* `{ type: 'function', function: { name, description, parameters } }` — so the
|
|
37
|
+
* name sits one level in. Reading a top-level `name` yields undefined for every
|
|
38
|
+
* entry, which reads as "the sidecar registered nothing" rather than as a
|
|
39
|
+
* decoding error.
|
|
40
|
+
*/
|
|
41
|
+
export function toolNamesFromDescriptors(listed) {
|
|
42
|
+
if (!Array.isArray(listed))
|
|
43
|
+
return [];
|
|
44
|
+
const names = [];
|
|
45
|
+
for (const entry of listed) {
|
|
46
|
+
if (!entry || typeof entry !== 'object')
|
|
47
|
+
continue;
|
|
48
|
+
const fn = entry.function;
|
|
49
|
+
if (!fn || typeof fn !== 'object')
|
|
50
|
+
continue;
|
|
51
|
+
const name = fn.name;
|
|
52
|
+
if (typeof name === 'string' && name)
|
|
53
|
+
names.push(name);
|
|
54
|
+
}
|
|
55
|
+
return names;
|
|
56
|
+
}
|
|
57
|
+
export class SidecarSupervisor extends EventEmitter {
|
|
58
|
+
options;
|
|
59
|
+
/** Last layer-1 refuse when start() threw (settings page still needs a posture). */
|
|
60
|
+
static lastSpawnRefusal = null;
|
|
61
|
+
child = null;
|
|
62
|
+
readyHealth = null;
|
|
63
|
+
sandboxPosture = null;
|
|
64
|
+
nextRequestId = 1;
|
|
65
|
+
pending = new Map();
|
|
66
|
+
stdoutBuffer = '';
|
|
67
|
+
stderrBuffer = '';
|
|
68
|
+
healthTimer = null;
|
|
69
|
+
failedPings = 0;
|
|
70
|
+
shuttingDown = false;
|
|
71
|
+
quitListener = null;
|
|
72
|
+
reverseHandlers = new Map();
|
|
73
|
+
constructor(options) {
|
|
74
|
+
super();
|
|
75
|
+
this.options = options;
|
|
76
|
+
}
|
|
77
|
+
/** Spawn the sidecar and wait for the ready marker. */
|
|
78
|
+
static async start(options = {}) {
|
|
79
|
+
const supervisor = new SidecarSupervisor(options);
|
|
80
|
+
try {
|
|
81
|
+
await supervisor.boot();
|
|
82
|
+
SidecarSupervisor.lastSpawnRefusal = null;
|
|
83
|
+
return supervisor;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (err instanceof SidecarSandboxUnavailableError) {
|
|
87
|
+
SidecarSupervisor.lastSpawnRefusal = err.posture;
|
|
88
|
+
}
|
|
89
|
+
// boot 失败后 child 的 exit→restart 循环仍在后台重试(例如另一个
|
|
90
|
+
// 宿主暂时持有 sessions.lock,冲突会自愈)。把实例挂在错误上,宿主
|
|
91
|
+
// 可监听 'ready' 在迟到就绪时补注册——否则 sidecar 活着但
|
|
92
|
+
// getSidecarSupervisor() 永远 null,聊天一直 503。
|
|
93
|
+
err.supervisor = supervisor;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Round-trip a JSON-RPC method call. */
|
|
98
|
+
async call(method, params, options = {}) {
|
|
99
|
+
const child = this.requireChild();
|
|
100
|
+
const id = this.nextRequestId++;
|
|
101
|
+
const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const timeout = options.timeoutMs ?? 60_000;
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
this.pending.delete(id);
|
|
106
|
+
reject(new SidecarMethodError(`sidecar method ${method} timed out after ${timeout}ms`, -32000, 'timeout', undefined));
|
|
107
|
+
}, timeout);
|
|
108
|
+
this.pending.set(id, {
|
|
109
|
+
resolve: (value) => resolve(value),
|
|
110
|
+
reject,
|
|
111
|
+
timer,
|
|
112
|
+
});
|
|
113
|
+
child.stdin.write(frame + '\n', (err) => {
|
|
114
|
+
if (err) {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
this.pending.delete(id);
|
|
117
|
+
reject(new SidecarMethodError(`failed to write to sidecar: ${err.message}`, -32000, 'transport_closed', undefined));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Convenience: list tools registered on the sidecar. */
|
|
123
|
+
async listTools() {
|
|
124
|
+
return await this.call('tool.list');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The gateway's live model catalog (`models.list`): ids the configured
|
|
128
|
+
* gateway actually accepts, joined with models.dev capabilities. The
|
|
129
|
+
* host passes the user's configured baseUrl/apiKey explicitly — the
|
|
130
|
+
* sidecar process env does not carry the app's settings. Fetch failures
|
|
131
|
+
* come back as `catalogStatus: 'offline'`/`'stale'` in the payload, not
|
|
132
|
+
* as RPC errors, so the picker can badge instead of breaking.
|
|
133
|
+
*/
|
|
134
|
+
async listModels(params = {}) {
|
|
135
|
+
return await this.call('models.list', params, {
|
|
136
|
+
// The sidecar fetches the gateway's /models over the network; clear
|
|
137
|
+
// that bound or the caller sees a transport timeout instead of the
|
|
138
|
+
// payload's own offline status.
|
|
139
|
+
timeoutMs: 20_000,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Names advertised by the sidecar's registry, decoded from `tool.list`. */
|
|
143
|
+
async listToolNames() {
|
|
144
|
+
return toolNamesFromDescriptors(await this.listTools());
|
|
145
|
+
}
|
|
146
|
+
/** Convenience: invoke a tool by name. */
|
|
147
|
+
async invokeTool(name, args = {}, extra = {}) {
|
|
148
|
+
return await this.call('tool.invoke', {
|
|
149
|
+
name,
|
|
150
|
+
arguments: args,
|
|
151
|
+
consentGranted: Boolean(extra.consentGranted),
|
|
152
|
+
context: extra.context,
|
|
153
|
+
}, { timeoutMs: extra.timeoutMs });
|
|
154
|
+
}
|
|
155
|
+
/** Convenience: ping for a health snapshot. */
|
|
156
|
+
async ping() {
|
|
157
|
+
return await this.call('system.ping', null, { timeoutMs: 5_000 });
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* W6-1 single source of truth: run the structured-edit algorithm in the
|
|
161
|
+
* sidecar (Python `file_edit.apply_edits`) on caller-supplied content. The
|
|
162
|
+
* host keeps all file I/O (read / version check / atomic write); only the
|
|
163
|
+
* locate-and-replace surgery crosses the wire so the desktop and the
|
|
164
|
+
* headless / ACP workspace tools share one implementation.
|
|
165
|
+
*/
|
|
166
|
+
async applyEdits(params) {
|
|
167
|
+
return await this.call('workspace.apply_edits', params, { timeoutMs: 15_000 });
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Skills single source of truth: parse + select SKILL.md modules in the
|
|
171
|
+
* sidecar (Python `skills.py`) from host-supplied roots. Returns both layers
|
|
172
|
+
* with bodies; the host applies its own layer filter / budget / name lookup.
|
|
173
|
+
*/
|
|
174
|
+
async listSkills(params) {
|
|
175
|
+
const result = await this.call('skills.list', params, { timeoutMs: 15_000 });
|
|
176
|
+
return result.skills;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Run a streaming chat completion through the sidecar's `agent.chat.stream`
|
|
180
|
+
* method. Subscribes to `stream.chunk` / `stream.done` / `stream.error`
|
|
181
|
+
* notifications, demuxes them by `streamId`, and surfaces them through the
|
|
182
|
+
* supplied callbacks.
|
|
183
|
+
*
|
|
184
|
+
* Returns the `streamId` so callers can correlate cancel requests.
|
|
185
|
+
*/
|
|
186
|
+
async streamChat(request, handlers) {
|
|
187
|
+
const result = await this.call('agent.chat.stream', request, { timeoutMs: request.startTimeoutMs ?? 30_000 });
|
|
188
|
+
const streamId = result.streamId;
|
|
189
|
+
const onChunk = (params) => {
|
|
190
|
+
const payload = params;
|
|
191
|
+
if (!payload || payload.streamId !== streamId)
|
|
192
|
+
return;
|
|
193
|
+
handlers.onChunk?.(payload);
|
|
194
|
+
};
|
|
195
|
+
const onDone = (params) => {
|
|
196
|
+
const payload = params;
|
|
197
|
+
if (!payload || payload.streamId !== streamId)
|
|
198
|
+
return;
|
|
199
|
+
this.off('stream.chunk', onChunk);
|
|
200
|
+
this.off('stream.done', onDone);
|
|
201
|
+
this.off('stream.error', onError);
|
|
202
|
+
this.off('agent.child', onChild);
|
|
203
|
+
handlers.onDone?.(payload);
|
|
204
|
+
};
|
|
205
|
+
const onError = (params) => {
|
|
206
|
+
const payload = params;
|
|
207
|
+
if (!payload || payload.streamId !== streamId)
|
|
208
|
+
return;
|
|
209
|
+
this.off('stream.chunk', onChunk);
|
|
210
|
+
this.off('stream.done', onDone);
|
|
211
|
+
this.off('stream.error', onError);
|
|
212
|
+
this.off('agent.child', onChild);
|
|
213
|
+
handlers.onError?.(payload);
|
|
214
|
+
};
|
|
215
|
+
// P3.1 orchestration lifecycle: `agent.child` notifications carry the
|
|
216
|
+
// same streamId, demuxed here so the turn driver can surface child
|
|
217
|
+
// spawn/complete/fail/interrupt/resume in the UI.
|
|
218
|
+
const onChild = (params) => {
|
|
219
|
+
const payload = params;
|
|
220
|
+
if (!payload || payload.streamId !== streamId)
|
|
221
|
+
return;
|
|
222
|
+
handlers.onChildEvent?.(payload);
|
|
223
|
+
};
|
|
224
|
+
this.on('stream.chunk', onChunk);
|
|
225
|
+
this.on('stream.done', onDone);
|
|
226
|
+
this.on('stream.error', onError);
|
|
227
|
+
this.on('agent.child', onChild);
|
|
228
|
+
return streamId;
|
|
229
|
+
}
|
|
230
|
+
/** Best-effort cancel a sidecar stream by id. */
|
|
231
|
+
async cancelChat(streamId) {
|
|
232
|
+
try {
|
|
233
|
+
await this.call('agent.chat.cancel', { streamId }, { timeoutMs: 2_000 });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* best effort — sidecar may have already finished */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* W5-2: fork a durable record without running a turn — the non-destructive
|
|
241
|
+
* regenerate primitive.
|
|
242
|
+
*
|
|
243
|
+
* Reports why a fork did not happen instead of collapsing every cause to a
|
|
244
|
+
* single falsy value. The caller's fallback destroys the reply's UI row and
|
|
245
|
+
* lets the next turn append into the same record, so a declined address and a
|
|
246
|
+
* failed request are not interchangeable — see {@link SidecarSessionForkOutcome}.
|
|
247
|
+
*/
|
|
248
|
+
async forkSession(params) {
|
|
249
|
+
try {
|
|
250
|
+
const fork = await this.call('agent.session.fork', params, { timeoutMs: 10_000 });
|
|
251
|
+
return { ok: true, fork };
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
// `invalid_request` is the sidecar rejecting the address itself; anything
|
|
255
|
+
// else (transport, timeout, sidecar fault) never reached that judgement.
|
|
256
|
+
const method = err instanceof SidecarMethodError ? err : undefined;
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
declined: method?.kind === 'invalid_request',
|
|
260
|
+
reason: method
|
|
261
|
+
? `${method.kind ?? 'error'} (${method.code}): ${method.message}`
|
|
262
|
+
: err instanceof Error
|
|
263
|
+
? err.message
|
|
264
|
+
: String(err),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* W1.2.1: branch-family view of a record (lineage + direct children).
|
|
270
|
+
* Soft-fail null when the sidecar has no such record — the caller renders
|
|
271
|
+
* "no branches" rather than an error.
|
|
272
|
+
*/
|
|
273
|
+
async sessionBranches(recordId) {
|
|
274
|
+
try {
|
|
275
|
+
return await this.call('agent.session.branches', { recordId }, { timeoutMs: 10_000 });
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Session tree: full branch family containing a record, expanded from
|
|
283
|
+
* the family root (`agent.session.tree`). Unlike {@link sessionBranches}
|
|
284
|
+
* (lineage + direct children only), this sees cousins and deeper
|
|
285
|
+
* descendants — the activation guard and the tree modal both need it.
|
|
286
|
+
* Soft-fail null when the sidecar has no such record — the caller
|
|
287
|
+
* renders "no branches" rather than an error.
|
|
288
|
+
*/
|
|
289
|
+
async sessionTree(recordId) {
|
|
290
|
+
try {
|
|
291
|
+
return await this.call('agent.session.tree', { recordId }, { timeoutMs: 10_000 });
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* W1.2.1: projected transcript of a record (post-boundary visible span) —
|
|
299
|
+
* the read path for rendering a branch after a switch. Soft-fail null.
|
|
300
|
+
*/
|
|
301
|
+
async sessionMessages(recordId) {
|
|
302
|
+
try {
|
|
303
|
+
return await this.call('agent.session.messages', { recordId }, { timeoutMs: 15_000 });
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Inject a user message into a running CoreLoop turn (mid-turn steering).
|
|
311
|
+
* Soft-fails (`{ ok: false }`) when the turn already ended — the caller
|
|
312
|
+
* should then send the message as a normal new turn instead.
|
|
313
|
+
*/
|
|
314
|
+
async steerChat(streamId, content) {
|
|
315
|
+
try {
|
|
316
|
+
const result = await this.call('agent.chat.steer', { streamId, content }, { timeoutMs: 2_000 });
|
|
317
|
+
return result.ok === true;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Register a host-side handler for a reverse (sidecar -> host) request.
|
|
325
|
+
*
|
|
326
|
+
* When the sidecar hosts the agent loop but a tool must execute in the
|
|
327
|
+
* Electron process (shell, filesystem, MCP), the sidecar sends a reverse
|
|
328
|
+
* `tool.invoke` request; the handler registered for that method runs the
|
|
329
|
+
* real tool and its return value is sent back as the JSON-RPC result.
|
|
330
|
+
*/
|
|
331
|
+
onReverseRequest(method, handler) {
|
|
332
|
+
this.reverseHandlers.set(method, handler);
|
|
333
|
+
}
|
|
334
|
+
/** Returns the most recent ready snapshot collected at boot. */
|
|
335
|
+
getBootSnapshot() {
|
|
336
|
+
return this.readyHealth;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* W4-3: the layer-1 (sidecar process sandbox) posture recorded at
|
|
340
|
+
* spawn-plan time. This is the value the renderer reads (via
|
|
341
|
+
* `GET /api/v2/sidecar/sandbox-posture`) to disclose confined / opt-out
|
|
342
|
+
* / refused-start. A refused start also lands on `lastSpawnRefusal`.
|
|
343
|
+
*/
|
|
344
|
+
getSandboxPosture() {
|
|
345
|
+
return this.sandboxPosture;
|
|
346
|
+
}
|
|
347
|
+
/** Graceful shutdown. */
|
|
348
|
+
async shutdown() {
|
|
349
|
+
if (this.shuttingDown)
|
|
350
|
+
return;
|
|
351
|
+
this.shuttingDown = true;
|
|
352
|
+
this.stopHealthTimer();
|
|
353
|
+
if (this.quitListener) {
|
|
354
|
+
offAppWillQuit(this.quitListener);
|
|
355
|
+
this.quitListener = null;
|
|
356
|
+
}
|
|
357
|
+
const child = this.child;
|
|
358
|
+
if (!child)
|
|
359
|
+
return;
|
|
360
|
+
try {
|
|
361
|
+
await this.call('system.shutdown', null, { timeoutMs: 2_000 });
|
|
362
|
+
}
|
|
363
|
+
catch { /* sidecar might already be terminating */ }
|
|
364
|
+
await new Promise((resolve) => {
|
|
365
|
+
const timer = setTimeout(() => {
|
|
366
|
+
try {
|
|
367
|
+
child.kill('SIGKILL');
|
|
368
|
+
}
|
|
369
|
+
catch { /* noop */ }
|
|
370
|
+
resolve();
|
|
371
|
+
}, 2_000);
|
|
372
|
+
child.once('exit', () => {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
resolve();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
this.child = null;
|
|
378
|
+
this.failPending(new SidecarShutdownError('sidecar shut down'));
|
|
379
|
+
}
|
|
380
|
+
// ------------------------------------------------------------------
|
|
381
|
+
// Internal: boot
|
|
382
|
+
// ------------------------------------------------------------------
|
|
383
|
+
async boot() {
|
|
384
|
+
const py = this.resolvePythonBinary();
|
|
385
|
+
const entry = this.options.entryModule ?? 'steerable_sidecar';
|
|
386
|
+
const args = ['-m', entry, ...(this.options.args ?? [])];
|
|
387
|
+
const spawnPlan = await this.resolveSandboxedSpawn(py, args);
|
|
388
|
+
const child = spawn(spawnPlan.command, spawnPlan.args, {
|
|
389
|
+
cwd: this.options.cwd,
|
|
390
|
+
env: { ...process.env, ...this.options.env, ...spawnPlan.env },
|
|
391
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
392
|
+
});
|
|
393
|
+
this.child = child;
|
|
394
|
+
this.attachListeners(child);
|
|
395
|
+
try {
|
|
396
|
+
this.readyHealth = await this.waitForReady(this.options.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
try {
|
|
400
|
+
child.kill('SIGKILL');
|
|
401
|
+
}
|
|
402
|
+
catch { /* noop */ }
|
|
403
|
+
this.child = null;
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
406
|
+
this.installAppQuitHook();
|
|
407
|
+
this.startHealthTimer();
|
|
408
|
+
this.emit('ready', this.readyHealth);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Wrap the sidecar spawn in the platform's process sandbox.
|
|
412
|
+
*
|
|
413
|
+
* macOS Seatbelt, Linux bwrap/Landlock, Windows restricted-token
|
|
414
|
+
* passthrough. Any failure refuses start — confinement requested means
|
|
415
|
+
* the process does not run unsandboxed. Explicit opt-out
|
|
416
|
+
* (`sandbox: false` / `STEERABLE_SIDECAR_SANDBOX=0`) is the only
|
|
417
|
+
* unconfined path. Every exit records `sandboxPosture`.
|
|
418
|
+
*/
|
|
419
|
+
async resolveSandboxedSpawn(py, args) {
|
|
420
|
+
const plain = { command: py, args };
|
|
421
|
+
const enabled = this.options.sandbox ?? process.env.STEERABLE_SIDECAR_SANDBOX !== '0';
|
|
422
|
+
if (!enabled) {
|
|
423
|
+
this.sandboxPosture = {
|
|
424
|
+
backend: 'none',
|
|
425
|
+
enforcement: 'none',
|
|
426
|
+
reason: this.options.sandbox === false ? 'disabled_by_option' : 'disabled_by_env',
|
|
427
|
+
};
|
|
428
|
+
return plain;
|
|
429
|
+
}
|
|
430
|
+
const steerableDir = join(homedir(), '.steerable');
|
|
431
|
+
mkdirSync(steerableDir, { recursive: true });
|
|
432
|
+
// 除默认 ~/.steerable 外的 writable roots(storage path 在 userData/tmp
|
|
433
|
+
// 等外部目录时由宿主传入)。macOS Seatbelt 与 Linux bwrap 都要求 root
|
|
434
|
+
// 已存在,这里统一先建好;Windows helper 同样要求已存在。
|
|
435
|
+
const writableRoots = [steerableDir];
|
|
436
|
+
for (const root of this.options.sandboxWritableRoots ?? []) {
|
|
437
|
+
if (!root || root === steerableDir || writableRoots.includes(root))
|
|
438
|
+
continue;
|
|
439
|
+
mkdirSync(root, { recursive: true });
|
|
440
|
+
writableRoots.push(root);
|
|
441
|
+
}
|
|
442
|
+
const allowedHosts = this.options.sandboxAllowedHosts ??
|
|
443
|
+
(process.env.STEERABLE_SIDECAR_SANDBOX_ALLOWED_HOSTS ?? '')
|
|
444
|
+
.split(',')
|
|
445
|
+
.map((h) => h.trim())
|
|
446
|
+
.filter(Boolean);
|
|
447
|
+
const webEgress = Boolean(this.options.sandboxWebEgress) && allowedHosts.length > 0;
|
|
448
|
+
// 3.1b: egress-proxy 模式下 web_fetch 的 SSRF 预检在沙箱内解析 DNS,
|
|
449
|
+
// 只放行解析器 socket(无 IP 可达性);webEgress 已含解析器,互斥。
|
|
450
|
+
const allowResolver = Boolean(this.options.sandboxAllowResolver) && allowedHosts.length > 0 && !webEgress;
|
|
451
|
+
if (process.platform === 'darwin') {
|
|
452
|
+
return this.wrapSeatbelt(py, args, writableRoots, allowedHosts, webEgress, allowResolver);
|
|
453
|
+
}
|
|
454
|
+
if (process.platform === 'linux') {
|
|
455
|
+
return this.wrapLinux(py, args, writableRoots);
|
|
456
|
+
}
|
|
457
|
+
if (process.platform === 'win32') {
|
|
458
|
+
return this.wrapWindows(py, args, writableRoots);
|
|
459
|
+
}
|
|
460
|
+
const posture = {
|
|
461
|
+
backend: 'none',
|
|
462
|
+
enforcement: 'none',
|
|
463
|
+
reason: 'platform_unsupported',
|
|
464
|
+
};
|
|
465
|
+
this.sandboxPosture = posture;
|
|
466
|
+
throw new SidecarSandboxUnavailableError(`sandbox: no process confinement on ${process.platform}; refusing unsandboxed spawn`, posture);
|
|
467
|
+
}
|
|
468
|
+
refuse(posture, message, cause) {
|
|
469
|
+
this.sandboxPosture = posture;
|
|
470
|
+
this.options.onLogLine?.(message);
|
|
471
|
+
throw new SidecarSandboxUnavailableError(message, posture, cause);
|
|
472
|
+
}
|
|
473
|
+
async wrapSeatbelt(py, args, writableRoots, allowedHosts, webEgress, allowResolver) {
|
|
474
|
+
if (!existsSync(SEATBELT_EXECUTABLE)) {
|
|
475
|
+
this.refuse({ backend: 'none', enforcement: 'none', reason: 'seatbelt_missing' }, 'sandbox: /usr/bin/sandbox-exec missing; refusing unsandboxed spawn');
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const profileArgs = [
|
|
479
|
+
'-m',
|
|
480
|
+
'steerable_sidecar.sandbox',
|
|
481
|
+
'profile',
|
|
482
|
+
...writableRoots.flatMap((root) => ['--writable-root', root]),
|
|
483
|
+
...allowedHosts.flatMap((h) => ['--allow-host', h]),
|
|
484
|
+
...(webEgress ? ['--allow-web-egress'] : []),
|
|
485
|
+
...(allowResolver ? ['--allow-resolver'] : []),
|
|
486
|
+
];
|
|
487
|
+
const { stdout } = await execFileAsync(py, profileArgs, { timeout: 10_000 });
|
|
488
|
+
const profile = stdout.trim();
|
|
489
|
+
if (!profile.includes('(deny default)')) {
|
|
490
|
+
throw new Error('generated profile is not a Seatbelt policy');
|
|
491
|
+
}
|
|
492
|
+
this.options.onLogLine?.(`sandbox: Seatbelt active (writes: ${writableRoots.join(', ')} + scratch` +
|
|
493
|
+
(allowedHosts.length ? `; egress: ${allowedHosts.join(', ')}` : '; egress: open') +
|
|
494
|
+
(webEgress ? ' + web tools: DNS, any host on 80/443' : '') +
|
|
495
|
+
(allowResolver ? ' + resolver only (web tools egress via the per-host proxy)' : '') +
|
|
496
|
+
')');
|
|
497
|
+
this.sandboxPosture = { backend: 'seatbelt', enforcement: 'partial', reason: 'active' };
|
|
498
|
+
return {
|
|
499
|
+
command: SEATBELT_EXECUTABLE,
|
|
500
|
+
args: ['-p', profile, py, ...args],
|
|
501
|
+
env: {
|
|
502
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
503
|
+
// macOS denies a nested sandbox_apply once the outer profile allows
|
|
504
|
+
// outbound network, so a layer-1-confined sidecar cannot wrap its own
|
|
505
|
+
// run_code child. The marker tells the sidecar to let that child
|
|
506
|
+
// inherit this layer-1 boundary instead of failing the nested wrap.
|
|
507
|
+
// Linux (bwrap/Landlock stack) and Windows don't set it — run_code
|
|
508
|
+
// keeps its dedicated layer-2 there.
|
|
509
|
+
STEERABLE_SIDECAR_CONFINED: '1',
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
if (err instanceof SidecarSandboxUnavailableError)
|
|
515
|
+
throw err;
|
|
516
|
+
this.refuse({ backend: 'none', enforcement: 'none', reason: 'profile_failed' }, `sandbox: profile generation failed; refusing unsandboxed spawn: ${String(err)}`, err);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async wrapLinux(py, args, writableRoots) {
|
|
520
|
+
// 3.1c:框架的 linux-wrap 只接受 --writable-root / --no-network——
|
|
521
|
+
// bwrap 的 allowed_hosts 仅是接口兼容(不强制),Landlock 根本没有
|
|
522
|
+
// per-host egress。所以 Linux 的按主机管控不在 layer-1:egress-proxy
|
|
523
|
+
// 模式下靠 sidecar 进程的 HTTPS_PROXY env(boot.ts 注入,bwrap/landlock
|
|
524
|
+
// 都透传环境)+ sidecar 应用层域名名单强制,网络命名空间保持共享。
|
|
525
|
+
// 这里如实记录,不假装接上了实际不强制的参数。
|
|
526
|
+
const egressViaProxy = this.options.env?.STEERABLE_EGRESS_CONFINED === '1';
|
|
527
|
+
const wrapArgs = [
|
|
528
|
+
'-m',
|
|
529
|
+
'steerable_sidecar.sandbox',
|
|
530
|
+
'linux-wrap',
|
|
531
|
+
...writableRoots.flatMap((root) => ['--writable-root', root]),
|
|
532
|
+
'--',
|
|
533
|
+
py,
|
|
534
|
+
...args,
|
|
535
|
+
];
|
|
536
|
+
try {
|
|
537
|
+
const { stdout } = await execFileAsync(py, wrapArgs, { timeout: 15_000 });
|
|
538
|
+
const plan = JSON.parse(stdout.trim());
|
|
539
|
+
if (!Array.isArray(plan.argv) || plan.argv.length < 1 || typeof plan.argv[0] !== 'string') {
|
|
540
|
+
throw new Error('linux-wrap did not return an argv');
|
|
541
|
+
}
|
|
542
|
+
const argv = plan.argv.map(String);
|
|
543
|
+
const backend = plan.backend === 'landlock' ? 'landlock' : plan.backend === 'bwrap' ? 'bwrap' : null;
|
|
544
|
+
if (!backend) {
|
|
545
|
+
throw new Error(`linux-wrap unknown backend ${String(plan.backend)}`);
|
|
546
|
+
}
|
|
547
|
+
this.options.onLogLine?.(`sandbox: ${backend} active (writes: ${writableRoots.join(', ')} + scratch; ` +
|
|
548
|
+
(egressViaProxy
|
|
549
|
+
? 'egress: per-host via the egress proxy (HTTPS_PROXY env) + app-layer domain list; layer-1 network shared (bwrap/landlock have no per-host pinning)'
|
|
550
|
+
: 'egress: open → partial') +
|
|
551
|
+
')');
|
|
552
|
+
this.sandboxPosture = { backend, enforcement: 'partial', reason: 'active' };
|
|
553
|
+
return {
|
|
554
|
+
command: argv[0],
|
|
555
|
+
args: argv.slice(1),
|
|
556
|
+
env: { PYTHONDONTWRITEBYTECODE: '1' },
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
if (err instanceof SidecarSandboxUnavailableError)
|
|
561
|
+
throw err;
|
|
562
|
+
this.refuse({ backend: 'none', enforcement: 'none', reason: 'wrap_failed' }, `sandbox: Linux process wrap failed; refusing unsandboxed spawn: ${String(err)}`, err);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
wrapWindows(py, args, writableRoots) {
|
|
566
|
+
const helper = resolveWinSpawnHelperPath();
|
|
567
|
+
if (!helper) {
|
|
568
|
+
this.refuse({ backend: 'none', enforcement: 'none', reason: 'helper_missing' }, 'sandbox: win-spawn-helper.exe not found; refusing unsandboxed sidecar spawn');
|
|
569
|
+
}
|
|
570
|
+
const steerableDir = writableRoots[0];
|
|
571
|
+
const extraRoots = writableRoots.slice(1);
|
|
572
|
+
// 受限令牌下系统临时目录不可写,Python tempfile.gettempdir() 探测不到
|
|
573
|
+
// 可用目录会直接 FileNotFoundError。把子进程的 TEMP/TMP 指到 writable
|
|
574
|
+
// root 内,保证 sidecar 的 spill/临时文件始终有处可写。
|
|
575
|
+
const confinedTmp = join(steerableDir, 'tmp');
|
|
576
|
+
mkdirSync(confinedTmp, { recursive: true });
|
|
577
|
+
this.options.onLogLine?.('sandbox: windows-restricted-token active (writes: ~/.steerable' +
|
|
578
|
+
(extraRoots.length ? ` + ${extraRoots.join(', ')}` : '') +
|
|
579
|
+
'; network not enforced → partial)');
|
|
580
|
+
this.sandboxPosture = {
|
|
581
|
+
backend: 'windows-restricted-token',
|
|
582
|
+
enforcement: 'partial',
|
|
583
|
+
reason: 'active',
|
|
584
|
+
};
|
|
585
|
+
return {
|
|
586
|
+
command: helper,
|
|
587
|
+
args: [
|
|
588
|
+
'--passthrough',
|
|
589
|
+
'--writable-root',
|
|
590
|
+
steerableDir,
|
|
591
|
+
...extraRoots.flatMap((root) => ['--writable-root', root]),
|
|
592
|
+
'--',
|
|
593
|
+
py,
|
|
594
|
+
...args,
|
|
595
|
+
],
|
|
596
|
+
env: {
|
|
597
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
598
|
+
TEMP: confinedTmp,
|
|
599
|
+
TMP: confinedTmp,
|
|
600
|
+
// A child spawned by a restricted-token process runs under the same
|
|
601
|
+
// restricted token (CreateProcess inherits the caller's primary
|
|
602
|
+
// token), so the layer-1 wrap already confines run_code's child by
|
|
603
|
+
// inheritance — there is no layer-2 backend on Windows to nest.
|
|
604
|
+
// The marker lets the sidecar report that honestly as
|
|
605
|
+
// backend=inherited / enforcement=partial instead of refusing with
|
|
606
|
+
// sandbox_unavailable.
|
|
607
|
+
STEERABLE_SIDECAR_CONFINED: '1',
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
attachListeners(child) {
|
|
612
|
+
child.stdout.setEncoding('utf-8');
|
|
613
|
+
child.stderr.setEncoding('utf-8');
|
|
614
|
+
child.stdout.on('data', (chunk) => this.handleStdoutChunk(chunk));
|
|
615
|
+
child.stderr.on('data', (chunk) => this.handleStderrChunk(chunk));
|
|
616
|
+
child.on('exit', (code, signal) => {
|
|
617
|
+
this.emit('exit', { code, signal });
|
|
618
|
+
this.failPending(new SidecarShutdownError(`sidecar exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`));
|
|
619
|
+
if (!this.shuttingDown) {
|
|
620
|
+
this.scheduleRestart('child exited unexpectedly');
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
child.on('error', (err) => {
|
|
624
|
+
this.emit('error', err);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
installAppQuitHook() {
|
|
628
|
+
const hook = () => {
|
|
629
|
+
void this.shutdown();
|
|
630
|
+
};
|
|
631
|
+
this.quitListener = hook;
|
|
632
|
+
onAppWillQuit(hook);
|
|
633
|
+
}
|
|
634
|
+
async waitForReady(timeoutMs) {
|
|
635
|
+
return new Promise((resolve, reject) => {
|
|
636
|
+
const timer = setTimeout(() => {
|
|
637
|
+
cleanup();
|
|
638
|
+
reject(new SidecarBootError(`timed out waiting for sidecar ready marker after ${timeoutMs}ms`));
|
|
639
|
+
}, timeoutMs);
|
|
640
|
+
const onReady = (snapshot) => {
|
|
641
|
+
cleanup();
|
|
642
|
+
resolve(snapshot);
|
|
643
|
+
};
|
|
644
|
+
const onExitEarly = (info) => {
|
|
645
|
+
cleanup();
|
|
646
|
+
reject(new SidecarBootError(`sidecar exited before ready (code=${info.code ?? 'null'}, signal=${info.signal ?? 'null'})`));
|
|
647
|
+
};
|
|
648
|
+
const cleanup = () => {
|
|
649
|
+
clearTimeout(timer);
|
|
650
|
+
this.off('__ready_marker__', onReady);
|
|
651
|
+
this.off('exit', onExitEarly);
|
|
652
|
+
};
|
|
653
|
+
this.once('__ready_marker__', onReady);
|
|
654
|
+
this.once('exit', onExitEarly);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
// ------------------------------------------------------------------
|
|
658
|
+
// Internal: stream parsing
|
|
659
|
+
// ------------------------------------------------------------------
|
|
660
|
+
handleStdoutChunk(chunk) {
|
|
661
|
+
this.stdoutBuffer += chunk;
|
|
662
|
+
let nl = this.stdoutBuffer.indexOf('\n');
|
|
663
|
+
while (nl !== -1) {
|
|
664
|
+
const line = this.stdoutBuffer.slice(0, nl).trim();
|
|
665
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1);
|
|
666
|
+
if (line)
|
|
667
|
+
this.handleStdoutLine(line);
|
|
668
|
+
nl = this.stdoutBuffer.indexOf('\n');
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
handleStderrChunk(chunk) {
|
|
672
|
+
this.stderrBuffer += chunk;
|
|
673
|
+
let nl = this.stderrBuffer.indexOf('\n');
|
|
674
|
+
while (nl !== -1) {
|
|
675
|
+
const line = this.stderrBuffer.slice(0, nl);
|
|
676
|
+
this.stderrBuffer = this.stderrBuffer.slice(nl + 1);
|
|
677
|
+
this.handleStderrLine(line);
|
|
678
|
+
nl = this.stderrBuffer.indexOf('\n');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
handleStderrLine(line) {
|
|
682
|
+
if (line.startsWith(READY_PREFIX)) {
|
|
683
|
+
try {
|
|
684
|
+
const payload = JSON.parse(line.slice(READY_PREFIX.length));
|
|
685
|
+
this.emit('__ready_marker__', payload);
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
this.emit('error', new SidecarBootError(`failed to parse ready marker: ${err.message}`, err));
|
|
689
|
+
}
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
this.options.onLogLine?.(line);
|
|
693
|
+
}
|
|
694
|
+
handleStdoutLine(line) {
|
|
695
|
+
let payload;
|
|
696
|
+
try {
|
|
697
|
+
payload = JSON.parse(line);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
this.emit('error', new Error(`malformed sidecar frame: ${line.slice(0, 200)}`));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// A frame with both an id and a method is a *request* from the sidecar
|
|
704
|
+
// (reverse channel) — not a response to one of ours. Serve it.
|
|
705
|
+
if (payload.id !== undefined && typeof payload.method === 'string') {
|
|
706
|
+
void this.handleReverseRequest(payload);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (payload.id !== undefined) {
|
|
710
|
+
this.dispatchResponse(payload);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (typeof payload.method === 'string') {
|
|
714
|
+
this.dispatchNotification(payload.method, payload.params);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async handleReverseRequest(payload) {
|
|
718
|
+
const handler = this.reverseHandlers.get(payload.method);
|
|
719
|
+
let response;
|
|
720
|
+
if (!handler) {
|
|
721
|
+
response = {
|
|
722
|
+
jsonrpc: '2.0',
|
|
723
|
+
id: payload.id,
|
|
724
|
+
error: {
|
|
725
|
+
code: -32601,
|
|
726
|
+
kind: 'method_not_found',
|
|
727
|
+
message: `no host handler for reverse method '${payload.method}'`,
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
try {
|
|
733
|
+
const result = await handler(payload.params);
|
|
734
|
+
response = { jsonrpc: '2.0', id: payload.id, result: result ?? null };
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
response = {
|
|
738
|
+
jsonrpc: '2.0',
|
|
739
|
+
id: payload.id,
|
|
740
|
+
error: {
|
|
741
|
+
code: -32603,
|
|
742
|
+
kind: 'internal',
|
|
743
|
+
message: err.message,
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
this.writeFrame(response);
|
|
749
|
+
}
|
|
750
|
+
writeFrame(frame) {
|
|
751
|
+
const child = this.child;
|
|
752
|
+
if (!child)
|
|
753
|
+
return;
|
|
754
|
+
child.stdin.write(JSON.stringify(frame) + '\n', (err) => {
|
|
755
|
+
if (err)
|
|
756
|
+
this.emit('error', new Error(`failed to write reverse response: ${err.message}`));
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
dispatchResponse(payload) {
|
|
760
|
+
const pending = this.pending.get(payload.id);
|
|
761
|
+
if (!pending)
|
|
762
|
+
return;
|
|
763
|
+
this.pending.delete(payload.id);
|
|
764
|
+
clearTimeout(pending.timer);
|
|
765
|
+
if (payload.error) {
|
|
766
|
+
pending.reject(new SidecarMethodError(payload.error.message, payload.error.code, payload.error.kind, payload.error.data));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
pending.resolve(payload.result);
|
|
770
|
+
}
|
|
771
|
+
dispatchNotification(method, params) {
|
|
772
|
+
if (method === 'stream.chunk') {
|
|
773
|
+
this.options.onStreamChunk?.(params);
|
|
774
|
+
}
|
|
775
|
+
if (method === 'lifecycle.shutdown') {
|
|
776
|
+
this.emit('lifecycle:shutdown', params);
|
|
777
|
+
}
|
|
778
|
+
this.emit(method, params);
|
|
779
|
+
}
|
|
780
|
+
// ------------------------------------------------------------------
|
|
781
|
+
// Internal: health + restart
|
|
782
|
+
// ------------------------------------------------------------------
|
|
783
|
+
startHealthTimer() {
|
|
784
|
+
const interval = this.options.healthIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
|
|
785
|
+
if (interval <= 0)
|
|
786
|
+
return;
|
|
787
|
+
this.healthTimer = setInterval(() => {
|
|
788
|
+
void this.runHealthCheck();
|
|
789
|
+
}, interval);
|
|
790
|
+
}
|
|
791
|
+
stopHealthTimer() {
|
|
792
|
+
if (this.healthTimer) {
|
|
793
|
+
clearInterval(this.healthTimer);
|
|
794
|
+
this.healthTimer = null;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
async runHealthCheck() {
|
|
798
|
+
if (this.shuttingDown || !this.child)
|
|
799
|
+
return;
|
|
800
|
+
try {
|
|
801
|
+
await this.ping();
|
|
802
|
+
this.failedPings = 0;
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
this.failedPings += 1;
|
|
806
|
+
this.emit('health:fail', { count: this.failedPings, error: err });
|
|
807
|
+
const threshold = this.options.restartAfterFailedPings ?? DEFAULT_RESTART_AFTER_FAILED_PINGS;
|
|
808
|
+
if (this.failedPings >= threshold) {
|
|
809
|
+
this.failedPings = 0;
|
|
810
|
+
this.scheduleRestart(`failed ${threshold} consecutive pings`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
scheduleRestart(reason) {
|
|
815
|
+
if (this.shuttingDown)
|
|
816
|
+
return;
|
|
817
|
+
this.emit('restart:scheduled', { reason });
|
|
818
|
+
this.stopHealthTimer();
|
|
819
|
+
setTimeout(() => {
|
|
820
|
+
void this.restart(reason);
|
|
821
|
+
}, 250);
|
|
822
|
+
}
|
|
823
|
+
async restart(reason) {
|
|
824
|
+
if (this.shuttingDown)
|
|
825
|
+
return;
|
|
826
|
+
this.emit('restart:starting', { reason });
|
|
827
|
+
if (this.child) {
|
|
828
|
+
try {
|
|
829
|
+
this.child.kill('SIGTERM');
|
|
830
|
+
}
|
|
831
|
+
catch { /* noop */ }
|
|
832
|
+
}
|
|
833
|
+
this.child = null;
|
|
834
|
+
try {
|
|
835
|
+
await this.boot();
|
|
836
|
+
this.emit('restart:succeeded', { reason });
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
this.emit('restart:failed', { reason, error: err });
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// ------------------------------------------------------------------
|
|
843
|
+
// Internal: helpers
|
|
844
|
+
// ------------------------------------------------------------------
|
|
845
|
+
requireChild() {
|
|
846
|
+
if (!this.child) {
|
|
847
|
+
throw new SidecarShutdownError('sidecar is not running');
|
|
848
|
+
}
|
|
849
|
+
return this.child;
|
|
850
|
+
}
|
|
851
|
+
failPending(err) {
|
|
852
|
+
for (const pending of this.pending.values()) {
|
|
853
|
+
clearTimeout(pending.timer);
|
|
854
|
+
pending.reject(err);
|
|
855
|
+
}
|
|
856
|
+
this.pending.clear();
|
|
857
|
+
}
|
|
858
|
+
resolvePythonBinary() {
|
|
859
|
+
return resolveSidecarPython(this.options.pythonExecutable);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Resolve the sidecar's Python interpreter: explicit option →
|
|
864
|
+
* STEERABLE_SIDECAR_PYTHON → bundled python-runtime → sibling framework
|
|
865
|
+
* venv (dev layout) → system python. Module-level so the egress-proxy
|
|
866
|
+
* launcher (W1.3.3) spawns the same interpreter the sidecar uses.
|
|
867
|
+
*/
|
|
868
|
+
export function resolveSidecarPython(pythonExecutable) {
|
|
869
|
+
if (pythonExecutable) {
|
|
870
|
+
return pythonExecutable;
|
|
871
|
+
}
|
|
872
|
+
const explicit = process.env.STEERABLE_SIDECAR_PYTHON;
|
|
873
|
+
if (explicit && existsSync(explicit))
|
|
874
|
+
return explicit;
|
|
875
|
+
const platformTag = (() => {
|
|
876
|
+
switch (process.platform) {
|
|
877
|
+
case 'darwin':
|
|
878
|
+
return process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
|
879
|
+
case 'win32':
|
|
880
|
+
return 'win32-x64';
|
|
881
|
+
default:
|
|
882
|
+
return 'linux-x64';
|
|
883
|
+
}
|
|
884
|
+
})();
|
|
885
|
+
const binaryName = process.platform === 'win32' ? 'python.exe' : 'python3';
|
|
886
|
+
// build_sidecar.py 的产物布局是 <platform>/python/<exe>(Windows:
|
|
887
|
+
// python/python.exe;POSIX:python/bin/python3,见 build_sidecar.py 的
|
|
888
|
+
// python_binary())。保留无 python/ 层的旧布局候选做向后兼容。
|
|
889
|
+
const runtimeBases = [];
|
|
890
|
+
try {
|
|
891
|
+
const resourcesPath = process.resourcesPath;
|
|
892
|
+
if (resourcesPath) {
|
|
893
|
+
runtimeBases.push(join(resourcesPath, 'python-runtime'));
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
catch { /* not in Electron */ }
|
|
897
|
+
runtimeBases.push(join(__dirname, '..', '..', 'python-runtime'));
|
|
898
|
+
const candidates = [];
|
|
899
|
+
for (const base of runtimeBases) {
|
|
900
|
+
candidates.push(join(base, platformTag, 'python', binaryName));
|
|
901
|
+
candidates.push(join(base, platformTag, 'python', 'bin', binaryName));
|
|
902
|
+
candidates.push(join(base, platformTag, binaryName));
|
|
903
|
+
candidates.push(join(base, platformTag, 'bin', binaryName));
|
|
904
|
+
}
|
|
905
|
+
// Dev-layout convenience: the framework repo root .venv (uv sync). The
|
|
906
|
+
// shell lives inside the framework repo (packages/agent-shell/ts), so the
|
|
907
|
+
// venv is an in-repo ancestor — no sibling-checkout probing. Without this,
|
|
908
|
+
// a default-on sidecar on a dev machine falls through to system python3,
|
|
909
|
+
// which lacks steerable_sidecar, and the router silently degrades to the
|
|
910
|
+
// TS loop. Guarded by existsSync — absent in packaged builds, where the
|
|
911
|
+
// bundled python-runtime candidates above win.
|
|
912
|
+
// Windows 的 venv 解释器在 Scripts/python.exe(没有 bin/python3)。
|
|
913
|
+
// 本模块在两个平面执行:源码(vitest,ts/src/sidecar/)与编译产物
|
|
914
|
+
// (ts/dist/sidecar/)——到框架仓库根分别是四层与五层,两个候选都压入,
|
|
915
|
+
// 由下方 existsSync 挑出存在的那个。
|
|
916
|
+
for (const up of [
|
|
917
|
+
join('..', '..', '..', '..'),
|
|
918
|
+
join('..', '..', '..', '..', '..'),
|
|
919
|
+
]) {
|
|
920
|
+
const frameworkVenv = join(__dirname, up, '.venv');
|
|
921
|
+
candidates.push(join(frameworkVenv, 'bin', 'python3'));
|
|
922
|
+
if (process.platform === 'win32') {
|
|
923
|
+
candidates.push(join(frameworkVenv, 'Scripts', 'python.exe'));
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
for (const candidate of candidates) {
|
|
927
|
+
if (existsSync(candidate))
|
|
928
|
+
return candidate;
|
|
929
|
+
}
|
|
930
|
+
// Fallback to system python (developer machines).
|
|
931
|
+
return process.platform === 'win32' ? 'python' : 'python3';
|
|
932
|
+
}
|