@code-yeongyu/senpi 2026.10.18 → 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 (190) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/bundle/chunks/{anthropic-messages-DDBWM4JZ.js → anthropic-messages-EMTBHJMM.js} +2 -2
  3. package/dist/bundle/chunks/{args-2MYPVFPH.js → args-LWR7VFKS.js} +1 -1
  4. package/dist/bundle/chunks/{azure-openai-responses-3UG5TQU7.js → azure-openai-responses-SNWKFDXK.js} +1 -1
  5. package/dist/bundle/chunks/chatgpt-subscription.js +1 -1
  6. package/dist/bundle/chunks/{chunk-SOF44GFZ.js → chunk-35M5MVUZ.js} +149 -145
  7. package/dist/bundle/chunks/chunk-3DNYAO3P.js +2 -0
  8. package/dist/bundle/chunks/{chunk-T2MHSEQ2.js → chunk-5AOKDC6G.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-MYXNQ7YZ.js → chunk-7QPFFAJT.js} +3 -3
  10. package/dist/bundle/chunks/{chunk-R36U2DJC.js → chunk-AUZXSLZN.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-PMTG4YNS.js → chunk-BOCN744P.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-XTAWER7O.js → chunk-CICJOV6I.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-4UCLDXEF.js → chunk-FM7NDZR5.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-TBRWPNA4.js → chunk-FSENE7JK.js} +2 -2
  15. package/dist/bundle/chunks/{chunk-TUD7XRIS.js → chunk-G7LLAQXI.js} +3 -3
  16. package/dist/bundle/chunks/{chunk-BGD3MZ47.js → chunk-GOKAWZUO.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-XC6UG5SI.js → chunk-IM6ZWYTW.js} +2 -2
  18. package/dist/bundle/chunks/{chunk-KHATAB7P.js → chunk-KM4FMQII.js} +1 -1
  19. package/dist/bundle/chunks/chunk-LAWS3FYQ.js +11 -0
  20. package/dist/bundle/chunks/{chunk-W6YQYGE2.js → chunk-MVNVDGYF.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-W4BO6S7F.js → chunk-PMGEQYEP.js} +2 -2
  22. package/dist/bundle/chunks/chunk-QGJLZ6BI.js +2 -0
  23. package/dist/bundle/chunks/{chunk-5RQNCWS2.js → chunk-QJ6MUFXY.js} +1 -1
  24. package/dist/bundle/chunks/{chunk-57Q7Q627.js → chunk-QRGAMVEE.js} +1 -1
  25. package/dist/bundle/chunks/{chunk-W2257FYP.js → chunk-QXEWAKXA.js} +1 -1
  26. package/dist/bundle/chunks/{chunk-UUMKAA7S.js → chunk-RL5QMGQD.js} +9 -9
  27. package/dist/bundle/chunks/{chunk-D2UQYZVK.js → chunk-SHKXJWK3.js} +1 -1
  28. package/dist/bundle/chunks/chunk-T36LJ6MW.js +3 -0
  29. package/dist/bundle/chunks/{chunk-S4V72F76.js → chunk-VCD7FD64.js} +1 -1
  30. package/dist/bundle/chunks/{chunk-MYPTZCPF.js → chunk-VEKNDMUA.js} +1 -1
  31. package/dist/bundle/chunks/{chunk-IRBAGET4.js → chunk-VPV7ADF3.js} +4 -4
  32. package/dist/bundle/chunks/{chunk-6DDAEDY7.js → chunk-WEHIEACU.js} +1 -1
  33. package/dist/bundle/chunks/{chunk-C2HSPFXN.js → chunk-WQLMQGOW.js} +1 -1
  34. package/dist/bundle/chunks/{chunk-QZIKKUUP.js → chunk-X6TWPBI6.js} +2 -2
  35. package/dist/bundle/chunks/{chunk-VYWODOAM.js → chunk-YUMRZIKJ.js} +6 -6
  36. package/dist/bundle/chunks/chunk-ZPSJQHDK.js +395 -0
  37. package/dist/bundle/chunks/{cli-main-MVVPTGMS.js → cli-main-EABMHWFV.js} +1 -1
  38. package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-H4ZPUBX4.js → cloudflare-workers-ai-system-one-PF7FU63D.js} +1 -1
  39. package/dist/bundle/chunks/{google-generative-ai-YLAYNQLO.js → google-generative-ai-UFCHYLRE.js} +1 -1
  40. package/dist/bundle/chunks/{google-vertex-THFXM5R2.js → google-vertex-2FWDY7XX.js} +1 -1
  41. package/dist/bundle/chunks/{help-fast-path-H4ERPCR5.js → help-fast-path-BYWJN6YX.js} +1 -1
  42. package/dist/bundle/chunks/{host-command-2CMRIKC3.js → host-command-5T34JLBP.js} +1 -1
  43. package/dist/bundle/chunks/{host-lifecycle-6Y2EKT4U.js → host-lifecycle-GSRIQP6Z.js} +1 -1
  44. package/dist/bundle/chunks/host-lifecycle.js +5 -5
  45. package/dist/bundle/chunks/{host-session-command-MVHY4TKA.js → host-session-command-LF6JODPX.js} +1 -1
  46. package/dist/bundle/chunks/{interactive-mode-2ZQ43N73.js → interactive-mode-GMWHBIUC.js} +1 -1
  47. package/dist/bundle/chunks/{list-tips-AWDAUHF2.js → list-tips-VSWED3PT.js} +1 -1
  48. package/dist/bundle/chunks/{mistral-conversations-GUGTVRCL.js → mistral-conversations-QHZLDTSP.js} +1 -1
  49. package/dist/bundle/chunks/{multi-session-host-NO4PIOVG.js → multi-session-host-3J7STKGD.js} +6 -6
  50. package/dist/bundle/chunks/openai-codex-responses-7YOBPZ2N.js +10 -0
  51. package/dist/bundle/chunks/{openai-completions-I5N2UIEK.js → openai-completions-HSZPYXEA.js} +1 -1
  52. package/dist/bundle/chunks/{openai-responses-SGLUJVZW.js → openai-responses-7W2KF3W7.js} +1 -1
  53. package/dist/bundle/chunks/openrouter.js +1 -1
  54. package/dist/bundle/chunks/{package-manager-cli-HTZQXYNI.js → package-manager-cli-LWQZSWKL.js} +1 -1
  55. package/dist/bundle/chunks/radius.js +1 -1
  56. package/dist/bundle/chunks/{rotation-stream-TOOIK43C.js → rotation-stream-QRRWL73X.js} +1 -1
  57. package/dist/bundle/chunks/{rpc-mode-6ZWGPICX.js → rpc-mode-6LSWRKXR.js} +1 -1
  58. package/dist/bundle/chunks/{schedule-command-2YW4B655.js → schedule-command-7CXMW4WK.js} +1 -1
  59. package/dist/bundle/chunks/{session-control-endpoint-XC2LPLCD.js → session-control-endpoint-E265WAIQ.js} +1 -1
  60. package/dist/bundle/chunks/{session-picker-7W7GIRJT.js → session-picker-XRB3LP5W.js} +1 -1
  61. package/dist/bundle/chunks/session-worker.js +315 -310
  62. package/dist/bundle/chunks/{typesafe-system-one-VBWWTJTF.js → typesafe-system-one-YA75435G.js} +1 -1
  63. package/dist/bundle/cli.js +2 -2
  64. package/dist/bundle/index.js +1 -1
  65. package/dist/bundle/rpc-entry.js +1 -1
  66. package/dist/bundle/runtime-manifest.json +1 -1
  67. package/dist/core/agent-run-continuation.d.ts +24 -0
  68. package/dist/core/agent-run-continuation.js +57 -0
  69. package/dist/core/agent-session.d.ts +38 -1
  70. package/dist/core/agent-session.js +122 -13
  71. package/dist/core/extension-handler-stall-report.d.ts +11 -0
  72. package/dist/core/extension-handler-stall-report.js +26 -0
  73. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +1 -0
  74. package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +13 -1
  75. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
  76. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +2 -0
  77. package/dist/core/extensions/builtin/anthropic-subscription/executable.d.ts +5 -0
  78. package/dist/core/extensions/builtin/anthropic-subscription/executable.js +11 -1
  79. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
  80. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +3 -2
  81. package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -0
  82. package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +29 -0
  83. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +2 -0
  84. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +4 -0
  85. package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +4 -2
  86. package/dist/core/extensions/handler-budget.d.ts +84 -0
  87. package/dist/core/extensions/handler-budget.js +136 -0
  88. package/dist/core/extensions/runner.d.ts +23 -0
  89. package/dist/core/extensions/runner.js +184 -99
  90. package/dist/core/footer-data-provider.js +2 -1
  91. package/dist/core/keybindings.d.ts +1 -1
  92. package/dist/core/keybindings.js +3 -2
  93. package/dist/core/model-resolver.js +1 -1
  94. package/dist/core/pending-input-journal.d.ts +6 -1
  95. package/dist/core/pending-input-journal.js +90 -34
  96. package/dist/core/pending-input-public.d.ts +11 -0
  97. package/dist/core/pending-input-public.js +10 -0
  98. package/dist/core/pending-input-session-port.d.ts +15 -0
  99. package/dist/core/pending-input-session-port.js +93 -0
  100. package/dist/core/pending-input-session.d.ts +108 -0
  101. package/dist/core/pending-input-session.js +220 -0
  102. package/dist/core/pending-input-store.d.ts +32 -1
  103. package/dist/core/pending-input-store.js +74 -26
  104. package/dist/core/pending-input-types.d.ts +25 -4
  105. package/dist/core/pending-input.js +36 -18
  106. package/dist/core/session-entry-file.js +1 -0
  107. package/dist/core/session-log.js +1 -1
  108. package/dist/core/session-manager.d.ts +1 -0
  109. package/dist/core/session-manager.js +5 -1
  110. package/dist/core/session-move/bundle.d.ts +39 -0
  111. package/dist/core/session-move/bundle.js +108 -0
  112. package/dist/core/session-move/export.d.ts +18 -0
  113. package/dist/core/session-move/export.js +83 -0
  114. package/dist/core/session-move/finish.d.ts +24 -0
  115. package/dist/core/session-move/finish.js +81 -0
  116. package/dist/core/session-move/import.d.ts +32 -0
  117. package/dist/core/session-move/import.js +124 -0
  118. package/dist/core/session-move/move-ledger.d.ts +47 -0
  119. package/dist/core/session-move/move-ledger.js +151 -0
  120. package/dist/core/session-move/ownership.d.ts +61 -0
  121. package/dist/core/session-move/ownership.js +89 -0
  122. package/dist/core/session-move/requeue.d.ts +8 -0
  123. package/dist/core/session-move/requeue.js +18 -0
  124. package/dist/core/session-move/write-guard.d.ts +22 -0
  125. package/dist/core/session-move/write-guard.js +44 -0
  126. package/dist/core/settings-manager.d.ts +13 -0
  127. package/dist/core/settings-manager.js +17 -0
  128. package/dist/core/turn-deadline.d.ts +2 -0
  129. package/dist/core/turn-deadline.js +10 -3
  130. package/dist/modes/interactive/components/ask-user-async-widget.d.ts +7 -0
  131. package/dist/modes/interactive/components/ask-user-async-widget.js +20 -6
  132. package/dist/modes/rpc/connection-handler.js +3 -0
  133. package/dist/modes/rpc/effect-commands.js +3 -4
  134. package/dist/modes/rpc/host-decision.d.ts +1 -1
  135. package/dist/modes/rpc/host-decision.js +2 -0
  136. package/dist/modes/rpc/host-ensure-liveness.d.ts +8 -0
  137. package/dist/modes/rpc/host-ensure-liveness.js +18 -1
  138. package/dist/modes/rpc/host-ensure-types.d.ts +2 -0
  139. package/dist/modes/rpc/host-ensure.js +8 -2
  140. package/dist/modes/rpc/host-lifecycle-proxy.d.ts +9 -2
  141. package/dist/modes/rpc/host-lifecycle-proxy.js +36 -8
  142. package/dist/modes/rpc/host-observe-request.js +1 -0
  143. package/dist/modes/rpc/multi-session-host.js +3 -33
  144. package/dist/modes/rpc/rpc-client.d.ts +10 -0
  145. package/dist/modes/rpc/rpc-client.js +11 -0
  146. package/dist/modes/rpc/rpc-input-validation.js +8 -0
  147. package/dist/modes/rpc/rpc-types.d.ts +25 -1
  148. package/dist/modes/rpc/session-command-activity.js +1 -0
  149. package/dist/modes/rpc/session-control-actions-data.js +17 -8
  150. package/dist/modes/rpc/session-held.js +1 -0
  151. package/dist/modes/rpc/session-release.js +16 -1
  152. package/dist/modes/rpc/socket-ownership.d.ts +17 -0
  153. package/dist/modes/rpc/socket-ownership.js +52 -0
  154. package/dist/modes/rpc/tool-child/admission.d.ts +42 -0
  155. package/dist/modes/rpc/tool-child/admission.js +81 -0
  156. package/dist/modes/rpc/tool-child/admission.test.d.ts +2 -0
  157. package/dist/modes/rpc/tool-child/admission.test.js +166 -0
  158. package/dist/modes/rpc/tool-child/codec.d.ts +21 -0
  159. package/dist/modes/rpc/tool-child/codec.js +137 -0
  160. package/dist/modes/rpc/tool-child/codec.test.d.ts +2 -0
  161. package/dist/modes/rpc/tool-child/codec.test.js +234 -0
  162. package/dist/modes/rpc/tool-child/context.d.ts +42 -0
  163. package/dist/modes/rpc/tool-child/context.js +23 -0
  164. package/dist/modes/rpc/tool-child/flow-control.d.ts +24 -0
  165. package/dist/modes/rpc/tool-child/flow-control.js +80 -0
  166. package/dist/modes/rpc/tool-child/payload-ref-paths.d.ts +9 -0
  167. package/dist/modes/rpc/tool-child/payload-ref-paths.js +45 -0
  168. package/dist/modes/rpc/tool-child/payload-ref.d.ts +58 -0
  169. package/dist/modes/rpc/tool-child/payload-ref.js +166 -0
  170. package/dist/modes/rpc/tool-child/payload-ref.test.d.ts +2 -0
  171. package/dist/modes/rpc/tool-child/payload-ref.test.js +254 -0
  172. package/dist/modes/rpc/tool-child/payload-wire.d.ts +13 -0
  173. package/dist/modes/rpc/tool-child/payload-wire.js +22 -0
  174. package/dist/modes/rpc/tool-child/protocol-guard.d.ts +9 -0
  175. package/dist/modes/rpc/tool-child/protocol-guard.js +128 -0
  176. package/dist/modes/rpc/tool-child/protocol.d.ts +192 -0
  177. package/dist/modes/rpc/tool-child/protocol.js +54 -0
  178. package/dist/utils/wsl.d.ts +2 -2
  179. package/dist/utils/wsl.js +4 -4
  180. package/docs/providers.md +2 -0
  181. package/docs/rpc.md +20 -2
  182. package/docs/settings.md +1 -0
  183. package/docs/terminal-setup.md +2 -0
  184. package/package.json +10 -6
  185. package/dist/bundle/chunks/chunk-COJK5FDH.js +0 -2
  186. package/dist/bundle/chunks/chunk-ES3GHZE5.js +0 -395
  187. package/dist/bundle/chunks/chunk-IDXW4FAO.js +0 -2
  188. package/dist/bundle/chunks/chunk-PFNBY43W.js +0 -2
  189. package/dist/bundle/chunks/chunk-SA2ZLWBD.js +0 -11
  190. package/dist/bundle/chunks/openai-codex-responses-5LRKZCBW.js +0 -10
@@ -0,0 +1,220 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { SessionPendingInputJournal } from "./pending-input-journal.js";
3
+ import { createSessionQueuedInputPort } from "./pending-input-session-port.js";
4
+ import { PENDING_INPUT_TRANSITION, PendingInputStore } from "./pending-input-store.js";
5
+ export class ClientMessageIdConflictError extends Error {
6
+ constructor(clientMessageId) {
7
+ super(`Client message id ${clientMessageId} was already used for a different message.`);
8
+ this.code = "client_message_id_conflict";
9
+ this.name = "ClientMessageIdConflictError";
10
+ this.clientMessageId = clientMessageId;
11
+ }
12
+ }
13
+ /**
14
+ * The pending inputs of one AgentSession (senpi#3549): the store, opened on the first identified input, and the
15
+ * agent queue port over it. Input without a client id never touches the store and keeps the legacy queue path.
16
+ */
17
+ export class SessionPendingInputs {
18
+ constructor(options) {
19
+ this.lifetimeId = randomUUID();
20
+ this.attempts = 0;
21
+ /** Bumped synchronously by every clear, so a change can tell a clear it raced from a real divergence. */
22
+ this.clears = 0;
23
+ this.options = options;
24
+ // A session file that already holds pending inputs is recovered now: the agent's first drain must see them.
25
+ const journaled = options.sessionManager
26
+ .getEntries()
27
+ .some((entry) => entry.type === "custom" && entry.customType === PENDING_INPUT_TRANSITION);
28
+ // Opened eagerly, unawaited: every later use awaits this same opening and surfaces its failure, so the
29
+ // eager start only must not report it a second time as an unhandled rejection.
30
+ if (journaled)
31
+ this.store().catch(() => undefined);
32
+ }
33
+ /**
34
+ * How a restored input (from the RPC admission ledger after a reopen) re-enters the native queue: under the
35
+ * store's rebound queue id while it is still queued; not at all once the store retired it (delivered, removed,
36
+ * failed); `undefined` when the store never held it, so the caller restores it as before.
37
+ */
38
+ async restoreHandle(clientMessageId) {
39
+ if (this.storeOpening === undefined)
40
+ return undefined;
41
+ const input = (await this.storeOpening).current(clientMessageId);
42
+ if (input === undefined)
43
+ return undefined;
44
+ return input.phase === "queued" ? { queuedId: input.handle.queuedId } : "retired";
45
+ }
46
+ /**
47
+ * Admits an identified input and, only when it is newly admitted, puts its native copy in the agent queue
48
+ * inside the same gated step, so no drain, edit or removal can run between the two. A repeated submit of the
49
+ * same input is answered from the record (`replayed`) and enqueues nothing.
50
+ */
51
+ async admit(input, enqueue) {
52
+ const store = await this.store();
53
+ return store.transaction(async (tx) => {
54
+ const result = await tx.submit({
55
+ clientMessageId: input.clientMessageId,
56
+ fingerprint: admissionFingerprint(input),
57
+ text: input.text,
58
+ attachments: (input.images ?? []).map(attachmentFingerprint),
59
+ requestedMode: input.mode,
60
+ });
61
+ if (result?.outcome === "client_message_id_conflict")
62
+ throw new ClientMessageIdConflictError(input.clientMessageId);
63
+ const admitted = tx.current(input.clientMessageId);
64
+ if (result?.outcome === "admitted" && admitted !== undefined)
65
+ enqueue(admitted.handle.queuedId);
66
+ return result;
67
+ });
68
+ }
69
+ /**
70
+ * Edits or removes an identified input: the store transition is journaled first, and only when it answers
71
+ * `edited`/`removed` does `applyNative` change the native queue, inside the same gated step, so no drain
72
+ * can see one without the other. Returns undefined when the store never admitted this input.
73
+ */
74
+ async change(request, applyNative) {
75
+ if (this.storeOpening === undefined)
76
+ return undefined;
77
+ const clearsAtStart = this.clears;
78
+ const store = await this.storeOpening;
79
+ return store.transaction(async (tx) => {
80
+ const current = tx.current(request.clientMessageId);
81
+ if (current === undefined)
82
+ return undefined;
83
+ const target = {
84
+ effectId: request.effectId ?? randomUUID(),
85
+ handle: current.handle,
86
+ expectedRevision: request.expectedRevision ?? current.revision,
87
+ };
88
+ const result = request.text === undefined
89
+ ? await tx.apply(request.clientMessageId, { type: "remove", ...target })
90
+ : await tx.apply(request.clientMessageId, {
91
+ type: "edit",
92
+ ...target,
93
+ text: request.text,
94
+ attachments: current.attachments,
95
+ });
96
+ const outcome = legacyOutcome(result?.outcome);
97
+ if (outcome !== "edited" && outcome !== "removed")
98
+ return outcome;
99
+ const native = applyNative();
100
+ // A synchronous clear can empty the queue while the change is being journaled; its own record of the
101
+ // clear is queued behind this step and retires the input, so the change simply found nothing. Any other
102
+ // missing native copy is a real divergence (review L1, L-c).
103
+ if (native === "not_found" && this.clears !== clearsAtStart)
104
+ return "not_found";
105
+ if (native !== outcome)
106
+ throw new PendingInputDivergenceError(request.clientMessageId, outcome, native);
107
+ return outcome;
108
+ });
109
+ }
110
+ /**
111
+ * Records an explicit clear (Stop, `clear_queue`, unload with clearQueue) of identified inputs that just left the
112
+ * native queue: each still-queued one is removed by `stop`, so a reopen never queues it again.
113
+ */
114
+ async recordCleared(clientMessageIds) {
115
+ this.clears += 1;
116
+ if (this.storeOpening === undefined || clientMessageIds.length === 0)
117
+ return;
118
+ const store = await this.storeOpening;
119
+ // One record for the whole clear: it lands or not as a unit, never split around a later entry.
120
+ await store.transaction(async (tx) => {
121
+ const queued = clientMessageIds.filter((clientMessageId) => tx.current(clientMessageId)?.phase === "queued");
122
+ if (queued.length === 0)
123
+ return;
124
+ await tx.applyAll(queued.map((clientMessageId) => ({
125
+ clientMessageId,
126
+ event: { type: "cancel", cause: "stop", consumerFenced: true },
127
+ })));
128
+ });
129
+ }
130
+ /**
131
+ * Pending inputs (queued, sending, failed) and the outcome of each known id, consistent through
132
+ * `throughCursor`: apply it, then only updates with a later cursor. Absence from `inputs` is never an outcome.
133
+ */
134
+ async snapshot(knownClientMessageIds = []) {
135
+ const store = this.storeOpening === undefined ? undefined : await this.storeOpening;
136
+ const inputs = (store?.snapshot() ?? []).filter((input) => input.state !== "delivered" && input.state !== "removed");
137
+ const outcomes = knownClientMessageIds.map((clientMessageId) => {
138
+ const read = store?.read(clientMessageId);
139
+ return read?.status === "admitted"
140
+ ? { clientMessageId, status: "admitted", projection: read.projection }
141
+ : { clientMessageId, status: "not_admitted" };
142
+ });
143
+ return { lifetimeId: this.lifetimeId, throughCursor: store?.throughCursor ?? 0, inputs, outcomes };
144
+ }
145
+ /** The agent's queue port. Before the store opens nothing identified is queued, so it drains natively. */
146
+ queuedInputPort() {
147
+ return {
148
+ claim: async (queue, drain) => (await this.openPort())?.claim(queue, drain) ?? drain(),
149
+ commitConsumption: async (queue, messages) => (await this.openPort())?.commitConsumption(queue, messages),
150
+ release: async (queue, messages, giveBack) => {
151
+ const port = await this.openPort();
152
+ if (port === undefined)
153
+ giveBack.restore();
154
+ else
155
+ await port.release(queue, messages, giveBack);
156
+ },
157
+ };
158
+ }
159
+ store() {
160
+ this.storeOpening ??= PendingInputStore.open({
161
+ journal: this.options.journal ?? new SessionPendingInputJournal(this.options.sessionManager),
162
+ lifetimeId: this.lifetimeId,
163
+ nextQueuedId: this.options.nextQueuedId,
164
+ // The agent port commits consumption before any provider sees an input, so an attempt left `sending` by a
165
+ // crash was never sent and returns to the queue under its id (T28).
166
+ uncommittedAttemptsAreUnsent: true,
167
+ }).then((store) => {
168
+ const onChange = this.options.onChange;
169
+ if (onChange !== undefined)
170
+ store.subscribe((projections, cursor) => onChange({ lifetimeId: this.lifetimeId, cursor, changes: projections }));
171
+ return store;
172
+ });
173
+ return this.storeOpening;
174
+ }
175
+ async openPort() {
176
+ if (this.storeOpening === undefined)
177
+ return undefined;
178
+ const store = await this.storeOpening;
179
+ this.port ??= createSessionQueuedInputPort({
180
+ store,
181
+ ownerId: `${process.pid}:${this.lifetimeId}`,
182
+ writerGeneration: 1,
183
+ nextAttemptId: () => `${this.lifetimeId}:${++this.attempts}`,
184
+ });
185
+ return this.port;
186
+ }
187
+ }
188
+ function admissionFingerprint(input) {
189
+ const images = (input.images ?? []).map(attachmentFingerprint);
190
+ return sha256(JSON.stringify([input.mode, input.text, images]));
191
+ }
192
+ function attachmentFingerprint(image) {
193
+ return `${image.mimeType}:${sha256(image.data)}`;
194
+ }
195
+ function sha256(value) {
196
+ return createHash("sha256").update(value).digest("hex");
197
+ }
198
+ /** The store changed an input that the native queue no longer held: the two views disagree and must not be hidden. */
199
+ export class PendingInputDivergenceError extends Error {
200
+ constructor(clientMessageId, recorded, native) {
201
+ super(`Queued input ${clientMessageId} was ${recorded} in its record but ${native} in the queue.`);
202
+ this.code = "pending_input_divergence";
203
+ this.name = "PendingInputDivergenceError";
204
+ }
205
+ }
206
+ /** `sending` keeps the legacy spelling `already_delivered` (the handoff began); the snapshot says `sending`. */
207
+ function legacyOutcome(outcome) {
208
+ switch (outcome) {
209
+ case "edited":
210
+ case "removed":
211
+ case "conflict":
212
+ case "already_delivered":
213
+ return outcome;
214
+ case "sending":
215
+ return "already_delivered";
216
+ default:
217
+ return "not_found";
218
+ }
219
+ }
220
+ //# sourceMappingURL=pending-input-session.js.map
@@ -7,6 +7,8 @@ export interface PendingInputJournalRecord {
7
7
  readonly cursor: number;
8
8
  readonly input: PendingInput;
9
9
  readonly result?: PendingInputOperationResult;
10
+ /** Further inputs changed in the same step (a consumer's batch), recorded atomically with `input`. */
11
+ readonly batch?: readonly PendingInput[];
10
12
  }
11
13
  export interface PendingInputJournal {
12
14
  append(record: PendingInputJournalRecord): Promise<void>;
@@ -18,13 +20,27 @@ export interface PendingInputStoreOptions {
18
20
  readonly nextQueuedId: () => string;
19
21
  readonly dispatch?: (token: DeliveryToken, input: PendingInput) => void;
20
22
  readonly restoreToDraft?: (text: string) => void;
23
+ /** Passed to every reopen rebind; see the `rebind` event (senpi#3549). */
24
+ readonly uncommittedAttemptsAreUnsent?: boolean;
21
25
  /**
22
26
  * Receives a listener's throw; an owner should always supply it, since the default rethrow is uncaught. A committed transition is not undone by a failing observer, and its dispatch
23
27
  * still runs; by default the error is rethrown on a later microtask so it is never silently lost.
24
28
  */
25
29
  readonly onListenerError?: (error: unknown) => void;
26
30
  }
27
- export type PendingInputListener = (projection: PendingInputProjection) => void;
31
+ /** Called after each committed transition with its projection and the journal cursor it was written at. */
32
+ export type PendingInputListener = (projections: readonly PendingInputProjection[], cursor: number) => void;
33
+ /** Transitions inside one gated step: each is committed directly, since the caller already holds the gate. */
34
+ export interface PendingInputTransaction {
35
+ submit(event: Omit<SubmitEvent, "type" | "handle">): Promise<PendingInputOperationResult | undefined>;
36
+ apply(clientMessageId: string, event: Exclude<PendingInputEvent, SubmitEvent>): Promise<PendingInputOperationResult | undefined>;
37
+ /** Applies every change in one journal record: all of them land or none does. */
38
+ applyAll(changes: readonly {
39
+ readonly clientMessageId: string;
40
+ readonly event: Exclude<PendingInputEvent, SubmitEvent>;
41
+ }[]): Promise<(PendingInputOperationResult | undefined)[]>;
42
+ current(clientMessageId: string): PendingInput | undefined;
43
+ }
28
44
  export type PendingInputRead = {
29
45
  readonly status: "admitted";
30
46
  readonly projection: PendingInputProjection;
@@ -62,11 +78,26 @@ export declare class PendingInputStore {
62
78
  apply(clientMessageId: string, event: Exclude<PendingInputEvent, SubmitEvent>): Promise<PendingInputOperationResult | undefined>;
63
79
  /** Explicit Stop over the inputs the caller saw: each named revision is removed or reported, never guessed. */
64
80
  stop(items: readonly StopItem[], consumerFenced: boolean): Promise<StopReceipt>;
81
+ /**
82
+ * Runs `work` under the gate every command shares, so a consumer can change its native buffer and the
83
+ * journal in one step that no edit, removal or other consumer can interleave with.
84
+ */
85
+ transaction<T>(work: (tx: PendingInputTransaction) => Promise<T>): Promise<T>;
86
+ /** The full record of one input, for the session that owns this store. */
87
+ current(clientMessageId: string): PendingInput | undefined;
65
88
  read(clientMessageId: string): PendingInputRead;
89
+ /** The cursor of the newest committed transition; a snapshot taken now covers everything through it. */
90
+ get throughCursor(): number;
66
91
  snapshot(): readonly PendingInputProjection[];
67
92
  subscribe(listener: PendingInputListener): () => void;
68
93
  private serialize;
94
+ private admit;
69
95
  private commit;
96
+ /**
97
+ * Reduces every change, then records all that changed in ONE journal record, so a crash or a failed write
98
+ * keeps either all of them or none (a consumer committing a batch never sees half of it delivered).
99
+ */
100
+ private commitMany;
70
101
  private reportListenerError;
71
102
  }
72
103
  /** A previous write may have landed without being synced; only a reopen can say what the journal holds. */
@@ -14,25 +14,24 @@ export class PendingInputStore {
14
14
  static async open(options) {
15
15
  const store = new PendingInputStore(options);
16
16
  for (const record of await options.journal.read()) {
17
- store.inputs.set(record.input.clientMessageId, record.input);
17
+ for (const input of [record.input, ...(record.batch ?? [])])
18
+ store.inputs.set(input.clientMessageId, input);
18
19
  store.cursor = Math.max(store.cursor, record.cursor);
19
20
  }
20
21
  for (const input of [...store.inputs.values()]) {
21
22
  if (input.phase === "delivered" || input.phase === "removed")
22
23
  continue;
23
24
  const handle = { lifetimeId: options.lifetimeId, queuedId: options.nextQueuedId() };
24
- await store.commit(input.clientMessageId, { type: "rebind", handle });
25
+ await store.commit(input.clientMessageId, {
26
+ type: "rebind",
27
+ handle,
28
+ attemptsUnsent: options.uncommittedAttemptsAreUnsent === true,
29
+ });
25
30
  }
26
31
  return store;
27
32
  }
28
33
  submit(event) {
29
- return this.serialize(() => {
30
- const handle = this.inputs.get(event.clientMessageId)?.handle ?? {
31
- lifetimeId: this.options.lifetimeId,
32
- queuedId: this.options.nextQueuedId(),
33
- };
34
- return this.commit(event.clientMessageId, { ...event, type: "submit", handle });
35
- });
34
+ return this.serialize(() => this.admit(event));
36
35
  }
37
36
  apply(clientMessageId, event) {
38
37
  return this.serialize(() => this.commit(clientMessageId, event));
@@ -67,12 +66,32 @@ export class PendingInputStore {
67
66
  return { removed, conflicts, retained, stale };
68
67
  });
69
68
  }
69
+ /**
70
+ * Runs `work` under the gate every command shares, so a consumer can change its native buffer and the
71
+ * journal in one step that no edit, removal or other consumer can interleave with.
72
+ */
73
+ transaction(work) {
74
+ return this.serialize(() => work({
75
+ submit: (event) => this.admit(event),
76
+ apply: (clientMessageId, event) => this.commit(clientMessageId, event),
77
+ applyAll: (changes) => this.commitMany(changes),
78
+ current: (clientMessageId) => this.inputs.get(clientMessageId),
79
+ }));
80
+ }
81
+ /** The full record of one input, for the session that owns this store. */
82
+ current(clientMessageId) {
83
+ return this.inputs.get(clientMessageId);
84
+ }
70
85
  read(clientMessageId) {
71
86
  const input = this.inputs.get(clientMessageId);
72
87
  return input === undefined
73
88
  ? { status: "not_admitted", clientMessageId }
74
89
  : { status: "admitted", projection: projectPendingInput(input) };
75
90
  }
91
+ /** The cursor of the newest committed transition; a snapshot taken now covers everything through it. */
92
+ get throughCursor() {
93
+ return this.cursor;
94
+ }
76
95
  snapshot() {
77
96
  return [...this.inputs.values()].map(projectPendingInput);
78
97
  }
@@ -90,18 +109,43 @@ export class PendingInputStore {
90
109
  this.gate = run.catch(() => undefined);
91
110
  return run;
92
111
  }
112
+ admit(event) {
113
+ const handle = this.inputs.get(event.clientMessageId)?.handle ?? {
114
+ lifetimeId: this.options.lifetimeId,
115
+ queuedId: this.options.nextQueuedId(),
116
+ };
117
+ return this.commit(event.clientMessageId, { ...event, type: "submit", handle });
118
+ }
93
119
  async commit(clientMessageId, event) {
94
- const current = this.inputs.get(clientMessageId);
95
- const decision = reducePendingInput(current, event);
96
- const next = decision.next;
97
- const result = decision.result?.clientMessageId === "" ? { ...decision.result, clientMessageId } : decision.result;
98
- if (next === undefined || decision.effects.length === 0)
99
- return result;
120
+ return (await this.commitMany([{ clientMessageId, event }]))[0];
121
+ }
122
+ /**
123
+ * Reduces every change, then records all that changed in ONE journal record, so a crash or a failed write
124
+ * keeps either all of them or none (a consumer committing a batch never sees half of it delivered).
125
+ */
126
+ async commitMany(changes) {
127
+ const results = [];
128
+ const committed = [];
129
+ const staged = new Map();
130
+ for (const { clientMessageId, event } of changes) {
131
+ const current = staged.get(clientMessageId) ?? this.inputs.get(clientMessageId);
132
+ const decision = reducePendingInput(current, event);
133
+ const result = decision.result?.clientMessageId === "" ? { ...decision.result, clientMessageId } : decision.result;
134
+ results.push(result);
135
+ if (decision.next === undefined || decision.effects.length === 0)
136
+ continue;
137
+ staged.set(clientMessageId, decision.next);
138
+ committed.push({ next: decision.next, decision: { ...decision, result } });
139
+ }
140
+ const [first, ...rest] = committed;
141
+ if (first === undefined)
142
+ return results;
100
143
  const record = {
101
144
  type: PENDING_INPUT_TRANSITION,
102
145
  cursor: this.cursor + 1,
103
- input: next,
104
- ...(result === undefined ? {} : { result }),
146
+ input: first.next,
147
+ ...(first.decision.result === undefined ? {} : { result: first.decision.result }),
148
+ ...(rest.length === 0 ? {} : { batch: rest.map((entry) => entry.next) }),
105
149
  };
106
150
  try {
107
151
  await this.options.journal.append(record);
@@ -114,23 +158,27 @@ export class PendingInputStore {
114
158
  throw error;
115
159
  }
116
160
  this.cursor = record.cursor;
117
- this.inputs.set(clientMessageId, next);
118
- const projection = projectPendingInput(next);
161
+ for (const { next } of committed)
162
+ this.inputs.set(next.clientMessageId, next);
163
+ // One notification per record: every input it changed, under its one cursor.
164
+ const projections = committed.map(({ next }) => projectPendingInput(next));
119
165
  for (const listener of this.listeners) {
120
166
  try {
121
- listener(projection);
167
+ listener(projections, record.cursor);
122
168
  }
123
169
  catch (error) {
124
170
  this.reportListenerError(error);
125
171
  }
126
172
  }
127
- for (const effect of decision.effects) {
128
- if (effect.type === "dispatch")
129
- this.options.dispatch?.(effect.token, next);
130
- if (effect.type === "restore_to_draft")
131
- this.options.restoreToDraft?.(effect.text);
173
+ for (const { next, decision } of committed) {
174
+ for (const effect of decision.effects) {
175
+ if (effect.type === "dispatch")
176
+ this.options.dispatch?.(effect.token, next);
177
+ if (effect.type === "restore_to_draft")
178
+ this.options.restoreToDraft?.(effect.text);
179
+ }
132
180
  }
133
- return result;
181
+ return results;
134
182
  }
135
183
  reportListenerError(error) {
136
184
  const report = this.options.onListenerError;
@@ -34,6 +34,8 @@ export interface SupersededVersion {
34
34
  export interface PendingInputFailure {
35
35
  readonly delivery: DeliveryCertainty;
36
36
  readonly reason: string;
37
+ /** The reservation that failed, kept so later evidence must name the attempt it is about. */
38
+ readonly attempt?: DeliveryToken;
37
39
  }
38
40
  /** The recorded answer of one operation, replayed byte for byte when the same effect id repeats. */
39
41
  export interface RecordedOperation {
@@ -94,12 +96,22 @@ export type PendingInputEvent = SubmitEvent | {
94
96
  } | {
95
97
  readonly type: "reserve";
96
98
  readonly token: DeliveryToken;
99
+ }
100
+ /** The consumer gave the attempt back before any of it was committed or sent: queued again, same id. */
101
+ | {
102
+ readonly type: "release";
103
+ readonly token: DeliveryToken;
97
104
  } | {
98
105
  readonly type: "consumed";
99
106
  readonly token: DeliveryToken;
107
+ }
108
+ /** A handled-command receipt: with the reservation's token, or naming the lifetime of a still-queued input. */
109
+ | {
110
+ readonly type: "handled";
111
+ readonly token: DeliveryToken;
100
112
  } | {
101
113
  readonly type: "handled";
102
- readonly token?: DeliveryToken;
114
+ readonly lifetimeId: string;
103
115
  } | {
104
116
  readonly type: "refused";
105
117
  readonly token: DeliveryToken;
@@ -109,13 +121,15 @@ export type PendingInputEvent = SubmitEvent | {
109
121
  readonly token: DeliveryToken;
110
122
  readonly reason: string;
111
123
  }
112
- /** An identity-bearing delivery witness found after the fact (a late event or recovery). */
124
+ /** An identity-bearing delivery witness found after the fact (a late event or recovery), for one failed attempt. */
113
125
  | {
114
126
  readonly type: "witnessed";
127
+ readonly attemptId: string;
115
128
  }
116
- /** Complete, fenced evidence that no consumer can still deliver the attempt. */
129
+ /** Complete, fenced evidence that no consumer can still deliver the named failed attempt. */
117
130
  | {
118
131
  readonly type: "non_delivery_proven";
132
+ readonly attemptId: string;
119
133
  }
120
134
  /** Teardown that is not an explicit user action: reload, unload, crash, handoff. */
121
135
  | {
@@ -129,9 +143,16 @@ export type PendingInputEvent = SubmitEvent | {
129
143
  readonly type: "cancel";
130
144
  readonly cause: "stop" | "child_cancel";
131
145
  readonly consumerFenced: boolean;
132
- } | {
146
+ }
147
+ /**
148
+ * A reopen. `attemptsUnsent` is the journal owner's proof that an attempt still `sending` was never handed to
149
+ * its consumer: the engine commits consumption before any provider sees the input, so a reservation without a
150
+ * later delivered record returns to the queue under the same id. Without it the attempt stays uncertain.
151
+ */
152
+ | {
133
153
  readonly type: "rebind";
134
154
  readonly handle: PendingInputHandle;
155
+ readonly attemptsUnsent?: boolean;
135
156
  };
136
157
  export type OperationOutcome = "admitted" | "replayed" | "client_message_id_conflict" | "edited" | "removed" | "requeued" | "already_delivered" | "not_found" | "conflict" | "sending" | "not_retryable" | "not_editable" | "unresolved" | "effect_id_conflict" | "stale_handle";
137
158
  export interface PendingInputOperationResult {
@@ -54,15 +54,21 @@ function reduceLifecycle(current, event) {
54
54
  };
55
55
  return { next, effects: [{ type: "persist" }, { type: "dispatch", token: event.token }] };
56
56
  }
57
+ case "release": {
58
+ if (!holdsAttempt(current, event.token))
59
+ return unchanged(current);
60
+ const { attempt: _attempt, ...rest } = current;
61
+ return persist({ ...rest, phase: "queued" });
62
+ }
57
63
  case "consumed":
58
64
  return holdsAttempt(current, event.token) ? persist(deliver(current, "consumed")) : unchanged(current);
59
65
  case "handled":
60
- if (current.phase === "queued")
61
- return persist(deliver(current, "handled"));
62
- if (event.token !== undefined && holdsAttempt(current, event.token)) {
63
- return persist(deliver(current, "handled"));
64
- }
65
- return unchanged(current);
66
+ if ("token" in event)
67
+ return holdsAttempt(current, event.token) ? persist(deliver(current, "handled")) : unchanged(current);
68
+ // Without a token the receipt must name the lifetime of the still-queued input it settles.
69
+ return current.phase === "queued" && event.lifetimeId === current.handle.lifetimeId
70
+ ? persist(deliver(current, "handled"))
71
+ : unchanged(current);
66
72
  case "refused":
67
73
  return holdsAttempt(current, event.token)
68
74
  ? persist(fail(current, "not_delivered", event.reason))
@@ -76,12 +82,10 @@ function reduceLifecycle(current, event) {
76
82
  ? persist(fail(current, "uncertain", "the delivering owner was lost"))
77
83
  : unchanged(current);
78
84
  case "witnessed":
79
- return current.phase === "failed" && current.failure?.delivery === "uncertain"
80
- ? persist(deliver(current, "consumed"))
81
- : unchanged(current);
85
+ return uncertainAbout(current, event.attemptId) ? persist(deliver(current, "consumed")) : unchanged(current);
82
86
  case "non_delivery_proven":
83
- return current.phase === "failed" && current.failure?.delivery === "uncertain"
84
- ? persist(fail(current, "not_delivered", current.failure.reason))
87
+ return uncertainAbout(current, event.attemptId)
88
+ ? persist(fail(current, "not_delivered", current.failure?.reason ?? ""))
85
89
  : unchanged(current);
86
90
  case "teardown":
87
91
  return current.phase === "sending"
@@ -90,7 +94,7 @@ function reduceLifecycle(current, event) {
90
94
  case "cancel":
91
95
  return cancel(current, event.cause, event.consumerFenced);
92
96
  case "rebind":
93
- return rebind(current, event.handle);
97
+ return rebind(current, event.handle, event.attemptsUnsent === true);
94
98
  }
95
99
  }
96
100
  function cancel(current, cause, consumerFenced) {
@@ -108,13 +112,16 @@ function cancel(current, cause, consumerFenced) {
108
112
  : [{ type: "persist" }],
109
113
  };
110
114
  }
111
- function rebind(current, handle) {
115
+ function rebind(current, handle, attemptsUnsent) {
112
116
  if (current.phase === "delivered" || current.phase === "removed")
113
117
  return unchanged(current);
114
118
  const rebound = { ...current, handle, revision: 1 };
115
- return current.phase === "sending"
116
- ? persist(fail(rebound, "uncertain", "the session reopened during handoff"))
117
- : persist(rebound);
119
+ if (current.phase !== "sending")
120
+ return persist(rebound);
121
+ if (!attemptsUnsent)
122
+ return persist(fail(rebound, "uncertain", "the session reopened during handoff"));
123
+ const { attempt: _attempt, ...unsent } = rebound;
124
+ return persist({ ...unsent, phase: "queued" });
118
125
  }
119
126
  function tokenFitsInput(input, token) {
120
127
  return token.lifetimeId === input.handle.lifetimeId && token.clientMessageId === input.clientMessageId;
@@ -132,8 +139,19 @@ function deliver(input, deliveredBy) {
132
139
  return settled({ ...input, phase: "delivered", ...(deliveredBy === undefined ? {} : { deliveredBy }) });
133
140
  }
134
141
  function fail(input, delivery, reason) {
135
- const { attempt: _attempt, ...rest } = input;
136
- return { ...rest, phase: "failed", failure: { delivery, reason } };
142
+ const { attempt, ...rest } = input;
143
+ const failedAttempt = attempt ?? input.failure?.attempt;
144
+ return {
145
+ ...rest,
146
+ phase: "failed",
147
+ failure: { delivery, reason, ...(failedAttempt === undefined ? {} : { attempt: failedAttempt }) },
148
+ };
149
+ }
150
+ /** Late evidence only settles the uncertain failure of the exact attempt it names. */
151
+ function uncertainAbout(input, attemptId) {
152
+ return (input.phase === "failed" &&
153
+ input.failure?.delivery === "uncertain" &&
154
+ input.failure.attempt?.attemptId === attemptId);
137
155
  }
138
156
  function settled(input) {
139
157
  const { attempt: _attempt, failure: _failure, ...rest } = input;
@@ -134,6 +134,7 @@ export function terminateFinalSessionLine(filePath) {
134
134
  return;
135
135
  const segment = Buffer.alloc(size - segmentStart);
136
136
  readSync(fd, segment, 0, segment.length, segmentStart);
137
+ // Not synced on purpose: a lost truncate only brings back an uncommitted tail, and the next append syncs.
137
138
  if (parseSessionEntryLine(segment.toString("utf8")) === null)
138
139
  ftruncateSync(fd, segmentStart);
139
140
  else
@@ -7,7 +7,7 @@ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
7
7
  const MAX_STRING_LENGTH = 200;
8
8
  const DEBUG_PREFIX = `[${APP_NAME}-session]`;
9
9
  const BLOCKED_KEY = /^(?:__proto__|constructor|prototype|headers?|env(?:ironment)?|authorization|credential(?:s)?|password|secret|token|api_?key|client_?secret)$/i;
10
- const ALLOWED_DATA_KEY = /^(?:action|attemptId|disposition|stage|error|mode|count|willRetry|deferAdmission|delivered|restored|cause|accepted|skipped|rejectionCause|reason|durationMs|kind|retryable|phase|op|bytes|generation|requestId|tokens|tokensBefore|tokensAfter|contextWindow|attempt|aborted|sessionId|role|provider|model|source|actor|from|to|duringTurn|persistDefault)$/;
10
+ const ALLOWED_DATA_KEY = /^(?:action|attemptId|disposition|stage|error|mode|count|willRetry|deferAdmission|delivered|restored|cause|accepted|skipped|rejectionCause|reason|durationMs|kind|retryable|phase|op|bytes|generation|requestId|tokens|tokensBefore|tokensAfter|contextWindow|attempt|aborted|sessionId|role|provider|model|source|actor|from|to|duringTurn|persistDefault|extension|handlerEvent)$/;
11
11
  const SENSITIVE_TEXT = /((?:authorization\s*[:=]\s*(?:bearer|basic)\s+)|(?:bearer\s+)|(?:[?&](?:api[_-]?key|token|secret|password|auth(?:orization)?)=))[^\s&,"'}\]]+/gi;
12
12
  export function createSessionLogger(agentDir, options = {}) {
13
13
  if (typeof agentDir !== "string" || agentDir.length === 0) {
@@ -310,6 +310,7 @@ export declare class SessionManager {
310
310
  private sessionDir;
311
311
  private cwd;
312
312
  private persist;
313
+ private readonly writeGuard;
313
314
  private flushed;
314
315
  private headerWrite;
315
316
  private ownedHeaderFile;