@code-yeongyu/senpi 2026.10.18-1 → 2026.10.19

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 (154) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bundle/chunks/{anthropic-messages-3CFTFCKV.js → anthropic-messages-EMTBHJMM.js} +2 -2
  3. package/dist/bundle/chunks/{args-SJWMIH2H.js → args-LWR7VFKS.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-IRKZ6FVC.js → chunk-35M5MVUZ.js} +149 -145
  5. package/dist/bundle/chunks/chunk-3DNYAO3P.js +2 -0
  6. package/dist/bundle/chunks/{chunk-T2BEB5SO.js → chunk-7QPFFAJT.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-4RUVN7K3.js → chunk-BOCN744P.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-DDHBG2HU.js → chunk-FM7NDZR5.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-OMY4EC25.js → chunk-FSENE7JK.js} +2 -2
  10. package/dist/bundle/chunks/{chunk-Z2LCTXS2.js → chunk-G7LLAQXI.js} +3 -3
  11. package/dist/bundle/chunks/{chunk-NRHOGI4Z.js → chunk-IM6ZWYTW.js} +2 -2
  12. package/dist/bundle/chunks/{chunk-MLKYJIUK.js → chunk-KM4FMQII.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-OOWML2KE.js → chunk-PMGEQYEP.js} +2 -2
  14. package/dist/bundle/chunks/chunk-QGJLZ6BI.js +2 -0
  15. package/dist/bundle/chunks/{chunk-6EXDMO4S.js → chunk-QJ6MUFXY.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-FZTO2ICI.js → chunk-QRGAMVEE.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-3QVP3ZVI.js → chunk-QXEWAKXA.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-UUMKAA7S.js → chunk-RL5QMGQD.js} +9 -9
  19. package/dist/bundle/chunks/chunk-T36LJ6MW.js +3 -0
  20. package/dist/bundle/chunks/{chunk-GAHIHXVE.js → chunk-VEKNDMUA.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-TFXUF2IL.js → chunk-VPV7ADF3.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-RELAITQ7.js → chunk-X6TWPBI6.js} +1 -1
  23. package/dist/bundle/chunks/{chunk-PKDWFVE4.js → chunk-YUMRZIKJ.js} +6 -6
  24. package/dist/bundle/chunks/chunk-ZPSJQHDK.js +395 -0
  25. package/dist/bundle/chunks/{cli-main-JCSB54VT.js → cli-main-EABMHWFV.js} +1 -1
  26. package/dist/bundle/chunks/{help-fast-path-FVPGRSYA.js → help-fast-path-BYWJN6YX.js} +1 -1
  27. package/dist/bundle/chunks/{host-command-X2ZZFTME.js → host-command-5T34JLBP.js} +1 -1
  28. package/dist/bundle/chunks/{host-lifecycle-OKWTQFUJ.js → host-lifecycle-GSRIQP6Z.js} +1 -1
  29. package/dist/bundle/chunks/host-lifecycle.js +1 -1
  30. package/dist/bundle/chunks/{host-session-command-HKJKOAVS.js → host-session-command-LF6JODPX.js} +1 -1
  31. package/dist/bundle/chunks/{interactive-mode-GJERAFGV.js → interactive-mode-GMWHBIUC.js} +1 -1
  32. package/dist/bundle/chunks/{list-tips-AWDAUHF2.js → list-tips-VSWED3PT.js} +1 -1
  33. package/dist/bundle/chunks/{multi-session-host-JWMGLKAG.js → multi-session-host-3J7STKGD.js} +4 -4
  34. package/dist/bundle/chunks/{openai-codex-responses-RLYFSLK6.js → openai-codex-responses-7YOBPZ2N.js} +1 -1
  35. package/dist/bundle/chunks/{openai-completions-OWNSXMTD.js → openai-completions-HSZPYXEA.js} +1 -1
  36. package/dist/bundle/chunks/{package-manager-cli-FSVS2ND2.js → package-manager-cli-LWQZSWKL.js} +1 -1
  37. package/dist/bundle/chunks/{rotation-stream-M6QV2MMB.js → rotation-stream-QRRWL73X.js} +1 -1
  38. package/dist/bundle/chunks/{rpc-mode-TPG3J3ZJ.js → rpc-mode-6LSWRKXR.js} +1 -1
  39. package/dist/bundle/chunks/{schedule-command-PAVLVMWL.js → schedule-command-7CXMW4WK.js} +1 -1
  40. package/dist/bundle/chunks/{session-control-endpoint-NSMRMKKK.js → session-control-endpoint-E265WAIQ.js} +1 -1
  41. package/dist/bundle/chunks/{session-picker-UV6IVWJY.js → session-picker-XRB3LP5W.js} +1 -1
  42. package/dist/bundle/chunks/session-worker.js +298 -293
  43. package/dist/bundle/cli.js +2 -2
  44. package/dist/bundle/index.js +1 -1
  45. package/dist/bundle/rpc-entry.js +1 -1
  46. package/dist/bundle/runtime-manifest.json +1 -1
  47. package/dist/core/agent-run-continuation.d.ts +24 -0
  48. package/dist/core/agent-run-continuation.js +57 -0
  49. package/dist/core/agent-session.d.ts +38 -1
  50. package/dist/core/agent-session.js +122 -13
  51. package/dist/core/extension-handler-stall-report.d.ts +11 -0
  52. package/dist/core/extension-handler-stall-report.js +26 -0
  53. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +1 -0
  54. package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +13 -1
  55. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
  56. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +2 -0
  57. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
  58. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +3 -2
  59. package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -0
  60. package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +29 -0
  61. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +2 -0
  62. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +4 -0
  63. package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +4 -2
  64. package/dist/core/extensions/handler-budget.d.ts +84 -0
  65. package/dist/core/extensions/handler-budget.js +136 -0
  66. package/dist/core/extensions/runner.d.ts +23 -0
  67. package/dist/core/extensions/runner.js +184 -99
  68. package/dist/core/footer-data-provider.js +2 -1
  69. package/dist/core/keybindings.d.ts +1 -1
  70. package/dist/core/keybindings.js +3 -2
  71. package/dist/core/pending-input-journal.d.ts +6 -1
  72. package/dist/core/pending-input-journal.js +90 -34
  73. package/dist/core/pending-input-public.d.ts +11 -0
  74. package/dist/core/pending-input-public.js +10 -0
  75. package/dist/core/pending-input-session-port.d.ts +15 -0
  76. package/dist/core/pending-input-session-port.js +93 -0
  77. package/dist/core/pending-input-session.d.ts +108 -0
  78. package/dist/core/pending-input-session.js +220 -0
  79. package/dist/core/pending-input-store.d.ts +32 -1
  80. package/dist/core/pending-input-store.js +74 -26
  81. package/dist/core/pending-input-types.d.ts +25 -4
  82. package/dist/core/pending-input.js +36 -18
  83. package/dist/core/session-entry-file.js +1 -0
  84. package/dist/core/session-log.js +1 -1
  85. package/dist/core/session-manager.d.ts +1 -0
  86. package/dist/core/session-manager.js +5 -1
  87. package/dist/core/session-move/bundle.d.ts +39 -0
  88. package/dist/core/session-move/bundle.js +108 -0
  89. package/dist/core/session-move/export.d.ts +18 -0
  90. package/dist/core/session-move/export.js +83 -0
  91. package/dist/core/session-move/finish.d.ts +24 -0
  92. package/dist/core/session-move/finish.js +81 -0
  93. package/dist/core/session-move/import.d.ts +32 -0
  94. package/dist/core/session-move/import.js +124 -0
  95. package/dist/core/session-move/move-ledger.d.ts +47 -0
  96. package/dist/core/session-move/move-ledger.js +151 -0
  97. package/dist/core/session-move/ownership.d.ts +61 -0
  98. package/dist/core/session-move/ownership.js +89 -0
  99. package/dist/core/session-move/requeue.d.ts +8 -0
  100. package/dist/core/session-move/requeue.js +18 -0
  101. package/dist/core/session-move/write-guard.d.ts +22 -0
  102. package/dist/core/session-move/write-guard.js +44 -0
  103. package/dist/core/settings-manager.d.ts +13 -0
  104. package/dist/core/settings-manager.js +17 -0
  105. package/dist/core/turn-deadline.d.ts +2 -0
  106. package/dist/core/turn-deadline.js +10 -3
  107. package/dist/modes/interactive/components/ask-user-async-widget.d.ts +7 -0
  108. package/dist/modes/interactive/components/ask-user-async-widget.js +20 -6
  109. package/dist/modes/rpc/connection-handler.js +3 -0
  110. package/dist/modes/rpc/effect-commands.js +3 -4
  111. package/dist/modes/rpc/host-observe-request.js +1 -0
  112. package/dist/modes/rpc/rpc-client.d.ts +10 -0
  113. package/dist/modes/rpc/rpc-client.js +11 -0
  114. package/dist/modes/rpc/rpc-input-validation.js +8 -0
  115. package/dist/modes/rpc/rpc-types.d.ts +25 -1
  116. package/dist/modes/rpc/session-command-activity.js +1 -0
  117. package/dist/modes/rpc/session-control-actions-data.js +17 -8
  118. package/dist/modes/rpc/session-held.js +1 -0
  119. package/dist/modes/rpc/session-release.js +16 -1
  120. package/dist/modes/rpc/tool-child/admission.d.ts +42 -0
  121. package/dist/modes/rpc/tool-child/admission.js +81 -0
  122. package/dist/modes/rpc/tool-child/admission.test.d.ts +2 -0
  123. package/dist/modes/rpc/tool-child/admission.test.js +166 -0
  124. package/dist/modes/rpc/tool-child/codec.d.ts +21 -0
  125. package/dist/modes/rpc/tool-child/codec.js +137 -0
  126. package/dist/modes/rpc/tool-child/codec.test.d.ts +2 -0
  127. package/dist/modes/rpc/tool-child/codec.test.js +234 -0
  128. package/dist/modes/rpc/tool-child/context.d.ts +42 -0
  129. package/dist/modes/rpc/tool-child/context.js +23 -0
  130. package/dist/modes/rpc/tool-child/flow-control.d.ts +24 -0
  131. package/dist/modes/rpc/tool-child/flow-control.js +80 -0
  132. package/dist/modes/rpc/tool-child/payload-ref-paths.d.ts +9 -0
  133. package/dist/modes/rpc/tool-child/payload-ref-paths.js +45 -0
  134. package/dist/modes/rpc/tool-child/payload-ref.d.ts +58 -0
  135. package/dist/modes/rpc/tool-child/payload-ref.js +166 -0
  136. package/dist/modes/rpc/tool-child/payload-ref.test.d.ts +2 -0
  137. package/dist/modes/rpc/tool-child/payload-ref.test.js +254 -0
  138. package/dist/modes/rpc/tool-child/payload-wire.d.ts +13 -0
  139. package/dist/modes/rpc/tool-child/payload-wire.js +22 -0
  140. package/dist/modes/rpc/tool-child/protocol-guard.d.ts +9 -0
  141. package/dist/modes/rpc/tool-child/protocol-guard.js +128 -0
  142. package/dist/modes/rpc/tool-child/protocol.d.ts +192 -0
  143. package/dist/modes/rpc/tool-child/protocol.js +54 -0
  144. package/dist/utils/wsl.d.ts +2 -2
  145. package/dist/utils/wsl.js +4 -4
  146. package/docs/providers.md +2 -0
  147. package/docs/rpc.md +16 -2
  148. package/docs/settings.md +1 -0
  149. package/docs/terminal-setup.md +2 -0
  150. package/package.json +10 -6
  151. package/dist/bundle/chunks/chunk-COJK5FDH.js +0 -2
  152. package/dist/bundle/chunks/chunk-IDXW4FAO.js +0 -2
  153. package/dist/bundle/chunks/chunk-PFNBY43W.js +0 -2
  154. package/dist/bundle/chunks/chunk-QBD4LY7D.js +0 -395
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Host budget for extension event handlers (senpi#3650). Past `warnMs` the host reports a handler as stalled; at
3
+ * `timeoutMs`, or when the turn deadline releases the turn it belongs to, the host stops waiting on it and moves on
4
+ * (the handler keeps running detached). A value of 0 disables that limit.
5
+ */
6
+ export interface ExtensionHandlerBudget {
7
+ readonly warnMs: number;
8
+ readonly timeoutMs: number;
9
+ }
10
+ export type ExtensionHandlerStallStage = "warn" | "timeout" | "released" | "late_failure";
11
+ export interface ExtensionHandlerStall {
12
+ readonly extensionPath: string;
13
+ readonly event: string;
14
+ readonly stage: ExtensionHandlerStallStage;
15
+ readonly elapsedMs: number;
16
+ readonly timeoutMs: number;
17
+ readonly error?: string;
18
+ }
19
+ export type HandlerStopVerdict = "timed_out" | "released";
20
+ export type BudgetedHandlerOutcome<T> = {
21
+ readonly kind: "settled";
22
+ readonly value: T;
23
+ } | {
24
+ readonly kind: "failed";
25
+ readonly error: unknown;
26
+ } | {
27
+ readonly kind: "timed_out";
28
+ } | {
29
+ readonly kind: "released";
30
+ };
31
+ /** Waits on running handlers, tagged with the turn that owns them so a deadline releases only its own turn's waits. */
32
+ export declare class HeldHandlerWaits {
33
+ private readonly releases;
34
+ hold(scope: number, release: () => void): () => void;
35
+ release(scope: number): number;
36
+ }
37
+ export interface HeldHandlerSlot {
38
+ readonly waits: HeldHandlerWaits;
39
+ readonly scope: () => number;
40
+ }
41
+ export interface BudgetedHandlerRun<T> {
42
+ readonly extensionPath: string;
43
+ readonly event: string;
44
+ readonly budget: ExtensionHandlerBudget;
45
+ readonly run: (signal: AbortSignal) => Promise<T> | T;
46
+ readonly onStall?: (stall: ExtensionHandlerStall) => void;
47
+ readonly now?: () => number;
48
+ }
49
+ /** One handler under the budget, with an abort signal it can observe (the `session_shutdown` path). */
50
+ export declare function runBudgetedHandler<T>(options: BudgetedHandlerRun<T>): Promise<BudgetedHandlerOutcome<T>>;
51
+ export type HandlerCallOutcome = {
52
+ readonly ok: true;
53
+ readonly value: unknown;
54
+ } | {
55
+ readonly ok: false;
56
+ readonly error: unknown;
57
+ };
58
+ export interface BudgetedSequence<R> {
59
+ readonly event: string;
60
+ readonly length: number;
61
+ extensionPath(index: number): string;
62
+ /** Calls handler `index`; returns its result or promise, unwrapped by the sequence with a plain `await`. */
63
+ start(index: number): unknown;
64
+ /** Applies a handler's outcome; may return a promise only when it has async host work of its own. */
65
+ settle(index: number, outcome: HandlerCallOutcome): void | Promise<void>;
66
+ skip(index: number, verdict: HandlerStopVerdict, budget: ExtensionHandlerBudget): void;
67
+ /** Ends the walk early (for example a retired runner); the sequence then resolves with `finish()`. */
68
+ halted?(): boolean;
69
+ finish(): R;
70
+ }
71
+ export interface SequenceOptions {
72
+ readonly budget: ExtensionHandlerBudget;
73
+ readonly held?: HeldHandlerSlot;
74
+ readonly onStall?: (stall: ExtensionHandlerStall) => void;
75
+ readonly now?: () => number;
76
+ }
77
+ /**
78
+ * Walks an emission's handlers under the budget. A handler that settles in time is awaited exactly as a direct
79
+ * `await handler()` would be, so a timely emission keeps its microtask timing. A handler past its cap, or released
80
+ * by the turn deadline, is skipped: the walk resumes at the next handler in a new run that settles the emission,
81
+ * and the abandoned run stops when its handler ever returns. A failure after the host stopped waiting is reported.
82
+ */
83
+ export declare function runBudgetedSequence<R>(sequence: BudgetedSequence<R>, options: SequenceOptions): Promise<R>;
84
+ //# sourceMappingURL=handler-budget.d.ts.map
@@ -0,0 +1,136 @@
1
+ /** Waits on running handlers, tagged with the turn that owns them so a deadline releases only its own turn's waits. */
2
+ export class HeldHandlerWaits {
3
+ constructor() {
4
+ this.releases = new Map();
5
+ }
6
+ hold(scope, release) {
7
+ this.releases.set(release, scope);
8
+ return () => this.releases.delete(release);
9
+ }
10
+ release(scope) {
11
+ const owned = [...this.releases].filter(([, owner]) => owner === scope).map(([release]) => release);
12
+ for (const release of owned) {
13
+ this.releases.delete(release);
14
+ release();
15
+ }
16
+ return owned.length;
17
+ }
18
+ }
19
+ const describeError = (error) => (error instanceof Error ? error.message : String(error));
20
+ function watchHandler(options, stop, held) {
21
+ const { budget, extensionPath, event, now } = options;
22
+ const startedAt = now();
23
+ const stall = (stage) => options.onStall?.({ extensionPath, event, stage, elapsedMs: now() - startedAt, timeoutMs: budget.timeoutMs });
24
+ let stopped = false;
25
+ const end = (verdict) => {
26
+ if (stopped)
27
+ return;
28
+ stopped = true;
29
+ disarm();
30
+ stall(verdict === "timed_out" ? "timeout" : "released");
31
+ stop(verdict);
32
+ };
33
+ const warnTimer = budget.warnMs > 0 ? setTimeout(() => stall("warn"), budget.warnMs) : undefined;
34
+ const capTimer = budget.timeoutMs > 0 ? setTimeout(() => end("timed_out"), budget.timeoutMs) : undefined;
35
+ const unhold = held?.waits.hold(held.scope(), () => end("released"));
36
+ function disarm() {
37
+ clearTimeout(warnTimer);
38
+ clearTimeout(capTimer);
39
+ unhold?.();
40
+ }
41
+ return {
42
+ startedAt,
43
+ disarm: () => {
44
+ stopped = true;
45
+ disarm();
46
+ },
47
+ };
48
+ }
49
+ /** One handler under the budget, with an abort signal it can observe (the `session_shutdown` path). */
50
+ export async function runBudgetedHandler(options) {
51
+ const controller = new AbortController();
52
+ // The handler outcome is captured before any race, so a late rejection after a timeout is already handled.
53
+ let started;
54
+ try {
55
+ started = Promise.resolve(options.run(controller.signal));
56
+ }
57
+ catch (error) {
58
+ started = Promise.reject(error);
59
+ }
60
+ const settled = started.then((value) => ({ kind: "settled", value }), (error) => ({ kind: "failed", error }));
61
+ if (options.budget.warnMs <= 0 && options.budget.timeoutMs <= 0)
62
+ return settled;
63
+ let watch;
64
+ const outcome = await new Promise((resolve) => {
65
+ watch = watchHandler({ ...options, now: options.now ?? Date.now }, (verdict) => {
66
+ controller.abort(new Error(`${options.event} handler ${verdict === "timed_out" ? "timed out" : "released"}`));
67
+ resolve({ kind: verdict });
68
+ });
69
+ void settled.then(resolve);
70
+ });
71
+ watch?.disarm();
72
+ return outcome;
73
+ }
74
+ /**
75
+ * Walks an emission's handlers under the budget. A handler that settles in time is awaited exactly as a direct
76
+ * `await handler()` would be, so a timely emission keeps its microtask timing. A handler past its cap, or released
77
+ * by the turn deadline, is skipped: the walk resumes at the next handler in a new run that settles the emission,
78
+ * and the abandoned run stops when its handler ever returns. A failure after the host stopped waiting is reported.
79
+ */
80
+ export function runBudgetedSequence(sequence, options) {
81
+ const now = options.now ?? Date.now;
82
+ return new Promise((resolve, reject) => {
83
+ let generation = 0;
84
+ const run = async (from, owner) => {
85
+ for (let index = from; index < sequence.length; index += 1) {
86
+ if (owner !== generation)
87
+ return;
88
+ if (sequence.halted?.())
89
+ break;
90
+ const extensionPath = sequence.extensionPath(index);
91
+ const watchOptions = {
92
+ extensionPath,
93
+ event: sequence.event,
94
+ budget: options.budget,
95
+ onStall: options.onStall,
96
+ now,
97
+ };
98
+ const watch = watchHandler(watchOptions, (verdict) => {
99
+ if (owner !== generation)
100
+ return;
101
+ generation += 1;
102
+ sequence.skip(index, verdict, options.budget);
103
+ run(index + 1, generation).catch(reject);
104
+ }, options.held);
105
+ let outcome;
106
+ try {
107
+ outcome = { ok: true, value: await sequence.start(index) };
108
+ }
109
+ catch (error) {
110
+ outcome = { ok: false, error };
111
+ }
112
+ watch.disarm();
113
+ if (owner !== generation) {
114
+ if (!outcome.ok) {
115
+ options.onStall?.({
116
+ extensionPath,
117
+ event: sequence.event,
118
+ stage: "late_failure",
119
+ elapsedMs: now() - watch.startedAt,
120
+ timeoutMs: options.budget.timeoutMs,
121
+ error: describeError(outcome.error),
122
+ });
123
+ }
124
+ return;
125
+ }
126
+ const settling = sequence.settle(index, outcome);
127
+ if (settling !== undefined)
128
+ await settling;
129
+ }
130
+ if (owner === generation)
131
+ resolve(sequence.finish());
132
+ };
133
+ run(0, 0).catch(reject);
134
+ });
135
+ }
136
+ //# sourceMappingURL=handler-budget.js.map
@@ -13,6 +13,7 @@ import type { ModelRegistry } from "../model-registry.ts";
13
13
  import type { SessionManager } from "../session-manager.ts";
14
14
  import type { BuildSystemPromptOptions } from "../system-prompt.ts";
15
15
  import type { VirtualModelDefinition } from "../virtual-models.ts";
16
+ import { type ExtensionHandlerBudget, type ExtensionHandlerStall } from "./handler-budget.ts";
16
17
  import type { AgentBeforeSettleEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, BeforeProviderHeadersEvent, BeforeProviderRequestEvent, BoundaryContextPreview, ContextEvent, ContextWithSystemEvent, EntryRenderer, EntryRendererOptions, Extension, ExtensionActions, ExtensionCommandContext, ExtensionCommandContextActions, ExtensionContext, ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFlag, ExtensionMode, ExtensionRuntime, ExtensionShortcut, ExtensionToolContext, ExtensionUIContext, InputEvent, InputEventResult, InputSource, LoadExtensionsResult, MarkdownTransformer, MessageEndEvent, MessageRenderer, ModelSelectEvent, ModelSelectEventResult, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventResult, ProviderConfig, ProviderRequestPreparation, RegisteredMcpServerDeclaration, RegisteredTool, ReplacedSessionContext, ResolvedCommand, ResourcesDiscoverEvent, SessionBeforeCompactResult, SessionBeforeForkResult, SessionBeforeReloadResult, SessionBeforeSwitchResult, SessionBeforeTreeResult, SessionBoundaryDraft, SessionShutdownEvent, ToolCallEvent, ToolCallEventResult, ToolResultEvent, ToolResultEventResult, TurnEndEvent, UserBashEvent, UserBashEventResult } from "./types.ts";
17
18
  export declare class ExtensionRpcRequestNotFoundError extends Error {
18
19
  constructor(name: string);
@@ -122,6 +123,11 @@ export declare class ExtensionRunner {
122
123
  private modelRegistry;
123
124
  private eventBus;
124
125
  private errorListeners;
126
+ private eventHandlerBudget;
127
+ private handlerStallListener;
128
+ private handlerTurnScope;
129
+ private reportedInvalidHandlerBudget;
130
+ private readonly heldHandlerWaits;
125
131
  private getModel;
126
132
  private getServiceTier;
127
133
  private getEffectiveServiceTier;
@@ -226,6 +232,23 @@ export declare class ExtensionRunner {
226
232
  get isActive(): boolean;
227
233
  private assertActive;
228
234
  onError(listener: ExtensionErrorListener): () => void;
235
+ /**
236
+ * The host's budget for handlers of the queued lifecycle and boundary events, who hears their stalls, and the turn
237
+ * that owns a wait started now (the turn deadline releases only its own turn's waits).
238
+ */
239
+ setEventHandlerBudget(budget: () => ExtensionHandlerBudget, onStall: (stall: ExtensionHandlerStall) => void, turnScope?: () => number): void;
240
+ /** Stops waiting on the given turn's handlers that are still running (the turn deadline's release). */
241
+ releaseHeldHandlers(turn: number): number;
242
+ /**
243
+ * Resolved once per emission, before any handler starts. An invalid setting must never leave handlers unawaited,
244
+ * so it is reported once and the shipped defaults apply, as for `session_shutdown`.
245
+ */
246
+ private resolveEventHandlerBudget;
247
+ private walkHandlers;
248
+ private reportStoppedHandler;
249
+ private reportHandlerFailure;
250
+ /** Agent lifecycle events: every handler runs, serially, under the host budget (senpi#3650). */
251
+ private dispatchLifecycle;
229
252
  emitError(error: ExtensionError): void;
230
253
  hasHandlers(eventType: string): boolean;
231
254
  private getToolHookStatusMessage;
@@ -6,11 +6,12 @@ import { getAgentDir } from "../../config.js";
6
6
  import { theme } from "../../modes/interactive/theme/theme.js";
7
7
  import { stripAnsi } from "../../utils/ansi.js";
8
8
  import { createEventBus, EXTENSION_RPC_EVENT_CHANNEL } from "../event-bus.js";
9
- import { DEFAULT_SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS, DEFAULT_SESSION_SHUTDOWN_HANDLER_WARN_MS, SettingsManager, } from "../settings-manager.js";
9
+ import { DEFAULT_EXTENSION_EVENT_HANDLER_TIMEOUT_MS, DEFAULT_EXTENSION_EVENT_HANDLER_WARN_MS, DEFAULT_SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS, DEFAULT_SESSION_SHUTDOWN_HANDLER_WARN_MS, SettingsManager, } from "../settings-manager.js";
10
10
  import { goalFilePath } from "./builtin/goal/persistence.js";
11
11
  import { goalStoreRef } from "./builtin/goal/store-ref.js";
12
12
  import { disableServerSearch } from "./builtin/server-search-fallback.js";
13
13
  import { dispatchContextMessages } from "./context-dispatch.js";
14
+ import { HeldHandlerWaits, runBudgetedHandler, runBudgetedSequence, } from "./handler-budget.js";
14
15
  import { kernelToolsStorage } from "./kernel-tools-context.js";
15
16
  import { drainPendingProviderRegistrations } from "./loader.js";
16
17
  import { SessionStartTurnGate } from "./session-start-turn-gate.js";
@@ -101,6 +102,23 @@ function sanitizedToolHookStatusMessage(message) {
101
102
  .replace(/\s+/g, " ")
102
103
  .trim());
103
104
  }
105
+ /**
106
+ * Agent lifecycle events the session awaits on its serial event queue before it persists a turn (senpi#3650).
107
+ * Their handlers observe; none may hold the queue indefinitely, so each runs under the host's event handler budget.
108
+ */
109
+ const QUEUED_LIFECYCLE_EVENTS = new Set([
110
+ "agent_start",
111
+ "agent_end",
112
+ "agent_settled",
113
+ "turn_start",
114
+ "message_start",
115
+ "message_update",
116
+ "message_end",
117
+ "tool_execution_start",
118
+ "tool_execution_update",
119
+ "tool_execution_end",
120
+ ]);
121
+ const UNBOUNDED_HANDLER_BUDGET = { warnMs: 0, timeoutMs: 0 };
104
122
  /**
105
123
  * Helper function to emit session_shutdown event to extensions.
106
124
  * Returns true if the event was emitted, false if there were no handlers.
@@ -118,6 +136,16 @@ export async function emitSessionShutdownEvent(extensionRunner, event) {
118
136
  function snapshotEventHandlers(extensions, event) {
119
137
  return extensions.map((ext) => ({ ext, handlers: ext.handlers.get(event)?.slice() ?? [] }));
120
138
  }
139
+ /** The same snapshot as `snapshotEventHandlers`, in dispatch order, one entry per handler. */
140
+ function flattenHandlers(extensions, event) {
141
+ return snapshotEventHandlers(extensions, event).flatMap(({ ext, handlers }) => handlers.map((handler) => ({ path: ext.path, handler })));
142
+ }
143
+ function entryAt(entries, index) {
144
+ const entry = entries[index];
145
+ if (entry === undefined)
146
+ throw new RangeError(`no extension handler at index ${index}`);
147
+ return entry;
148
+ }
121
149
  export async function emitProjectTrustEvent(extensionsResult, event, ctx) {
122
150
  const errors = [];
123
151
  for (const { ext, handlers } of snapshotEventHandlers(extensionsResult.extensions, "project_trust")) {
@@ -203,6 +231,10 @@ export class ExtensionRunner {
203
231
  constructor(extensions, runtime, cwd, sessionManager, modelRegistry, eventBus = createEventBus()) {
204
232
  this.mode = "print";
205
233
  this.errorListeners = new Set();
234
+ this.eventHandlerBudget = () => UNBOUNDED_HANDLER_BUDGET;
235
+ this.handlerTurnScope = () => 0;
236
+ this.reportedInvalidHandlerBudget = false;
237
+ this.heldHandlerWaits = new HeldHandlerWaits();
206
238
  this.getModel = () => undefined;
207
239
  this.getServiceTier = () => undefined;
208
240
  this.getEffectiveServiceTier = () => this.getServiceTier();
@@ -676,6 +708,78 @@ export class ExtensionRunner {
676
708
  this.errorListeners.add(listener);
677
709
  return () => this.errorListeners.delete(listener);
678
710
  }
711
+ /**
712
+ * The host's budget for handlers of the queued lifecycle and boundary events, who hears their stalls, and the turn
713
+ * that owns a wait started now (the turn deadline releases only its own turn's waits).
714
+ */
715
+ setEventHandlerBudget(budget, onStall, turnScope = () => 0) {
716
+ this.eventHandlerBudget = budget;
717
+ this.handlerStallListener = onStall;
718
+ this.handlerTurnScope = turnScope;
719
+ }
720
+ /** Stops waiting on the given turn's handlers that are still running (the turn deadline's release). */
721
+ releaseHeldHandlers(turn) {
722
+ return this.heldHandlerWaits.release(turn);
723
+ }
724
+ /**
725
+ * Resolved once per emission, before any handler starts. An invalid setting must never leave handlers unawaited,
726
+ * so it is reported once and the shipped defaults apply, as for `session_shutdown`.
727
+ */
728
+ resolveEventHandlerBudget() {
729
+ try {
730
+ return this.eventHandlerBudget();
731
+ }
732
+ catch (err) {
733
+ if (!this.reportedInvalidHandlerBudget) {
734
+ this.reportedInvalidHandlerBudget = true;
735
+ console.warn(`Using the default extension event handler budget (warn ${DEFAULT_EXTENSION_EVENT_HANDLER_WARN_MS}ms, hard cap ${DEFAULT_EXTENSION_EVENT_HANDLER_TIMEOUT_MS}ms): ${err instanceof Error ? err.message : String(err)}`);
736
+ }
737
+ return {
738
+ warnMs: DEFAULT_EXTENSION_EVENT_HANDLER_WARN_MS,
739
+ timeoutMs: DEFAULT_EXTENSION_EVENT_HANDLER_TIMEOUT_MS,
740
+ };
741
+ }
742
+ }
743
+ walkHandlers(sequence) {
744
+ return runBudgetedSequence(sequence, {
745
+ budget: this.resolveEventHandlerBudget(),
746
+ held: { waits: this.heldHandlerWaits, scope: this.handlerTurnScope },
747
+ onStall: (stall) => this.handlerStallListener?.(stall),
748
+ });
749
+ }
750
+ reportStoppedHandler(extensionPath, event, verdict, budget) {
751
+ const error = verdict === "timed_out"
752
+ ? `handler timed out after ${budget.timeoutMs}ms`
753
+ : "handler released by the turn deadline";
754
+ this.emitError({ extensionPath, event, error });
755
+ }
756
+ reportHandlerFailure(extensionPath, event, err) {
757
+ this.emitError({
758
+ extensionPath,
759
+ event,
760
+ error: err instanceof Error ? err.message : String(err),
761
+ stack: err instanceof Error ? err.stack : undefined,
762
+ });
763
+ }
764
+ /** Agent lifecycle events: every handler runs, serially, under the host budget (senpi#3650). */
765
+ dispatchLifecycle(event) {
766
+ const entries = flattenHandlers(this.extensions, event.type);
767
+ return this.walkHandlers({
768
+ event: event.type,
769
+ length: entries.length,
770
+ extensionPath: (index) => entryAt(entries, index).path,
771
+ start: (index) => {
772
+ const { path, handler } = entryAt(entries, index);
773
+ return handler(event, this.createContext(path));
774
+ },
775
+ settle: (index, outcome) => {
776
+ if (!outcome.ok)
777
+ this.reportHandlerFailure(entryAt(entries, index).path, event.type, outcome.error);
778
+ },
779
+ skip: (index, verdict, budget) => this.reportStoppedHandler(entryAt(entries, index).path, event.type, verdict, budget),
780
+ finish: () => undefined,
781
+ });
782
+ }
679
783
  emitError(error) {
680
784
  for (const listener of this.errorListeners) {
681
785
  listener(error);
@@ -1186,34 +1290,36 @@ export class ExtensionRunner {
1186
1290
  throw err;
1187
1291
  }
1188
1292
  let valid = true;
1189
- for (const { ext, handlers } of snapshotEventHandlers(this.extensions, baseEvent.type)) {
1190
- for (const handler of handlers) {
1191
- if (!this.isActive)
1192
- return retired();
1293
+ const handlers = flattenHandlers(this.extensions, baseEvent.type);
1294
+ // Boundary handlers hold the agent loop's turn end and the run's settle, so they run under the same host budget
1295
+ // as the lifecycle events and the turn deadline can release them (senpi#3650).
1296
+ return this.walkHandlers({
1297
+ event: baseEvent.type,
1298
+ length: handlers.length,
1299
+ extensionPath: (index) => entryAt(handlers, index).path,
1300
+ halted: () => !this.isActive,
1301
+ start: (index) => {
1193
1302
  const event = {
1194
1303
  ...baseEvent,
1195
1304
  entries,
1196
1305
  continue: shouldContinue,
1197
1306
  context,
1198
1307
  };
1199
- try {
1200
- const handlerResult = (await handler(event, ctx));
1201
- if (!this.isActive)
1202
- return retired();
1308
+ return entryAt(handlers, index).handler(event, ctx);
1309
+ },
1310
+ settle: async (index, outcome) => {
1311
+ if (!this.isActive)
1312
+ return;
1313
+ const { path } = entryAt(handlers, index);
1314
+ if (outcome.ok) {
1315
+ const handlerResult = outcome.value;
1203
1316
  if (handlerResult?.entries !== undefined)
1204
1317
  entries = handlerResult.entries;
1205
1318
  if (handlerResult?.continue !== undefined)
1206
1319
  shouldContinue = handlerResult.continue;
1207
1320
  }
1208
- catch (err) {
1209
- if (!this.isActive)
1210
- return retired();
1211
- this.emitError({
1212
- extensionPath: ext.path,
1213
- event: baseEvent.type,
1214
- error: err instanceof Error ? err.message : String(err),
1215
- stack: err instanceof Error ? err.stack : undefined,
1216
- });
1321
+ else {
1322
+ this.reportHandlerFailure(path, baseEvent.type, outcome.error);
1217
1323
  }
1218
1324
  try {
1219
1325
  context = await buildContext(entries);
@@ -1221,18 +1327,19 @@ export class ExtensionRunner {
1221
1327
  }
1222
1328
  catch (err) {
1223
1329
  if (!this.isActive)
1224
- return retired();
1330
+ return;
1225
1331
  valid = false;
1226
1332
  this.emitError({
1227
- extensionPath: ext.path,
1333
+ extensionPath: path,
1228
1334
  event: baseEvent.type,
1229
1335
  error: `Invalid boundary entries: ${err instanceof Error ? err.message : String(err)}`,
1230
1336
  stack: err instanceof Error ? err.stack : undefined,
1231
1337
  });
1232
1338
  }
1233
- }
1234
- }
1235
- return valid && this.isActive ? { entries, continue: shouldContinue, context, valid: true } : retired();
1339
+ },
1340
+ skip: (index, verdict, budget) => this.reportStoppedHandler(entryAt(handlers, index).path, baseEvent.type, verdict, budget),
1341
+ finish: () => valid && this.isActive ? { entries, continue: shouldContinue, context, valid: true } : retired(),
1342
+ });
1236
1343
  }
1237
1344
  isSessionBeforeEvent(event) {
1238
1345
  return (event.type === "session_before_switch" ||
@@ -1275,53 +1382,26 @@ export class ExtensionRunner {
1275
1382
  * exactly as before.
1276
1383
  */
1277
1384
  async runSessionShutdownHandler(extensionPath, event, handler, budget) {
1278
- const controller = new AbortController();
1279
- const startedAt = Date.now();
1280
- let outcome;
1281
- // Settle the handler through a captured outcome so a late rejection after a
1282
- // timeout is already handled instead of becoming an unhandled rejection.
1283
- const settled = Promise.resolve(handler({ ...event, signal: controller.signal }, this.createContext(extensionPath))).then(() => {
1284
- outcome = { ok: true };
1285
- }, (error) => {
1286
- outcome = { ok: false, error };
1385
+ const outcome = await runBudgetedHandler({
1386
+ extensionPath,
1387
+ event: "session_shutdown",
1388
+ budget,
1389
+ run: (signal) => handler({ ...event, signal }, this.createContext(extensionPath)),
1390
+ onStall: (stall) => {
1391
+ if (stall.stage !== "warn")
1392
+ return;
1393
+ const capNote = budget.timeoutMs > 0 ? ` (hard cap ${budget.timeoutMs}ms)` : "";
1394
+ console.warn(`Extension ${extensionPath} is still running its session_shutdown handler after ${stall.elapsedMs}ms${capNote}.`);
1395
+ },
1287
1396
  });
1288
- if (budget.warnMs > 0 || budget.timeoutMs > 0) {
1289
- let warnTimer;
1290
- let capTimer;
1291
- let timedOut = false;
1292
- try {
1293
- timedOut = await new Promise((resolve) => {
1294
- if (budget.warnMs > 0) {
1295
- warnTimer = setTimeout(() => {
1296
- const capNote = budget.timeoutMs > 0 ? ` (hard cap ${budget.timeoutMs}ms)` : "";
1297
- console.warn(`Extension ${extensionPath} is still running its session_shutdown handler after ${Date.now() - startedAt}ms${capNote}.`);
1298
- }, budget.warnMs);
1299
- }
1300
- if (budget.timeoutMs > 0) {
1301
- capTimer = setTimeout(() => resolve(true), budget.timeoutMs);
1302
- }
1303
- void settled.then(() => resolve(false));
1304
- });
1305
- }
1306
- finally {
1307
- clearTimeout(warnTimer);
1308
- clearTimeout(capTimer);
1309
- }
1310
- if (timedOut) {
1311
- controller.abort(new Error(`session_shutdown handler timed out after ${budget.timeoutMs}ms`));
1312
- this.emitError({
1313
- extensionPath,
1314
- event: "session_shutdown",
1315
- error: `handler timed out after ${budget.timeoutMs}ms`,
1316
- });
1317
- return;
1318
- }
1319
- }
1320
- else {
1321
- await settled;
1322
- }
1323
- if (outcome && !outcome.ok) {
1397
+ if (outcome.kind === "failed")
1324
1398
  throw outcome.error;
1399
+ if (outcome.kind === "timed_out") {
1400
+ this.emitError({
1401
+ extensionPath,
1402
+ event: "session_shutdown",
1403
+ error: `handler timed out after ${budget.timeoutMs}ms`,
1404
+ });
1325
1405
  }
1326
1406
  }
1327
1407
  emit(event) {
@@ -1329,14 +1409,17 @@ export class ExtensionRunner {
1329
1409
  // Turns requested from any session_start handler start after the last one returns (senpi#1972).
1330
1410
  if (event.type === "session_start")
1331
1411
  return this.sessionStartTurns.dispatch(() => this.dispatchEmit(event));
1412
+ if (QUEUED_LIFECYCLE_EVENTS.has(event.type)) {
1413
+ return this.dispatchLifecycle(event);
1414
+ }
1332
1415
  return this.dispatchEmit(event);
1333
1416
  }
1334
1417
  async dispatchEmit(event) {
1335
1418
  let result;
1336
- // session_shutdown is the one host-bounded event: a hung handler must not hold
1337
- // Ctrl+C / quit / reload / new / resume hostage. Every other event still awaits
1338
- // its handlers without a cap (ask-user and approval dialogs legitimately block).
1339
- // The budget is resolved lazily so runners with no shutdown handler read no settings.
1419
+ // Host-bounded events: a hung session_shutdown handler must not hold Ctrl+C / quit / reload / new / resume
1420
+ // hostage (agent lifecycle events take `dispatchLifecycle`, senpi#3650). Every other event still awaits its
1421
+ // handlers without a cap (ask-user and approval dialogs legitimately block).
1422
+ // The shutdown budget is resolved lazily so runners with no shutdown handler read no settings.
1340
1423
  const isSessionShutdown = event.type === "session_shutdown";
1341
1424
  let shutdownBudget;
1342
1425
  for (const { ext, handlers } of snapshotEventHandlers(this.extensions, event.type)) {
@@ -1416,40 +1499,42 @@ export class ExtensionRunner {
1416
1499
  }
1417
1500
  return result;
1418
1501
  }
1419
- async emitMessageEnd(event) {
1502
+ emitMessageEnd(event) {
1420
1503
  let currentMessage = event.message;
1421
1504
  let modified = false;
1422
- for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "message_end")) {
1423
- for (const handler of handlers) {
1424
- try {
1425
- const currentEvent = { ...event, message: currentMessage };
1426
- const handlerResult = (await handler(currentEvent, this.createContext(ext.path)));
1427
- if (!handlerResult?.message)
1428
- continue;
1429
- if (handlerResult.message.role !== currentMessage.role) {
1430
- this.emitError({
1431
- extensionPath: ext.path,
1432
- event: "message_end",
1433
- error: "message_end handlers must return a message with the same role",
1434
- });
1435
- continue;
1436
- }
1437
- currentMessage = handlerResult.message;
1438
- modified = true;
1505
+ const entries = flattenHandlers(this.extensions, "message_end");
1506
+ return this.walkHandlers({
1507
+ event: "message_end",
1508
+ length: entries.length,
1509
+ extensionPath: (index) => entryAt(entries, index).path,
1510
+ start: (index) => {
1511
+ const { path, handler } = entryAt(entries, index);
1512
+ const currentEvent = { ...event, message: currentMessage };
1513
+ return handler(currentEvent, this.createContext(path));
1514
+ },
1515
+ settle: (index, outcome) => {
1516
+ const { path } = entryAt(entries, index);
1517
+ if (!outcome.ok) {
1518
+ this.reportHandlerFailure(path, "message_end", outcome.error);
1519
+ return;
1439
1520
  }
1440
- catch (err) {
1441
- const message = err instanceof Error ? err.message : String(err);
1442
- const stack = err instanceof Error ? err.stack : undefined;
1521
+ const handlerResult = outcome.value;
1522
+ if (!handlerResult?.message)
1523
+ return;
1524
+ if (handlerResult.message.role !== currentMessage.role) {
1443
1525
  this.emitError({
1444
- extensionPath: ext.path,
1526
+ extensionPath: path,
1445
1527
  event: "message_end",
1446
- error: message,
1447
- stack,
1528
+ error: "message_end handlers must return a message with the same role",
1448
1529
  });
1530
+ return;
1449
1531
  }
1450
- }
1451
- }
1452
- return modified ? currentMessage : undefined;
1532
+ currentMessage = handlerResult.message;
1533
+ modified = true;
1534
+ },
1535
+ skip: (index, verdict, budget) => this.reportStoppedHandler(entryAt(entries, index).path, "message_end", verdict, budget),
1536
+ finish: () => (modified ? currentMessage : undefined),
1537
+ });
1453
1538
  }
1454
1539
  async emitToolResult(event) {
1455
1540
  const currentEvent = { ...event };
@@ -2,6 +2,7 @@ import { execFile } from "child_process";
2
2
  import { existsSync, readFileSync, statSync, unwatchFile, watchFile } from "fs";
3
3
  import { dirname, join, resolve } from "path";
4
4
  import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js";
5
+ import { isWSL } from "../utils/wsl.js";
5
6
  /**
6
7
  * Find git metadata paths by walking up from cwd.
7
8
  * Handles both regular git repos (.git is a directory) and worktrees (.git is a file).
@@ -61,7 +62,7 @@ function resolveBranchWithGitAsync(repoDir) {
61
62
  });
62
63
  }
63
64
  function isWslEnvironment() {
64
- return process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
65
+ return process.platform === "linux" && isWSL();
65
66
  }
66
67
  function isWindowsMountedRepoPath(repoDir) {
67
68
  return /^\/mnt\/[a-z](?:\/|$)/i.test(repoDir);