@steerable/agent-shell 0.6.27 → 0.6.29

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.
@@ -1,5 +1,6 @@
1
1
  /**
2
- * 内置 subagent_type 画像集(CC `.claude/agents` parity)。
2
+ * 内置 subagent_type 画像集(CC `.claude/agents` parity)以及
3
+ * ChatAgent → 提及子代理画像的映射(`@` 委派 W1)。
3
4
  *
4
5
  * 桌面不再向 sidecar 传裸 `subagent: true`——命名画像让每个委派带
5
6
  * 工具域 / 轮次上限 / 并发性 / 系统提示,模型按画像 description 选择
@@ -11,7 +12,11 @@
11
12
  * - per-profile model 刻意不设:跟随父模型;模型路由留给设置面。
12
13
  * - 画像系统提示是种进子 loop 的第一条 system 消息(子代理看不到
13
14
  * 主对话的系统提示,画像提示就是它的全部角色设定)。
15
+ * - W2 落地前 toolFilter 必须与父本轮工具面取交集(子 ⊆ 父)。
14
16
  */
17
+ import { isToolAllowed, mergeAgentCapabilities, normalizeToolPolicy, resolveSkillExcludes, } from './agent-capability.js';
18
+ import { buildSystemPrompt } from './prompt-builder.js';
19
+ import { loadSkills } from './skill-loader.js';
15
20
  /** 只读工具域:探索与调研画像共享的「摸不到写操作」基线。 */
16
21
  const READ_ONLY_TOOLS = [
17
22
  'local_read_file',
@@ -21,10 +26,10 @@ const READ_ONLY_TOOLS = [
21
26
  'web_fetch',
22
27
  'mcp_list_tools',
23
28
  ];
29
+ const READ_ONLY_TOOL_SET = new Set(READ_ONLY_TOOLS);
24
30
  export const BUILTIN_SUBAGENT_PROFILES = {
25
31
  explore: {
26
32
  toolFilter: READ_ONLY_TOOLS,
27
- maxRounds: 12,
28
33
  concurrent: true,
29
34
  description: '只读代码侦察。搜索、读文件、定位「在哪里/是什么」类问题;不做任何修改,返回带路径:行号的发现。',
30
35
  systemPrompt: [
@@ -37,7 +42,6 @@ export const BUILTIN_SUBAGENT_PROFILES = {
37
42
  },
38
43
  research: {
39
44
  toolFilter: [...READ_ONLY_TOOLS, 'local_write_file'],
40
- maxRounds: 16,
41
45
  concurrent: true,
42
46
  description: '深度调研。多轮 web 搜索/抓取并交叉验证,给出带来源 URL 的结论;可把报告写入文件。',
43
47
  systemPrompt: [
@@ -52,7 +56,6 @@ export const BUILTIN_SUBAGENT_PROFILES = {
52
56
  coder: {
53
57
  // 不设 toolFilter:全工具域。写操作串行(concurrent: false),避免两个
54
58
  // 实现代理同时改同一批文件。
55
- maxRounds: 16,
56
59
  concurrent: false,
57
60
  description: '全工具实现代理。改代码、跑命令、自验结果;适合自包含的实现/修复任务。',
58
61
  systemPrompt: [
@@ -72,3 +75,176 @@ export const BUILTIN_SUBAGENT_PROFILES = {
72
75
  export function builtinSubagentParam() {
73
76
  return { profiles: BUILTIN_SUBAGENT_PROFILES };
74
77
  }
78
+ /**
79
+ * 画像名:优先稳定 ASCII slug,否则英文名 slugify,再否则 `agent-<id>`。
80
+ *
81
+ * @param agent 被提及的智能体。
82
+ * @returns 可作为 `subagent_type` enum 的画像名。
83
+ */
84
+ export function profileNameForAgent(agent) {
85
+ const slug = agent.slug?.trim() ?? '';
86
+ if (isStableProfileName(slug))
87
+ return slug;
88
+ const fromName = slugifyProfileName(agent.name);
89
+ if (fromName)
90
+ return fromName;
91
+ const fromId = agent.id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 16);
92
+ return fromId ? `agent-${fromId}` : 'agent';
93
+ }
94
+ function isStableProfileName(value) {
95
+ return /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(value);
96
+ }
97
+ function slugifyProfileName(raw) {
98
+ return raw
99
+ .toLowerCase()
100
+ .replace(/[^a-z0-9]+/g, '-')
101
+ .replace(/^-+|-+$/g, '')
102
+ .slice(0, 40);
103
+ }
104
+ /**
105
+ * W2 过渡期:子策略放行的工具与父本轮工具面取交集。
106
+ * 交集等于父全集时不设 filter(子继承父的工具域)。
107
+ *
108
+ * @param childPolicy 子智能体自己的工具策略。
109
+ * @param parentToolNames 父本轮已经广告给模型的工具名。
110
+ * @returns 子代理 toolFilter;`undefined` 表示不比父更窄。
111
+ */
112
+ export function resolveChildToolFilter(childPolicy, parentToolNames) {
113
+ const policy = normalizeToolPolicy(childPolicy);
114
+ const allowed = parentToolNames.filter((name) => isToolAllowed(policy, name));
115
+ // `all` 且交集等于父全集 → 不设 filter,子代理继承父的工具域。
116
+ // allowlist / denylist 始终下发交集清单,即使碰巧等于父全集——画像
117
+ // roster 要让模型看见该子代理真正收窄后的工具面。
118
+ if (policy.mode === 'all')
119
+ return undefined;
120
+ return allowed;
121
+ }
122
+ function isReadOnlyToolDomain(toolNames) {
123
+ return toolNames.every((name) => READ_ONLY_TOOL_SET.has(name));
124
+ }
125
+ function profileDescription(agent) {
126
+ const summary = (agent.rolePrompt || agent.description || '')
127
+ .replace(/\s+/g, ' ')
128
+ .trim()
129
+ .slice(0, 80);
130
+ return summary ? `${agent.name} — ${summary}` : agent.name;
131
+ }
132
+ /**
133
+ * 把本轮被 `@` 的智能体译成 sidecar 画像(含 W1 夹紧)。
134
+ *
135
+ * @param delegates 被 `@` 的智能体,按提及顺序(含被点名的父代理本身)。
136
+ * @param parentToolNames 父本轮模型可见工具名。
137
+ * @returns 画像名 → 画像。
138
+ */
139
+ export async function buildMentionSubagentProfiles(delegates, parentToolNames) {
140
+ const roster = await buildMentionDelegateRoster(delegates, parentToolNames);
141
+ return Object.fromEntries(roster.map((row) => [row.profileName, row.profile]));
142
+ }
143
+ /**
144
+ * 与 {@link buildMentionSubagentProfiles} 同源,额外带显示名供派发指令使用。
145
+ *
146
+ * @param delegates 被 `@` 的智能体,按提及顺序(含被点名的父代理本身)。
147
+ * @param parentToolNames 父本轮模型可见工具名。
148
+ * @returns 提及顺序的画像花名册。
149
+ */
150
+ export async function buildMentionDelegateRoster(delegates, parentToolNames) {
151
+ const roster = [];
152
+ const used = new Set();
153
+ for (const agent of delegates) {
154
+ let profileName = profileNameForAgent(agent);
155
+ if (used.has(profileName)) {
156
+ const suffix = agent.id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
157
+ profileName = suffix ? `${profileName}-${suffix}` : `${profileName}-dup`;
158
+ }
159
+ used.add(profileName);
160
+ roster.push({
161
+ agentId: agent.id,
162
+ name: agent.name,
163
+ profileName,
164
+ profile: await buildOneMentionProfile(agent, parentToolNames),
165
+ });
166
+ }
167
+ return roster;
168
+ }
169
+ async function buildOneMentionProfile(agent, parentToolNames) {
170
+ const toolFilter = resolveChildToolFilter(agent.toolPolicy, parentToolNames);
171
+ const domain = toolFilter ?? parentToolNames;
172
+ const readOnly = isReadOnlyToolDomain(domain);
173
+ const capability = mergeAgentCapabilities([agent]);
174
+ const excludeSkillNames = capability.allowExternalSkills
175
+ ? ['plan-mode']
176
+ : resolveSkillExcludes(capability, await loadSkills({ ignoreConditions: true }), ['plan-mode']);
177
+ const personaPreamble = agent.rolePrompt
178
+ ? `【当前角色】${agent.name}\n${agent.rolePrompt}`
179
+ : '';
180
+ const built = await buildSystemPrompt({
181
+ personaPreamble,
182
+ identityName: agent.name,
183
+ pinnedSkillNames: capability.pinnedSkills,
184
+ ignoreConditions: capability.loadAllSkills,
185
+ excludeSkillNames,
186
+ toolNames: domain,
187
+ });
188
+ return {
189
+ ...(toolFilter ? { toolFilter } : {}),
190
+ concurrent: readOnly,
191
+ description: profileDescription(agent),
192
+ systemPrompt: built.prompt,
193
+ };
194
+ }
195
+ /**
196
+ * 注入本轮最后一条用户消息的强制派发指令。
197
+ *
198
+ * 被点名的父代理自己也在名单里(`isSelf`):它要起一个独立副本去跑那份
199
+ * 子任务,而不是顺手自己做——否则 `requiredProfiles` 完成门会一直把回合
200
+ * 退回来。
201
+ *
202
+ * @param delegates 被点名的子代理(显示名 + 画像名 + 实际工具面 + 是否是自己)。
203
+ * @returns 派发指令正文。
204
+ */
205
+ export function buildDelegateDispatchInstruction(delegates) {
206
+ const names = delegates.map((row) => row.name).join('、');
207
+ const roster = delegates
208
+ .map((row) => {
209
+ const tools = row.toolFilter?.length
210
+ ? `本轮可用工具:${row.toolFilter.join('、')}`
211
+ : '本轮可用工具与你相同';
212
+ const self = row.isSelf ? '就是你自己的画像,起一个独立副本去跑;' : '';
213
+ return `- **${row.name}**(subagent_type=\`${row.profileName}\`):${self}${tools}`;
214
+ })
215
+ .join('\n');
216
+ const hasSelf = delegates.some((row) => row.isSelf);
217
+ return [
218
+ `用户点名了 **${names}**。本轮你必须把活交给它们,用 \`delegate_subagent\`(\`subagent_type\` 填画像名),每人至少一份子任务。`,
219
+ '',
220
+ roster,
221
+ '',
222
+ '- 子任务**彼此独立**时:一句话说明分工,紧接着在**同一轮**把全部调用发出,并把可并行的排在一起。',
223
+ '- 子任务**有依赖**时:按依赖顺序分轮派发,并把前一轮的结论写进后一轮的 `task`。',
224
+ '- 每份 `task` 必须自包含(目标、输入、交付形式、验收标准)——子代理看不到本对话。',
225
+ '- 禁止只写分工然后停手;也禁止漏掉任何一位被点名的代理。',
226
+ ...(hasSelf
227
+ ? [
228
+ '- 名单里包含你自己时同样要发 `delegate_subagent`:那是一个看不到本对话的独立副本,自己顺手做不算完成派发。',
229
+ ]
230
+ : []),
231
+ '- 若某子代理注明了本轮可用工具,不要尝试让它调用未列出的工具。',
232
+ ].join('\n');
233
+ }
234
+ /**
235
+ * 内置画像 + 本轮提及画像。被 `@` 超过 4 个时抬高池的并行上限。
236
+ *
237
+ * 提及画像同时作为 `requiredProfiles` 下发:强制派发此前只是提示词里的
238
+ * 一句话,模型跑了别的工具再叙述「已启动」就能蒙过所有既有纪律检查。
239
+ *
240
+ * @param mentionProfiles 本轮提及转出的画像;空对象则与 {@link builtinSubagentParam} 相同。
241
+ * @returns 下发给 sidecar 的 `subagent` 参数。
242
+ */
243
+ export function mergeTurnSubagentParam(mentionProfiles) {
244
+ const mentionNames = Object.keys(mentionProfiles);
245
+ return {
246
+ profiles: { ...BUILTIN_SUBAGENT_PROFILES, ...mentionProfiles },
247
+ ...(mentionNames.length > 4 ? { maxParallel: mentionNames.length } : {}),
248
+ ...(mentionNames.length > 0 ? { requiredProfiles: mentionNames } : {}),
249
+ };
250
+ }
@@ -5,12 +5,22 @@
5
5
  export type PersistedTurnBlock = {
6
6
  type: 'reasoning';
7
7
  content: string;
8
+ sealed?: boolean;
9
+ startedAtMs?: number;
10
+ durationMs?: number;
8
11
  } | {
9
12
  type: 'text';
10
13
  content: string;
14
+ sealed?: boolean;
11
15
  } | {
12
16
  type: 'tools';
13
17
  actions: Array<Record<string, unknown>>;
14
18
  };
15
- export declare function appendTimelineDelta(blocks: PersistedTurnBlock[], type: 'text' | 'reasoning', delta: string): void;
16
- export declare function syncTimelineTools(blocks: PersistedTurnBlock[], actions: Array<Record<string, unknown>>): void;
19
+ export declare function appendTimelineDelta(blocks: PersistedTurnBlock[], type: 'text' | 'reasoning', delta: string, now?: number): void;
20
+ /**
21
+ * Close the current reasoning/text segment so the next same-kind delta
22
+ * starts a new block. Mirrors apps/web `sealLastBlock`.
23
+ */
24
+ export declare function sealLastTimelineBlock(blocks: PersistedTurnBlock[], now?: number): void;
25
+ export declare function freezeTimelineReasoning(blocks: PersistedTurnBlock[], now?: number): void;
26
+ export declare function syncTimelineTools(blocks: PersistedTurnBlock[], actions: Array<Record<string, unknown>>, now?: number): void;
@@ -2,17 +2,44 @@
2
2
  * Call-order display blocks persisted on assistant message metadata.
3
3
  * Keep this JSON compatible with apps/web `turn-timeline.ts`.
4
4
  */
5
- export function appendTimelineDelta(blocks, type, delta) {
5
+ function freezeOpenReasoning(blocks, now = Date.now()) {
6
+ const last = blocks[blocks.length - 1];
7
+ if (!last || last.type !== 'reasoning' || last.durationMs != null)
8
+ return;
9
+ if (last.startedAtMs == null)
10
+ return;
11
+ last.durationMs = Math.max(0, now - last.startedAtMs);
12
+ }
13
+ export function appendTimelineDelta(blocks, type, delta, now = Date.now()) {
6
14
  if (!delta)
7
15
  return;
8
16
  const last = blocks[blocks.length - 1];
9
- if (last && last.type === type) {
17
+ if (last && last.type === type && !last.sealed) {
10
18
  last.content += delta;
11
19
  return;
12
20
  }
21
+ freezeOpenReasoning(blocks, now);
22
+ if (type === 'reasoning') {
23
+ blocks.push({ type, content: delta, startedAtMs: now });
24
+ return;
25
+ }
13
26
  blocks.push({ type, content: delta });
14
27
  }
15
- export function syncTimelineTools(blocks, actions) {
28
+ /**
29
+ * Close the current reasoning/text segment so the next same-kind delta
30
+ * starts a new block. Mirrors apps/web `sealLastBlock`.
31
+ */
32
+ export function sealLastTimelineBlock(blocks, now = Date.now()) {
33
+ const last = blocks[blocks.length - 1];
34
+ if (!last || last.type === 'tools' || last.sealed)
35
+ return;
36
+ freezeOpenReasoning(blocks, now);
37
+ last.sealed = true;
38
+ }
39
+ export function freezeTimelineReasoning(blocks, now = Date.now()) {
40
+ freezeOpenReasoning(blocks, now);
41
+ }
42
+ export function syncTimelineTools(blocks, actions, now = Date.now()) {
16
43
  let placed = 0;
17
44
  for (const block of blocks) {
18
45
  if (block.type === 'tools')
@@ -37,6 +64,7 @@ export function syncTimelineTools(blocks, actions) {
37
64
  last.actions.push(...added);
38
65
  }
39
66
  else {
67
+ freezeOpenReasoning(blocks, now);
40
68
  blocks.push({ type: 'tools', actions: added });
41
69
  }
42
70
  }
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import { EventEmitter } from 'node:events';
13
13
  import type { SidecarApplyEditsResult, SidecarChatStreamHandlers, SidecarChatStreamRequest, SidecarHealthSnapshot, SidecarMethodOptions, SidecarModelCatalog, SidecarReverseHandler, SidecarSandboxPosture, SidecarSessionBranches, SidecarSessionForkOutcome, SidecarSessionMessages, SidecarSessionTree, SidecarSkillModule, SidecarStartOptions, SidecarToolResult } from './types.js';
14
+ export declare const RUST_SIDECAR_ENV = "STEERABLE_RUST_SIDECAR";
15
+ export declare const RUST_SIDECAR_BIN_ENV = "STEERABLE_RUST_SIDECAR_BIN";
14
16
  /**
15
17
  * Tool names carried by a `tool.list` reply.
16
18
  *
@@ -216,3 +218,9 @@ export declare class SidecarSupervisor extends EventEmitter {
216
218
  * launcher (W1.3.3) spawns the same interpreter the sidecar uses.
217
219
  */
218
220
  export declare function resolveSidecarPython(pythonExecutable?: string): string;
221
+ export declare function rustSidecarEnabled(): boolean;
222
+ /**
223
+ * Resolve the optional Rust sidecar binary. Missing binary with the flag
224
+ * on is a silent fallback to Python (`python -m steerable_sidecar`).
225
+ */
226
+ export declare function resolveRustSidecarBin(explicit?: string): string | undefined;
@@ -25,6 +25,8 @@ const READY_PREFIX = '__SIDECAR_READY__:';
25
25
  const DEFAULT_BOOT_TIMEOUT_MS = 15_000;
26
26
  const DEFAULT_HEALTH_INTERVAL_MS = 5_000;
27
27
  const DEFAULT_RESTART_AFTER_FAILED_PINGS = 3;
28
+ export const RUST_SIDECAR_ENV = 'STEERABLE_RUST_SIDECAR';
29
+ export const RUST_SIDECAR_BIN_ENV = 'STEERABLE_RUST_SIDECAR_BIN';
28
30
  // Only /usr/bin/sandbox-exec is trusted — a PATH-relative lookup could
29
31
  // resolve to an attacker-planted binary (codex's rule).
30
32
  const SEATBELT_EXECUTABLE = '/usr/bin/sandbox-exec';
@@ -381,10 +383,19 @@ export class SidecarSupervisor extends EventEmitter {
381
383
  // Internal: boot
382
384
  // ------------------------------------------------------------------
383
385
  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);
386
+ const rustBin = rustSidecarEnabled()
387
+ ? resolveRustSidecarBin(this.options.rustSidecarBin)
388
+ : undefined;
389
+ let spawnPlan;
390
+ if (rustBin) {
391
+ spawnPlan = await this.resolveSandboxedSpawn(rustBin, this.options.args ?? []);
392
+ }
393
+ else {
394
+ const py = this.resolvePythonBinary();
395
+ const entry = this.options.entryModule ?? 'steerable_sidecar';
396
+ const args = ['-m', entry, ...(this.options.args ?? [])];
397
+ spawnPlan = await this.resolveSandboxedSpawn(py, args);
398
+ }
388
399
  const child = spawn(spawnPlan.command, spawnPlan.args, {
389
400
  cwd: this.options.cwd,
390
401
  env: { ...process.env, ...this.options.env, ...spawnPlan.env },
@@ -416,8 +427,8 @@ export class SidecarSupervisor extends EventEmitter {
416
427
  * (`sandbox: false` / `STEERABLE_SIDECAR_SANDBOX=0`) is the only
417
428
  * unconfined path. Every exit records `sandboxPosture`.
418
429
  */
419
- async resolveSandboxedSpawn(py, args) {
420
- const plain = { command: py, args };
430
+ async resolveSandboxedSpawn(command, args) {
431
+ const plain = { command, args };
421
432
  const enabled = this.options.sandbox ?? process.env.STEERABLE_SIDECAR_SANDBOX !== '0';
422
433
  if (!enabled) {
423
434
  this.sandboxPosture = {
@@ -446,16 +457,16 @@ export class SidecarSupervisor extends EventEmitter {
446
457
  .filter(Boolean);
447
458
  const webEgress = Boolean(this.options.sandboxWebEgress) && allowedHosts.length > 0;
448
459
  // 3.1b: egress-proxy 模式下 web_fetch 的 SSRF 预检在沙箱内解析 DNS,
449
- // 只放行解析器 socket(无 IP 可达性);webEgress 已含解析器,互斥。
460
+ // 但真正的出口走代理。Seatbelt 只放行 resolver socket,不放行 *:80/443。
450
461
  const allowResolver = Boolean(this.options.sandboxAllowResolver) && allowedHosts.length > 0 && !webEgress;
451
462
  if (process.platform === 'darwin') {
452
- return this.wrapSeatbelt(py, args, writableRoots, allowedHosts, webEgress, allowResolver);
463
+ return this.wrapSeatbelt(command, args, writableRoots, allowedHosts, webEgress, allowResolver);
453
464
  }
454
465
  if (process.platform === 'linux') {
455
- return this.wrapLinux(py, args, writableRoots);
466
+ return this.wrapLinux(command, args, writableRoots);
456
467
  }
457
468
  if (process.platform === 'win32') {
458
- return this.wrapWindows(py, args, writableRoots);
469
+ return this.wrapWindows(command, args, writableRoots);
459
470
  }
460
471
  const posture = {
461
472
  backend: 'none',
@@ -470,21 +481,23 @@ export class SidecarSupervisor extends EventEmitter {
470
481
  this.options.onLogLine?.(message);
471
482
  throw new SidecarSandboxUnavailableError(message, posture, cause);
472
483
  }
473
- async wrapSeatbelt(py, args, writableRoots, allowedHosts, webEgress, allowResolver) {
484
+ async wrapSeatbelt(command, args, writableRoots, allowedHosts, webEgress, allowResolver) {
474
485
  if (!existsSync(SEATBELT_EXECUTABLE)) {
475
486
  this.refuse({ backend: 'none', enforcement: 'none', reason: 'seatbelt_missing' }, 'sandbox: /usr/bin/sandbox-exec missing; refusing unsandboxed spawn');
476
487
  }
477
488
  try {
478
- const profileArgs = [
479
- '-m',
480
- 'steerable_sidecar.sandbox',
481
- 'profile',
489
+ const flags = [
482
490
  ...writableRoots.flatMap((root) => ['--writable-root', root]),
483
491
  ...allowedHosts.flatMap((h) => ['--allow-host', h]),
484
492
  ...(webEgress ? ['--allow-web-egress'] : []),
485
493
  ...(allowResolver ? ['--allow-resolver'] : []),
486
494
  ];
487
- const { stdout } = await execFileAsync(py, profileArgs, { timeout: 10_000 });
495
+ const rustBin = rustSidecarEnabled()
496
+ ? resolveRustSidecarBin(this.options.rustSidecarBin)
497
+ : undefined;
498
+ const { stdout } = rustBin
499
+ ? await execFileAsync(rustBin, ['sandbox', 'profile', ...flags], { timeout: 10_000 })
500
+ : await execFileAsync(command, ['-m', 'steerable_sidecar.sandbox', 'profile', ...flags], { timeout: 10_000 });
488
501
  const profile = stdout.trim();
489
502
  if (!profile.includes('(deny default)')) {
490
503
  throw new Error('generated profile is not a Seatbelt policy');
@@ -497,7 +510,7 @@ export class SidecarSupervisor extends EventEmitter {
497
510
  this.sandboxPosture = { backend: 'seatbelt', enforcement: 'partial', reason: 'active' };
498
511
  return {
499
512
  command: SEATBELT_EXECUTABLE,
500
- args: ['-p', profile, py, ...args],
513
+ args: ['-p', profile, command, ...args],
501
514
  env: {
502
515
  PYTHONDONTWRITEBYTECODE: '1',
503
516
  // macOS denies a nested sandbox_apply once the outer profile allows
@@ -516,7 +529,7 @@ export class SidecarSupervisor extends EventEmitter {
516
529
  this.refuse({ backend: 'none', enforcement: 'none', reason: 'profile_failed' }, `sandbox: profile generation failed; refusing unsandboxed spawn: ${String(err)}`, err);
517
530
  }
518
531
  }
519
- async wrapLinux(py, args, writableRoots) {
532
+ async wrapLinux(command, args, writableRoots) {
520
533
  // 3.1c:框架的 linux-wrap 只接受 --writable-root / --no-network——
521
534
  // bwrap 的 allowed_hosts 仅是接口兼容(不强制),Landlock 根本没有
522
535
  // per-host egress。所以 Linux 的按主机管控不在 layer-1:egress-proxy
@@ -524,17 +537,29 @@ export class SidecarSupervisor extends EventEmitter {
524
537
  // 都透传环境)+ sidecar 应用层域名名单强制,网络命名空间保持共享。
525
538
  // 这里如实记录,不假装接上了实际不强制的参数。
526
539
  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
- ];
540
+ const rustBin = rustSidecarEnabled()
541
+ ? resolveRustSidecarBin(this.options.rustSidecarBin)
542
+ : undefined;
543
+ const wrapArgs = rustBin
544
+ ? [
545
+ 'sandbox',
546
+ 'linux-wrap',
547
+ ...writableRoots.flatMap((root) => ['--writable-root', root]),
548
+ '--',
549
+ command,
550
+ ...args,
551
+ ]
552
+ : [
553
+ '-m',
554
+ 'steerable_sidecar.sandbox',
555
+ 'linux-wrap',
556
+ ...writableRoots.flatMap((root) => ['--writable-root', root]),
557
+ '--',
558
+ command,
559
+ ...args,
560
+ ];
536
561
  try {
537
- const { stdout } = await execFileAsync(py, wrapArgs, { timeout: 15_000 });
562
+ const { stdout } = await execFileAsync(rustBin ?? command, wrapArgs, { timeout: 15_000 });
538
563
  const plan = JSON.parse(stdout.trim());
539
564
  if (!Array.isArray(plan.argv) || plan.argv.length < 1 || typeof plan.argv[0] !== 'string') {
540
565
  throw new Error('linux-wrap did not return an argv');
@@ -562,7 +587,7 @@ export class SidecarSupervisor extends EventEmitter {
562
587
  this.refuse({ backend: 'none', enforcement: 'none', reason: 'wrap_failed' }, `sandbox: Linux process wrap failed; refusing unsandboxed spawn: ${String(err)}`, err);
563
588
  }
564
589
  }
565
- wrapWindows(py, args, writableRoots) {
590
+ wrapWindows(command, args, writableRoots) {
566
591
  const helper = resolveWinSpawnHelperPath();
567
592
  if (!helper) {
568
593
  this.refuse({ backend: 'none', enforcement: 'none', reason: 'helper_missing' }, 'sandbox: win-spawn-helper.exe not found; refusing unsandboxed sidecar spawn');
@@ -590,7 +615,7 @@ export class SidecarSupervisor extends EventEmitter {
590
615
  steerableDir,
591
616
  ...extraRoots.flatMap((root) => ['--writable-root', root]),
592
617
  '--',
593
- py,
618
+ command,
594
619
  ...args,
595
620
  ],
596
621
  env: {
@@ -930,3 +955,36 @@ export function resolveSidecarPython(pythonExecutable) {
930
955
  // Fallback to system python (developer machines).
931
956
  return process.platform === 'win32' ? 'python' : 'python3';
932
957
  }
958
+ function envFlag(name) {
959
+ const raw = (process.env[name] ?? '').trim().toLowerCase();
960
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
961
+ }
962
+ export function rustSidecarEnabled() {
963
+ return envFlag(RUST_SIDECAR_ENV);
964
+ }
965
+ /**
966
+ * Resolve the optional Rust sidecar binary. Missing binary with the flag
967
+ * on is a silent fallback to Python (`python -m steerable_sidecar`).
968
+ */
969
+ export function resolveRustSidecarBin(explicit) {
970
+ if (explicit) {
971
+ return existsSync(explicit) ? explicit : undefined;
972
+ }
973
+ const fromEnv = process.env[RUST_SIDECAR_BIN_ENV];
974
+ if (fromEnv) {
975
+ return existsSync(fromEnv) ? fromEnv : undefined;
976
+ }
977
+ const exe = process.platform === 'win32' ? 'steerable-sidecar.exe' : 'steerable-sidecar';
978
+ const relatives = [
979
+ join('..', '..', '..', '..', 'sidecar', 'rs', 'target', 'debug', exe),
980
+ join('..', '..', '..', '..', '..', 'sidecar', 'rs', 'target', 'debug', exe),
981
+ join('..', '..', '..', '..', 'sidecar', 'rs', 'target', 'release', exe),
982
+ join('..', '..', '..', '..', '..', 'sidecar', 'rs', 'target', 'release', exe),
983
+ ];
984
+ for (const rel of relatives) {
985
+ const candidate = join(__dirname, rel);
986
+ if (existsSync(candidate))
987
+ return candidate;
988
+ }
989
+ return undefined;
990
+ }
@@ -21,6 +21,8 @@ export interface SidecarSandboxPosture {
21
21
  export interface SidecarStartOptions {
22
22
  /** Override the python binary; defaults to the bundled portable runtime. */
23
23
  pythonExecutable?: string;
24
+ /** Optional Rust sidecar binary; used when STEERABLE_RUST_SIDECAR is on. */
25
+ rustSidecarBin?: string;
24
26
  /** Override the entrypoint module; defaults to ``steerable_sidecar``. */
25
27
  entryModule?: string;
26
28
  /** Extra arguments appended after ``-m <entryModule>``. */
@@ -352,16 +354,26 @@ export interface SidecarChatStreamRequest {
352
354
  subagent?: boolean | {
353
355
  toolFilter?: string[];
354
356
  maxParallel?: number;
357
+ /** Child round / tool-error walls; default to the parent loop's. */
358
+ maxRounds?: number;
359
+ maxToolErrors?: number;
355
360
  profiles?: Record<string, {
356
361
  toolFilter?: string[];
357
362
  model?: string;
358
363
  maxRounds?: number;
364
+ maxToolErrors?: number;
359
365
  concurrent?: boolean;
360
366
  description?: string;
361
367
  /** Profile system prompt, seeded as the child loop's first message
362
368
  * (CC `.claude/agents` body parity). */
363
369
  systemPrompt?: string;
364
370
  }>;
371
+ /**
372
+ * Profiles that MUST each receive a delegation this turn (the host's
373
+ * `@` mentions). A `completed` turn that skipped one is retried — the
374
+ * dispatch instruction alone is prompt text a model can narrate past.
375
+ */
376
+ requiredProfiles?: string[];
365
377
  };
366
378
  /** A6 layered skill disclosure: the sidecar injects the catalog layer
367
379
  * (first-round pre_step, recorded as a hook_action event) and answers
@@ -495,7 +507,7 @@ export interface SidecarStreamChunk {
495
507
  enforcement: string;
496
508
  };
497
509
  };
498
- /** CoreLoop notices: soft_timeout / budget_exhausted. */
510
+ /** CoreLoop notices: soft_timeout / budget_exhausted / round_end / hook_action. */
499
511
  notice?: {
500
512
  kind: string;
501
513
  [key: string]: unknown;
@@ -553,6 +565,12 @@ export interface SidecarChildEvent {
553
565
  status?: string;
554
566
  error?: string;
555
567
  profile?: string;
568
+ /**
569
+ * The child's own durable record (`<parent record>:child:<lineage id>`),
570
+ * present on `child_spawned` when the host wired a history store. Read it
571
+ * back to render the delegation's reasoning + tool calls.
572
+ */
573
+ recordId?: string;
556
574
  }
557
575
  export interface SidecarChatStreamHandlers {
558
576
  onChunk?: (chunk: SidecarStreamChunk) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
4
4
  "description": "Steerable framework — product-neutral desktop/headless agent host (Tier 5). Electron main + preload + headless HTTP server (BS) sharing one storage/sidecar/tooling core; scenario packs extend it through the @steerable/pack-sdk contract and are composed at build time by the consuming product.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -49,9 +49,9 @@
49
49
  "electron-store": "^8.1.0",
50
50
  "he": "^1.2.0",
51
51
  "node-pty": "^1.1.0",
52
- "@steerable/agent-harness": "0.6.27",
53
- "@steerable/pack-sdk": "0.6.27",
54
- "@steerable/agent-protocol": "0.6.27"
52
+ "@steerable/agent-harness": "0.6.29",
53
+ "@steerable/pack-sdk": "0.6.29",
54
+ "@steerable/agent-protocol": "0.6.29"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/better-sqlite3": "^7.6.13",