@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
@@ -119,6 +119,14 @@ export function rpcCommandPayloadError(command) {
119
119
  !(typeof value.effectId === "string" && value.effectId.length > 0 && value.effectId.length <= 256)) {
120
120
  return "effectId must be a non-empty string of at most 256 characters.";
121
121
  }
122
+ if (value.type === "get_pending_inputs" && value.knownClientMessageIds !== undefined) {
123
+ const ids = value.knownClientMessageIds;
124
+ if (!Array.isArray(ids) ||
125
+ ids.length > 64 ||
126
+ !ids.every((id) => typeof id === "string" && id.length > 0 && id.length <= 256)) {
127
+ return "knownClientMessageIds must be an array of at most 64 non-empty strings of at most 256 characters.";
128
+ }
129
+ }
122
130
  if (value.type === "get_effect_status" && value.sessionPath !== undefined && typeof value.sessionPath !== "string") {
123
131
  return "sessionPath must be a string.";
124
132
  }
@@ -19,6 +19,7 @@ import type { EngineOrdinal } from "../../core/engine-build-identity.ts";
19
19
  import type { ServiceTier } from "../../core/extensions/builtin/service-tier.ts";
20
20
  import type { ContextUsage, SessionControlAdmission, SessionKind } from "../../core/extensions/types.ts";
21
21
  import type { ResolvedMessagePath } from "../../core/message-path-resolution.ts";
22
+ import type { PendingInputSnapshot, PendingInputUpdate } from "../../core/pending-input-session.ts";
22
23
  import type { SessionEntry, SessionMessageEntry, SessionTreeNode, UsageTotals } from "../../core/session-manager.ts";
23
24
  import type { SourceInfo } from "../../core/source-info.ts";
24
25
  import type { ApiKeyVerification } from "../../core/verify-api-key.ts";
@@ -165,6 +166,16 @@ type RpcSessionCommand = {
165
166
  type: "get_effect_status";
166
167
  effectId: string;
167
168
  sessionPath?: string;
169
+ }
170
+ /**
171
+ * The pending inputs in durable custody (queued, sending, failed) and an explicit outcome for each listed id,
172
+ * consistent through `throughCursor` (senpi#3549). Subscribe to `pending_input_update` first, then apply this
173
+ * snapshot and only updates after its cursor. At most 64 ids.
174
+ */
175
+ | {
176
+ id?: string;
177
+ type: "get_pending_inputs";
178
+ knownClientMessageIds?: string[];
168
179
  } | {
169
180
  id?: string;
170
181
  type: "set_model";
@@ -689,7 +700,10 @@ export type RpcCommand = (RpcSessionCommand & {
689
700
  */
690
701
  type: "release_session";
691
702
  sessionId: string;
692
- reason: "takeover";
703
+ /** `move` seals the file for a move to another device (`moveId` required); writes are refused here after. */
704
+ reason: "takeover" | "move";
705
+ moveId?: string;
706
+ target?: string;
693
707
  interrupt?: boolean;
694
708
  force?: boolean;
695
709
  } | {
@@ -1202,6 +1216,12 @@ export type RpcResponse = {
1202
1216
  found: false;
1203
1217
  searched: "complete";
1204
1218
  };
1219
+ } | {
1220
+ id?: string;
1221
+ type: "response";
1222
+ command: "get_pending_inputs";
1223
+ success: true;
1224
+ data: PendingInputSnapshot;
1205
1225
  } | {
1206
1226
  id?: string;
1207
1227
  type: "response";
@@ -1824,6 +1844,10 @@ export interface RpcHighReasoningWarningEvent {
1824
1844
  provider: string;
1825
1845
  thinkingLevel: ThinkingLevel;
1826
1846
  }
1847
+ /** A pending input in durable custody changed; `changes` are complete projections, in cursor order. */
1848
+ export type RpcPendingInputUpdateEvent = {
1849
+ type: "pending_input_update";
1850
+ } & PendingInputUpdate;
1827
1851
  /** Emitted after explicit skill tokens are expanded for a user-authored request. */
1828
1852
  export interface RpcSkillInvocationEvent {
1829
1853
  type: "skill_invocation";
@@ -2,6 +2,7 @@
2
2
  const observations = new Set([
3
3
  "get_state",
4
4
  "get_effect_status",
5
+ "get_pending_inputs",
5
6
  "get_messages",
6
7
  "get_entries",
7
8
  "get_tree",
@@ -6,15 +6,24 @@
6
6
  import { modelSupportsAssistantPrefill } from "@earendil-works/pi-ai";
7
7
  import { getSupportedThinkingLevels } from "../../core/thinking-levels.js";
8
8
  export async function availableModelsData(session) {
9
- const models = await session.modelRegistry.modelRuntime.getAvailable();
9
+ const registry = session.modelRegistry;
10
+ const models = await registry.modelRuntime.getAvailable();
10
11
  return {
11
- models: models.map((model) => ({
12
- ...model,
13
- supportedThinkingLevels: getSupportedThinkingLevels(model),
14
- supportsAssistantPrefill: modelSupportsAssistantPrefill(model, {
15
- thinkingEnabled: session.thinkingLevel !== "off",
16
- }),
17
- })),
12
+ models: models.map((model) => {
13
+ // A models.json or extension definition can declare the priority pairing without putting it on
14
+ // the Model object; clients must see the values the session itself acts on (#3666).
15
+ const serviceTier = registry.getServiceTier(model);
16
+ const upstreamModelId = registry.getUpstreamModelId(model);
17
+ return {
18
+ ...model,
19
+ ...(serviceTier === undefined ? {} : { serviceTier }),
20
+ ...(upstreamModelId === undefined ? {} : { upstreamModelId }),
21
+ supportedThinkingLevels: getSupportedThinkingLevels(model),
22
+ supportsAssistantPrefill: modelSupportsAssistantPrefill(model, {
23
+ thinkingEnabled: session.thinkingLevel !== "off",
24
+ }),
25
+ };
26
+ }),
18
27
  };
19
28
  }
20
29
  /** The refusal of a level the active model cannot run; callers match this text. */
@@ -9,6 +9,7 @@ import { RpcSessionRegistryError } from "./session-registry-types.js";
9
9
  const READ_ONLY_OR_CONTROL = new Set([
10
10
  "get_state",
11
11
  "get_effect_status",
12
+ "get_pending_inputs",
12
13
  "get_steering_messages",
13
14
  "get_follow_up_messages",
14
15
  "get_available_models",
@@ -1,3 +1,4 @@
1
+ import { SessionMovedAwayError } from "../../core/session-move/ownership.js";
1
2
  import { isHandoffBusy } from "./handoff-activity.js";
2
3
  import { RPC_ERROR_ATTACHED, RPC_ERROR_HOST_DRAINING, RPC_ERROR_INVALID_RELEASE_REASON, RPC_ERROR_RELEASE_FAILED, RPC_ERROR_RELEASE_UNSUPPORTED, RPC_ERROR_SESSION_BUSY, RPC_ERROR_SESSION_CLOSING, RPC_ERROR_TURN_ACTIVE, } from "./rpc-types.js";
3
4
  import { abortAndSettle, NOTHING_DROPPED, takeQueuedInput, } from "./session-release-interrupt.js";
@@ -8,7 +9,8 @@ export const RELEASED_ADMISSION_CLOSED = "release_session handed this session to
8
9
  /** `errorData.hint` of a refusal whose `busy` names `queued`: only an interrupt hands that input back. */
9
10
  export const RELEASE_QUEUED_HINT = "queued user input is still owed a turn; release with interrupt: true to take it out and receive it in dropped.user_messages";
10
11
  export async function releaseSession(port, command) {
11
- if (command.reason !== "takeover")
12
+ const validMove = command.reason === "move" && typeof command.moveId === "string" && command.moveId !== "";
13
+ if (command.reason !== "takeover" && !validMove)
12
14
  return refusal(command.id, RPC_ERROR_INVALID_RELEASE_REASON);
13
15
  if (port.draining())
14
16
  return refusal(command.id, RPC_ERROR_HOST_DRAINING);
@@ -30,6 +32,8 @@ export async function releaseSession(port, command) {
30
32
  admission.close(RELEASED_ADMISSION_CLOSED);
31
33
  dropped = takeQueuedInput(first.session);
32
34
  await abortAndSettle(port, command.sessionId, first.session);
35
+ // What the clear retired is on disk before `session_released` seals the file (senpi#3549).
36
+ await first.session.whenQueueClearRecorded();
33
37
  }
34
38
  // A never-written session gets its file here, before the final check: its `session_released` entry
35
39
  // must reach disk, and a header write that fails is this release's failure, never a stray rejection.
@@ -41,6 +45,10 @@ export async function releaseSession(port, command) {
41
45
  return answer;
42
46
  }
43
47
  catch (cause) {
48
+ if (cause instanceof SessionMovedAwayError) {
49
+ answer = refusal(command.id, cause.code, { detail: cause.message });
50
+ return answer;
51
+ }
44
52
  answer = refusal(command.id, RPC_ERROR_RELEASE_FAILED, {
45
53
  detail: cause instanceof Error ? cause.message : String(cause),
46
54
  ...(interrupted ? { interrupted: true, dropped } : {}),
@@ -78,6 +86,13 @@ async function claimAndRelease(port, command, interrupted, dropped) {
78
86
  // On disk or not at all: a failed write leaves no entry a later append could chain onto.
79
87
  ready.session.sessionManager.appendCustomEntry(SESSION_RELEASED_ENTRY_TYPE, {
80
88
  reason: command.reason,
89
+ ...(command.reason === "move"
90
+ ? {
91
+ move_id: command.moveId,
92
+ target: command.target ?? "",
93
+ session_id: ready.session.sessionManager.getSessionId(),
94
+ }
95
+ : {}),
81
96
  interrupted,
82
97
  attachments: ready.attachments,
83
98
  host_instance: port.hostInstance ?? null,
@@ -0,0 +1,42 @@
1
+ import { type ToolChildMessageOf } from "./protocol.ts";
2
+ /**
3
+ * The one host-call capacity both sides enforce: `HOST_CALL_ACTIVE` running plus `HOST_CALL_WAITING` queued (264).
4
+ * The child admits against it before sending, the server admits against it on receipt; there is no second constant.
5
+ */
6
+ export declare function hostCallCapacity(): number;
7
+ export type HostCallRefusalCode = "host_call_capacity" | "host_call_duplicate";
8
+ export type HostCallDecision = {
9
+ state: "active";
10
+ } | {
11
+ state: "waiting";
12
+ } | {
13
+ state: "refused";
14
+ frame: ToolChildMessageOf<"host_call_refused">;
15
+ };
16
+ /** Lossless host-call admission: a call is running, queued FIFO, or refused with a frame naming its `callId`. */
17
+ export declare class HostCallAdmission {
18
+ #private;
19
+ get active(): number;
20
+ get waiting(): number;
21
+ submit(callId: string): HostCallDecision;
22
+ /** Ends a call (or withdraws a queued one) and returns the call promoted into the freed active slot, if any. */
23
+ settle(callId: string): string | undefined;
24
+ }
25
+ export type JobRefusalCode = "tool_child_jobs_full" | "tool_child_nested_full" | "tool_child_parent_unknown" | "tool_child_job_duplicate";
26
+ export type JobDecision = {
27
+ admitted: true;
28
+ } | {
29
+ admitted: false;
30
+ frame: ToolChildMessageOf<"job_refused">;
31
+ };
32
+ /**
33
+ * Per-child job budget. Ordinary jobs are capped at `TOOL_CHILD_LIMITS.jobs`. A nested job (started on behalf of a
34
+ * running job, e.g. a tool an eval cell calls) is admitted past that cap, so a full child cannot deadlock its own
35
+ * callers; nested jobs arrive through host calls and are bounded by `hostCallCapacity()`.
36
+ */
37
+ export declare class JobAdmission {
38
+ #private;
39
+ admit(jobId: string, parentJobId?: string): JobDecision;
40
+ finish(jobId: string): void;
41
+ }
42
+ //# sourceMappingURL=admission.d.ts.map
@@ -0,0 +1,81 @@
1
+ import { TOOL_CHILD_LIMITS } from "./protocol.js";
2
+ /**
3
+ * The one host-call capacity both sides enforce: `HOST_CALL_ACTIVE` running plus `HOST_CALL_WAITING` queued (264).
4
+ * The child admits against it before sending, the server admits against it on receipt; there is no second constant.
5
+ */
6
+ export function hostCallCapacity() {
7
+ return TOOL_CHILD_LIMITS.HOST_CALL_ACTIVE + TOOL_CHILD_LIMITS.HOST_CALL_WAITING;
8
+ }
9
+ /** Lossless host-call admission: a call is running, queued FIFO, or refused with a frame naming its `callId`. */
10
+ export class HostCallAdmission {
11
+ #active = new Set();
12
+ #waiting = [];
13
+ get active() {
14
+ return this.#active.size;
15
+ }
16
+ get waiting() {
17
+ return this.#waiting.length;
18
+ }
19
+ submit(callId) {
20
+ if (this.#active.has(callId) || this.#waiting.includes(callId))
21
+ return refuse(callId, "host_call_duplicate");
22
+ if (this.#active.size < TOOL_CHILD_LIMITS.HOST_CALL_ACTIVE && this.#waiting.length === 0) {
23
+ this.#active.add(callId);
24
+ return { state: "active" };
25
+ }
26
+ if (this.#active.size + this.#waiting.length >= hostCallCapacity())
27
+ return refuse(callId, "host_call_capacity");
28
+ this.#waiting.push(callId);
29
+ return { state: "waiting" };
30
+ }
31
+ /** Ends a call (or withdraws a queued one) and returns the call promoted into the freed active slot, if any. */
32
+ settle(callId) {
33
+ if (!this.#active.delete(callId)) {
34
+ const index = this.#waiting.indexOf(callId);
35
+ if (index >= 0)
36
+ this.#waiting.splice(index, 1);
37
+ return undefined;
38
+ }
39
+ const next = this.#waiting.shift();
40
+ if (next !== undefined)
41
+ this.#active.add(next);
42
+ return next;
43
+ }
44
+ }
45
+ function refuse(callId, code) {
46
+ return { state: "refused", frame: { type: "host_call_refused", callId, code } };
47
+ }
48
+ /**
49
+ * Per-child job budget. Ordinary jobs are capped at `TOOL_CHILD_LIMITS.jobs`. A nested job (started on behalf of a
50
+ * running job, e.g. a tool an eval cell calls) is admitted past that cap, so a full child cannot deadlock its own
51
+ * callers; nested jobs arrive through host calls and are bounded by `hostCallCapacity()`.
52
+ */
53
+ export class JobAdmission {
54
+ #ordinary = new Set();
55
+ #nested = new Map();
56
+ admit(jobId, parentJobId) {
57
+ if (this.#ordinary.has(jobId) || this.#nested.has(jobId))
58
+ return refuseJob(jobId, "tool_child_job_duplicate");
59
+ if (parentJobId === undefined) {
60
+ if (this.#ordinary.size >= TOOL_CHILD_LIMITS.jobs)
61
+ return refuseJob(jobId, "tool_child_jobs_full");
62
+ this.#ordinary.add(jobId);
63
+ return { admitted: true };
64
+ }
65
+ if (!this.#ordinary.has(parentJobId) && !this.#nested.has(parentJobId)) {
66
+ return refuseJob(jobId, "tool_child_parent_unknown");
67
+ }
68
+ if (this.#nested.size >= hostCallCapacity())
69
+ return refuseJob(jobId, "tool_child_nested_full");
70
+ this.#nested.set(jobId, parentJobId);
71
+ return { admitted: true };
72
+ }
73
+ finish(jobId) {
74
+ this.#ordinary.delete(jobId);
75
+ this.#nested.delete(jobId);
76
+ }
77
+ }
78
+ function refuseJob(jobId, code) {
79
+ return { admitted: false, frame: { type: "job_refused", jobId, code } };
80
+ }
81
+ //# sourceMappingURL=admission.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=admission.test.d.ts.map
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { HostCallAdmission, hostCallCapacity, JobAdmission } from "./admission.js";
3
+ import { encodeFrame, FrameDecoder } from "./codec.js";
4
+ import { isLossy, TraceBudget, WakeAdmission, WakeAdmissionError } from "./flow-control.js";
5
+ import { LOSSY_FRAME_TYPES, TOOL_CHILD_LIMITS } from "./protocol.js";
6
+ import { isToolChildMessage } from "./protocol-guard.js";
7
+ const callId = (index) => `call-${index}`;
8
+ function submitMany(admission, count) {
9
+ const states = { active: 0, waiting: 0, refused: [] };
10
+ for (let index = 1; index <= count; index += 1) {
11
+ const decision = admission.submit(callId(index));
12
+ if (decision.state === "refused")
13
+ states.refused.push(decision.frame.callId);
14
+ else
15
+ states[decision.state] += 1;
16
+ }
17
+ return states;
18
+ }
19
+ /** One loop turn: enough for an already-resolved promise to settle, never a timed wait. */
20
+ const turn = () => new Promise((resolve) => setImmediate(resolve));
21
+ function tracked(promise) {
22
+ const state = { settled: false };
23
+ promise.then(() => {
24
+ state.settled = true;
25
+ });
26
+ return state;
27
+ }
28
+ describe("host-call admission", () => {
29
+ it("has one capacity: 8 active + 256 waiting = 264", () => {
30
+ expect(hostCallCapacity()).toBe(264);
31
+ expect(hostCallCapacity()).toBe(TOOL_CHILD_LIMITS.HOST_CALL_ACTIVE + TOOL_CHILD_LIMITS.HOST_CALL_WAITING);
32
+ });
33
+ it("264 concurrent submissions -> 8 active, 256 waiting, 0 refused", () => {
34
+ const admission = new HostCallAdmission();
35
+ expect(submitMany(admission, 264)).toEqual({ active: 8, waiting: 256, refused: [] });
36
+ expect(admission.active).toBe(8);
37
+ expect(admission.waiting).toBe(256);
38
+ });
39
+ it("265 -> exactly the 265th is refused with a correlated host_call_refused frame", () => {
40
+ const admission = new HostCallAdmission();
41
+ expect(submitMany(admission, 265).refused).toEqual(["call-265"]);
42
+ const decision = new HostCallAdmission().submit("x");
43
+ expect(decision.state).toBe("active");
44
+ const full = new HostCallAdmission();
45
+ submitMany(full, 264);
46
+ const refused = full.submit("call-265");
47
+ if (refused.state !== "refused")
48
+ throw new Error("expected a refusal");
49
+ expect(refused.frame).toEqual({ type: "host_call_refused", callId: "call-265", code: "host_call_capacity" });
50
+ expect(isToolChildMessage(refused.frame)).toBe(true);
51
+ expect(new FrameDecoder().push(encodeFrame(refused.frame))).toEqual([refused.frame]);
52
+ });
53
+ it("promotes waiting calls in FIFO order", () => {
54
+ const admission = new HostCallAdmission();
55
+ submitMany(admission, 264);
56
+ const promoted = [];
57
+ for (let index = 1; index <= 264; index += 1)
58
+ promoted.push(admission.settle(callId(index)));
59
+ expect(promoted.slice(0, 256)).toEqual(Array.from({ length: 256 }, (_, offset) => callId(offset + 9)));
60
+ expect(promoted.slice(256)).toEqual(Array(8).fill(undefined));
61
+ expect(admission.active + admission.waiting).toBe(0);
62
+ });
63
+ it("child and server share the capacity: the child never forwards a call the server would refuse", () => {
64
+ const child = new HostCallAdmission();
65
+ const server = new HostCallAdmission();
66
+ const refusedByServer = [];
67
+ for (let index = 1; index <= 300; index += 1) {
68
+ if (child.submit(callId(index)).state === "refused")
69
+ continue;
70
+ const decision = server.submit(callId(index));
71
+ if (decision.state === "refused")
72
+ refusedByServer.push(decision.frame.callId);
73
+ }
74
+ expect(refusedByServer).toEqual([]);
75
+ expect(server.active + server.waiting).toBe(hostCallCapacity());
76
+ });
77
+ it("refuses a duplicate call id", () => {
78
+ const admission = new HostCallAdmission();
79
+ admission.submit("same");
80
+ const duplicate = admission.submit("same");
81
+ expect(duplicate.state === "refused" && duplicate.frame.code).toBe("host_call_duplicate");
82
+ });
83
+ });
84
+ describe("job admission", () => {
85
+ it("refuses the 33rd ordinary job while a nested job is still admitted", () => {
86
+ const jobs = new JobAdmission();
87
+ for (let index = 1; index <= 32; index += 1)
88
+ expect(jobs.admit(`job-${index}`)).toEqual({ admitted: true });
89
+ expect(jobs.admit("job-33")).toEqual({
90
+ admitted: false,
91
+ frame: { type: "job_refused", jobId: "job-33", code: "tool_child_jobs_full" },
92
+ });
93
+ expect(jobs.admit("nested-1", "job-1")).toEqual({ admitted: true });
94
+ expect(jobs.admit("nested-2", "missing")).toEqual({
95
+ admitted: false,
96
+ frame: { type: "job_refused", jobId: "nested-2", code: "tool_child_parent_unknown" },
97
+ });
98
+ jobs.finish("job-2");
99
+ expect(jobs.admit("job-33")).toEqual({ admitted: true });
100
+ });
101
+ it("bounds nested jobs by the host-call capacity, not by a second constant", () => {
102
+ const jobs = new JobAdmission();
103
+ jobs.admit("parent");
104
+ for (let index = 1; index <= hostCallCapacity(); index += 1) {
105
+ expect(jobs.admit(`nested-${index}`, "parent")).toEqual({ admitted: true });
106
+ }
107
+ const over = jobs.admit("nested-over", "parent");
108
+ expect(over.admitted === false && over.frame.code).toBe("tool_child_nested_full");
109
+ });
110
+ });
111
+ describe("wake flow control", () => {
112
+ it("the 65th pending wake waits for wake_ack and is never dropped", async () => {
113
+ const wakes = new WakeAdmission();
114
+ for (let index = 1; index <= 64; index += 1)
115
+ await wakes.send(`w-${index}`, 10);
116
+ const sixtyFifth = tracked(wakes.send("w-65", 10));
117
+ const sixtySixth = tracked(wakes.send("w-66", 10));
118
+ await turn();
119
+ expect(sixtyFifth.settled).toBe(false);
120
+ expect(wakes.blocked).toBe(2);
121
+ expect(wakes.pending).toBe(64);
122
+ wakes.ack("w-1");
123
+ await turn();
124
+ expect(sixtyFifth.settled).toBe(true);
125
+ expect(sixtySixth.settled).toBe(false);
126
+ wakes.ack("w-2");
127
+ await turn();
128
+ expect(sixtySixth.settled).toBe(true);
129
+ expect(wakes.pending).toBe(64);
130
+ expect(wakes.blocked).toBe(0);
131
+ });
132
+ it("bounds pending wakes by summed bytes and refuses one larger than the whole budget", async () => {
133
+ const wakes = new WakeAdmission();
134
+ const part = 600 * 1024;
135
+ await wakes.send("a", part);
136
+ const second = tracked(wakes.send("b", part));
137
+ await turn();
138
+ expect(second.settled).toBe(false);
139
+ wakes.ack("a");
140
+ await turn();
141
+ expect(second.settled).toBe(true);
142
+ await expect(wakes.send("huge", TOOL_CHILD_LIMITS.wakePendingBytes + 1)).rejects.toBeInstanceOf(WakeAdmissionError);
143
+ });
144
+ });
145
+ describe("trace budget", () => {
146
+ it("drops and counts the 1,025th trace frame in one second, then admits again in the next", () => {
147
+ const budget = new TraceBudget();
148
+ const frame = { type: "trace", atMs: 0, event: "tick" };
149
+ const admitted = Array.from({ length: 1025 }, () => budget.admit(frame, 5_000));
150
+ expect(admitted.filter(Boolean)).toHaveLength(1024);
151
+ expect(admitted.at(-1)).toBe(false);
152
+ expect(budget.dropped).toBe(1);
153
+ expect(budget.admit(frame, 6_000)).toBe(true);
154
+ expect(budget.dropped).toBe(1);
155
+ });
156
+ it("trace is the only lossy class; results, proofs and ticks are never lossy", () => {
157
+ expect([...LOSSY_FRAME_TYPES]).toEqual(["trace"]);
158
+ expect(isLossy({ type: "trace", atMs: 1, event: "e" })).toBe(true);
159
+ expect(isLossy({ type: "launch_proof", launchId: "l", ackTick: 1, invokeTick: 2 })).toBe(false);
160
+ expect(isLossy({ type: "result", jobId: "j", value: 1 })).toBe(false);
161
+ expect(isLossy({ type: "slot_state", launchId: "l", ackTick: 1, invokeTick: 2, invocations: 1 })).toBe(false);
162
+ expect(isToolChildMessage({ type: "trace", atMs: 1, event: "e", detail: { invokeTick: 2 } })).toBe(false);
163
+ expect(isToolChildMessage({ type: "trace", atMs: 1, event: "e", result: 1 })).toBe(false);
164
+ });
165
+ });
166
+ //# sourceMappingURL=admission.test.js.map
@@ -0,0 +1,21 @@
1
+ import { type ToolChildMessage } from "./protocol.ts";
2
+ export type ToolChildCodecErrorCode = "tool_child_frame_too_large" | "tool_child_truncated" | "tool_child_malformed" | "tool_child_encode_invalid";
3
+ export declare class ToolChildCodecError extends Error {
4
+ readonly code: ToolChildCodecErrorCode;
5
+ constructor(code: ToolChildCodecErrorCode, message: string);
6
+ }
7
+ /** A frame is a 4-byte big-endian body length followed by that many bytes of UTF-8 JSON. No delimiter, no NDJSON. */
8
+ export declare const FRAME_HEADER_BYTES = 4;
9
+ export declare function encodeFrame(message: ToolChildMessage): Uint8Array;
10
+ /**
11
+ * Incremental decoder for one channel direction. A length header above the cap is refused as soon as its four bytes
12
+ * arrive, before any body buffer exists. Any codec error is terminal: the stream is never resynchronized, so nothing
13
+ * after a bad frame can be silently skipped.
14
+ */
15
+ export declare class FrameDecoder {
16
+ #private;
17
+ get bufferedBytes(): number;
18
+ push(chunk: Uint8Array): ToolChildMessage[];
19
+ end(): void;
20
+ }
21
+ //# sourceMappingURL=codec.d.ts.map
@@ -0,0 +1,137 @@
1
+ import { TOOL_CHILD_LIMITS } from "./protocol.js";
2
+ import { isToolChildMessage } from "./protocol-guard.js";
3
+ export class ToolChildCodecError extends Error {
4
+ constructor(code, message) {
5
+ super(`${code}: ${message}`);
6
+ this.name = "ToolChildCodecError";
7
+ this.code = code;
8
+ }
9
+ }
10
+ /** A frame is a 4-byte big-endian body length followed by that many bytes of UTF-8 JSON. No delimiter, no NDJSON. */
11
+ export const FRAME_HEADER_BYTES = 4;
12
+ const tooLarge = (bytes) => new ToolChildCodecError("tool_child_frame_too_large", `${bytes} bytes exceeds ${TOOL_CHILD_LIMITS.frameBytes}`);
13
+ export function encodeFrame(message) {
14
+ // JSON.stringify drops undefined-valued properties (what optional fields rely on) and natively serializes
15
+ // NaN/Infinity as null on BOTH the inline and the ref path, so payload contents encode identically either way.
16
+ // The one case JSON cannot represent is a REQUIRED json field that is undefined (today only result.value - a
17
+ // tool returning undefined); it becomes an explicit null the guard takes. The round-trip with the decoder's own
18
+ // predicate refuses anything else unsendable HERE - a typed per-message error, never a channel-killing frame
19
+ // (senpi#3569 delta review: optional undefined must keep stringify's default drop).
20
+ const normalized = message.type === "result" && message.value === undefined ? { ...message, value: null } : message;
21
+ const json = JSON.stringify(normalized);
22
+ const bodyBytes = Buffer.byteLength(json, "utf8");
23
+ if (bodyBytes > TOOL_CHILD_LIMITS.frameBytes)
24
+ throw tooLarge(bodyBytes);
25
+ if (!isToolChildMessage(JSON.parse(json))) {
26
+ throw new ToolChildCodecError("tool_child_encode_invalid", "the message serializes to a frame the peer's guard rejects");
27
+ }
28
+ return frameFromBody(json, bodyBytes);
29
+ }
30
+ function frameFromBody(json, bodyBytes) {
31
+ const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + bodyBytes);
32
+ frame.writeUInt32BE(bodyBytes, 0);
33
+ frame.write(json, FRAME_HEADER_BYTES, "utf8");
34
+ return frame;
35
+ }
36
+ function parseBody(body) {
37
+ let value;
38
+ try {
39
+ value = JSON.parse(body.toString("utf8"));
40
+ }
41
+ catch {
42
+ throw new ToolChildCodecError("tool_child_malformed", "frame body is not JSON");
43
+ }
44
+ if (!isToolChildMessage(value))
45
+ throw new ToolChildCodecError("tool_child_malformed", "frame is not a protocol message");
46
+ return value;
47
+ }
48
+ /**
49
+ * Incremental decoder for one channel direction. A length header above the cap is refused as soon as its four bytes
50
+ * arrive, before any body buffer exists. Any codec error is terminal: the stream is never resynchronized, so nothing
51
+ * after a bad frame can be silently skipped.
52
+ */
53
+ export class FrameDecoder {
54
+ #chunks = [];
55
+ #buffered = 0;
56
+ #bodyBytes;
57
+ #failure;
58
+ get bufferedBytes() {
59
+ return this.#buffered;
60
+ }
61
+ push(chunk) {
62
+ if (this.#failure)
63
+ throw this.#failure;
64
+ if (chunk.length > 0) {
65
+ this.#chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
66
+ this.#buffered += chunk.length;
67
+ }
68
+ const messages = [];
69
+ try {
70
+ for (;;) {
71
+ if (this.#bodyBytes === undefined) {
72
+ if (this.#buffered < FRAME_HEADER_BYTES)
73
+ break;
74
+ const length = this.#take(FRAME_HEADER_BYTES).readUInt32BE(0);
75
+ if (length > TOOL_CHILD_LIMITS.frameBytes)
76
+ throw tooLarge(length);
77
+ this.#bodyBytes = length;
78
+ }
79
+ if (this.#buffered < this.#bodyBytes)
80
+ break;
81
+ const body = this.#take(this.#bodyBytes);
82
+ this.#bodyBytes = undefined;
83
+ messages.push(parseBody(body));
84
+ }
85
+ }
86
+ catch (error) {
87
+ if (error instanceof ToolChildCodecError)
88
+ this.#fail(error);
89
+ throw error;
90
+ }
91
+ return messages;
92
+ }
93
+ end() {
94
+ if (this.#failure)
95
+ throw this.#failure;
96
+ if (this.#buffered === 0 && this.#bodyBytes === undefined)
97
+ return;
98
+ const truncated = new ToolChildCodecError("tool_child_truncated", `stream ended inside a frame (${this.#buffered} of ${this.#bodyBytes ?? FRAME_HEADER_BYTES} bytes)`);
99
+ this.#fail(truncated);
100
+ throw truncated;
101
+ }
102
+ #fail(error) {
103
+ this.#failure = error;
104
+ this.#chunks = [];
105
+ this.#buffered = 0;
106
+ this.#bodyBytes = undefined;
107
+ }
108
+ #take(bytes) {
109
+ const first = this.#chunks[0];
110
+ if (first !== undefined && first.length >= bytes) {
111
+ const taken = first.subarray(0, bytes);
112
+ if (first.length === bytes)
113
+ this.#chunks.shift();
114
+ else
115
+ this.#chunks[0] = first.subarray(bytes);
116
+ this.#buffered -= bytes;
117
+ return taken;
118
+ }
119
+ const out = Buffer.allocUnsafe(bytes);
120
+ let filled = 0;
121
+ while (filled < bytes) {
122
+ const chunk = this.#chunks[0];
123
+ if (chunk === undefined)
124
+ break;
125
+ const count = Math.min(chunk.length, bytes - filled);
126
+ chunk.copy(out, filled, 0, count);
127
+ filled += count;
128
+ if (count === chunk.length)
129
+ this.#chunks.shift();
130
+ else
131
+ this.#chunks[0] = chunk.subarray(count);
132
+ }
133
+ this.#buffered -= bytes;
134
+ return out;
135
+ }
136
+ }
137
+ //# sourceMappingURL=codec.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=codec.test.d.ts.map