@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.3

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-c5hpqt9r.md} +61 -0
  3. package/dist/cli.js +3856 -3850
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/ask-dialog.d.ts +0 -1
  18. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  19. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +2 -0
  23. package/dist/types/secrets/index.d.ts +22 -0
  24. package/dist/types/secrets/obfuscator.d.ts +1 -1
  25. package/dist/types/session/agent-session-types.d.ts +4 -0
  26. package/dist/types/session/agent-session.d.ts +9 -0
  27. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  28. package/dist/types/session/session-storage.d.ts +5 -2
  29. package/dist/types/session/session-tools.d.ts +2 -1
  30. package/dist/types/session/turn-recovery.d.ts +7 -1
  31. package/dist/types/system-prompt.d.ts +8 -0
  32. package/dist/types/task/executor.d.ts +27 -0
  33. package/dist/types/task/persisted-revive.d.ts +7 -0
  34. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  35. package/dist/types/tools/browser/attach.d.ts +9 -1
  36. package/dist/types/tools/browser/launch.d.ts +50 -1
  37. package/dist/types/tools/browser/registry.d.ts +7 -0
  38. package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
  39. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  40. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  42. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  43. package/dist/types/utils/image-loading.d.ts +2 -0
  44. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  45. package/dist/types/web/search/types.d.ts +2 -2
  46. package/package.json +12 -12
  47. package/src/commit/agentic/index.ts +2 -1
  48. package/src/commit/pipeline.ts +3 -2
  49. package/src/config/keybindings.ts +6 -1
  50. package/src/config/settings-schema.ts +16 -3
  51. package/src/edit/hashline/block-resolver.ts +5 -5
  52. package/src/edit/hashline/diff.ts +22 -15
  53. package/src/edit/hashline/execute.ts +14 -13
  54. package/src/edit/renderer.ts +3 -3
  55. package/src/eval/executor-base.ts +39 -0
  56. package/src/eval/jl/executor.ts +13 -2
  57. package/src/eval/js/context-manager.ts +97 -14
  58. package/src/eval/js/executor.ts +3 -0
  59. package/src/eval/js/index.ts +1 -0
  60. package/src/eval/py/executor.ts +55 -15
  61. package/src/eval/rb/executor.ts +8 -1
  62. package/src/extensibility/extensions/runner.ts +121 -4
  63. package/src/extensibility/extensions/types.ts +15 -0
  64. package/src/extensibility/extensions/wrapper.ts +19 -2
  65. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  66. package/src/launch/broker.ts +59 -41
  67. package/src/live/visualizer.ts +8 -1
  68. package/src/lsp/index.ts +51 -11
  69. package/src/main.ts +3 -0
  70. package/src/modes/acp/acp-agent.ts +16 -5
  71. package/src/modes/components/ask-dialog.ts +65 -23
  72. package/src/modes/components/assistant-message.ts +5 -10
  73. package/src/modes/components/custom-editor.ts +1 -1
  74. package/src/modes/components/keybinding-hints.ts +4 -13
  75. package/src/modes/components/status-line/segments.ts +4 -5
  76. package/src/modes/components/tool-execution.ts +4 -5
  77. package/src/modes/controllers/input-controller.ts +18 -40
  78. package/src/modes/controllers/live-command-controller.ts +1 -0
  79. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  80. package/src/modes/interactive-mode.ts +59 -2
  81. package/src/modes/theme/theme.ts +13 -6
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/prompts/system/plan-mode-active.md +5 -5
  85. package/src/sdk.ts +47 -16
  86. package/src/secrets/index.ts +83 -3
  87. package/src/secrets/obfuscator.ts +70 -15
  88. package/src/session/agent-session-types.ts +7 -1
  89. package/src/session/agent-session.ts +394 -113
  90. package/src/session/codex-auto-reset.ts +594 -123
  91. package/src/session/eval-runner.ts +2 -0
  92. package/src/session/messages.ts +1 -1
  93. package/src/session/session-maintenance.ts +50 -3
  94. package/src/session/session-manager.ts +4 -3
  95. package/src/session/session-stats.ts +14 -3
  96. package/src/session/session-storage.ts +47 -15
  97. package/src/session/session-tools.ts +30 -2
  98. package/src/session/turn-recovery.ts +15 -2
  99. package/src/slash-commands/builtin-registry.ts +21 -15
  100. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  101. package/src/system-prompt.ts +13 -1
  102. package/src/task/executor.ts +168 -15
  103. package/src/task/persisted-revive.ts +26 -1
  104. package/src/telemetry-export.ts +8 -4
  105. package/src/tools/bash-interceptor.ts +85 -8
  106. package/src/tools/bash.ts +12 -6
  107. package/src/tools/browser/attach.ts +35 -10
  108. package/src/tools/browser/launch.ts +110 -16
  109. package/src/tools/browser/registry.ts +140 -31
  110. package/src/tools/browser/shared-daemon.ts +190 -0
  111. package/src/tools/browser/tab-protocol.ts +8 -0
  112. package/src/tools/browser/tab-supervisor.ts +31 -4
  113. package/src/tools/browser/tab-worker.ts +32 -2
  114. package/src/tools/browser.ts +1 -1
  115. package/src/tools/output-meta.ts +16 -1
  116. package/src/tools/shell-tokenize.ts +101 -0
  117. package/src/utils/image-loading.ts +8 -3
  118. package/src/utils/shell-snapshot.ts +31 -21
  119. package/src/web/search/providers/codex.ts +31 -6
  120. package/src/web/search/providers/duckduckgo.ts +183 -15
  121. package/src/web/search/types.ts +6 -1
@@ -2200,6 +2200,135 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
2200
2200
  };
2201
2201
  }
2202
2202
 
2203
+ /** Inputs for {@link attachIrcWakeTurnMonitor}. */
2204
+ export interface IrcWakeTurnMonitorOptions {
2205
+ /** Registry id of the kept-alive subagent whose autonomous IRC wake turns are monitored. */
2206
+ id: string;
2207
+ index?: number;
2208
+ agent: AgentDefinition;
2209
+ description?: string;
2210
+ modelOverride?: string | string[];
2211
+ eventBus?: EventBus;
2212
+ parentToolCallId?: string;
2213
+ /** Fallback session file when the registry ref carries none. */
2214
+ sessionFile?: string;
2215
+ maxRuntimeMs?: number;
2216
+ outputSchema?: unknown;
2217
+ outputSchemaMode?: StructuredSubagentSchemaMode;
2218
+ outputSchemaSource?: StructuredSubagentSchemaSource;
2219
+ artifactsDir?: string;
2220
+ }
2221
+
2222
+ /**
2223
+ * Bracket a kept-alive subagent's autonomous IRC wake turns with a task run
2224
+ * monitor so RPC/collab subscribers see the same `subagent_lifecycle` /
2225
+ * `subagent_progress` frames a first run emits. Shared by the live executor
2226
+ * reviver and the persisted cold-revive path so a resumed process's parked
2227
+ * subagents are not blind spots. The observer runs after the session has
2228
+ * flushed its post-prompt settle (see {@link AgentSession.setIrcWakeTurnObserver}).
2229
+ */
2230
+ export function attachIrcWakeTurnMonitor(session: AgentSession, options: IrcWakeTurnMonitorOptions): void {
2231
+ const { id, agent } = options;
2232
+ const index = options.index ?? 0;
2233
+ const maxRuntimeMs = options.maxRuntimeMs ?? 0;
2234
+ session.setIrcWakeTurnObserver(records => {
2235
+ const ircTask =
2236
+ records
2237
+ .map(record => {
2238
+ const body =
2239
+ record.details && typeof record.details === "object"
2240
+ ? Reflect.get(record.details, "message")
2241
+ : undefined;
2242
+ return typeof body === "string" ? body : record.content;
2243
+ })
2244
+ .filter(Boolean)
2245
+ .join("\n\n") || "IRC follow-up";
2246
+ const turnStartTime = Date.now();
2247
+ const sessionFile = AgentRegistry.global().get(id)?.sessionFile ?? options.sessionFile ?? undefined;
2248
+ const turnMonitor = createSubagentRunMonitor({
2249
+ index,
2250
+ id,
2251
+ agent,
2252
+ task: ircTask,
2253
+ description: options.description,
2254
+ modelOverride: options.modelOverride,
2255
+ eventBus: options.eventBus,
2256
+ parentToolCallId: options.parentToolCallId,
2257
+ detached: true,
2258
+ sessionFile,
2259
+ softRequestBudget: 0,
2260
+ softRequestBudgetNotice: false,
2261
+ maxRuntimeMs,
2262
+ });
2263
+
2264
+ if (options.eventBus) {
2265
+ options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
2266
+ id,
2267
+ agent: agent.name,
2268
+ parentToolCallId: options.parentToolCallId,
2269
+ detached: true,
2270
+ agentSource: agent.source,
2271
+ description: options.description,
2272
+ status: "started",
2273
+ sessionFile,
2274
+ index,
2275
+ });
2276
+ }
2277
+
2278
+ turnMonitor.setActiveSession(session);
2279
+ const unsubscribeTurn = turnMonitor.attach(session);
2280
+ return async turnError => {
2281
+ unsubscribeTurn();
2282
+ const activeSession = turnMonitor.takeActiveSession();
2283
+ if (activeSession) turnMonitor.captureSalvage(activeSession);
2284
+ const lastAssistant = session.getLastAssistantMessage();
2285
+ const yielded = turnMonitor.yieldCalled();
2286
+ const runtimeLimitExceeded = turnMonitor.runtimeLimitExceeded();
2287
+ const aborted = runtimeLimitExceeded || (lastAssistant?.stopReason === "aborted" && !yielded);
2288
+ const error =
2289
+ lastAssistant?.stopReason === "error"
2290
+ ? lastAssistant.errorMessage || "Subagent failed"
2291
+ : turnError !== undefined && !yielded
2292
+ ? turnError instanceof Error
2293
+ ? turnError.stack || turnError.message
2294
+ : String(turnError)
2295
+ : undefined;
2296
+ turnMonitor.finish();
2297
+ try {
2298
+ await finalizeRunResult({
2299
+ monitor: turnMonitor,
2300
+ done: {
2301
+ exitCode: aborted || error ? 1 : 0,
2302
+ error,
2303
+ aborted,
2304
+ abortReason: aborted ? turnMonitor.resolveAbortReasonText() : undefined,
2305
+ durationMs: Date.now() - turnStartTime,
2306
+ },
2307
+ index,
2308
+ id,
2309
+ agent,
2310
+ task: ircTask,
2311
+ modelOverride: options.modelOverride,
2312
+ outputSchema: options.outputSchema,
2313
+ outputSchemaMode: options.outputSchemaMode,
2314
+ outputSchemaSource: options.outputSchemaSource,
2315
+ artifactsDir: options.artifactsDir,
2316
+ eventBus: options.eventBus,
2317
+ parentToolCallId: options.parentToolCallId,
2318
+ detached: true,
2319
+ sessionFile,
2320
+ startTime: turnStartTime,
2321
+ });
2322
+ } catch (finalizeError) {
2323
+ logger.warn("IRC subagent turn finalization failed", {
2324
+ id,
2325
+ error: finalizeError instanceof Error ? finalizeError.message : String(finalizeError),
2326
+ });
2327
+ }
2328
+ };
2329
+ });
2330
+ }
2331
+
2203
2332
  /**
2204
2333
  * Settle a subagent's registry lifecycle after a run: terminal teardown for
2205
2334
  * hard aborts, unregister for one-shot helpers, park for isolated runs, and
@@ -2544,6 +2673,23 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2544
2673
  }
2545
2674
  });
2546
2675
  };
2676
+ const installIrcWakeTurnMonitor = (target: AgentSession): void => {
2677
+ attachIrcWakeTurnMonitor(target, {
2678
+ id,
2679
+ index,
2680
+ agent,
2681
+ description: options.description,
2682
+ modelOverride,
2683
+ eventBus: options.eventBus,
2684
+ parentToolCallId: options.parentToolCallId,
2685
+ sessionFile: subtaskSessionFile,
2686
+ maxRuntimeMs,
2687
+ outputSchema,
2688
+ outputSchemaMode: options.outputSchemaMode,
2689
+ outputSchemaSource: options.outputSchemaSource,
2690
+ artifactsDir: options.artifactsDir,
2691
+ });
2692
+ };
2547
2693
 
2548
2694
  const runSubagent = async (): Promise<{
2549
2695
  exitCode: number;
@@ -2604,7 +2750,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2604
2750
  }
2605
2751
  checkAbort();
2606
2752
  if (!registryFromParent) {
2607
- await awaitAbortable(modelRegistry.refresh());
2753
+ modelRegistry.refreshInBackground();
2608
2754
  } else {
2609
2755
  logger.debug("runSubagent: reusing parent modelRegistry; skipping refresh");
2610
2756
  }
@@ -2685,6 +2831,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2685
2831
  const effectiveThinkingLevel =
2686
2832
  effortLevel ?? (explicitThinkingLevel ? resolvedThinkingLevel : (thinkingLevel ?? resolvedThinkingLevel));
2687
2833
  resolvedAt = performance.now();
2834
+ const effectiveCwd = worktree ?? cwd;
2835
+ const sessionManagerPromise = sessionFile
2836
+ ? SessionManager.open(sessionFile, undefined, undefined, {
2837
+ initialCwd: effectiveCwd,
2838
+ suppressBreadcrumb: true,
2839
+ })
2840
+ : Promise.resolve(SessionManager.inMemory(effectiveCwd));
2841
+ // Setup below can fail before this promise's consumption boundary.
2842
+ // Observe rejection immediately while preserving it for the later await.
2843
+ sessionManagerPromise.catch(() => {});
2688
2844
  // Per-agent prewalk: the agent definition's `prewalk` frontmatter or the
2689
2845
  // `task.agentPrewalk` settings override hands the subagent off to a
2690
2846
  // fast/cheap target at its first edit/write — the same mechanism as the
@@ -2697,6 +2853,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2697
2853
  agentPrewalk: resolveAgentPrewalkDefault(agent, settings.get("task.prewalk")),
2698
2854
  });
2699
2855
  if (prewalkPattern) {
2856
+ await awaitAbortable(modelRegistry.awaitBackgroundRefresh());
2700
2857
  const resolvedPrewalk = resolveModelOverride([prewalkPattern], modelRegistry, settings);
2701
2858
  const target = resolvedPrewalk.model;
2702
2859
  if (!target || !modelRegistry.hasConfiguredAuth(target)) {
@@ -2718,20 +2875,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2718
2875
  }
2719
2876
  }
2720
2877
 
2721
- const effectiveCwd = worktree ?? cwd;
2722
- const sessionManager = sessionFile
2723
- ? await awaitAbortable(
2724
- SessionManager.open(sessionFile, undefined, undefined, {
2725
- initialCwd: effectiveCwd,
2726
- suppressBreadcrumb: true,
2727
- }),
2728
- )
2729
- : SessionManager.inMemory(effectiveCwd);
2730
- if (options.parentArtifactManager) {
2731
- sessionManager.adoptArtifactManager(options.parentArtifactManager);
2732
- }
2733
- sessionOpenedAt = performance.now();
2734
-
2735
2878
  const restrictToolNames = options.restrictToolNames === true;
2736
2879
  const enableMCP = !restrictToolNames && (options.enableMCP ?? true);
2737
2880
  const mcpManager = enableMCP ? options.mcpManager : undefined;
@@ -2851,6 +2994,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2851
2994
  },
2852
2995
  });
2853
2996
 
2997
+ const sessionManager = await awaitAbortable(sessionManagerPromise);
2998
+ if (options.parentArtifactManager) {
2999
+ sessionManager.adoptArtifactManager(options.parentArtifactManager);
3000
+ }
3001
+ sessionOpenedAt = performance.now();
3002
+
2854
3003
  const sessionPromise = createAgentSession(buildSubagentSessionOptions(sessionManager, null));
2855
3004
  let session: AgentSession;
2856
3005
  try {
@@ -2882,6 +3031,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2882
3031
  buildSubagentSessionOptions(reopened, expectedAgentRef),
2883
3032
  );
2884
3033
  installRegistryStatusSync(revived);
3034
+ installIrcWakeTurnMonitor(revived);
2885
3035
  return revived;
2886
3036
  };
2887
3037
  }
@@ -3053,6 +3203,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3053
3203
  const session = monitor.takeActiveSession();
3054
3204
  if (session) {
3055
3205
  monitor.captureSalvage(session);
3206
+ if (options.keepAlive !== false && worktree === undefined) {
3207
+ installIrcWakeTurnMonitor(session);
3208
+ }
3056
3209
  await finalizeSubagentLifecycle({
3057
3210
  id,
3058
3211
  session,
@@ -9,7 +9,9 @@ import { createAgentSession } from "../sdk";
9
9
  import type { AgentSession } from "../session/agent-session";
10
10
  import type { AuthStorage } from "../session/auth-storage";
11
11
  import { SessionManager } from "../session/session-manager";
12
- import { createMCPProxyTools, createSubagentSettings } from "./executor";
12
+ import type { EventBus } from "../utils/event-bus";
13
+ import { attachIrcWakeTurnMonitor, createMCPProxyTools, createSubagentSettings } from "./executor";
14
+ import type { AgentDefinition } from "./types";
13
15
 
14
16
  /**
15
17
  * Ambient context the reviver needs at revive time. The top-level session is
@@ -24,6 +26,12 @@ export interface PersistedSubagentReviveContext {
24
26
  settings: Settings;
25
27
  /** LSP policy of the top-level session; revived subagents inherit it rather than defaulting on. */
26
28
  enableLsp: boolean;
29
+ /**
30
+ * Shared event bus feeding RPC/collab subagent subscriptions. Passed through
31
+ * to the wake-turn monitor so an IRC send to a cold-revived subagent emits
32
+ * the same lifecycle/progress frames a live run does.
33
+ */
34
+ eventBus?: EventBus;
27
35
  }
28
36
 
29
37
  /**
@@ -135,6 +143,23 @@ export function createPersistedSubagentReviverFactory(
135
143
  if (event.type === "agent_start") registry.setStatus(ref.id, "running", session);
136
144
  else if (event.type === "agent_end") registry.setStatus(ref.id, "idle", session);
137
145
  });
146
+ // Persisted files predate an agent-source field, so cold-revived frames
147
+ // report the runtime-neutral `user` source; name comes from the ref.
148
+ const wakeAgent: AgentDefinition = {
149
+ name: ref.displayName,
150
+ description: "",
151
+ systemPrompt: init.systemPrompt,
152
+ source: "user",
153
+ };
154
+ attachIrcWakeTurnMonitor(session, {
155
+ id: ref.id,
156
+ agent: wakeAgent,
157
+ eventBus: ctx.eventBus,
158
+ sessionFile,
159
+ outputSchema: init.outputSchema,
160
+ outputSchemaMode: init.outputSchemaMode,
161
+ artifactsDir: ctx.session.sessionFile?.slice(0, -6),
162
+ });
138
163
  return session;
139
164
  };
140
165
  };
@@ -37,7 +37,7 @@ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-ho
37
37
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
38
38
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
39
39
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
40
- import { resourceFromAttributes } from "@opentelemetry/resources";
40
+ import { detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
41
41
  import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
42
42
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
43
43
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
@@ -146,9 +146,13 @@ export async function initTelemetryExport(): Promise<void> {
146
146
  }
147
147
 
148
148
  async function registerProviders(signalConfig: SignalConfig): Promise<void> {
149
- const resource = resourceFromAttributes({
150
- "service.name": process.env.OTEL_SERVICE_NAME ?? SERVICE_NAME,
151
- });
149
+ // `envDetector` parses OTEL_RESOURCE_ATTRIBUTES (percent-decoded, per spec) and
150
+ // OTEL_SERVICE_NAME; merged last so both take precedence over the fallback
151
+ // service.name — with OTEL_SERVICE_NAME still winning service.name inside the
152
+ // detector itself.
153
+ const resource = resourceFromAttributes({ "service.name": SERVICE_NAME }).merge(
154
+ detectResources({ detectors: [envDetector] }),
155
+ );
152
156
 
153
157
  if (signalConfig.trace) {
154
158
  const exporter = new OTLPTraceExporter();
@@ -6,6 +6,7 @@
6
6
  * the specialized tools instead.
7
7
  */
8
8
  import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema";
9
+ import { extractFlatShellCommandSegments } from "./shell-tokenize";
9
10
 
10
11
  export interface InterceptionResult {
11
12
  /** If true, the bash command should be blocked */
@@ -32,6 +33,78 @@ function compileRules(rules: BashInterceptorRule[]): Array<{ rule: BashIntercept
32
33
  return compiled;
33
34
  }
34
35
 
36
+ /** Finds the end of a shell word, respecting quotes and escapes; returns null for incomplete syntax. */
37
+ function skipShellWord(command: string, start: number): number | null {
38
+ let inSingle = false;
39
+ let inDouble = false;
40
+ for (let i = start; i < command.length; i++) {
41
+ const ch = command[i];
42
+ if (inSingle) {
43
+ if (ch === "'") inSingle = false;
44
+ continue;
45
+ }
46
+ if (inDouble) {
47
+ if (ch === "\\") {
48
+ if (i + 1 >= command.length) return null;
49
+ i++;
50
+ continue;
51
+ }
52
+ if (ch === '"') inDouble = false;
53
+ continue;
54
+ }
55
+ if (ch === "'") {
56
+ inSingle = true;
57
+ continue;
58
+ }
59
+ if (ch === '"') {
60
+ inDouble = true;
61
+ continue;
62
+ }
63
+ if (ch === "\\") {
64
+ if (i + 1 >= command.length) return null;
65
+ i++;
66
+ continue;
67
+ }
68
+ if (ch === " " || ch === "\t") return i;
69
+ }
70
+ return inSingle || inDouble ? null : command.length;
71
+ }
72
+
73
+ /** Removes leading `NAME=value` assignments without interpreting shell syntax. */
74
+ function withoutLeadingEnvironmentAssignments(command: string): string | null {
75
+ let index = 0;
76
+ let foundAssignment = false;
77
+ while (index < command.length) {
78
+ while (command[index] === " " || command[index] === "\t") index++;
79
+ const assignmentStart = index;
80
+ if (!/[A-Za-z_]/.test(command[index] ?? "")) break;
81
+ let nameEnd = index + 1;
82
+ while (/[A-Za-z0-9_]/.test(command[nameEnd] ?? "")) nameEnd++;
83
+ if (command[nameEnd] !== "=") {
84
+ return foundAssignment ? command.slice(assignmentStart).trimStart() : null;
85
+ }
86
+ const wordEnd = skipShellWord(command, nameEnd + 1);
87
+ if (wordEnd === null) return null;
88
+ foundAssignment = true;
89
+ index = wordEnd;
90
+ if (index === command.length) return null;
91
+ }
92
+ if (!foundAssignment) return null;
93
+ const commandWithoutAssignments = command.slice(index).trimStart();
94
+ return commandWithoutAssignments.length > 0 ? commandWithoutAssignments : null;
95
+ }
96
+
97
+ function interceptionCandidates(command: string): string[] {
98
+ const candidates = [command.trim()];
99
+ const segments = extractFlatShellCommandSegments(command);
100
+ candidates.push(...segments.map(segment => segment.trim()));
101
+ for (const segment of segments) {
102
+ const withoutAssignments = withoutLeadingEnvironmentAssignments(segment);
103
+ if (withoutAssignments) candidates.push(withoutAssignments);
104
+ }
105
+ return candidates;
106
+ }
107
+
35
108
  /**
36
109
  * Check if a bash command should be intercepted.
37
110
  *
@@ -43,10 +116,10 @@ export function checkBashInterception(
43
116
  command: string,
44
117
  availableTools: string[],
45
118
  rules: BashInterceptorRule[] = DEFAULT_BASH_INTERCEPTOR_RULES,
119
+ originalCommand = command,
46
120
  ): InterceptionResult {
47
- // Normalize command for pattern matching
48
- const normalizedCommand = command.trim();
49
121
  const compiled = compileRules(rules);
122
+ const candidates = interceptionCandidates(command);
50
123
 
51
124
  for (const { rule, regex } of compiled) {
52
125
  // Only block if the suggested tool is actually available
@@ -54,12 +127,16 @@ export function checkBashInterception(
54
127
  continue;
55
128
  }
56
129
 
57
- if (regex.test(normalizedCommand)) {
58
- return {
59
- block: true,
60
- message: `Blocked: ${rule.message}\n\nOriginal command: ${command}`,
61
- suggestedTool: rule.tool,
62
- };
130
+ for (const candidate of candidates) {
131
+ // A configured global or sticky regex carries state across calls.
132
+ regex.lastIndex = 0;
133
+ if (regex.test(candidate)) {
134
+ return {
135
+ block: true,
136
+ message: `Blocked: ${rule.message}\n\nOriginal command: ${originalCommand}`,
137
+ suggestedTool: rule.tool,
138
+ };
139
+ }
63
140
  }
64
141
  }
65
142
 
package/src/tools/bash.ts CHANGED
@@ -843,13 +843,18 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
843
843
  return { kind: "steer" };
844
844
  }
845
845
 
846
+ // Cancellable threshold: a bare Bun.sleep(thresholdMs) leaves a live, ref'd
847
+ // timer for the full threshold after the command finishes (or abort/steer)
848
+ // wins the race first — delaying SDK/headless shutdown and accumulating
849
+ // timers under fast command rates. Settle a withResolvers promise from
850
+ // setTimeout so the finally can clear it regardless of which waiter wins.
851
+ const { promise: thresholdPromise, resolve: resolveThreshold } = Promise.withResolvers<{
852
+ kind: "running";
853
+ }>();
854
+ const thresholdTimer = setTimeout(() => resolveThreshold({ kind: "running" }), thresholdMs);
846
855
  const waiters: Array<
847
856
  Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "steer" } | { kind: "aborted" }>
848
- > = [job.completion, Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const }))];
849
-
850
- if (!signal && !steeringSignal) {
851
- return await Promise.race(waiters);
852
- }
857
+ > = [job.completion, thresholdPromise];
853
858
 
854
859
  const { promise: abortedPromise, resolve: resolveAborted } = Promise.withResolvers<{ kind: "aborted" }>();
855
860
  const onAbort = () => resolveAborted({ kind: "aborted" });
@@ -866,6 +871,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
866
871
  try {
867
872
  return await Promise.race(waiters);
868
873
  } finally {
874
+ clearTimeout(thresholdTimer);
869
875
  signal?.removeEventListener("abort", onAbort);
870
876
  steeringSignal?.removeEventListener("abort", onSteer);
871
877
  }
@@ -920,7 +926,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
920
926
  const rules = this.session.settings.getBashInterceptorRules();
921
927
  const commandsToCheck = rawCommand === command ? [command] : [rawCommand, command];
922
928
  for (const commandToCheck of commandsToCheck) {
923
- const interception = checkBashInterception(commandToCheck, ctx?.toolNames ?? [], rules);
929
+ const interception = checkBashInterception(commandToCheck, ctx?.toolNames ?? [], rules, rawCommand);
924
930
  if (interception.block) {
925
931
  throw new ToolError(interception.message ?? "Command blocked");
926
932
  }
@@ -118,12 +118,23 @@ export async function findReusableCdp(
118
118
  return null;
119
119
  }
120
120
 
121
+ export function shouldPreserveConnectedBrowserFocus(target?: string): boolean {
122
+ return !target;
123
+ }
124
+
121
125
  /**
122
126
  * Pick the best page target on an attached browser. Prefer discoverable page
123
127
  * targets first so Chromium/Edge attach flows that hide pages from
124
128
  * `browser.pages()` can still return a usable tab.
129
+ *
130
+ * `preferVisible` is for attaching to a browser a human is using: among equally
131
+ * usable tabs, take the one that is actually foregrounded rather than whichever
132
+ * target CDP happens to enumerate first.
125
133
  */
126
- export async function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page> {
134
+ export async function pickElectronTarget(
135
+ browser: Browser,
136
+ options: { matcher?: string; preferVisible?: boolean } = {},
137
+ ): Promise<Page> {
127
138
  const discoveredPages = await Promise.all(
128
139
  browser.targets().map(async target => {
129
140
  if (String(target.type()) !== "page") return null;
@@ -132,14 +143,14 @@ export async function pickElectronTarget(browser: Browser, matcher?: string): Pr
132
143
  );
133
144
  const usablePages = discoveredPages.filter((page): page is Page => page !== null);
134
145
  if (usablePages.length > 0) {
135
- return pickPageFromList(usablePages, matcher);
146
+ return pickPageFromList(usablePages, options);
136
147
  }
137
148
 
138
149
  const fallbackPages = await browser.pages();
139
150
  if (!fallbackPages.length) {
140
151
  throw new ToolError("No page targets available on the attached browser");
141
152
  }
142
- return pickPageFromList(fallbackPages, matcher);
153
+ return pickPageFromList(fallbackPages, options);
143
154
  }
144
155
 
145
156
  async function enrichPages(pages: Page[]): Promise<Array<{ page: Page; url: string; title: string }>> {
@@ -152,19 +163,33 @@ async function enrichPages(pages: Page[]): Promise<Array<{ page: Page; url: stri
152
163
  );
153
164
  }
154
165
 
155
- async function pickPageFromList(pages: Page[], matcher?: string): Promise<Page> {
166
+ async function pickPageFromList(pages: Page[], options: { matcher?: string; preferVisible?: boolean }): Promise<Page> {
156
167
  const enriched = await enrichPages(pages);
157
- if (matcher) {
158
- const needle = matcher.toLowerCase();
168
+ if (options.matcher) {
169
+ const needle = options.matcher.toLowerCase();
159
170
  const hit = enriched.find(p => p.url.toLowerCase().includes(needle) || p.title.toLowerCase().includes(needle));
160
171
  if (hit) return hit.page;
161
172
  const summary = enriched.map(p => `- ${p.title || "(untitled)"} ${p.url}`).join("\n");
162
- throw new ToolError(`No page target matched ${JSON.stringify(matcher)}. Available pages:\n${summary}`);
173
+ throw new ToolError(`No page target matched ${JSON.stringify(options.matcher)}. Available pages:\n${summary}`);
163
174
  }
164
- return (
165
- enriched.find(p => !ATTACH_TARGET_SKIP_PATTERN.test(p.url) && !ATTACH_TARGET_SKIP_PATTERN.test(p.title))?.page ??
166
- enriched[0]!.page
175
+ const usable = enriched.filter(
176
+ p => !ATTACH_TARGET_SKIP_PATTERN.test(p.url) && !ATTACH_TARGET_SKIP_PATTERN.test(p.title),
167
177
  );
178
+ if (options.preferVisible && usable.length > 1) {
179
+ // Best-effort foreground probe; a tab that cannot answer counts as hidden.
180
+ const visibility = await Promise.all(
181
+ usable.map(async p => {
182
+ try {
183
+ return (await p.page.evaluate(() => document.visibilityState === "visible")) === true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }),
188
+ );
189
+ const foreground = visibility.indexOf(true);
190
+ if (foreground >= 0) return usable[foreground]!.page;
191
+ }
192
+ return usable[0]?.page ?? enriched[0]!.page;
168
193
  }
169
194
 
170
195
  /**