@trim21/personal-pi-extensions 0.1.588 → 0.1.591

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.
@@ -24,11 +24,12 @@ import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-r
24
24
  import { commandPatternsFor } from "./approval-suggest.js";
25
25
  import {
26
26
  type BwrapConfig,
27
- type BwrapMode,
28
27
  findBwrap,
29
28
  findMihomo,
29
+ type FsMode,
30
30
  getBwrapConfigPaths,
31
31
  loadBwrapConfig,
32
+ type NetworkMode,
32
33
  resolveBwrap,
33
34
  resolveBwrapPath,
34
35
  type ResolvedBwrap,
@@ -260,18 +261,30 @@ class BashOutput {
260
261
  }
261
262
  }
262
263
 
263
- function notifyMode(
264
+ /** 模式切换的两轴变更:fs / network 各自独立,至少给一个。 */
265
+ export interface SandboxChange {
266
+ fs?: FsMode;
267
+ network?: NetworkMode;
268
+ }
269
+
270
+ const FS_LABELS: Record<FsMode, string> = {
271
+ readonly: "fs readonly: read-only filesystem",
272
+ "workspace-write": "fs workspace-write: / is read-only, /tmp/ and the workspace are writable",
273
+ "allow-all": "fs allow-all: filesystem fully writable",
274
+ };
275
+
276
+ const NETWORK_LABELS: Record<NetworkMode, string> = {
277
+ block: "network block: network off",
278
+ limited: "network limited: network filtered by allowlist",
279
+ "allow-all": "network allow-all: network unrestricted",
280
+ };
281
+
282
+ function notifyChange(
264
283
  ctx: { ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
265
- mode: BwrapMode,
284
+ change: SandboxChange,
266
285
  ): void {
267
- const labels: Record<BwrapMode, string> = {
268
- "allow-all": "allow-all: sandbox off, network on",
269
- "workspace-write": "workspace-write: sandbox on, network off",
270
- "allow-net": "allow-net: sandbox on, network on, workspace writable",
271
- "net-allowlist": "net-allowlist: sandbox on, network filtered by allowlist",
272
- readonly: "readonly: sandbox on, network off, read-only fs",
273
- };
274
- ctx.ui.notify(labels[mode], "info");
286
+ if (change.fs !== undefined) ctx.ui.notify(FS_LABELS[change.fs], "info");
287
+ if (change.network !== undefined) ctx.ui.notify(NETWORK_LABELS[change.network], "info");
275
288
  }
276
289
 
277
290
  /** 沙箱默认写边界:根只读、工作区与 /tmp 可写、.git 只读。不展开用户配置的额外可写路径。 */
@@ -279,15 +292,27 @@ const SANDBOX_WRITE_RULES = "/ is read-only, /tmp/ and ./ are writable, ./.git/
279
292
 
280
293
  /** 网络层级:关 / 只放行白名单 / 完全放开(白名单域名本身不列出,对判断失败无用)。 */
281
294
  function describeNetwork(resolved: ResolvedBwrap): string {
282
- if (!resolved.network) return "network access is off";
283
- if (resolved.networkAllowlist.length > 0)
284
- return "network access is limited to allowlisted addresses";
285
- return "network access is unrestricted";
295
+ switch (resolved.network) {
296
+ case "block": {
297
+ return "network access is off";
298
+ }
299
+ case "limited": {
300
+ return "network access is limited to a white list";
301
+ }
302
+ case "allow-all": {
303
+ return "network access is unrestricted";
304
+ }
305
+ }
286
306
  }
287
307
 
288
308
  /** 沙箱作用域一句话:写边界 + 网络层级(沙箱状态块与沙盒内执行提醒共用)。 */
289
309
  function describeLimits(resolved: ResolvedBwrap): string {
290
- const writes = resolved.mode === "readonly" ? "the filesystem is read-only" : SANDBOX_WRITE_RULES;
310
+ const writes =
311
+ resolved.fs === "readonly"
312
+ ? "the filesystem is read-only"
313
+ : resolved.fs === "allow-all"
314
+ ? "the filesystem is fully writable"
315
+ : SANDBOX_WRITE_RULES;
291
316
  return `${writes}; ${describeNetwork(resolved)}`;
292
317
  }
293
318
 
@@ -306,7 +331,7 @@ const SANDBOX_ESCAPE_HATCH =
306
331
  * 命令失败时作为独立信息块附上的沙箱状态:默认写边界 + 网络层级,以及在沙盒外重跑的手段。
307
332
  * 写边界只说沙箱布局(不展开用户配置的可写路径),网络只说层级(不列白名单域名)。
308
333
  * 整块包在 `<system-reminder>` 里,与其它系统注入的提示同一形态,模型不会把它当成命令输出。
309
- * 命令没经沙箱(allow-all、审批通过的全权限、Windows)时返回 undefined:
334
+ * 命令没经沙箱(fs 与 network 均 allow-all、审批通过的全权限、Windows)时返回 undefined:
310
335
  * 没有沙箱就没什么可提示的。
311
336
  */
312
337
  export function describeSandbox(
@@ -346,7 +371,7 @@ export function sandboxHintBlock(hint: string | undefined): { type: "text"; text
346
371
  export class BwrapRuntime {
347
372
  private resolved: ResolvedBwrap | undefined;
348
373
  private bwrapUnavailable = false;
349
- /** net-allowlist 首次执行时解析一次 mihomo 路径,之后随 runtime 复用,不逐命令扫描 PATH。 */
374
+ /** network limited 首次执行时解析一次 mihomo 路径,之后随 runtime 复用,不逐命令扫描 PATH。 */
350
375
  private mihomoPath: string | undefined;
351
376
  /**
352
377
  * 非沙盒请求策略由创建方注入:聚合入口把它与文件工具的 write-guard 共享同一实例,
@@ -382,8 +407,8 @@ export class BwrapRuntime {
382
407
  findBwrap(runtime.bwrapPath);
383
408
  } catch (error) {
384
409
  // Fail closed: a missing bwrap binary must not silently degrade to an
385
- // unsandboxed allow-all session. Commands are refused until the user
386
- // explicitly opts out via the bwrap-allow-all command.
410
+ // unsandboxed session. Commands are refused until the user explicitly
411
+ // opts out via /bwrap-fs-allow-all and /bwrap-network-allow-all.
387
412
  this.bwrapUnavailable = true;
388
413
  this.resolved = undefined;
389
414
  ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("error", "bwrap: unavailable"));
@@ -391,11 +416,11 @@ export class BwrapRuntime {
391
416
  return;
392
417
  }
393
418
  }
394
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
419
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime)));
395
420
  ctx.ui.notify(
396
421
  runtime.bwrapEnabled
397
- ? `bwrap initialized (${runtime.mode})`
398
- : `bwrap mode: ${runtime.mode}`,
422
+ ? `bwrap initialized (${this.modeText(runtime)})`
423
+ : `bwrap sandbox off (${this.modeText(runtime)})`,
399
424
  "info",
400
425
  );
401
426
  });
@@ -412,12 +437,12 @@ export class BwrapRuntime {
412
437
  if (isWindows) {
413
438
  modeText = "Every bash command requires user approval before it runs.";
414
439
  } else if (this.config === undefined) {
415
- modeText = `Current bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`;
440
+ modeText = `Current bwrap sandbox: filesystem **${runtime.fs}**, network **${runtime.network}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`;
416
441
  } else {
417
- modeText = `Current bwrap mode: **${runtime.mode}**, fixed by the agent's declared sandbox config. Unsandboxed execution requests are refused without approval.`;
442
+ modeText = `Current bwrap sandbox: filesystem **${runtime.fs}**, network **${runtime.network}**, fixed by the agent's declared sandbox config. Unsandboxed execution requests are refused without approval.`;
418
443
  }
419
444
  } else {
420
- modeText = `Current bwrap mode: **${runtime.mode}**. This headless session cannot ask for approval: commands that require user approval are denied.`;
445
+ modeText = `Current bwrap sandbox: filesystem **${runtime.fs}**, network **${runtime.network}**. This headless session cannot ask for approval: commands that require user approval are denied.`;
421
446
  }
422
447
  const unavailableText =
423
448
  !isWindows && this.bwrapUnavailable
@@ -439,11 +464,23 @@ export class BwrapRuntime {
439
464
  if (this.config === undefined) this.registerCommands(pi);
440
465
  }
441
466
 
442
- setMode(cwd: string, mode: BwrapMode): ResolvedBwrap {
443
- this.resolved =
444
- this.config === undefined
445
- ? loadSandboxConfig({ workspace: cwd, mode })
446
- : resolveBwrap({ ...this.config, mode });
467
+ setMode(cwd: string, change: SandboxChange): ResolvedBwrap {
468
+ if (this.config === undefined) {
469
+ this.resolved = loadSandboxConfig({
470
+ workspace: cwd,
471
+ ...(change.fs !== undefined && { fsMode: change.fs }),
472
+ ...(change.network !== undefined && { networkMode: change.network }),
473
+ });
474
+ } else {
475
+ this.resolved = resolveBwrap({
476
+ ...this.config,
477
+ fs: { ...this.config.fs, ...(change.fs !== undefined && { mode: change.fs }) },
478
+ network: {
479
+ ...this.config.network,
480
+ ...(change.network !== undefined && { mode: change.network }),
481
+ },
482
+ });
483
+ }
447
484
  return this.resolved;
448
485
  }
449
486
 
@@ -465,7 +502,7 @@ export class BwrapRuntime {
465
502
  ) {
466
503
  throw new Error(
467
504
  "bwrap (bubblewrap) not found; refusing to execute commands without sandboxing. " +
468
- "Install bubblewrap and restart the session, or disable the sandbox explicitly with /bwrap-allow-all.",
505
+ "Install bubblewrap and restart the session, or disable sandboxing explicitly with /bwrap-fs-allow-all and /bwrap-network-allow-all.",
469
506
  );
470
507
  }
471
508
  // workspace 恒为 session 工作区;cwd 只是本次命令的进程执行目录,
@@ -473,7 +510,7 @@ export class BwrapRuntime {
473
510
  const workspace = request.ctx.cwd;
474
511
  const execCwd = request.cwd ?? workspace;
475
512
  // 需要人工审批:非 Windows 仅 requestFullAccess;Windows 上默认所有命令
476
- // (allow-all 模式是显式 opt-out,仍直接执行)。
513
+ // (fs 与 network 均 allow-all 是显式 opt-out,仍直接执行)。
477
514
  const needsApproval = request.requestFullAccess === true || (isWindows && runtime.bwrapEnabled);
478
515
  // 用户在审批框选「Run this in sandbox」拒绝提权:命令降级为沙盒内执行
479
516
  let userChoseSandbox = false;
@@ -502,12 +539,12 @@ export class BwrapRuntime {
502
539
  )) === "sandbox";
503
540
  }
504
541
  }
505
- // 不经沙箱的三种情形:Windows(无 bubblewrap)、审批通过的全权限、allow-all 模式;
506
- // 例外是用户选「Run this in sandbox」:提权被拒,命令照常进沙箱
542
+ // 不经沙箱的三种情形:Windows(无 bubblewrap)、审批通过的全权限、fs 与 network 均
543
+ // allow-all;例外是用户选「Run this in sandbox」:提权被拒,命令照常进沙箱
507
544
  const local = !userChoseSandbox && (isWindows || needsApproval || !runtime.bwrapEnabled);
508
- // mihomoPath 是 ResolvedBwrap 的 override 语义:首次 net-allowlist 执行时解析
545
+ // mihomoPath 是 ResolvedBwrap 的 override 语义:首次 limited 执行时解析
509
546
  // 一次存入私有字段,之后写回 resolved 直达 createNetworkStack,不逐命令扫描 PATH
510
- if (runtime.network && runtime.networkAllowlist.length > 0) {
547
+ if (runtime.network === "limited") {
511
548
  runtime.mihomoPath ??= this.mihomoPath ?? findMihomo();
512
549
  this.mihomoPath = runtime.mihomoPath;
513
550
  }
@@ -813,34 +850,40 @@ export class BwrapRuntime {
813
850
  description: "Show bwrap sandbox configuration",
814
851
  flags: Type.Object({}),
815
852
  },
816
- "bwrap-allow-all": {
817
- name: "bwrap-allow-all",
853
+ "bwrap-fs-readonly": {
854
+ name: "bwrap-fs-readonly",
855
+ usage: "",
856
+ description: "Filesystem: read-only (no writes)",
857
+ flags: Type.Object({}),
858
+ },
859
+ "bwrap-fs-workspace-write": {
860
+ name: "bwrap-fs-workspace-write",
818
861
  usage: "",
819
- description: "Disable bwrap sandbox, full access",
862
+ description: "Filesystem: / read-only, /tmp and workspace writable",
820
863
  flags: Type.Object({}),
821
864
  },
822
- "bwrap-workspace-write": {
823
- name: "bwrap-workspace-write",
865
+ "bwrap-fs-allow-all": {
866
+ name: "bwrap-fs-allow-all",
824
867
  usage: "",
825
- description: "Sandbox on, network off, workspace writable",
868
+ description: "Filesystem: fully writable",
826
869
  flags: Type.Object({}),
827
870
  },
828
- "bwrap-allow-net": {
829
- name: "bwrap-allow-net",
871
+ "bwrap-network-block": {
872
+ name: "bwrap-network-block",
830
873
  usage: "",
831
- description: "Sandbox on, network on, workspace writable",
874
+ description: "Network: off",
832
875
  flags: Type.Object({}),
833
876
  },
834
- "bwrap-net-allowlist": {
835
- name: "bwrap-net-allowlist",
877
+ "bwrap-network-limited": {
878
+ name: "bwrap-network-limited",
836
879
  usage: "",
837
- description: "Sandbox on, network filtered by allowlist, workspace writable",
880
+ description: "Network: only allowlisted addresses",
838
881
  flags: Type.Object({}),
839
882
  },
840
- "bwrap-readonly": {
841
- name: "bwrap-readonly",
883
+ "bwrap-network-allow-all": {
884
+ name: "bwrap-network-allow-all",
842
885
  usage: "",
843
- description: "Sandbox on, network off, no writes",
886
+ description: "Network: unrestricted",
844
887
  flags: Type.Object({}),
845
888
  },
846
889
  "bwrap-reload": {
@@ -876,7 +919,7 @@ export class BwrapRuntime {
876
919
  return;
877
920
  }
878
921
  if (!runtime.bwrapEnabled) {
879
- commandCtx.ui.notify(`bwrap disabled (mode: ${runtime.mode})`, "info");
922
+ commandCtx.ui.notify(`bwrap sandbox off (${this.modeText(runtime)})`, "info");
880
923
  return;
881
924
  }
882
925
  const writable = runtime.writablePaths.map((path) =>
@@ -884,24 +927,25 @@ export class BwrapRuntime {
884
927
  );
885
928
  const deny = runtime.denyPaths.map((path) => resolveBwrapPath(path, commandCtx.cwd));
886
929
  commandCtx.ui.notify(
887
- `bwrap ${runtime.mode} ${runtime.network ? "net" : "no-net"} write:[${writable.join(", ")}] deny:[${deny.join(", ") || "-"}]`,
930
+ `bwrap fs:${runtime.fs} net:${runtime.network} write:[${writable.join(", ")}] deny:[${deny.join(", ") || "-"}]`,
888
931
  "info",
889
932
  );
890
933
  }),
891
934
  });
892
935
 
893
- for (const [name, mode] of [
894
- ["bwrap-allow-all", "allow-all"],
895
- ["bwrap-workspace-write", "workspace-write"],
896
- ["bwrap-allow-net", "allow-net"],
897
- ["bwrap-net-allowlist", "net-allowlist"],
898
- ["bwrap-readonly", "readonly"],
936
+ for (const [name, change] of [
937
+ ["bwrap-fs-readonly", { fs: "readonly" }],
938
+ ["bwrap-fs-workspace-write", { fs: "workspace-write" }],
939
+ ["bwrap-fs-allow-all", { fs: "allow-all" }],
940
+ ["bwrap-network-block", { network: "block" }],
941
+ ["bwrap-network-limited", { network: "limited" }],
942
+ ["bwrap-network-allow-all", { network: "allow-all" }],
899
943
  ] as const) {
900
944
  pi.registerCommand(name, {
901
945
  description: specs[name].description,
902
946
  handler: (args, ctx) =>
903
947
  this.runCommand(pi, specs[name], args, ctx, (commandCtx) =>
904
- this.switchMode(pi, mode, commandCtx),
948
+ this.switchMode(pi, change, commandCtx),
905
949
  ),
906
950
  });
907
951
  }
@@ -932,28 +976,42 @@ export class BwrapRuntime {
932
976
  this.resolved = undefined;
933
977
  this.bwrapUnavailable = false;
934
978
  const runtime = this.resolve(ctx);
935
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
936
- ctx.ui.notify(`bwrap config reloaded (mode: ${runtime.mode})`, "info");
979
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime)));
980
+ ctx.ui.notify(`bwrap config reloaded (${this.modeText(runtime)})`, "info");
937
981
  }
938
982
 
939
- private switchMode(pi: ExtensionAPI, mode: BwrapMode, ctx: ExtensionCommandContext): void {
983
+ private switchMode(pi: ExtensionAPI, change: SandboxChange, ctx: ExtensionCommandContext): void {
940
984
  if (!ctx.hasUI) {
941
- ctx.ui.notify("bwrap mode cannot be changed without an interactive UI", "warning");
985
+ ctx.ui.notify("bwrap modes cannot be changed without an interactive UI", "warning");
942
986
  return;
943
987
  }
944
- this.setMode(ctx.cwd, mode);
945
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(mode)));
946
- notifyMode(ctx, mode);
947
- pi.sendMessage({
948
- customType: "info",
949
- content: `Bwrap sandbox mode changed to "${mode}".`,
950
- display: true,
951
- });
988
+ const runtime = this.setMode(ctx.cwd, change);
989
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime)));
990
+ notifyChange(ctx, change);
991
+ if (change.fs !== undefined) {
992
+ pi.sendMessage({
993
+ customType: "info",
994
+ content: `Bwrap filesystem mode changed to "${change.fs}".`,
995
+ display: true,
996
+ });
997
+ }
998
+ if (change.network !== undefined) {
999
+ pi.sendMessage({
1000
+ customType: "info",
1001
+ content: `Bwrap network mode changed to "${change.network}".`,
1002
+ display: true,
1003
+ });
1004
+ }
1005
+ }
1006
+
1007
+ /** 模式文案:fs 与 network 两轴。 */
1008
+ private modeText(runtime: ResolvedBwrap): string {
1009
+ return `fs: ${runtime.fs}, network: ${runtime.network}`;
952
1010
  }
953
1011
 
954
- /** 状态行文案:模式 + 非沙盒请求策略(拒绝时标注,便于解释模型的请求为何被拒)。 */
955
- private statusLabel(mode: BwrapMode): string {
956
- return `bwrap: ${mode}${this.policy.deniesRequests() ? " (requests denied)" : ""}`;
1012
+ /** 状态行文案:两轴模式 + 非沙盒请求策略(拒绝时标注,便于解释模型的请求为何被拒)。 */
1013
+ private statusLabel(runtime: ResolvedBwrap): string {
1014
+ return `bwrap: fs=${runtime.fs} net=${runtime.network}${this.policy.deniesRequests() ? " (requests denied)" : ""}`;
957
1015
  }
958
1016
 
959
1017
  /**
@@ -970,7 +1028,7 @@ export class BwrapRuntime {
970
1028
  );
971
1029
  const runtime = this.resolve(ctx);
972
1030
  if (ctx.hasUI && runtime.bwrapEnabled && !this.bwrapUnavailable) {
973
- ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime.mode)));
1031
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime)));
974
1032
  }
975
1033
  }
976
1034
 
@@ -19,11 +19,12 @@ import {
19
19
  buildBwrapInvocation,
20
20
  bwrapArgv,
21
21
  type BwrapConfig,
22
- type BwrapMode,
23
22
  createBwrapBashOperations,
24
23
  createNetworkStack,
24
+ type FsMode,
25
25
  getBwrapConfigPaths,
26
26
  loadBwrapConfig,
27
+ type NetworkMode,
27
28
  type NetworkStackLog,
28
29
  resolveBwrap,
29
30
  resolveBwrapPath,
@@ -36,8 +37,10 @@ export interface SandboxConfigInput {
36
37
  workspace: string;
37
38
  /** 只使用该配置文件(跳过全局 + 项目两级合并)。 */
38
39
  configPath?: string;
39
- /** 覆盖配置中的 mode(等价于会话内切 /bwrap 模式)。 */
40
- mode?: BwrapMode;
40
+ /** 覆盖配置中的 fs.mode(等价于会话内切 /bwrap-fs-* 模式)。 */
41
+ fsMode?: FsMode;
42
+ /** 覆盖配置中的 network.mode(等价于会话内切 /bwrap-network-* 模式)。 */
43
+ networkMode?: NetworkMode;
41
44
  }
42
45
 
43
46
  export interface SandboxRunOptions {
@@ -53,7 +56,7 @@ export interface SandboxRunOptions {
53
56
  signal?: AbortSignal;
54
57
  /** 超时秒数:kill 整个进程组并以 TimeoutError 结束。 */
55
58
  timeout?: number;
56
- /** 调用方已决定不经沙箱(审批通过的全权限、Windows)。allow-all 模式无需显式设置。 */
59
+ /** 调用方已决定不经沙箱(审批通过的全权限、Windows)。fs 与 network 均 allow-all 时无需显式设置。 */
57
60
  unsandboxed?: boolean;
58
61
  /** 网络栈子进程输出转发,仅诊断用(默认丢弃)。 */
59
62
  log?: NetworkStackLog;
@@ -92,13 +95,19 @@ export function loadSandboxConfig(input: SandboxConfigInput): ResolvedBwrap {
92
95
  : // 同一文件两侧:loadBwrapConfig 内部去重,等价于「只读这一个文件」
93
96
  { global: configPath, project: configPath };
94
97
  const config: BwrapConfig = loadBwrapConfig(workspace, paths);
95
- const configured: BwrapConfig =
96
- input.mode === undefined ? config : { ...config, mode: input.mode };
98
+ const configured: BwrapConfig = {
99
+ ...config,
100
+ fs: { ...config.fs, ...(input.fsMode !== undefined && { mode: input.fsMode }) },
101
+ network: {
102
+ ...config.network,
103
+ ...(input.networkMode !== undefined && { mode: input.networkMode }),
104
+ },
105
+ };
97
106
  return resolveSandboxPaths(resolveBwrap(configured), workspace);
98
107
  }
99
108
 
100
109
  /**
101
- * 在 resolved 描述的沙箱里执行一条命令;net-allowlist 模式下现建现停网络栈。
110
+ * 在 resolved 描述的沙箱里执行一条命令;network limited 模式下现建现停网络栈。
102
111
  * 超时与取消的错误语义由底层 operations.exec 抛出(TimeoutError / AbortError)。
103
112
  */
104
113
  export async function runInSandbox(
@@ -17,9 +17,10 @@
17
17
  * model: claude-haiku-4-5 # optional; overrides the global default
18
18
  * thinkingLevel: high # optional; overrides the global default
19
19
  * sandbox: # optional; fixed bwrap sandbox for the bash tool
20
- * mode: readonly
21
- * extraWritablePaths:
22
- * - /tmp
20
+ * fs:
21
+ * mode: readonly
22
+ * extraWritablePaths:
23
+ * - /tmp
23
24
  * ---
24
25
  * System prompt for the agent goes here.
25
26
  *
@@ -34,8 +35,8 @@
34
35
  * so the user's bwrap.json is not consulted for that agent. Unsandboxed
35
36
  * execution requests are refused and /bwrap-* mode commands are not registered.
36
37
  * Fields are optional and fall back to the standard bwrap defaults (e.g.
37
- * `sandbox: {mode: readonly}` alone is enough for a read-only shell);
38
- * `extraWritablePaths` is the writable escape hatch under a read-only mode.
38
+ * `sandbox: { fs: { mode: readonly } }` alone is enough for a read-only shell);
39
+ * `fs.extraWritablePaths` is the writable escape hatch under a read-only fs mode.
39
40
  * Without `sandbox`, the subagent's bash follows the user bwrap config.
40
41
  *
41
42
  * Global defaults for provider/model/thinkingLevel come from
@@ -22,14 +22,17 @@
22
22
  * so one log entry is always exactly one rendered line. The final line is
23
23
  * always the subagent name as a code span (`` `scout` ``), followed by the
24
24
  * live usage stats when there are any; it rides outside the rolling window so
25
- * it is never trimmed.
25
+ * it is never trimmed. While the model is thinking, a transient
26
+ * `thinking ( N chars )` line sits between the log and the footer showing the
27
+ * live character count of the streamed thinking; it disappears when the
28
+ * thinking block ends.
26
29
  *
27
30
  * Security default: without an explicit `tools:` in the frontmatter, the
28
31
  * subagent only gets read-only tools (read/grep/find/ls) — no bash/write/edit.
29
32
  * A frontmatter `sandbox:` gives the bash tool a fixed bwrap sandbox config
30
- * (bwrap.json shape, e.g. mode readonly): it is passed to the subagent's bwrap
31
- * runtime as the complete config, unsandboxed execution requests are refused,
32
- * and /bwrap-* mode commands are not registered (see spawn-agent-agents.ts).
33
+ * (bwrap.json shape, e.g. fs.mode readonly): it is passed to the subagent's
34
+ * bwrap runtime as the complete config, unsandboxed execution requests are
35
+ * refused, and /bwrap-* commands are not registered (see spawn-agent-agents.ts).
33
36
  */
34
37
 
35
38
  import { existsSync } from "node:fs";
@@ -418,6 +421,9 @@ export async function runAgent(
418
421
  // 不同名按调用顺序罗列;任何非工具行都会打断合并。
419
422
  let toolLineSegments: string[] = [];
420
423
  let toolLine: { name: string; count: number } | undefined;
424
+ // 思考中状态:thinking_start 打开、thinking_delta 累计字符数、thinking_end
425
+ // 关闭;非 undefined 时 emitUpdate 在 footer 上方插一行瞬态 thinking 状态。
426
+ let thinkingChars: number | undefined;
421
427
 
422
428
  const pushLogLine = (line: string) => {
423
429
  logLines.push(line);
@@ -460,8 +466,11 @@ export async function runAgent(
460
466
  const usageLine = formatUsageStats(result.usage, result.model);
461
467
  const name = sanitizeProgressLine(result.agent);
462
468
  const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
469
+ const lines = [...logLines];
470
+ if (thinkingChars !== undefined) lines.push(`thinking ( ${thinkingChars} chars )`);
471
+ lines.push(footer);
463
472
  onUpdate?.({
464
- content: [{ type: "text", text: [...logLines, footer].join("\n") }],
473
+ content: [{ type: "text", text: lines.join("\n") }],
465
474
  details: {},
466
475
  });
467
476
  };
@@ -470,11 +479,33 @@ export async function runAgent(
470
479
  switch (event.type) {
471
480
  case "message_update": {
472
481
  // A completed text block (text_end carries the full content) becomes a
473
- // `text:` log line. Deltas/thinking are intentionally not logged.
482
+ // `text:` log line. Deltas are intentionally not logged; thinking
483
+ // deltas feed the transient `thinking ( N chars )` status line instead.
474
484
  const delta = event.assistantMessageEvent;
475
- if (delta.type === "text_end") {
476
- pushLogLine(`text: ${foldProgressLine(sanitizeProgressLine(delta.content))}`);
477
- emitUpdate();
485
+ switch (delta.type) {
486
+ case "text_end": {
487
+ pushLogLine(`text: ${foldProgressLine(sanitizeProgressLine(delta.content))}`);
488
+ emitUpdate();
489
+ break;
490
+ }
491
+ case "thinking_start": {
492
+ // 思考横跨轮次边界:打断工具行合并,下一批工具调用另起一行。
493
+ toolLine = undefined;
494
+ thinkingChars = 0;
495
+ emitUpdate();
496
+ break;
497
+ }
498
+ case "thinking_delta": {
499
+ thinkingChars = (thinkingChars ?? 0) + delta.delta.length;
500
+ emitUpdate();
501
+ break;
502
+ }
503
+ case "thinking_end": {
504
+ thinkingChars = undefined;
505
+ emitUpdate();
506
+ break;
507
+ }
508
+ // No default
478
509
  }
479
510
 
480
511
  break;