@code-yeongyu/senpi 2026.11.15-2 → 2026.11.15-3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/bundle/chunks/{chunk-Q4RJ4WLE.js → chunk-225TDKTA.js} +4 -4
  3. package/dist/bundle/chunks/{chunk-QSU2456D.js → chunk-3KR3CDGM.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-AOTHPTMU.js → chunk-5GCZFK4X.js} +2 -2
  5. package/dist/bundle/chunks/{chunk-BMADOJHZ.js → chunk-5QIFH624.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-THYIV26N.js → chunk-GQEXLLWJ.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-3RILB2OD.js → chunk-OJOPOUAY.js} +9 -9
  8. package/dist/bundle/chunks/{chunk-64FJ4QQJ.js → chunk-PPCAJQDJ.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-74HCKLRH.js → chunk-TEXE4HT6.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-RRFDLGX6.js → chunk-TNYFCGI7.js} +156 -156
  11. package/dist/bundle/chunks/{cli-main-GNQY2LTU.js → cli-main-CV3ODX4A.js} +1 -1
  12. package/dist/bundle/chunks/{hand-entry-D3DNH6XL.js → hand-entry-2NN7ZTY6.js} +1 -1
  13. package/dist/bundle/chunks/{interactive-mode-ATBPY4M5.js → interactive-mode-TGHZWM2T.js} +1 -1
  14. package/dist/bundle/chunks/{main-OMFVSMUR.js → main-B7WEY4JW.js} +1 -1
  15. package/dist/bundle/chunks/{multi-session-host-ACQ5N4UB.js → multi-session-host-53YLL2OD.js} +1 -1
  16. package/dist/bundle/chunks/{package-manager-cli-2VMRV4HL.js → package-manager-cli-2U54AVKW.js} +1 -1
  17. package/dist/bundle/chunks/{role-BTH5PVME.js → role-4IXZZAL4.js} +1 -1
  18. package/dist/bundle/chunks/{rpc-mode-XUK5SYKH.js → rpc-mode-RRYXENML.js} +1 -1
  19. package/dist/bundle/chunks/{runtime-session-VO3X4OE3.js → runtime-session-KKHEYZOH.js} +1 -1
  20. package/dist/bundle/chunks/{session-control-endpoint-Y5EIFFPY.js → session-control-endpoint-HVULSIUR.js} +1 -1
  21. package/dist/bundle/chunks/session-worker.js +331 -331
  22. package/dist/bundle/cli.js +2 -2
  23. package/dist/bundle/index.js +1 -1
  24. package/dist/bundle/rpc-entry.js +1 -1
  25. package/dist/bundle/runtime-manifest.json +1 -1
  26. package/dist/core/agent-session.d.ts +6 -0
  27. package/dist/core/agent-session.js +34 -3
  28. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.d.ts +2 -0
  29. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
  30. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
  31. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +22 -4
  32. package/dist/core/extensions/builtin/anthropic-subscription/index.js +7 -2
  33. package/dist/core/extensions/builtin/anthropic-subscription/limit-recheck.d.ts +15 -0
  34. package/dist/core/extensions/builtin/anthropic-subscription/limit-recheck.js +55 -0
  35. package/dist/core/extensions/builtin/anthropic-subscription/session-stream.d.ts +1 -0
  36. package/dist/core/extensions/builtin/anthropic-subscription/session-stream.js +1 -0
  37. package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +8 -5
  38. package/dist/core/extensions/builtin/anthropic-subscription/stream.d.ts +1 -1
  39. package/dist/core/extensions/builtin/anthropic-subscription/stream.js +3 -1
  40. package/dist/core/extensions/builtin/terminal/extension-state.js +15 -0
  41. package/dist/core/extensions/builtin/terminal/notify.d.ts +2 -1
  42. package/dist/core/extensions/builtin/terminal/notify.js +15 -6
  43. package/dist/core/extensions/builtin/terminal/session-bundle.d.ts +5 -1
  44. package/dist/core/extensions/builtin/terminal/session-bundle.js +41 -1
  45. package/dist/core/extensions/builtin/terminal/tools/context.d.ts +4 -0
  46. package/dist/core/extensions/loader.js +5 -0
  47. package/dist/core/extensions/runner.d.ts +1 -0
  48. package/dist/core/extensions/runner.js +5 -0
  49. package/dist/core/extensions/types.d.ts +7 -0
  50. package/dist/core/run-background-jobs.d.ts +33 -0
  51. package/dist/core/run-background-jobs.js +47 -0
  52. package/dist/core/tools/bash-core.d.ts +50 -0
  53. package/dist/core/tools/bash-core.js +169 -0
  54. package/dist/core/tools/bash-schema.d.ts +48 -0
  55. package/dist/core/tools/bash-schema.js +34 -0
  56. package/dist/core/tools/bash.d.ts +4 -70
  57. package/dist/core/tools/bash.js +32 -409
  58. package/dist/core/tools/coding-tools-core.d.ts +24 -0
  59. package/dist/core/tools/coding-tools-core.js +14 -0
  60. package/dist/core/tools/edit-core.d.ts +56 -0
  61. package/dist/core/tools/edit-core.js +175 -0
  62. package/dist/core/tools/edit-diff.d.ts +0 -5
  63. package/dist/core/tools/edit-diff.js +0 -32
  64. package/dist/core/tools/edit-preview.d.ts +7 -0
  65. package/dist/core/tools/edit-preview.js +34 -0
  66. package/dist/core/tools/edit.d.ts +5 -43
  67. package/dist/core/tools/edit.js +10 -158
  68. package/dist/core/tools/encoding.d.ts +10 -0
  69. package/dist/core/tools/encoding.js +58 -0
  70. package/dist/core/tools/file-tool-host.d.ts +25 -0
  71. package/dist/core/tools/file-tool-host.js +109 -0
  72. package/dist/core/tools/fs-error-code.d.ts +2 -0
  73. package/dist/core/tools/fs-error-code.js +15 -0
  74. package/dist/core/tools/machine-output-spill.d.ts +11 -0
  75. package/dist/core/tools/machine-output-spill.js +65 -0
  76. package/dist/core/tools/machine.d.ts +4 -24
  77. package/dist/core/tools/machine.js +4 -44
  78. package/dist/core/tools/output-accumulator-core.d.ts +84 -0
  79. package/dist/core/tools/output-accumulator-core.js +200 -0
  80. package/dist/core/tools/output-accumulator.d.ts +9 -61
  81. package/dist/core/tools/output-accumulator.js +90 -265
  82. package/dist/core/tools/path-utils.js +3 -48
  83. package/dist/core/tools/posix-signals.d.ts +3 -0
  84. package/dist/core/tools/posix-signals.js +113 -0
  85. package/dist/core/tools/read-core.d.ts +64 -0
  86. package/dist/core/tools/read-core.js +180 -0
  87. package/dist/core/tools/read-text.d.ts +23 -0
  88. package/dist/core/tools/read-text.js +90 -0
  89. package/dist/core/tools/read.d.ts +6 -47
  90. package/dist/core/tools/read.js +13 -218
  91. package/dist/core/tools/renderers/bash.d.ts +0 -1
  92. package/dist/core/tools/renderers/bash.js +0 -1
  93. package/dist/core/tools/renderers/edit.d.ts +1 -1
  94. package/dist/core/tools/renderers/edit.js +1 -1
  95. package/dist/core/tools/secret-output-redactor.js +2 -1
  96. package/dist/core/tools/shell-operations.d.ts +53 -0
  97. package/dist/core/tools/shell-operations.js +115 -0
  98. package/dist/core/tools/shell-output.d.ts +20 -0
  99. package/dist/core/tools/shell-output.js +152 -0
  100. package/dist/core/tools/tail-window.js +5 -7
  101. package/dist/core/tools/tool-machine.d.ts +30 -0
  102. package/dist/core/tools/tool-machine.js +47 -0
  103. package/dist/core/tools/truncate.js +15 -14
  104. package/dist/core/tools/write-core.d.ts +47 -0
  105. package/dist/core/tools/write-core.js +108 -0
  106. package/dist/core/tools/write-result.d.ts +0 -1
  107. package/dist/core/tools/write-result.js +0 -13
  108. package/dist/core/tools/write.d.ts +6 -31
  109. package/dist/core/tools/write.js +25 -77
  110. package/dist/core/turn-origin.d.ts +4 -2
  111. package/dist/core/turn-origin.js +15 -8
  112. package/dist/modes/interactive/session-control-session-commands.js +1 -1
  113. package/dist/modes/rpc/connection-handler.js +2 -2
  114. package/dist/modes/rpc/effect-commands.d.ts +1 -4
  115. package/dist/modes/rpc/method-classes-schema-session.d.ts +1 -0
  116. package/dist/modes/rpc/method-classes-schema-session.js +5 -1
  117. package/dist/modes/rpc/method-classes-schema.d.ts +1 -0
  118. package/dist/modes/rpc/pause-command-validation.js +2 -0
  119. package/dist/modes/rpc/rpc-mutation-response-types.d.ts +2 -4
  120. package/dist/modes/rpc/rpc-session-command-types.d.ts +1 -0
  121. package/dist/modes/rpc/session-control-actions-data.d.ts +7 -3
  122. package/dist/modes/rpc/session-control-actions-data.js +12 -6
  123. package/dist/tools-core.d.ts +14 -0
  124. package/dist/tools-core.js +9 -0
  125. package/dist/utils/image-mime.d.ts +4 -0
  126. package/dist/utils/image-mime.js +99 -0
  127. package/dist/utils/mime.d.ts +1 -1
  128. package/dist/utils/mime.js +2 -97
  129. package/docs/extensions.md +7 -1
  130. package/docs/rpc.md +14 -0
  131. package/package.json +11 -7
  132. package/vendor/senpi-machine-ops/files/local-files.js +34 -5
@@ -0,0 +1,55 @@
1
+ import { subscriptionLimits } from "../../../subscription-limits/limit-store.js";
2
+ import { currentPersonTurn } from "../../../turn-origin.js";
3
+ /**
4
+ * One allowance per account on an admitted person's turn (senpi#6727). Its identity follows the actual message,
5
+ * not a predicted end event: a fresh run drops it even when the previous retry was cancelled or blocked.
6
+ */
7
+ export function registerLimitRecheck(pi) {
8
+ let sessionManager;
9
+ let turn;
10
+ let attempted;
11
+ const reset = () => {
12
+ sessionManager = undefined;
13
+ turn = undefined;
14
+ attempted = undefined;
15
+ };
16
+ pi.on("agent_start", (_event, ctx) => {
17
+ sessionManager = ctx.sessionManager;
18
+ });
19
+ pi.on("session_start", reset);
20
+ return {
21
+ accounts: () => {
22
+ const activeTurn = sessionManager === undefined ? undefined : currentPersonTurn(sessionManager);
23
+ if (activeTurn !== turn) {
24
+ turn = activeTurn;
25
+ attempted = turn === undefined ? undefined : new Set();
26
+ }
27
+ return attempted;
28
+ },
29
+ reset,
30
+ };
31
+ }
32
+ /** A rate-limit block with no successful usage reading newer than the block. Errors are not usage evidence. */
33
+ export function unverifiedLimitBlock(account, now) {
34
+ if (account.blockReason !== "rate_limit" || (account.blockedUntil ?? 0) <= now)
35
+ return false;
36
+ const limits = subscriptionLimits
37
+ .snapshot()
38
+ .find((entry) => entry.provider === "anthropic-subscription" && entry.account === account.name);
39
+ return (account.blockedAt === undefined ||
40
+ limits === undefined ||
41
+ limits.windows.length === 0 ||
42
+ Date.parse(limits.reportedAt) <= account.blockedAt);
43
+ }
44
+ /** The selection view alone is opened. Persistent state and model-specific blocks remain authoritative. */
45
+ export function recheckSelection(accounts, attempted, now) {
46
+ if (attempted === undefined)
47
+ return accounts;
48
+ return accounts.map((account) => {
49
+ if (attempted.has(account.name) || !unverifiedLimitBlock(account, now))
50
+ return account;
51
+ const { blockReason: _reason, blockedUntil: _until, blockedAt: _at, ...available } = account;
52
+ return available;
53
+ });
54
+ }
55
+ //# sourceMappingURL=limit-recheck.js.map
@@ -7,6 +7,7 @@ export type ResidentSessionStreamInput = {
7
7
  model: Model<Api>;
8
8
  context: Context;
9
9
  streamOptions: SimpleStreamOptions;
10
+ recheckedAccounts?: Set<string>;
10
11
  providerSettings: AnthropicSubscriptionProviderSettings;
11
12
  pinnedAccount?: string;
12
13
  buildOptions: Parameters<typeof queryWithAuthLane>[0]["buildOptions"];
@@ -209,6 +209,7 @@ function residentAuthLaneMessages(input) {
209
209
  prompt: "",
210
210
  query: getSdkBoundary().query,
211
211
  providerSettings: input.providerSettings,
212
+ recheckedAccounts: input.recheckedAccounts,
212
213
  env: input.streamOptions.env,
213
214
  signal: input.streamOptions.signal,
214
215
  sessionId: input.streamOptions.affinitySessionId ?? input.streamOptions.sessionId,
@@ -13,9 +13,11 @@ export function withAuthGuidance(error, message, ran, now = Date.now()) {
13
13
  ? allAccountsBlockedGuidance(error.soonestUnblockAt, error.blockReason, error.accounts)
14
14
  : singleAccountUnavailableGuidance(error.unavailableAccount, error.soonestUnblockAt, error.blockReason);
15
15
  // The model-limit sentence is how the fallback chain learns the limit binds this model only (senpi#5007).
16
+ // A pool that recovers on its own names its soonest retry, so the session's retry waits for it instead of
17
+ // spending its budget on local refusals that cannot change before then (senpi#6727).
16
18
  return error.limitedModel === undefined
17
- ? guidance
18
- : `${modelLimitSentence(error, error.limitedModel, now)}\n${guidance}${retryAfterMarker(error, now)}`;
19
+ ? `${guidance}${retryAfterMarker(error.rateLimitedUntil, now)}`
20
+ : `${modelLimitSentence(error, error.limitedModel, now)}\n${guidance}${retryAfterMarker(error.soonestUnblockAt, now)}`;
19
21
  }
20
22
  const kind = classifySdkError(error).kind;
21
23
  const account = errorAccountContext(error instanceof ClassifiedSdkError ? error.original : error);
@@ -31,7 +33,9 @@ export function withAuthGuidance(error, message, ran, now = Date.now()) {
31
33
  const pool = error instanceof ClassifiedSdkError ? error.poolState : undefined;
32
34
  const poolGuidance = pool && exhaustedPoolGuidance(pool.soonestUnblockAt, pool.accounts);
33
35
  const hints = [guidance, versionGuidance, poolGuidance].filter((hint) => hint !== undefined);
34
- return hints.length > 0 ? `${message}\n${hints.join("\n")}` : message;
36
+ const text = hints.length > 0 ? `${message}\n${hints.join("\n")}` : message;
37
+ // The last account's failure left no account to try: the next one is free at the pool's soonest retry (senpi#6727).
38
+ return `${text}${retryAfterMarker(pool?.rateLimitedUntil, now)}`;
35
39
  }
36
40
  /**
37
41
  * Names the limit, the model and its reset as relative prose, the form `usageLimitResetMs` (the
@@ -49,8 +53,7 @@ function modelLimitSentence(error, model, now) {
49
53
  return `${subject} hit the usage limit for model ${model}; it resets in ${Math.floor(minutes / 60)} hours ${minutes % 60} minutes, at ${new Date(until).toISOString()}.`;
50
54
  }
51
55
  /** The session's retry-delay reader only takes the marker at the very end of the text. */
52
- function retryAfterMarker(error, now) {
53
- const until = error.soonestUnblockAt;
56
+ function retryAfterMarker(until, now) {
54
57
  return until === undefined || !Number.isFinite(until) ? "" : ` (retry-after-ms: ${Math.max(0, until - now)})`;
55
58
  }
56
59
  //# sourceMappingURL=stream-guidance.js.map
@@ -1,4 +1,4 @@
1
1
  import { type Api, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions } from "@earendil-works/pi-ai";
2
2
  export declare const NATIVE_COMPACTION_WHILE_SENPI_OWNS = "Claude Code compacted this session natively although senpi owns compaction on this lane (compactionOwner: \"senpi\"); the turn was stopped so only one side compacts. Run /compact, or set anthropicSubscriptionProvider.compactionOwner to \"sdk\" to let Claude Code compact.";
3
- export declare function streamAnthropicSubscription(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
3
+ export declare function streamAnthropicSubscription(model: Model<Api>, context: Context, options?: SimpleStreamOptions, recheckedAccounts?: Set<string>): AssistantMessageEventStream;
4
4
  //# sourceMappingURL=stream.d.ts.map
@@ -27,7 +27,7 @@ import { emptyOutput, errorMessage, mapStopReason, updateUsage } from "./stream-
27
27
  import { toolWatch } from "./tool-watch.js";
28
28
  import { resolveSdkTools } from "./tools.js";
29
29
  export const NATIVE_COMPACTION_WHILE_SENPI_OWNS = 'Claude Code compacted this session natively although senpi owns compaction on this lane (compactionOwner: "senpi"); the turn was stopped so only one side compacts. Run /compact, or set anthropicSubscriptionProvider.compactionOwner to "sdk" to let Claude Code compact.';
30
- export function streamAnthropicSubscription(model, context, options) {
30
+ export function streamAnthropicSubscription(model, context, options, recheckedAccounts) {
31
31
  const stream = createAssistantMessageEventStream();
32
32
  void (async () => {
33
33
  const output = emptyOutput(model);
@@ -166,6 +166,7 @@ export function streamAnthropicSubscription(model, context, options) {
166
166
  model,
167
167
  context,
168
168
  streamOptions: options,
169
+ recheckedAccounts,
169
170
  providerSettings,
170
171
  pinnedAccount: getSessionClaudeAccountPin(options.sessionId),
171
172
  buildOptions,
@@ -193,6 +194,7 @@ export function streamAnthropicSubscription(model, context, options) {
193
194
  onAttemptStart,
194
195
  providerSettings,
195
196
  env: options?.env,
197
+ recheckedAccounts,
196
198
  signal: options?.signal,
197
199
  sessionId: affinityKey,
198
200
  model: model.id,
@@ -118,6 +118,20 @@ export function bundleSinks(pi, state) {
118
118
  },
119
119
  };
120
120
  }
121
+ /** A background session belongs to the turn that started it, so interrupting that turn with `background: "stop"` stops it. */
122
+ function recordRunJob(state, id, description) {
123
+ const bundle = state.bundle;
124
+ if (bundle === null)
125
+ return;
126
+ const release = withTerminalContext(() => state.ctx?.registerRunBackgroundJob?.({
127
+ kind: "bash",
128
+ id,
129
+ description,
130
+ stop: (turnId) => bundle.stopForInterrupt(id, turnId),
131
+ }), () => { });
132
+ if (release !== undefined)
133
+ bundle.trackRunJob(id, release);
134
+ }
121
135
  export function buildToolContext(pi, state) {
122
136
  const requireBundle = () => {
123
137
  // Lazily create a bundle so the tools work even when invoked directly (e.g. via the
@@ -170,6 +184,7 @@ export function buildToolContext(pi, state) {
170
184
  ...(command === undefined ? {} : { command }),
171
185
  ...(autoReapMinutes === undefined ? {} : { autoReapMinutes }),
172
186
  });
187
+ recordRunJob(state, id, description);
173
188
  const runtime = state.bundle?.manager.get(id);
174
189
  if (runtime) {
175
190
  const key = withTerminalContext(() => sessionKeyOf(state.ctx), () => { });
@@ -14,7 +14,7 @@ export interface TerminalNotifierDeps {
14
14
  details?: unknown;
15
15
  }, options: {
16
16
  triggerTurn: boolean;
17
- deliverAs: "steer" | "followUp";
17
+ deliverAs: "steer" | "followUp" | "nextTurn";
18
18
  }) => void;
19
19
  readonly getContext: () => ExtensionContext | undefined;
20
20
  readonly getMode: () => NotifyMode;
@@ -24,6 +24,7 @@ export declare const NOTICE_TAIL_MAX_CHARS = 2000;
24
24
  export interface TerminalNotificationDelivery {
25
25
  readonly send: (content: string, options?: {
26
26
  readonly forceWake?: boolean;
27
+ readonly nextTurn?: boolean;
27
28
  readonly details?: unknown;
28
29
  }) => void;
29
30
  }
@@ -21,10 +21,12 @@ export function getTerminalNotificationDelivery(deps, customType = TERMINAL_NOTI
21
21
  content,
22
22
  display: false,
23
23
  ...(options?.details === undefined ? {} : { details: options.details }),
24
- }, {
25
- triggerTurn: true,
26
- deliverAs: mode === "wake" || options?.forceWake === true ? "steer" : "followUp",
27
- }),
24
+ }, options?.nextTurn === true
25
+ ? { triggerTurn: false, deliverAs: "nextTurn" }
26
+ : {
27
+ triggerTurn: true,
28
+ deliverAs: mode === "wake" || options?.forceWake === true ? "steer" : "followUp",
29
+ }),
28
30
  };
29
31
  }
30
32
  function minutes(count) {
@@ -69,7 +71,11 @@ function buildNotice(id, runtime, trustText) {
69
71
  tailSection = `\nFinal output:\n${shown}${note}`;
70
72
  }
71
73
  const trustSection = trustText === undefined ? "" : `\n${trustText}`;
72
- return `<system-reminder>Background terminal session ${id} finished: ${status}${codeText}.${reapText}${tailSection}${trustSection}</system-reminder>`;
74
+ const interrupted = runtime.stoppedByInterrupt;
75
+ const ended = interrupted === undefined
76
+ ? `finished: ${status}${codeText}.`
77
+ : `was stopped when turn ${interrupted.turnId} was interrupted (${status}${codeText}).`;
78
+ return `<system-reminder>Background terminal session ${id} ${ended}${reapText}${tailSection}${trustSection}</system-reminder>`;
73
79
  }
74
80
  /**
75
81
  * Notifies an interactive agent once when a background session completes.
@@ -108,7 +114,10 @@ export class TerminalNotifier {
108
114
  exitCode: runtime.exitResult?.exitCode ?? null,
109
115
  output: runtime.fullOutput(),
110
116
  });
111
- delivery.send(buildNotice(id, runtime, annotation?.text), annotation === undefined ? undefined : { details: { trust: annotation.trust } });
117
+ delivery.send(buildNotice(id, runtime, annotation?.text), {
118
+ ...(runtime.stoppedByInterrupt === undefined ? {} : { nextTurn: true }),
119
+ ...(annotation === undefined ? {} : { details: { trust: annotation.trust } }),
120
+ });
112
121
  }
113
122
  }
114
123
  //# sourceMappingURL=notify.js.map
@@ -32,9 +32,13 @@ export declare class TerminalSessionBundle {
32
32
  park(): void;
33
33
  backgroundSnapshot(): readonly WakeSourceStateItem[];
34
34
  notifyBackgroundStart(id: string, description: string, startedAtMs?: number, details?: BackgroundStartDetails): void;
35
+ /** Hold the release of the run job `id` was recorded as; its exit drops it. */
36
+ trackRunJob(id: string, release: () => void): void;
37
+ /** Stop a session the interrupted turn `turnId` started; its exit notice then reaches the next turn without waking. */
38
+ stopForInterrupt(id: string, turnId: string): Promise<void>;
35
39
  /** The auto-reap's stop did not end a session still on the schedule: one notice; its age notices go on. */
36
40
  notifyBackgroundReapFailed(id: string, autoReapMinutes: number, reason: string): void;
37
- notifyBackgroundExit(id: string, runtime: TerminalExitView): void;
41
+ notifyBackgroundExit(id: string, exited: TerminalExitView): void;
38
42
  teardown(): Promise<void>;
39
43
  }
40
44
  /** Park a bundle across a reload; at most one parked bundle per session, older ones torn down. */
@@ -19,6 +19,9 @@ export class TerminalSessionBundle {
19
19
  #ages = new BackgroundAgeSchedule((event) => this.#dispatchAge(event));
20
20
  /** Age events that came due while parked: only the newest per session is worth delivering. */
21
21
  #parkedAges = new Map();
22
+ /** Background sessions an interrupted turn's stop ended, by the turn id: their exit notice does not wake. */
23
+ #interruptStops = new Map();
24
+ #runJobReleases = new Map();
22
25
  #torndown = false;
23
26
  constructor(options) {
24
27
  this.manager = new TerminalManager(options);
@@ -71,6 +74,24 @@ export class TerminalSessionBundle {
71
74
  this.#ages.add({ id, description, startedAtMs, ...details });
72
75
  this.#sinks?.onBackgroundState(this.backgroundSnapshot());
73
76
  }
77
+ /** Hold the release of the run job `id` was recorded as; its exit drops it. */
78
+ trackRunJob(id, release) {
79
+ if (this.#torndown)
80
+ return release();
81
+ this.#runJobReleases.set(id, release);
82
+ }
83
+ /** Stop a session the interrupted turn `turnId` started; its exit notice then reaches the next turn without waking. */
84
+ async stopForInterrupt(id, turnId) {
85
+ const runtime = this.manager.get(id);
86
+ if (runtime === null)
87
+ throw new Error(`${id} runs on the session's tool child, which this stop does not reach`);
88
+ this.#interruptStops.set(id, turnId);
89
+ const stopped = await this.manager.stop(id);
90
+ if (stopped && runtime.exited)
91
+ return;
92
+ this.#interruptStops.delete(id);
93
+ throw new Error(`${id} was still running after the stop (TERM, then KILL)`);
94
+ }
74
95
  /** The auto-reap's stop did not end a session still on the schedule: one notice; its age notices go on. */
75
96
  notifyBackgroundReapFailed(id, autoReapMinutes, reason) {
76
97
  if (this.#torndown)
@@ -81,9 +102,14 @@ export class TerminalSessionBundle {
81
102
  const event = backgroundAgeEvent("reap_failed", { ...subject, autoReapMinutes }, Date.now());
82
103
  this.#dispatchAge({ ...event, reason });
83
104
  }
84
- notifyBackgroundExit(id, runtime) {
105
+ notifyBackgroundExit(id, exited) {
85
106
  if (this.#torndown)
86
107
  return;
108
+ this.#runJobReleases.get(id)?.();
109
+ this.#runJobReleases.delete(id);
110
+ const interruptedTurn = this.#interruptStops.get(id);
111
+ this.#interruptStops.delete(id);
112
+ const runtime = interruptedTurn === undefined ? exited : stoppedByInterruptView(exited, interruptedTurn);
87
113
  const subject = this.#ages.remove(id);
88
114
  this.#parkedAges.delete(id);
89
115
  if (subject && runtime.autoReapedAfterMinutes !== undefined) {
@@ -107,6 +133,10 @@ export class TerminalSessionBundle {
107
133
  this.#parkedExits.clear();
108
134
  this.#ages.dispose();
109
135
  this.#parkedAges.clear();
136
+ for (const release of this.#runJobReleases.values())
137
+ release();
138
+ this.#runJobReleases.clear();
139
+ this.#interruptStops.clear();
110
140
  this.monitors.dispose();
111
141
  if (this.#backgrounds.size > 0) {
112
142
  this.#backgrounds.clear();
@@ -137,6 +167,16 @@ export class TerminalSessionBundle {
137
167
  this.#parkedMonitorEvents.shift();
138
168
  }
139
169
  }
170
+ function stoppedByInterruptView(runtime, turnId) {
171
+ return {
172
+ exitResult: runtime.exitResult,
173
+ ...(runtime.autoReapedAfterMinutes === undefined
174
+ ? {}
175
+ : { autoReapedAfterMinutes: runtime.autoReapedAfterMinutes }),
176
+ stoppedByInterrupt: { turnId },
177
+ fullOutput: () => runtime.fullOutput(),
178
+ };
179
+ }
140
180
  const parkedBundles = new Map();
141
181
  /** Park a bundle across a reload; at most one parked bundle per session, older ones torn down. */
142
182
  export async function parkBundle(sessionKey, bundle) {
@@ -64,6 +64,10 @@ export interface TerminalExitView {
64
64
  readonly exitResult: Pick<TerminalSessionExit, "exitCode" | "signal" | "cancelled" | "timedOut"> | null;
65
65
  /** Set when the opt-in auto-reap stopped the session at this age (senpi#6201). */
66
66
  readonly autoReapedAfterMinutes?: number;
67
+ /** Set when `interrupt { background: "stop" }` on turn `turnId` stopped the session: its notice does not wake. */
68
+ readonly stoppedByInterrupt?: {
69
+ readonly turnId: string;
70
+ };
67
71
  fullOutput(): string;
68
72
  }
69
73
  export interface TerminalLaunchBarrier {
@@ -843,6 +843,11 @@ function createExtensionAPI(extension, runtime, cwd, eventBus, session) {
843
843
  if (state !== "loading")
844
844
  return;
845
845
  runtime.assertActive();
846
+ // Checked before anything is applied: another extension may have registered a device machine while this load's
847
+ // registration was staged (from an event fired during this factory). The load then fails whole, nothing half-applied.
848
+ if (deviceMachineStaged && runtime.deviceMachine !== undefined) {
849
+ throw new DeviceMachineConflictError(runtime.deviceMachine.extensionPath, extension.path);
850
+ }
846
851
  for (const [name, value] of pendingFlagValues) {
847
852
  if (!runtime.flagValues.has(name))
848
853
  runtime.flagValues.set(name, value);
@@ -169,6 +169,7 @@ export declare class ExtensionRunner {
169
169
  private engineTurnsHeldFn;
170
170
  private noteHeldWakeFn;
171
171
  private pauseActions;
172
+ private registerRunBackgroundJobFn;
172
173
  private questionInput;
173
174
  private personAnswerPending;
174
175
  private isProjectTrustedFn;
@@ -534,6 +534,7 @@ export class ExtensionRunner {
534
534
  fenceRun: contextActions.fenceRun,
535
535
  releaseRunFence: contextActions.releaseRunFence,
536
536
  };
537
+ this.registerRunBackgroundJobFn = contextActions.registerRunBackgroundJob;
537
538
  this.questionInput = contextActions.questionInput;
538
539
  this.isProjectTrustedFn = contextActions.isProjectTrusted;
539
540
  this.getSignalFn = contextActions.getSignal;
@@ -1345,6 +1346,10 @@ export class ExtensionRunner {
1345
1346
  runner.assertActive();
1346
1347
  runner.pauseActions.releaseRunFence?.(runRef);
1347
1348
  },
1349
+ registerRunBackgroundJob: (job) => {
1350
+ runner.assertActive();
1351
+ return runner.registerRunBackgroundJobFn?.(job) ?? (() => { });
1352
+ },
1348
1353
  isProjectTrusted: () => {
1349
1354
  runner.assertActive();
1350
1355
  return runner.isProjectTrustedFn();
@@ -55,6 +55,7 @@ import type { ExtensionKernelTools } from "./kernel-tools-context.ts";
55
55
  import type { SessionControlActions, SessionControlWakeEvent } from "./session-control-types.ts";
56
56
  import type { SessionDialogApi } from "./session-dialog-api.ts";
57
57
  import type { UserActivityKind } from "./user-activity.ts";
58
+ import type { RunBackgroundJob } from "../run-background-jobs.ts";
58
59
  export type { ExecOptions, ExecResult } from "../exec.ts";
59
60
  export type { AppKeybinding, KeybindingsManager } from "../keybindings.ts";
60
61
  export type { BuildSystemPromptOptions, NormalizedBuildSystemPromptOptions } from "../system-prompt.ts";
@@ -483,6 +484,11 @@ export interface ExtensionContext {
483
484
  kind: "dag" | "task" | "workpool" | "team";
484
485
  id: string;
485
486
  }): void;
487
+ /**
488
+ * Record a background job the running turn started, so `interrupt { background: "stop" }` on that turn stops it.
489
+ * Outside a turn nothing is recorded. The returned release drops the job once it ended on its own.
490
+ */
491
+ registerRunBackgroundJob?(job: RunBackgroundJob): () => void;
486
492
  /** Whether project-local trust is active for this context. */
487
493
  isProjectTrusted(): boolean;
488
494
  /** The current abort signal, or undefined when the agent is not streaming. */
@@ -2956,6 +2962,7 @@ export interface ExtensionContextActions {
2956
2962
  kind: "dag" | "task" | "workpool" | "team";
2957
2963
  id: string;
2958
2964
  }) => void;
2965
+ registerRunBackgroundJob?: (job: RunBackgroundJob) => () => void;
2959
2966
  /** Internal delivery for a human-settled builtin question, never exposed on ExtensionAPI. */
2960
2967
  questionInput?: () => void;
2961
2968
  isProjectTrusted: () => boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The background jobs each turn started (a bash command run with `run_in_background` or one that auto-detached),
3
+ * keyed by the turn id (`turn_epoch`) that was running when the job became background. `interrupt { background:
4
+ * "stop" }` stops the jobs of the turn it interrupted, so nothing that turn left running wakes the session later.
5
+ * A job leaves the registry when it ends on its own (`release`) or once it is stopped.
6
+ */
7
+ /** A job the turn started; `stop` ends it without waking the session and rejects with the reason it could not. */
8
+ export interface RunBackgroundJob {
9
+ readonly kind: "bash";
10
+ readonly id: string;
11
+ readonly description?: string;
12
+ stop(turnId: string): Promise<void>;
13
+ }
14
+ export interface StoppedRunBackgroundJob {
15
+ readonly kind: RunBackgroundJob["kind"];
16
+ readonly id: string;
17
+ readonly description?: string;
18
+ }
19
+ export interface FailedRunBackgroundJob extends StoppedRunBackgroundJob {
20
+ readonly reason: string;
21
+ }
22
+ /** What `interrupt { background: "stop" }` stopped, and each job it could not stop with the reason. */
23
+ export interface RunBackgroundStopOutcome {
24
+ readonly stopped: readonly StoppedRunBackgroundJob[];
25
+ readonly failed: readonly FailedRunBackgroundJob[];
26
+ }
27
+ export declare class RunBackgroundJobs {
28
+ #private;
29
+ /** Record `job` under `turnId`; the returned release drops it once it ended on its own. */
30
+ register(turnId: string, job: RunBackgroundJob): () => void;
31
+ stop(turnId: string): Promise<RunBackgroundStopOutcome>;
32
+ }
33
+ //# sourceMappingURL=run-background-jobs.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The background jobs each turn started (a bash command run with `run_in_background` or one that auto-detached),
3
+ * keyed by the turn id (`turn_epoch`) that was running when the job became background. `interrupt { background:
4
+ * "stop" }` stops the jobs of the turn it interrupted, so nothing that turn left running wakes the session later.
5
+ * A job leaves the registry when it ends on its own (`release`) or once it is stopped.
6
+ */
7
+ function named(job) {
8
+ return { kind: job.kind, id: job.id, ...(job.description === undefined ? {} : { description: job.description }) };
9
+ }
10
+ export class RunBackgroundJobs {
11
+ #byTurn = new Map();
12
+ /** Record `job` under `turnId`; the returned release drops it once it ended on its own. */
13
+ register(turnId, job) {
14
+ let jobs = this.#byTurn.get(turnId);
15
+ if (jobs === undefined) {
16
+ jobs = new Set();
17
+ this.#byTurn.set(turnId, jobs);
18
+ }
19
+ jobs.add(job);
20
+ return () => this.#drop(turnId, job);
21
+ }
22
+ async stop(turnId) {
23
+ const jobs = [...(this.#byTurn.get(turnId) ?? [])];
24
+ this.#byTurn.delete(turnId);
25
+ const stopped = [];
26
+ const failed = [];
27
+ for (const job of jobs) {
28
+ try {
29
+ await job.stop(turnId);
30
+ stopped.push(named(job));
31
+ }
32
+ catch (error) {
33
+ failed.push({ ...named(job), reason: error instanceof Error ? error.message : String(error) });
34
+ }
35
+ }
36
+ return { stopped, failed };
37
+ }
38
+ #drop(turnId, job) {
39
+ const jobs = this.#byTurn.get(turnId);
40
+ if (jobs === undefined)
41
+ return;
42
+ jobs.delete(job);
43
+ if (jobs.size === 0)
44
+ this.#byTurn.delete(turnId);
45
+ }
46
+ }
47
+ //# sourceMappingURL=run-background-jobs.js.map
@@ -0,0 +1,50 @@
1
+ import type { ExecShell } from "../../../vendor/senpi-machine-ops/index.js";
2
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
3
+ import type { ShellTrust } from "../../engine-trust/shell-trust.ts";
4
+ import type { ToolDefinition } from "../extensions/types.ts";
5
+ import { type BashSpawnHook, type BashToolDetails, bashSchema, type ShellToolConfig } from "./bash-schema.ts";
6
+ import { type OutputSpillFactory } from "./output-accumulator-core.ts";
7
+ import { type BashOperations } from "./shell-operations.ts";
8
+ import type { ToolMachine } from "./tool-machine.ts";
9
+ /** Annotates a failed command's result with the engine trust status; undefined leaves the result as is. */
10
+ export type ShellTrustAnnotator = (result: {
11
+ exitCode: number;
12
+ output: string;
13
+ }) => {
14
+ text: string | undefined;
15
+ trust: ShellTrust;
16
+ } | undefined;
17
+ export interface ShellToolCoreOptions {
18
+ /** Runs the policy gate once per call. */
19
+ machine: ToolMachine;
20
+ /** Where the command runs. */
21
+ operations: BashOperations;
22
+ /** Where output past the display limits is kept; the path appears in the model's truncation notice. */
23
+ outputSpill: OutputSpillFactory;
24
+ /** The base environment before the session's `PI_*` values. Default: empty. */
25
+ shellEnv?: () => NodeJS.ProcessEnv;
26
+ /** Default: no annotation. */
27
+ annotateTrust?: ShellTrustAnnotator;
28
+ /** Redact credential-shaped output before streaming or spilling. Default: false. */
29
+ redactSecretsInOutput?: boolean;
30
+ /** Command prefix prepended to every command (for example shell setup commands) */
31
+ commandPrefix?: string;
32
+ /** Expose current Pi session metadata as PI_* environment variables. Default: true */
33
+ exposeSessionEnvironment?: boolean;
34
+ /** Hook to adjust command, cwd, or env before execution */
35
+ spawnHook?: BashSpawnHook;
36
+ }
37
+ /** A shell tool's model-facing definition and execution, without renderers: the shared core of bash and powershell. */
38
+ export declare function createShellToolCore(cwd: string, config: ShellToolConfig, options: ShellToolCoreOptions): ToolDefinition<typeof bashSchema, BashToolDetails | undefined>;
39
+ export interface CoreBashToolOptions extends Omit<ShellToolCoreOptions, "operations" | "outputSpill" | "annotateTrust" | "shellEnv"> {
40
+ /** Where the command runs. Default: `process.exec` on `machine`, in `shell`. */
41
+ operations?: BashOperations;
42
+ /** The shell for the default operations. Default: the machine's own (`/bin/sh -c` on POSIX). */
43
+ shell?: ExecShell;
44
+ /** Default: a file under `/tmp` on `machine` (see `createMachineOutputSpill`). */
45
+ outputSpill?: OutputSpillFactory;
46
+ }
47
+ /** The bash tool for a host whose only effect channel is `machine`. */
48
+ export declare function createCoreBashToolDefinition(cwd: string, options: CoreBashToolOptions): ToolDefinition<typeof bashSchema, BashToolDetails | undefined>;
49
+ export declare function createCoreBashTool(cwd: string, options: CoreBashToolOptions): AgentTool<typeof bashSchema>;
50
+ //# sourceMappingURL=bash-core.d.ts.map