@skydiveai/pi-extensions 0.1.254-beta.42 → 0.1.254-beta.44

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 +155 -48
  2. package/package.json +1 -1
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$16 = 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$16.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$16.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$16.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$16.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$15 = 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$15.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$15.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$15.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$15.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$14 = 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$14.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$14.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$14.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$14.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$14.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$13 = 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$13.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$13.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$13.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$13.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$13.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$13.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$13.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$13.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$13.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$13.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$13.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$13.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$13.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$13.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$12 = 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$12.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$11 = 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$11.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$11.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$10 = 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$10.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$10.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$9 = 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
@@ -3429,7 +3429,7 @@ const log$9 = logger.child({ module: "disk-guard" });
3429
3429
  */
3430
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$1 = "# 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$1 = "# 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$1() {
3442
+ function guardDisabledByEnv$2() {
3443
3443
  for (const name of [
3444
3444
  "DISK_GUARD_DISABLE",
3445
3445
  "SKYDIVE_DISK_GUARD_DISABLE",
@@ -3451,7 +3451,7 @@ function guardDisabledByEnv$1() {
3451
3451
  return false;
3452
3452
  }
3453
3453
  /** True when the command carries the in-band bypass marker. */
3454
- function hasBypassMarker$1(command) {
3454
+ function hasBypassMarker$2(command) {
3455
3455
  return BYPASS_MARKER$1.test(command);
3456
3456
  }
3457
3457
  /**
@@ -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$1(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$1}\` 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$1()) 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$1(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$9.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,112 @@ 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
3571
3677
  //#region src/extensions/sleep-guard.ts
3572
3678
  /**
3573
3679
  * Foreground sleep guard: block `sleep` commands that hold the turn open.
@@ -5941,6 +6047,7 @@ const all = [
5941
6047
  toolCallEnvExtension,
5942
6048
  bashDefaultTimeoutExtension,
5943
6049
  diskGuardExtension,
6050
+ noopLoopGuardExtension,
5944
6051
  sleepGuardExtension,
5945
6052
  toolCallSummaryExtension
5946
6053
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.254-beta.42",
3
+ "version": "0.1.254-beta.44",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",