@skydiveai/pi-extensions 0.1.254-beta.5 → 0.1.254-beta.51
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.
- package/dist/index.mjs +498 -50
- 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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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.
|
|
3
|
+
"version": "0.1.254-beta.51",
|
|
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.
|
|
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": "^
|
|
56
|
-
"vitest": "^2.
|
|
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"
|