@skydiveai/pi-extensions 0.1.254-beta.5 → 0.1.254-beta.50

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 (2) hide show
  1. package/dist/index.mjs +498 -50
  2. package/package.json +4 -4
package/dist/index.mjs CHANGED
@@ -260,7 +260,7 @@ function createHealthHandler({ metadata }) {
260
260
  const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task is done, if this changes what you can do for the user, record it in `soul.md` so it carries into future conversations rather than being rediscovered from scratch (then commit and push).";
261
261
  //#endregion
262
262
  //#region src/extensions/tool-call-summary.ts
263
- const log$15 = logger.child({ module: "tool-call-summary-extension" });
263
+ const log$17 = logger.child({ module: "tool-call-summary-extension" });
264
264
  /**
265
265
  * The injected parameter name. Deliberately markdown-safe: the previous wire
266
266
  * name (`__skydive_summary__`, now `TOOL_CALL_SUMMARY_FIELD_LEGACY`) carried
@@ -494,7 +494,7 @@ function withSummaryStrippedBeforeValidation(tool) {
494
494
  try {
495
495
  stripped = withoutInjectedSummary(args);
496
496
  } catch (err) {
497
- log$15.error({
497
+ log$17.error({
498
498
  err,
499
499
  event: "tool_call_summary_prepare_strip_failed",
500
500
  toolName: tool.name
@@ -525,7 +525,7 @@ function stripInjectedSummary(pi, toolName, input) {
525
525
  if (toolDeclaresSummaryParam(pi, toolName)) return;
526
526
  for (const field of TOOL_CALL_SUMMARY_FIELDS) delete input[field];
527
527
  } catch (err) {
528
- log$15.error({
528
+ log$17.error({
529
529
  err,
530
530
  event: "tool_call_summary_strip_failed",
531
531
  toolName
@@ -541,7 +541,7 @@ function buildInjectedPayload(pi, payload) {
541
541
  try {
542
542
  return injectToolCallSummary(payload, getStrictToolNames(pi));
543
543
  } catch (err) {
544
- log$15.error({
544
+ log$17.error({
545
545
  err,
546
546
  event: "tool_call_summary_injection_failed"
547
547
  }, "tool_call_summary injection failed; passing payload through unchanged");
@@ -560,7 +560,7 @@ const toolCallSummaryExtension = (pi) => {
560
560
  pi.registerTool(withSummaryStrippedBeforeValidation(createEditToolDefinition(ctx.cwd)));
561
561
  editOverrideRegistered = true;
562
562
  } catch (err) {
563
- log$15.error({
563
+ log$17.error({
564
564
  err,
565
565
  event: "tool_call_summary_edit_override_failed"
566
566
  }, "failed to register summary-tolerant edit tool; built-in remains active");
@@ -591,7 +591,7 @@ const toolCallSummaryExtension = (pi) => {
591
591
  * or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
592
592
  * `tools/_example.ts` documents the shape without registering.
593
593
  */
594
- const log$14 = logger.child({ module: "local-tools-extension" });
594
+ const log$16 = logger.child({ module: "local-tools-extension" });
595
595
  const TOOLS_DIRNAME = "tools";
596
596
  const fileState = /* @__PURE__ */ new Map();
597
597
  let pendingLocalToolsUpdate = null;
@@ -735,7 +735,7 @@ async function reconcileAndQueue({ pi, dir, reason }) {
735
735
  dir
736
736
  });
737
737
  if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
738
- log$14.info({
738
+ log$16.info({
739
739
  event: "local_tools_reconcile",
740
740
  reason,
741
741
  total_tools: summary.totalTools,
@@ -757,7 +757,7 @@ const localToolsExtension = (pi) => {
757
757
  reason: "session_start"
758
758
  });
759
759
  } catch (err) {
760
- log$14.error({
760
+ log$16.error({
761
761
  err,
762
762
  event: "local_tools_reconcile_failed"
763
763
  }, "local tools reconcile failed");
@@ -769,7 +769,7 @@ const localToolsExtension = (pi) => {
769
769
  try {
770
770
  current = await listToolFiles(dir);
771
771
  } catch (err) {
772
- log$14.warn({
772
+ log$16.warn({
773
773
  err,
774
774
  event: "local_tools_listing_failed"
775
775
  }, "tools/ listing failed");
@@ -791,7 +791,7 @@ const localToolsExtension = (pi) => {
791
791
  reason: "auto_reload"
792
792
  });
793
793
  } catch (err) {
794
- log$14.error({
794
+ log$16.error({
795
795
  err,
796
796
  event: "local_tools_auto_reload_failed"
797
797
  }, "auto-reload after tools/ change failed");
@@ -1356,7 +1356,7 @@ async function loadMcpConfig(path) {
1356
1356
  * Clients are keyed by JSON-stringified config and reused across
1357
1357
  * reloads — only changed configs reconnect.
1358
1358
  */
1359
- const log$13 = logger.child({ module: "mcp-extension" });
1359
+ const log$15 = logger.child({ module: "mcp-extension" });
1360
1360
  async function closeConnected(connected) {
1361
1361
  try {
1362
1362
  await connected.client.close();
@@ -1928,7 +1928,7 @@ var McpExtension = class {
1928
1928
  });
1929
1929
  this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
1930
1930
  if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
1931
- log$13.info({
1931
+ log$15.info({
1932
1932
  event: "mcp_reconcile",
1933
1933
  reason,
1934
1934
  total_tools: summary.totalTools,
@@ -1955,7 +1955,7 @@ var McpExtension = class {
1955
1955
  contextWindow: ctx.model?.contextWindow
1956
1956
  });
1957
1957
  } catch (err) {
1958
- log$13.error({
1958
+ log$15.error({
1959
1959
  err,
1960
1960
  event: "mcp_reconcile_failed"
1961
1961
  }, "MCP reconcile failed");
@@ -1969,7 +1969,7 @@ var McpExtension = class {
1969
1969
  for (const legacy of renamed) {
1970
1970
  if (this.loggedLegacyToolNames.has(legacy)) continue;
1971
1971
  this.loggedLegacyToolNames.add(legacy);
1972
- log$13.info({
1972
+ log$15.info({
1973
1973
  event: "mcp_tool_legacy_name",
1974
1974
  legacy_name: legacy,
1975
1975
  tool_name: this.legacyToolNames.get(legacy)
@@ -1983,7 +1983,7 @@ var McpExtension = class {
1983
1983
  try {
1984
1984
  mtime = await readConfigMtimeMs(configPath);
1985
1985
  } catch (err) {
1986
- log$13.warn({
1986
+ log$15.warn({
1987
1987
  err,
1988
1988
  event: "mcp_mtime_check_failed"
1989
1989
  }, "mtime check on mcp.config.json failed");
@@ -1998,7 +1998,7 @@ var McpExtension = class {
1998
1998
  contextWindow: ctx.model?.contextWindow
1999
1999
  });
2000
2000
  } catch (err) {
2001
- log$13.error({
2001
+ log$15.error({
2002
2002
  err,
2003
2003
  event: "mcp_auto_reload_failed"
2004
2004
  }, "auto-reload after mcp.config.json change failed");
@@ -2329,7 +2329,7 @@ const HEARTBEAT_THROTTLE_MS = 6e4;
2329
2329
  const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
2330
2330
  const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
2331
2331
  const DAEMON_URL = "http://localhost:38994";
2332
- const log$12 = logger.child({ module: "platform-ext" });
2332
+ const log$14 = logger.child({ module: "platform-ext" });
2333
2333
  function sandboxClient() {
2334
2334
  const apiUrl = apiBaseUrl();
2335
2335
  if (!apiUrl) return null;
@@ -2397,7 +2397,7 @@ async function fetchHarnessFlags() {
2397
2397
  try {
2398
2398
  const res = await client["feature-flags"].$get();
2399
2399
  if (!res.ok) {
2400
- log$12.debug({
2400
+ log$14.debug({
2401
2401
  status: res.status,
2402
2402
  event: "feature_flags_fetch_failed"
2403
2403
  }, "feature-flags fetch failed");
@@ -2409,7 +2409,7 @@ async function fetchHarnessFlags() {
2409
2409
  closingText: body.closingText ?? null
2410
2410
  };
2411
2411
  } catch (err) {
2412
- log$12.debug({
2412
+ log$14.debug({
2413
2413
  err,
2414
2414
  event: "feature_flags_fetch_error"
2415
2415
  }, "feature-flags request errored");
@@ -2420,7 +2420,7 @@ function postHeartbeat({ messageId }) {
2420
2420
  const client = sandboxClient();
2421
2421
  if (!client) return;
2422
2422
  client.heartbeat.$post({ json: { messageId } }).catch((err) => {
2423
- log$12.debug({
2423
+ log$14.debug({
2424
2424
  err,
2425
2425
  event: "heartbeat_failed"
2426
2426
  }, "heartbeat failed");
@@ -2432,7 +2432,7 @@ async function resolveConversationFromApi(messageId) {
2432
2432
  try {
2433
2433
  const res = await client["message-conversation"].$get({ query: { messageId } });
2434
2434
  if (!res.ok) {
2435
- log$12.warn({
2435
+ log$14.warn({
2436
2436
  status: res.status,
2437
2437
  messageId,
2438
2438
  event: "resolve_conversation_failed"
@@ -2441,7 +2441,7 @@ async function resolveConversationFromApi(messageId) {
2441
2441
  }
2442
2442
  return (await res.json()).conversationId ?? null;
2443
2443
  } catch (err) {
2444
- log$12.warn({
2444
+ log$14.warn({
2445
2445
  err,
2446
2446
  messageId,
2447
2447
  event: "resolve_conversation_error"
@@ -2466,13 +2466,13 @@ async function putBackgroundTaskJournalSpec({ messageId, spec }) {
2466
2466
  messageId,
2467
2467
  spec
2468
2468
  } });
2469
- if (!res.ok) log$12.warn({
2469
+ if (!res.ok) log$14.warn({
2470
2470
  status: res.status,
2471
2471
  taskId: spec.id,
2472
2472
  event: "bg_journal_put_failed"
2473
2473
  }, "bg-task journal PUT failed");
2474
2474
  } catch (err) {
2475
- log$12.warn({
2475
+ log$14.warn({
2476
2476
  err,
2477
2477
  taskId: spec.id,
2478
2478
  event: "bg_journal_put_failed"
@@ -2488,7 +2488,7 @@ async function deleteBackgroundTaskJournalSpec({ messageId, taskId }) {
2488
2488
  taskId
2489
2489
  } });
2490
2490
  } catch (err) {
2491
- log$12.debug({
2491
+ log$14.debug({
2492
2492
  err,
2493
2493
  taskId,
2494
2494
  event: "bg_journal_delete_failed"
@@ -2503,7 +2503,7 @@ async function listBackgroundTaskJournalSpecs({ messageId }) {
2503
2503
  if (!res.ok) return [];
2504
2504
  return (await res.json()).specs ?? [];
2505
2505
  } catch (err) {
2506
- log$12.debug({
2506
+ log$14.debug({
2507
2507
  err,
2508
2508
  event: "bg_journal_list_failed"
2509
2509
  }, "bg-task journal GET failed");
@@ -2517,7 +2517,7 @@ function postBackgroundTasksSnapshot({ messageId, tasks }) {
2517
2517
  messageId,
2518
2518
  tasks
2519
2519
  } }).catch((err) => {
2520
- log$12.debug({
2520
+ log$14.debug({
2521
2521
  err,
2522
2522
  event: "bg_tasks_snapshot_failed"
2523
2523
  }, "bg-tasks snapshot publish failed");
@@ -2615,7 +2615,7 @@ function createToolHeartbeat({ messageId }) {
2615
2615
  }
2616
2616
  heartbeatCount++;
2617
2617
  if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
2618
- log$12.warn({
2618
+ log$14.warn({
2619
2619
  heartbeatCount,
2620
2620
  activeToolCalls: [...activeToolCalls]
2621
2621
  }, "tool heartbeat max reached, stopping");
@@ -2646,7 +2646,7 @@ function postToDaemon(path, body) {
2646
2646
  headers: { "content-type": "application/json" },
2647
2647
  body: JSON.stringify(body)
2648
2648
  }).catch((err) => {
2649
- log$12.debug({
2649
+ log$14.debug({
2650
2650
  err,
2651
2651
  path,
2652
2652
  event: "daemon_post_failed"
@@ -2655,7 +2655,7 @@ function postToDaemon(path, body) {
2655
2655
  }
2656
2656
  function createPlatformExtensions({ sessionId, channelContext }) {
2657
2657
  return (pi) => {
2658
- log$12.info({
2658
+ log$14.info({
2659
2659
  sessionId,
2660
2660
  hasChannelContext: Boolean(channelContext)
2661
2661
  }, "platform extension initialized");
@@ -2704,7 +2704,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
2704
2704
  });
2705
2705
  });
2706
2706
  pi.on("agent_end", () => {
2707
- log$12.info({ sessionId }, "session ending");
2707
+ log$14.info({ sessionId }, "session ending");
2708
2708
  postToDaemon("/session/end", { sessionId });
2709
2709
  });
2710
2710
  };
@@ -2731,7 +2731,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
2731
2731
  * read on the hot path before every LLM call), it falls back to the default
2732
2732
  * for that knob and logs once.
2733
2733
  */
2734
- const log$11 = logger.child({ module: "context-management-config" });
2734
+ const log$13 = logger.child({ module: "context-management-config" });
2735
2735
  const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
2736
2736
  enabled: false,
2737
2737
  perResultMaxBytes: 16 * 1024,
@@ -2776,7 +2776,7 @@ function resolveContextManagementConfig(env = process.env) {
2776
2776
  clearAtLeastTokens: env.SKYDIVE_CTX_CLEAR_AT_LEAST_TOKENS
2777
2777
  });
2778
2778
  if (!parsed.success) {
2779
- log$11.warn({
2779
+ log$13.warn({
2780
2780
  event: "context_management_config_invalid",
2781
2781
  err: parsed.error
2782
2782
  }, "falling back to default context-management config");
@@ -2860,7 +2860,7 @@ function hasFlagSource(env = process.env) {
2860
2860
  * alive and an indeterminate result (no api url / transient failure) leaves the
2861
2861
  * last-known values untouched so a blip can't silently flip behavior.
2862
2862
  */
2863
- const log$10 = logger.child({ module: "feature-flags-poll" });
2863
+ const log$12 = logger.child({ module: "feature-flags-poll" });
2864
2864
  const FLAG_POLL_INTERVAL_MS = 6e4;
2865
2865
  let contextManagement = null;
2866
2866
  let closingText = null;
@@ -2900,7 +2900,7 @@ function apply(name, next) {
2900
2900
  if (next !== prev) for (const cb of subscribers[name]) try {
2901
2901
  cb(next);
2902
2902
  } catch (err) {
2903
- log$10.warn({
2903
+ log$12.warn({
2904
2904
  err,
2905
2905
  flag: name
2906
2906
  }, "flag subscriber threw");
@@ -2913,7 +2913,7 @@ async function pollOnce() {
2913
2913
  apply("contextManagement", flags.contextManagement ?? null);
2914
2914
  apply("closingText", flags.closingText ?? null);
2915
2915
  } catch (err) {
2916
- log$10.debug({ err }, "feature-flag poll threw");
2916
+ log$12.debug({ err }, "feature-flag poll threw");
2917
2917
  }
2918
2918
  }
2919
2919
  /**
@@ -3166,7 +3166,7 @@ const bashDefaultTimeoutExtension = (pi) => {
3166
3166
  * turn over an observability feature.
3167
3167
  */
3168
3168
  const execFileAsync = promisify(execFile);
3169
- const log$9 = logger.child({ module: "resource-pressure-warning" });
3169
+ const log$11 = logger.child({ module: "resource-pressure-warning" });
3170
3170
  const POLL_INTERVAL_MS = 1e4;
3171
3171
  function envOverride(name) {
3172
3172
  for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
@@ -3363,7 +3363,7 @@ const resourcePressureWarningExtension = (pi) => {
3363
3363
  warnedDiskThisTurn = true;
3364
3364
  }
3365
3365
  if (trigger === null) return;
3366
- log$9.warn({
3366
+ log$11.warn({
3367
3367
  trigger,
3368
3368
  mem,
3369
3369
  cpuPct,
@@ -3401,7 +3401,7 @@ const resourcePressureWarningExtension = (pi) => {
3401
3401
  if (!timer) {
3402
3402
  timer = setInterval(() => {
3403
3403
  checkOnce().catch((err) => {
3404
- log$9.error({ err }, "resource pressure check failed");
3404
+ log$11.error({ err }, "resource pressure check failed");
3405
3405
  });
3406
3406
  }, POLL_INTERVAL_MS);
3407
3407
  timer.unref?.();
@@ -3417,7 +3417,7 @@ const resourcePressureWarningExtension = (pi) => {
3417
3417
  };
3418
3418
  //#endregion
3419
3419
  //#region src/extensions/disk-guard.ts
3420
- const log$8 = logger.child({ module: "disk-guard" });
3420
+ const log$10 = logger.child({ module: "disk-guard" });
3421
3421
  /**
3422
3422
  * In-band bypass. The guard is a safety net, not a jail: when the agent knows
3423
3423
  * a flagged command is genuinely safe (writing to a different mount, a tiny
@@ -3427,9 +3427,9 @@ const log$8 = logger.child({ module: "disk-guard" });
3427
3427
  * command does, and matched case-insensitively with flexible spacing so the
3428
3428
  * agent doesn't have to reproduce it byte-for-byte.
3429
3429
  */
3430
- const BYPASS_MARKER = /#\s*disk-guard:\s*allow\b/i;
3430
+ const BYPASS_MARKER$1 = /#\s*disk-guard:\s*allow\b/i;
3431
3431
  /** The exact marker text the block message tells the agent to append. */
3432
- const BYPASS_HINT = "# disk-guard: allow";
3432
+ const BYPASS_HINT$2 = "# disk-guard: allow";
3433
3433
  /**
3434
3434
  * Harness-level kill switch: set DISK_GUARD_DISABLE=1 to turn the guard off
3435
3435
  * entirely. This is the "I own my harness, let me opt out" knob — an agent
@@ -3439,7 +3439,7 @@ const BYPASS_HINT = "# disk-guard: allow";
3439
3439
  * first; the SKYDIVE_/ANYONE_ prefixes are accepted too for consistency with
3440
3440
  * the other env overrides. Empty/unset/"0"/"false" leave the guard on.
3441
3441
  */
3442
- function guardDisabledByEnv() {
3442
+ function guardDisabledByEnv$2() {
3443
3443
  for (const name of [
3444
3444
  "DISK_GUARD_DISABLE",
3445
3445
  "SKYDIVE_DISK_GUARD_DISABLE",
@@ -3451,8 +3451,8 @@ function guardDisabledByEnv() {
3451
3451
  return false;
3452
3452
  }
3453
3453
  /** True when the command carries the in-band bypass marker. */
3454
- function hasBypassMarker(command) {
3455
- return BYPASS_MARKER.test(command);
3454
+ function hasBypassMarker$2(command) {
3455
+ return BYPASS_MARKER$1.test(command);
3456
3456
  }
3457
3457
  /**
3458
3458
  * Commands that reclaim space or merely inspect it. If any of these verbs
@@ -3540,24 +3540,24 @@ function isSpaceHungryCommand(command) {
3540
3540
  function shouldBlockForDisk(command, diskPct) {
3541
3541
  if (diskPct === null) return false;
3542
3542
  if (diskPct < 95) return false;
3543
- if (hasBypassMarker(command)) return false;
3543
+ if (hasBypassMarker$2(command)) return false;
3544
3544
  return isSpaceHungryCommand(command);
3545
3545
  }
3546
3546
  /** The agent-facing explanation returned as the blocked tool result. */
3547
3547
  function diskBlockReason(command, diskPct) {
3548
- return `Blocked: the sandbox disk is ${diskPct}% full and this command (\`${command.trim().slice(0, 120)}\`) writes a large amount, so it would fail partway with ENOSPC and leave a corrupt result. Reclaim space FIRST, then retry. Free ONLY what THIS conversation created — scratch/build output you wrote this run, downloads you're done with, and worktrees/branches whose work you've already committed and pushed (\`git worktree remove\`, \`yarn cache clean\`, delete your own scratch). Do NOT blindly wipe /tmp or delete a clone/worktree you don't recognize — other conversations share this box. Check headroom with \`df -h /\` and \`du -sh ~/workspace/* 2>/dev/null\`. If you genuinely can't free enough, stop and tell the user you're blocked on disk rather than retrying the write. If you're certain this command is safe anyway (writes elsewhere, tiny bounded size, delete-then-write), force it through by appending \` ${BYPASS_HINT}\` to the command.`;
3548
+ return `Blocked: the sandbox disk is ${diskPct}% full and this command (\`${command.trim().slice(0, 120)}\`) writes a large amount, so it would fail partway with ENOSPC and leave a corrupt result. Reclaim space FIRST, then retry. Free ONLY what THIS conversation created — scratch/build output you wrote this run, downloads you're done with, and worktrees/branches whose work you've already committed and pushed (\`git worktree remove\`, \`yarn cache clean\`, delete your own scratch). Do NOT blindly wipe /tmp or delete a clone/worktree you don't recognize — other conversations share this box. Check headroom with \`df -h /\` and \`du -sh ~/workspace/* 2>/dev/null\`. If you genuinely can't free enough, stop and tell the user you're blocked on disk rather than retrying the write. If you're certain this command is safe anyway (writes elsewhere, tiny bounded size, delete-then-write), force it through by appending \` ${BYPASS_HINT$2}\` to the command.`;
3549
3549
  }
3550
3550
  const diskGuardExtension = (pi) => {
3551
3551
  pi.on("tool_call", async (event) => {
3552
3552
  if (event.toolName !== "bash") return;
3553
- if (guardDisabledByEnv()) return;
3553
+ if (guardDisabledByEnv$2()) return;
3554
3554
  const command = event.input.command;
3555
3555
  if (typeof command !== "string" || command.length === 0) return;
3556
- if (hasBypassMarker(command)) return;
3556
+ if (hasBypassMarker$2(command)) return;
3557
3557
  if (!isSpaceHungryCommand(command)) return;
3558
3558
  const diskPct = await readDiskUsePct();
3559
3559
  if (!shouldBlockForDisk(command, diskPct)) return;
3560
- log$8.warn({
3560
+ log$10.warn({
3561
3561
  diskPct,
3562
3562
  command: command.slice(0, 200)
3563
3563
  }, "blocked space-hungry bash command on near-full disk");
@@ -3568,6 +3568,452 @@ const diskGuardExtension = (pi) => {
3568
3568
  });
3569
3569
  };
3570
3570
  //#endregion
3571
+ //#region src/extensions/noop-loop-guard.ts
3572
+ const log$9 = logger.child({ module: "noop-loop-guard" });
3573
+ /** Bare shell no-op: `true` or `:` (case-insensitive). */
3574
+ const BARE_NOOP_RE = /^(true|:)\s*$/i;
3575
+ /**
3576
+ * Shell metacharacters that make an `echo` argument more than a literal:
3577
+ * variables, command substitution, redirection, pipes. A matching argument
3578
+ * means real (or at least unknown) work, so it is never a no-op.
3579
+ */
3580
+ const ECHO_ARG_METACHARS = /[$`><|&(){}]/;
3581
+ /** Longest `echo` argument still treated as a pure no-op. */
3582
+ const ECHO_ARG_MAX_LEN = 64;
3583
+ /** Marker that forces a single command through the guard. */
3584
+ const BYPASS_HINT$1 = "noop-guard: allow";
3585
+ /** Env vars that turn the guard off for the process. */
3586
+ const KILL_SWITCH_KEYS = ["NOOP_LOOP_GUARD_DISABLE", "SKYDIVE_NOOP_LOOP_GUARD_DISABLE"];
3587
+ function guardDisabledByEnv$1() {
3588
+ return KILL_SWITCH_KEYS.some((key) => {
3589
+ const value = process.env[key];
3590
+ return value !== void 0 && value !== "" && value !== "0";
3591
+ });
3592
+ }
3593
+ function hasBypassMarker$1(command) {
3594
+ return command.toLowerCase().includes(BYPASS_HINT$1);
3595
+ }
3596
+ /**
3597
+ * True when one `;`/`&&`/newline-separated segment is a pure no-op: a bare
3598
+ * `true`/`:`, a comment-only segment, or a bare/pure-`echo` call.
3599
+ */
3600
+ function segmentIsNoop(segment) {
3601
+ let s = segment.trim();
3602
+ if (s.length === 0) return true;
3603
+ if (s.startsWith("#")) return true;
3604
+ const hash = s.indexOf("#");
3605
+ if (hash > 0) s = s.slice(0, hash).trim();
3606
+ if (s.length === 0) return true;
3607
+ if (BARE_NOOP_RE.test(s)) return true;
3608
+ return isEchoNoop(s);
3609
+ }
3610
+ /**
3611
+ * True when the command is an `echo` that only prints a short literal: no
3612
+ * shell metacharacters, argument at most ECHO_ARG_MAX_LEN characters. Bare
3613
+ * `echo` (no argument) is also a no-op. Anything with `$`, backticks,
3614
+ * redirection, or pipes is real work and never matches.
3615
+ */
3616
+ function isEchoNoop(command) {
3617
+ const m = /^echo\s*(.*)$/.exec(command.trim());
3618
+ if (!m || m[1] === void 0) return false;
3619
+ let arg = m[1].trim();
3620
+ if (arg.endsWith(";")) arg = arg.slice(0, -1).trim();
3621
+ if (arg.length === 0) return true;
3622
+ if (arg.length > ECHO_ARG_MAX_LEN) return false;
3623
+ const quoted = /^(?:"([^"]*)"|'([^']*)')$/.exec(arg);
3624
+ if (quoted) arg = quoted[1] ?? quoted[2] ?? "";
3625
+ return arg.length <= ECHO_ARG_MAX_LEN && !ECHO_ARG_METACHARS.test(arg);
3626
+ }
3627
+ /**
3628
+ * True when the whole command is a shell no-op: only bare `true`/`:`,
3629
+ * pure `echo` calls, and comments, joined by `;`, `&&`, or newlines. Commands
3630
+ * that merely CONTAIN a no-op (`git foo || true`) are real work and never
3631
+ * match — `||` is deliberately not a no-op separator, because the left side
3632
+ * of `||` runs.
3633
+ */
3634
+ function isBareNoop(command) {
3635
+ const trimmed = command.trim();
3636
+ if (trimmed.length === 0) return false;
3637
+ return trimmed.split(/;|&&|\n/).every(segmentIsNoop);
3638
+ }
3639
+ function noopBlockReason(streak, aborted) {
3640
+ const stop = "No-op loop: a bare `true`/`:` or a pure `echo` does nothing; it only costs a model round trip. If you are holding silence (e.g. right after `platform channel suppress-reply`) or waiting on something, the right move is to END THE TURN: emitting nothing IS the silence, and it costs zero commands. If a real process needs the wait, use `bg_run` or schedule a wake (`platform automation create --at <time>`).";
3641
+ const escalate = aborted ? ` This run has now fired ${streak} consecutive no-ops; the harness is aborting it.` : ` ${Math.max(25 - streak, 0)} more before the harness aborts the run.`;
3642
+ const bypass = ` If this specific command genuinely needs to run, append \` # ${BYPASS_HINT$1}\`.`;
3643
+ return stop + escalate + bypass;
3644
+ }
3645
+ /** Count one bash command against the streak; returns the new streak. */
3646
+ function recordNoopCommand(state, command) {
3647
+ if (!isBareNoop(command) || hasBypassMarker$1(command)) {
3648
+ state.streak = 0;
3649
+ return 0;
3650
+ }
3651
+ state.streak += 1;
3652
+ return state.streak;
3653
+ }
3654
+ const noopLoopGuardExtension = (pi) => {
3655
+ const state = { streak: 0 };
3656
+ pi.on("tool_call", async (event, ctx) => {
3657
+ if (event.toolName !== "bash") return;
3658
+ if (guardDisabledByEnv$1()) return;
3659
+ const command = event.input.command;
3660
+ if (typeof command !== "string" || command.length === 0) return;
3661
+ const streak = recordNoopCommand(state, command);
3662
+ if (streak < 3) return;
3663
+ const aborted = streak >= 25;
3664
+ log$9.warn({
3665
+ streak,
3666
+ aborted,
3667
+ toolCallId: event.toolCallId
3668
+ }, "no-op loop guard fired");
3669
+ if (aborted) ctx.abort();
3670
+ return {
3671
+ block: true,
3672
+ reason: noopBlockReason(streak, aborted)
3673
+ };
3674
+ });
3675
+ };
3676
+ //#endregion
3677
+ //#region src/extensions/sleep-guard.ts
3678
+ /**
3679
+ * Foreground sleep guard: block `sleep` commands that hold the turn open.
3680
+ *
3681
+ * A foreground `sleep N` blocks the whole turn loop for N seconds: steers queue
3682
+ * behind it, the user sees silence, and nothing productive happens — agents
3683
+ * reaching for it are usually trying to "guarantee a resume" (wait for CI, a
3684
+ * deploy, a background job) without trusting the wakeup paths they actually
3685
+ * have. The wakeup paths exist — `bg_run` wakes the agent on completion, and a
3686
+ * scheduled wake (`platform automation create --at`) fires at a chosen time —
3687
+ * so the harness enforces their use instead of asking prose to win.
3688
+ *
3689
+ * Detection follows the shape Claude Code ships for the same problem: a
3690
+ * STANDALONE or LEADING integer `sleep N` is the poll/wedge pattern and is
3691
+ * blocked at 2s and up; float durations (`sleep 0.5`) and sub-2s sleeps are
3692
+ * legitimate pacing and stay free, and sleeps buried inside pipelines,
3693
+ * subshells, or larger command sequences are left alone (they pace real work
3694
+ * rather than idle the turn). On top of that shape we keep a backstop for
3695
+ * sleeps buried mid-command: a total of MAX_FOREGROUND_SLEEP_SECONDS of
3696
+ * foreground sleep in one command is still blocked, because even buried it
3697
+ * holds the turn.
3698
+ *
3699
+ * When the blocked command is a PURE sleep (nothing but sleeps and shell
3700
+ * separators), the guard doesn't bounce the agent at all: it schedules the
3701
+ * one-shot wake itself (`platform automation create --at`) and returns a
3702
+ * synthetic result saying the wake is set — the agent ends its turn and is
3703
+ * resumed at the requested time, which is exactly the semantics it was trying
3704
+ * to buy with the sleep, without holding the turn open. Mixed commands (sleep
3705
+ * plus other statements) and poll loops get guidance instead, because a timed
3706
+ * wake can't reproduce them: the right shape there is `bg_run` on the actual
3707
+ * command being waited on. A bare `sleep` parked in `bg_run` is also fine —
3708
+ * it is the tool to reach for when an out-of-band process needs the sandbox
3709
+ * to stay awake — though a pure sleep task wakes the agent with an empty
3710
+ * result, so background the real command instead when one exists.
3711
+ *
3712
+ * Poll loops are caught separately: `while`/`until` re-execute their sleep, so
3713
+ * the literal sum undercounts a real wait — an unbounded `while true|:|1` loop
3714
+ * with any sleep is blocked (it can never end on its own).
3715
+ *
3716
+ * It is never a jail: appending `# sleep-guard: allow` forces a single command
3717
+ * through, and SLEEP_GUARD_DISABLE=1 turns the guard off for the process (same
3718
+ * conventions as disk-guard).
3719
+ *
3720
+ * Static analysis only: `sleep "$DURATION"` or a sleep behind an alias is not
3721
+ * caught (we can't know the value). False negatives just keep today's
3722
+ * behavior; the bypass marker covers the rare false positive (e.g. a heredoc
3723
+ * embedding a script).
3724
+ */
3725
+ const execFileP = promisify(execFile);
3726
+ const log$8 = logger.child({ module: "sleep-guard" });
3727
+ /** How long the `platform automation create` call may take before we fall back to guidance. */
3728
+ const SCHEDULE_TIMEOUT_MS = 15e3;
3729
+ /** Title for the one-shot wakes this guard schedules. Visible in the Routines UI. */
3730
+ const WAKE_TITLE = "sleep wake (auto)";
3731
+ const WAKE_SUMMARY = "One-shot timer wake the sleep guard scheduled in place of a blocked foreground sleep.";
3732
+ /**
3733
+ * The current conversation id, so the wake can resume THIS conversation
3734
+ * instead of firing into a bare cron run that has none of the task's
3735
+ * context. A bare `--at` wake starts a fresh run whose only input is the
3736
+ * description; the platform pattern for resuming is to have the firing post
3737
+ * into the original conversation, which replays it with full context.
3738
+ */
3739
+ async function currentConversationId() {
3740
+ try {
3741
+ const { stdout } = await execFileP("platform", [
3742
+ "channel",
3743
+ "info",
3744
+ "--json"
3745
+ ], { timeout: SCHEDULE_TIMEOUT_MS });
3746
+ const parsed = JSON.parse(stdout);
3747
+ return typeof parsed.conversationId === "string" && parsed.conversationId ? parsed.conversationId : null;
3748
+ } catch {
3749
+ return null;
3750
+ }
3751
+ }
3752
+ function wakeDescription(seconds, conversationId) {
3753
+ const context = `Timer wake (auto-scheduled by the sleep guard, replacing a foreground sleep of ${Math.round(seconds)}s). `;
3754
+ if (conversationId) return context + `First run \`platform conversations post ${conversationId} "sleep wake fired: continue the interrupted task from where it left off; read the recent messages for context, then proceed.\"\` and end your turn, so the original conversation resumes with its full context.`;
3755
+ return context + "Continue the interrupted task; read the recent messages for context, then proceed.";
3756
+ }
3757
+ /** In-band bypass marker, matched like disk-guard's. */
3758
+ const BYPASS_MARKER = /#\s*sleep-guard:\s*allow\b/i;
3759
+ /** The exact marker text the block message tells the agent to append. */
3760
+ const BYPASS_HINT = "# sleep-guard: allow";
3761
+ /** Harness-level kill switch, same env conventions as disk-guard. */
3762
+ function guardDisabledByEnv() {
3763
+ for (const name of [
3764
+ "SLEEP_GUARD_DISABLE",
3765
+ "SKYDIVE_SLEEP_GUARD_DISABLE",
3766
+ "ANYONE_SLEEP_GUARD_DISABLE"
3767
+ ]) {
3768
+ const value = process.env[name];
3769
+ if (value != null && value !== "" && value !== "0" && value !== "false") return true;
3770
+ }
3771
+ return false;
3772
+ }
3773
+ /** True when the command carries the in-band bypass marker. */
3774
+ function hasBypassMarker(command) {
3775
+ return BYPASS_MARKER.test(command);
3776
+ }
3777
+ /**
3778
+ * One `sleep <duration>` invocation, parsed permissively the way GNU sleep
3779
+ * reads args: one or more `number[unit]` tokens, summed. No unit means seconds
3780
+ * (s); m/h/d are minutes/hours/days. Deliberately matches ONLY the sleep and
3781
+ * its duration tokens, so everything after (including a backgrounding `&`)
3782
+ * stays in the remainder for the backgrounded check and for the pure-sleep
3783
+ * check below.
3784
+ */
3785
+ const SLEEP_CALL = /\bsleep\s+((?:\d+(?:\.\d+)?[smhd]?[^\S\n]*)+)/gi;
3786
+ /**
3787
+ * A same-length copy of the command with quoted regions ('…', "…") and
3788
+ * comments (# to end of line) replaced by spaces, so offsets are unchanged.
3789
+ * Detection runs on this copy: prose and fixtures — `echo 'sleep 900'`, a
3790
+ * printed snippet, `grep -n 'while true' app.ts` — are not real sleep calls
3791
+ * and must not be counted. Backticks and $() are NOT blanked: they execute,
3792
+ * so a sleep inside them still holds the turn and stays detectable.
3793
+ */
3794
+ function blankQuotedRegions(command) {
3795
+ const chars = command.split("");
3796
+ let quote = null;
3797
+ for (let i = 0; i < chars.length; i++) {
3798
+ const ch = chars[i];
3799
+ if (quote !== null) if (ch === quote) quote = null;
3800
+ else {
3801
+ chars[i] = " ";
3802
+ if (quote === "\"" && ch === "\\" && i + 1 < chars.length) chars[++i] = " ";
3803
+ }
3804
+ else if (ch === "'" || ch === "\"") quote = ch;
3805
+ else if (ch === "#") {
3806
+ while (i < chars.length && chars[i] !== "\n") chars[i++] = " ";
3807
+ i--;
3808
+ }
3809
+ }
3810
+ return chars.join("");
3811
+ }
3812
+ /**
3813
+ * Remove line-continuation backslash-newline pairs (an odd run of
3814
+ * backslashes before the newline), so a `&` on the continued line is
3815
+ * visible to checks that look at what follows a sleep.
3816
+ */
3817
+ function stripLineContinuations(text) {
3818
+ return text.replace(/(\\*)\\\n/g, (_m, run) => run.length % 2 === 0 ? run : _m);
3819
+ }
3820
+ function parseDurationTokens(args) {
3821
+ let total = 0;
3822
+ for (const token of args.trim().split(/\s+/)) {
3823
+ const match = /^(\d+(?:\.\d+)?)([smhd]?)$/i.exec(token);
3824
+ if (!match) continue;
3825
+ const value = Number(match[1]);
3826
+ if (!Number.isFinite(value) || value <= 0) continue;
3827
+ const unit = match[2].toLowerCase();
3828
+ total += value * (unit === "m" ? 60 : unit === "h" ? 3600 : unit === "d" ? 86400 : 1);
3829
+ }
3830
+ return total;
3831
+ }
3832
+ /**
3833
+ * Index of the end of the logical line that contains `start`: a trailing
3834
+ * backslash continues the line, so in
3835
+ *
3836
+ * sleep 900 \
3837
+ * & echo hi
3838
+ *
3839
+ * the `&` on the continued line backgrounds the sleep and must be seen.
3840
+ */
3841
+ function logicalLineEnd(command, start) {
3842
+ let end = command.indexOf("\n", start);
3843
+ while (end !== -1) {
3844
+ let backslashes = 0;
3845
+ for (let i = end - 1; i >= 0 && command[i] === "\\"; i--) backslashes++;
3846
+ if (backslashes % 2 === 1) end = command.indexOf("\n", end + 1);
3847
+ else break;
3848
+ }
3849
+ return end === -1 ? command.length : end;
3850
+ }
3851
+ /**
3852
+ * A matched sleep is already backgrounded when a single `&` (not `&&`) appears
3853
+ * later on the same logical line — `sleep 900 &` and `(sleep 900; curl x) &`
3854
+ * never hold the turn, so they don't count toward the cap. A `&` on a LATER
3855
+ * physical line does not background the sleep (it backgrounds that line's
3856
+ * command), so the remainder stops at the logical line end.
3857
+ */
3858
+ function isBackgrounded(remainderOfLine) {
3859
+ return /(^|[^&])&(?!\&)/.test(remainderOfLine);
3860
+ }
3861
+ /** Sum of foreground (non-backgrounded) literal sleep seconds in the command. */
3862
+ function sumForegroundSleeps(command) {
3863
+ let total = 0;
3864
+ for (const match of command.matchAll(SLEEP_CALL)) {
3865
+ const seconds = parseDurationTokens(match[1] ?? "");
3866
+ if (seconds <= 0) continue;
3867
+ const start = (match.index ?? 0) + match[0].length;
3868
+ if (isBackgrounded(command.slice(start, logicalLineEnd(command, start)))) continue;
3869
+ total += seconds;
3870
+ }
3871
+ return total;
3872
+ }
3873
+ /**
3874
+ * The Claude Code wedge shape: the command STARTS with a standalone
3875
+ * `sleep N` (optionally followed by more statements). Only a leading sleep can
3876
+ * be a pure wait; the first thing a command does decides what it is.
3877
+ */
3878
+ function detectLeadingSleep(command) {
3879
+ const trimmed = command.trimStart();
3880
+ const match = /^sleep\s+((?:\d+(?:\.\d+)?[smhd]?\s*)+)/.exec(trimmed);
3881
+ if (!match) return null;
3882
+ const seconds = parseDurationTokens(match[1]);
3883
+ if (seconds < 2) return null;
3884
+ const rest = stripLineContinuations(trimmed.slice(match[0].length)).trim();
3885
+ if (/^&(?!&)/.test(rest)) return null;
3886
+ return {
3887
+ seconds,
3888
+ rest
3889
+ };
3890
+ }
3891
+ /** True when the command is a `while`/`until` loop (sleep re-executes in it). */
3892
+ function isPollLoop(command) {
3893
+ return /\b(while|until)\b/.test(command);
3894
+ }
3895
+ /** True when the loop condition can never become false on its own. */
3896
+ function isUnboundedLoop(command) {
3897
+ return /\bwhile\s+(?:true|1|:)(?![\w])/.test(command);
3898
+ }
3899
+ /**
3900
+ * True when the command is nothing but sleep calls joined by shell separators
3901
+ * (`sleep 900`, `sleep 5 && sleep 10`, comments) — the only shape a timed wake
3902
+ * can reproduce exactly.
3903
+ */
3904
+ function isPureSleepCommand(command) {
3905
+ return command.replace(BYPASS_MARKER, "").replace(/#[^\n]*/g, "").replace(SLEEP_CALL, "").trim().replace(/[\s;&|]+/g, "") === "";
3906
+ }
3907
+ /** The decision, factored out and pure so it's exhaustively testable. */
3908
+ function shouldBlockForSleep(command) {
3909
+ if (hasBypassMarker(command)) return null;
3910
+ const scanned = blankQuotedRegions(command);
3911
+ const leading = detectLeadingSleep(scanned);
3912
+ if (leading) return {
3913
+ seconds: leading.seconds,
3914
+ leading: true,
3915
+ unboundedLoop: false
3916
+ };
3917
+ const seconds = sumForegroundSleeps(scanned);
3918
+ if (seconds === 0) return null;
3919
+ if (seconds >= 10) return {
3920
+ seconds,
3921
+ leading: false,
3922
+ unboundedLoop: false
3923
+ };
3924
+ if (isPollLoop(scanned) && isUnboundedLoop(scanned)) return {
3925
+ seconds,
3926
+ leading: false,
3927
+ unboundedLoop: true
3928
+ };
3929
+ return null;
3930
+ }
3931
+ /**
3932
+ * Per-session ledger of auto-scheduled wakes. An agent that re-runs its
3933
+ * blocked sleep (ignoring the do-not-re-run message) must not mint a second
3934
+ * routine for the same resume time, and a stuck agent must not accumulate
3935
+ * routines without bound.
3936
+ */
3937
+ var SleepWakeLedger = class {
3938
+ claimed = /* @__PURE__ */ new Set();
3939
+ claim(at) {
3940
+ if (this.claimed.has(at)) return "duplicate";
3941
+ if (this.claimed.size >= 10) return "capped";
3942
+ this.claimed.add(at);
3943
+ return "scheduled";
3944
+ }
3945
+ };
3946
+ /**
3947
+ * Schedule the one-shot wake via the platform CLI. Best-effort: on failure the
3948
+ * caller falls back to the guidance block so a CLI hiccup can never wedge the
3949
+ * guard.
3950
+ */
3951
+ async function scheduleSleepWake(seconds, ledger) {
3952
+ const at = new Date(Date.now() + seconds * 1e3).toISOString().replace(/\.\d{3}Z$/, "Z");
3953
+ const claim = ledger.claim(at);
3954
+ if (claim === "capped") return null;
3955
+ if (claim === "duplicate") return at;
3956
+ try {
3957
+ await execFileP("platform", [
3958
+ "automation",
3959
+ "create",
3960
+ "--at",
3961
+ at,
3962
+ "--title",
3963
+ WAKE_TITLE,
3964
+ "--summary",
3965
+ WAKE_SUMMARY,
3966
+ wakeDescription(seconds, await currentConversationId())
3967
+ ], { timeout: SCHEDULE_TIMEOUT_MS });
3968
+ return at;
3969
+ } catch (err) {
3970
+ log$8.warn({ err }, "failed to auto-schedule sleep wake; falling back to guidance");
3971
+ return null;
3972
+ }
3973
+ }
3974
+ /** The agent-facing explanation when the wake was auto-scheduled. */
3975
+ function sleepWakeReason(seconds, at) {
3976
+ return `Foreground sleep replaced: instead of holding the turn open for ${Math.round(seconds)}s, a one-shot wake is scheduled for ${at} (in ${Math.round(seconds)}s). End your turn now — the wake fires and resumes this conversation with its context. Do NOT re-run the sleep.`;
3977
+ }
3978
+ /** The agent-facing explanation returned as the blocked tool result. */
3979
+ function sleepBlockReason(command, violation) {
3980
+ return `Blocked: ${violation.unboundedLoop ? "this poll loop re-runs its sleep forever, so it never ends on its own" : violation.leading ? `this starts with a standalone sleep of ${Math.round(violation.seconds)}s` : `this sleeps ${Math.round(violation.seconds)}s in total in the foreground`}, which holds the whole turn open — the user sees silence and queued messages just wait. Waiting by sleeping is the wrong primitive; use the wakeup paths instead: (1) run the actual long command you're waiting on with \`bg_run\` — the build, deploy, or watch — and you're woken when it finishes; \`bg_run "sleep N"\` is also fine when you need the sandbox to stay awake for a process running out of band, though a pure sleep task wakes you with an empty result. (2) if you need a resume at a specific time, schedule one (\`platform automation create --at <time>\`); (3) if nothing needs doing until then, just end the turn. Sub-2s pacing sleeps are fine. If this specific command genuinely needs the wait, force it through by appending \` ${BYPASS_HINT}\`.`;
3981
+ }
3982
+ const sleepGuardExtension = (pi) => {
3983
+ const wakeLedger = new SleepWakeLedger();
3984
+ pi.on("tool_call", async (event) => {
3985
+ if (event.toolName !== "bash") return;
3986
+ if (guardDisabledByEnv()) return;
3987
+ const command = event.input.command;
3988
+ if (typeof command !== "string" || command.length === 0) return;
3989
+ const violation = shouldBlockForSleep(command);
3990
+ if (!violation) return;
3991
+ log$8.warn({
3992
+ seconds: violation.seconds,
3993
+ leading: violation.leading,
3994
+ unboundedLoop: violation.unboundedLoop,
3995
+ command: command.slice(0, 200)
3996
+ }, "blocked foreground sleep in bash command");
3997
+ if (!violation.unboundedLoop && violation.seconds <= 86400 && isPureSleepCommand(blankQuotedRegions(command))) {
3998
+ const at = await scheduleSleepWake(violation.seconds, wakeLedger);
3999
+ if (at) {
4000
+ log$8.info({
4001
+ at,
4002
+ seconds: violation.seconds
4003
+ }, "auto-scheduled sleep wake");
4004
+ return {
4005
+ block: true,
4006
+ reason: sleepWakeReason(violation.seconds, at)
4007
+ };
4008
+ }
4009
+ }
4010
+ return {
4011
+ block: true,
4012
+ reason: sleepBlockReason(command, violation)
4013
+ };
4014
+ });
4015
+ };
4016
+ //#endregion
3571
4017
  //#region src/extensions/context-management-trim.ts
3572
4018
  const CLEARED_PLACEHOLDER = "[old tool result cleared to save context — re-run the tool or re-read the source to recover it]";
3573
4019
  /** Rough token estimate (~4 chars/token); good enough for trigger decisions. */
@@ -5601,6 +6047,8 @@ const all = [
5601
6047
  toolCallEnvExtension,
5602
6048
  bashDefaultTimeoutExtension,
5603
6049
  diskGuardExtension,
6050
+ noopLoopGuardExtension,
6051
+ sleepGuardExtension,
5604
6052
  toolCallSummaryExtension
5605
6053
  ];
5606
6054
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.254-beta.5",
3
+ "version": "0.1.254-beta.50",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",
@@ -48,12 +48,12 @@
48
48
  "@earendil-works/pi-coding-agent": "0.80.10",
49
49
  "@types/node": "^24.0.0",
50
50
  "@typescript/native-preview": "^7.0.0-dev.20260113.1",
51
- "@vitest/coverage-v8": "^2.1.8",
51
+ "@vitest/coverage-v8": "^3.2.4",
52
52
  "msw": "^2.14.2",
53
53
  "pino-pretty": "^13.0.0",
54
54
  "tsdown": "^0.21.10",
55
- "typescript": "^5.9.3",
56
- "vitest": "^2.1.8"
55
+ "typescript": "^6.0.3",
56
+ "vitest": "^3.2.7"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@earendil-works/pi-coding-agent": ">=0.74.0 <0.81.0"