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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/bundle/chunks/{app-server-command-XSPV6BRF.js → app-server-command-HDTNYVSW.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-PLUQUK3T.js → chunk-CUBEOOBJ.js} +15 -15
  4. package/dist/bundle/chunks/{chunk-ESZHT7IE.js → chunk-IMTGM3RJ.js} +1 -1
  5. package/dist/bundle/chunks/chunk-OQGI752B.js +2 -0
  6. package/dist/bundle/chunks/{chunk-Z56RXBL6.js → chunk-QDR3GME3.js} +2 -2
  7. package/dist/bundle/chunks/{chunk-ZPO5FWHM.js → chunk-QILSJ5PQ.js} +2 -2
  8. package/dist/bundle/chunks/chunk-V6IBT2QS.js +8 -0
  9. package/dist/bundle/chunks/{chunk-IGKWKAMN.js → chunk-XHTF6VKZ.js} +4 -3
  10. package/dist/bundle/chunks/{cli-main-L7WQP5V6.js → cli-main-IPY5CCRP.js} +1 -1
  11. package/dist/bundle/chunks/{help-fast-path-O6MBSNMK.js → help-fast-path-LFGHCIWO.js} +1 -1
  12. package/dist/bundle/chunks/{host-lifecycle-AI7NH6ZO.js → host-lifecycle-YPVGQSGS.js} +1 -1
  13. package/dist/bundle/chunks/{interactive-host-runtime-DKJKH5DU.js → interactive-host-runtime-KZLF3AZD.js} +1 -1
  14. package/dist/bundle/chunks/{interactive-mode-FUW5LNKL.js → interactive-mode-HFE7ROP2.js} +1 -1
  15. package/dist/bundle/chunks/multi-session-host-VMVADJFM.js +20 -0
  16. package/dist/bundle/chunks/{package-manager-cli-J26DB6GM.js → package-manager-cli-AX4WQCCG.js} +1 -1
  17. package/dist/bundle/chunks/rotation-stream-FSZVBG2T.js +2 -0
  18. package/dist/bundle/chunks/{rpc-mode-A3U63XX7.js → rpc-mode-PDYLQ5BZ.js} +1 -1
  19. package/dist/bundle/chunks/session-worker.js +71 -68
  20. package/dist/bundle/cli.js +1 -1
  21. package/dist/bundle/index.js +1 -1
  22. package/dist/bundle/rpc-entry.js +1 -1
  23. package/dist/cli/args.d.ts +19 -1
  24. package/dist/cli/args.d.ts.map +1 -1
  25. package/dist/cli/args.js +27 -1
  26. package/dist/cli/args.js.map +1 -1
  27. package/dist/core/agent-session-runtime.d.ts +14 -1
  28. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  29. package/dist/core/agent-session-runtime.js.map +1 -1
  30. package/dist/core/auth-storage.js +2 -2
  31. package/dist/core/auth-storage.js.map +1 -1
  32. package/dist/core/credential-pool/rotation-stream.d.ts.map +1 -1
  33. package/dist/core/credential-pool/rotation-stream.js +10 -5
  34. package/dist/core/credential-pool/rotation-stream.js.map +1 -1
  35. package/dist/core/extensions/builtin/mcp/index.d.ts.map +1 -1
  36. package/dist/core/extensions/builtin/mcp/index.js +11 -0
  37. package/dist/core/extensions/builtin/mcp/index.js.map +1 -1
  38. package/dist/core/extensions/builtin/mcp/service.d.ts +9 -0
  39. package/dist/core/extensions/builtin/mcp/service.d.ts.map +1 -1
  40. package/dist/core/extensions/builtin/mcp/service.js +14 -1
  41. package/dist/core/extensions/builtin/mcp/service.js.map +1 -1
  42. package/dist/core/extensions/builtin/mcp/startup-race.d.ts +20 -0
  43. package/dist/core/extensions/builtin/mcp/startup-race.d.ts.map +1 -1
  44. package/dist/core/extensions/builtin/mcp/startup-race.js +38 -1
  45. package/dist/core/extensions/builtin/mcp/startup-race.js.map +1 -1
  46. package/dist/core/extensions/index.d.ts +2 -2
  47. package/dist/core/extensions/index.d.ts.map +1 -1
  48. package/dist/core/extensions/index.js +1 -1
  49. package/dist/core/extensions/index.js.map +1 -1
  50. package/dist/core/extensions/loader.d.ts +9 -4
  51. package/dist/core/extensions/loader.d.ts.map +1 -1
  52. package/dist/core/extensions/loader.js +20 -9
  53. package/dist/core/extensions/loader.js.map +1 -1
  54. package/dist/core/extensions/types.d.ts +37 -0
  55. package/dist/core/extensions/types.d.ts.map +1 -1
  56. package/dist/core/extensions/types.js +8 -0
  57. package/dist/core/extensions/types.js.map +1 -1
  58. package/dist/core/footer-data-provider.d.ts +2 -1
  59. package/dist/core/footer-data-provider.d.ts.map +1 -1
  60. package/dist/core/footer-data-provider.js +21 -35
  61. package/dist/core/footer-data-provider.js.map +1 -1
  62. package/dist/core/model-registry.d.ts.map +1 -1
  63. package/dist/core/model-registry.js +3 -2
  64. package/dist/core/model-registry.js.map +1 -1
  65. package/dist/core/model-runtime.d.ts +4 -2
  66. package/dist/core/model-runtime.d.ts.map +1 -1
  67. package/dist/core/model-runtime.js +9 -5
  68. package/dist/core/model-runtime.js.map +1 -1
  69. package/dist/core/provider-api-key-auth.js +3 -3
  70. package/dist/core/provider-api-key-auth.js.map +1 -1
  71. package/dist/core/provider-composer.d.ts +9 -3
  72. package/dist/core/provider-composer.d.ts.map +1 -1
  73. package/dist/core/provider-composer.js +7 -4
  74. package/dist/core/provider-composer.js.map +1 -1
  75. package/dist/core/resolve-config-command.d.ts +10 -0
  76. package/dist/core/resolve-config-command.d.ts.map +1 -0
  77. package/dist/core/resolve-config-command.js +106 -0
  78. package/dist/core/resolve-config-command.js.map +1 -0
  79. package/dist/core/resolve-config-value.d.ts +4 -8
  80. package/dist/core/resolve-config-value.d.ts.map +1 -1
  81. package/dist/core/resolve-config-value.js +16 -105
  82. package/dist/core/resolve-config-value.js.map +1 -1
  83. package/dist/core/resource-loader.d.ts +7 -1
  84. package/dist/core/resource-loader.d.ts.map +1 -1
  85. package/dist/core/resource-loader.js +13 -9
  86. package/dist/core/resource-loader.js.map +1 -1
  87. package/dist/main.d.ts +5 -3
  88. package/dist/main.d.ts.map +1 -1
  89. package/dist/main.js +25 -11
  90. package/dist/main.js.map +1 -1
  91. package/dist/modes/rpc/child-reaper-syscalls.d.ts +18 -0
  92. package/dist/modes/rpc/child-reaper-syscalls.d.ts.map +1 -0
  93. package/dist/modes/rpc/child-reaper-syscalls.js +136 -0
  94. package/dist/modes/rpc/child-reaper-syscalls.js.map +1 -0
  95. package/dist/modes/rpc/child-reaper.d.ts +56 -0
  96. package/dist/modes/rpc/child-reaper.d.ts.map +1 -0
  97. package/dist/modes/rpc/child-reaper.js +140 -0
  98. package/dist/modes/rpc/child-reaper.js.map +1 -0
  99. package/dist/modes/rpc/custom-capability.d.ts +16 -0
  100. package/dist/modes/rpc/custom-capability.d.ts.map +1 -1
  101. package/dist/modes/rpc/custom-capability.js +16 -0
  102. package/dist/modes/rpc/custom-capability.js.map +1 -1
  103. package/dist/modes/rpc/host-memory-sampler.d.ts +48 -0
  104. package/dist/modes/rpc/host-memory-sampler.d.ts.map +1 -0
  105. package/dist/modes/rpc/host-memory-sampler.js +72 -0
  106. package/dist/modes/rpc/host-memory-sampler.js.map +1 -0
  107. package/dist/modes/rpc/loop-lag-watchdog.d.ts +51 -0
  108. package/dist/modes/rpc/loop-lag-watchdog.d.ts.map +1 -0
  109. package/dist/modes/rpc/loop-lag-watchdog.js +78 -0
  110. package/dist/modes/rpc/loop-lag-watchdog.js.map +1 -0
  111. package/dist/modes/rpc/multi-session-host.d.ts.map +1 -1
  112. package/dist/modes/rpc/multi-session-host.js +34 -0
  113. package/dist/modes/rpc/multi-session-host.js.map +1 -1
  114. package/dist/modes/rpc/rpc-client.d.ts +4 -2
  115. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  116. package/dist/modes/rpc/rpc-client.js +2 -1
  117. package/dist/modes/rpc/rpc-client.js.map +1 -1
  118. package/dist/modes/rpc/rpc-input-validation.d.ts +28 -0
  119. package/dist/modes/rpc/rpc-input-validation.d.ts.map +1 -1
  120. package/dist/modes/rpc/rpc-input-validation.js +63 -0
  121. package/dist/modes/rpc/rpc-input-validation.js.map +1 -1
  122. package/dist/modes/rpc/rpc-mode.d.ts +18 -5
  123. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  124. package/dist/modes/rpc/rpc-mode.js +18 -5
  125. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  126. package/dist/modes/rpc/rpc-types.d.ts +79 -2
  127. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  128. package/dist/modes/rpc/rpc-types.js +6 -0
  129. package/dist/modes/rpc/rpc-types.js.map +1 -1
  130. package/dist/modes/rpc/session-attribution.d.ts +57 -0
  131. package/dist/modes/rpc/session-attribution.d.ts.map +1 -0
  132. package/dist/modes/rpc/session-attribution.js +132 -0
  133. package/dist/modes/rpc/session-attribution.js.map +1 -0
  134. package/dist/modes/rpc/session-binding.d.ts.map +1 -1
  135. package/dist/modes/rpc/session-binding.js +19 -8
  136. package/dist/modes/rpc/session-binding.js.map +1 -1
  137. package/dist/modes/rpc/session-command-router.d.ts +26 -3
  138. package/dist/modes/rpc/session-command-router.d.ts.map +1 -1
  139. package/dist/modes/rpc/session-command-router.js +95 -19
  140. package/dist/modes/rpc/session-command-router.js.map +1 -1
  141. package/dist/modes/rpc/session-event-fanout.d.ts +2 -0
  142. package/dist/modes/rpc/session-event-fanout.d.ts.map +1 -1
  143. package/dist/modes/rpc/session-event-fanout.js +6 -0
  144. package/dist/modes/rpc/session-event-fanout.js.map +1 -1
  145. package/dist/modes/rpc/session-event-writer.d.ts +29 -0
  146. package/dist/modes/rpc/session-event-writer.d.ts.map +1 -1
  147. package/dist/modes/rpc/session-event-writer.js +57 -0
  148. package/dist/modes/rpc/session-event-writer.js.map +1 -1
  149. package/dist/modes/rpc/session-registry.d.ts +42 -9
  150. package/dist/modes/rpc/session-registry.d.ts.map +1 -1
  151. package/dist/modes/rpc/session-registry.js +49 -3
  152. package/dist/modes/rpc/session-registry.js.map +1 -1
  153. package/dist/modes/rpc/worker-session-registry.d.ts +2 -10
  154. package/dist/modes/rpc/worker-session-registry.d.ts.map +1 -1
  155. package/dist/modes/rpc/worker-session-registry.js +6 -2
  156. package/dist/modes/rpc/worker-session-registry.js.map +1 -1
  157. package/docs/rpc.md +126 -20
  158. package/node_modules/@code-yeongyu/senpi-codemode/CHANGELOG.md +12 -0
  159. package/node_modules/@code-yeongyu/senpi-codemode/package.json +4 -4
  160. package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/javascript.wasm +0 -0
  161. package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/provenance.json +22 -0
  162. package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/web-tree-sitter.wasm +0 -0
  163. package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
  164. package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
  165. package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  166. package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
  167. package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai-coding-cn.json +1 -1
  168. package/node_modules/@earendil-works/pi-ai/package.json +2 -2
  169. package/node_modules/@earendil-works/pi-pty/package.json +1 -1
  170. package/node_modules/@earendil-works/pi-telemetry/package.json +1 -1
  171. package/node_modules/@earendil-works/pi-tui/package.json +1 -1
  172. package/package.json +7 -7
  173. package/dist/bundle/chunks/chunk-3Q5PD4KW.js +0 -8
  174. package/dist/bundle/chunks/chunk-YLYLKL7X.js +0 -2
  175. package/dist/bundle/chunks/multi-session-host-SMNZXFPT.js +0 -18
  176. package/dist/bundle/chunks/rotation-stream-SCVFIQ2O.js +0 -2
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which session - and which of its tools - the host event loop is working for.
3
+ *
4
+ * Every in-process session shares one loop, so a stall observed by the loop-lag
5
+ * watchdog has to be attributed to the work that was running, not to the host at
6
+ * large. Attribution is carried in `AsyncLocalStorage` (so anything an attributed
7
+ * command starts can read it) and mirrored in a process-local activity registry the
8
+ * watchdog can sample from a timer callback, where the async context of the blocked
9
+ * work is long gone.
10
+ */
11
+ export interface SessionAttribution {
12
+ /** Routing handle of the session this work belongs to. */
13
+ readonly sessionId?: string;
14
+ /** Tool currently executing for that session. */
15
+ readonly tool?: string;
16
+ }
17
+ /**
18
+ * Run `task` attributed to `attribution`: its async chain reads the attribution from
19
+ * `AsyncLocalStorage`, and the work stays blamable for as long as it is IN FLIGHT.
20
+ *
21
+ * The whole lifetime matters, not just the first synchronous segment: a routed command
22
+ * almost never blocks the loop before its first await - an extension request, a tool, a
23
+ * turn all block in a later continuation, by which time a synchronous-only registration
24
+ * has already been retired (observed on a live host: `no attributed session` for a
25
+ * 1.7 s stall caused by one session's extension request).
26
+ */
27
+ export declare function runWithSessionAttribution<T>(attribution: SessionAttribution, task: () => T): T;
28
+ /**
29
+ * Open-ended attribution for work that outlives its dispatch, i.e. a tool executing
30
+ * inside a turn. The span inherits the ambient session id when the caller does not
31
+ * name one, and stays blamable until the returned disposer runs.
32
+ */
33
+ export declare function openSessionAttributionSpan(attribution: SessionAttribution): () => void;
34
+ /** Current activity counter; the watchdog samples it once per tick. */
35
+ export declare function sessionActivityMark(): number;
36
+ /**
37
+ * The activity to blame for work that ran after `mark`. A synchronous body that
38
+ * finished inside the window wins (it is the code that just held the loop); otherwise
39
+ * the newest open span - a tool mid-execution - takes the blame. Neither means the
40
+ * stall belongs to the host itself, and the record is emitted without a session.
41
+ */
42
+ export declare function sessionActivitySince(mark: number): SessionAttribution | undefined;
43
+ /** Tool spans of one session, driven by the records its runtime emits. */
44
+ export interface ToolAttributionSpans {
45
+ /** Inspect one session record; tool execution records open and close spans. */
46
+ observe(record: object): void;
47
+ /** Close every span of this session (turn settled, or the binding went away). */
48
+ closeAll(): void;
49
+ }
50
+ /**
51
+ * Track which tool a session is executing, from the record stream its runtime already
52
+ * produces. In-process sessions run their tools ON the host loop, so this is where a
53
+ * blocking tool becomes attributable; worker sessions block their own isolate and
54
+ * deliberately have no spans here.
55
+ */
56
+ export declare function createToolAttributionSpans(sessionId: string): ToolAttributionSpans;
57
+ //# sourceMappingURL=session-attribution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-attribution.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/session-attribution.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,MAAM,WAAW,kBAAkB;IAClC,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,iDAAiD;IACjD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACvB;AAeD;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,WAAW,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CAoB9F;AAMD;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,WAAW,EAAE,kBAAkB,GAAG,MAAM,IAAI,CAYtF;AAED,uEAAuE;AACvE,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS,CAKjF;AAED,0EAA0E;AAC1E,MAAM,WAAW,oBAAoB;IACpC,+EAA+E;IAC/E,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,iFAAiF;IACjF,QAAQ,IAAI,IAAI,CAAC;CACjB;AAWD;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,oBAAoB,CA0BlF"}
@@ -0,0 +1,132 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const attributionStore = new AsyncLocalStorage();
3
+ /**
4
+ * Monotonic activity counter. Ordering is all the watchdog needs - it samples the mark
5
+ * on every tick and asks what ran since - so the registry never reads a clock and stays
6
+ * deterministic under fake timers.
7
+ */
8
+ let sequence = 0;
9
+ /** Attributed work that is still running, keyed by its sequence number (ascending). */
10
+ const openActivities = new Map();
11
+ /** The most recent synchronous activity that has finished. */
12
+ let lastFinished;
13
+ /**
14
+ * Run `task` attributed to `attribution`: its async chain reads the attribution from
15
+ * `AsyncLocalStorage`, and the work stays blamable for as long as it is IN FLIGHT.
16
+ *
17
+ * The whole lifetime matters, not just the first synchronous segment: a routed command
18
+ * almost never blocks the loop before its first await - an extension request, a tool, a
19
+ * turn all block in a later continuation, by which time a synchronous-only registration
20
+ * has already been retired (observed on a live host: `no attributed session` for a
21
+ * 1.7 s stall caused by one session's extension request).
22
+ */
23
+ export function runWithSessionAttribution(attribution, task) {
24
+ const mark = ++sequence;
25
+ openActivities.set(mark, attribution);
26
+ const finish = () => {
27
+ if (!openActivities.delete(mark))
28
+ return;
29
+ lastFinished = { sequence: ++sequence, attribution };
30
+ };
31
+ let awaited = false;
32
+ try {
33
+ const result = attributionStore.run(attribution, task);
34
+ if (isPromiseLike(result)) {
35
+ awaited = true;
36
+ // Settlement of a COPY: the caller still receives (and must handle) the
37
+ // original rejection - this branch only ends the attribution window.
38
+ void Promise.resolve(result).then(finish, finish);
39
+ }
40
+ return result;
41
+ }
42
+ finally {
43
+ if (!awaited)
44
+ finish();
45
+ }
46
+ }
47
+ function isPromiseLike(value) {
48
+ return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
49
+ }
50
+ /**
51
+ * Open-ended attribution for work that outlives its dispatch, i.e. a tool executing
52
+ * inside a turn. The span inherits the ambient session id when the caller does not
53
+ * name one, and stays blamable until the returned disposer runs.
54
+ */
55
+ export function openSessionAttributionSpan(attribution) {
56
+ const ambient = attributionStore.getStore();
57
+ const merged = {
58
+ sessionId: attribution.sessionId ?? ambient?.sessionId,
59
+ tool: attribution.tool,
60
+ };
61
+ const mark = ++sequence;
62
+ openActivities.set(mark, merged);
63
+ return () => {
64
+ if (!openActivities.delete(mark))
65
+ return;
66
+ lastFinished = { sequence: ++sequence, attribution: merged };
67
+ };
68
+ }
69
+ /** Current activity counter; the watchdog samples it once per tick. */
70
+ export function sessionActivityMark() {
71
+ return sequence;
72
+ }
73
+ /**
74
+ * The activity to blame for work that ran after `mark`. A synchronous body that
75
+ * finished inside the window wins (it is the code that just held the loop); otherwise
76
+ * the newest open span - a tool mid-execution - takes the blame. Neither means the
77
+ * stall belongs to the host itself, and the record is emitted without a session.
78
+ */
79
+ export function sessionActivitySince(mark) {
80
+ if (lastFinished !== undefined && lastFinished.sequence > mark)
81
+ return lastFinished.attribution;
82
+ let newest;
83
+ for (const attribution of openActivities.values())
84
+ newest = attribution;
85
+ return newest;
86
+ }
87
+ function toolRecord(record) {
88
+ if (!("type" in record) || typeof record.type !== "string")
89
+ return undefined;
90
+ return {
91
+ type: record.type,
92
+ toolCallId: "toolCallId" in record && typeof record.toolCallId === "string" ? record.toolCallId : undefined,
93
+ toolName: "toolName" in record && typeof record.toolName === "string" ? record.toolName : undefined,
94
+ };
95
+ }
96
+ /**
97
+ * Track which tool a session is executing, from the record stream its runtime already
98
+ * produces. In-process sessions run their tools ON the host loop, so this is where a
99
+ * blocking tool becomes attributable; worker sessions block their own isolate and
100
+ * deliberately have no spans here.
101
+ */
102
+ export function createToolAttributionSpans(sessionId) {
103
+ const spans = new Map();
104
+ const closeAll = () => {
105
+ for (const close of spans.values())
106
+ close();
107
+ spans.clear();
108
+ };
109
+ return {
110
+ closeAll,
111
+ observe: (record) => {
112
+ const parsed = toolRecord(record);
113
+ if (parsed === undefined)
114
+ return;
115
+ if (parsed.type === "tool_execution_start" && parsed.toolCallId !== undefined) {
116
+ spans.get(parsed.toolCallId)?.();
117
+ spans.set(parsed.toolCallId, openSessionAttributionSpan({ sessionId, tool: parsed.toolName }));
118
+ return;
119
+ }
120
+ if (parsed.type === "tool_execution_end" && parsed.toolCallId !== undefined) {
121
+ spans.get(parsed.toolCallId)?.();
122
+ spans.delete(parsed.toolCallId);
123
+ return;
124
+ }
125
+ // A settled turn cannot still be inside a tool: release whatever is left, so a
126
+ // tool whose end record never arrived cannot keep taking the blame forever.
127
+ if (parsed.type === "agent_settled" || parsed.type === "agent_idle")
128
+ closeAll();
129
+ },
130
+ };
131
+ }
132
+ //# sourceMappingURL=session-attribution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-attribution.js","sourceRoot":"","sources":["../../../src/modes/rpc/session-attribution.ts"],"names":[],"mappings":"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","sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\n\n/**\n * Which session - and which of its tools - the host event loop is working for.\n *\n * Every in-process session shares one loop, so a stall observed by the loop-lag\n * watchdog has to be attributed to the work that was running, not to the host at\n * large. Attribution is carried in `AsyncLocalStorage` (so anything an attributed\n * command starts can read it) and mirrored in a process-local activity registry the\n * watchdog can sample from a timer callback, where the async context of the blocked\n * work is long gone.\n */\nexport interface SessionAttribution {\n\t/** Routing handle of the session this work belongs to. */\n\treadonly sessionId?: string;\n\t/** Tool currently executing for that session. */\n\treadonly tool?: string;\n}\n\nconst attributionStore = new AsyncLocalStorage<SessionAttribution>();\n\n/**\n * Monotonic activity counter. Ordering is all the watchdog needs - it samples the mark\n * on every tick and asks what ran since - so the registry never reads a clock and stays\n * deterministic under fake timers.\n */\nlet sequence = 0;\n/** Attributed work that is still running, keyed by its sequence number (ascending). */\nconst openActivities = new Map<number, SessionAttribution>();\n/** The most recent synchronous activity that has finished. */\nlet lastFinished: { readonly sequence: number; readonly attribution: SessionAttribution } | undefined;\n\n/**\n * Run `task` attributed to `attribution`: its async chain reads the attribution from\n * `AsyncLocalStorage`, and the work stays blamable for as long as it is IN FLIGHT.\n *\n * The whole lifetime matters, not just the first synchronous segment: a routed command\n * almost never blocks the loop before its first await - an extension request, a tool, a\n * turn all block in a later continuation, by which time a synchronous-only registration\n * has already been retired (observed on a live host: `no attributed session` for a\n * 1.7 s stall caused by one session's extension request).\n */\nexport function runWithSessionAttribution<T>(attribution: SessionAttribution, task: () => T): T {\n\tconst mark = ++sequence;\n\topenActivities.set(mark, attribution);\n\tconst finish = (): void => {\n\t\tif (!openActivities.delete(mark)) return;\n\t\tlastFinished = { sequence: ++sequence, attribution };\n\t};\n\tlet awaited = false;\n\ttry {\n\t\tconst result = attributionStore.run(attribution, task);\n\t\tif (isPromiseLike(result)) {\n\t\t\tawaited = true;\n\t\t\t// Settlement of a COPY: the caller still receives (and must handle) the\n\t\t\t// original rejection - this branch only ends the attribution window.\n\t\t\tvoid Promise.resolve(result).then(finish, finish);\n\t\t}\n\t\treturn result;\n\t} finally {\n\t\tif (!awaited) finish();\n\t}\n}\n\nfunction isPromiseLike(value: unknown): value is PromiseLike<unknown> {\n\treturn typeof value === \"object\" && value !== null && \"then\" in value && typeof value.then === \"function\";\n}\n\n/**\n * Open-ended attribution for work that outlives its dispatch, i.e. a tool executing\n * inside a turn. The span inherits the ambient session id when the caller does not\n * name one, and stays blamable until the returned disposer runs.\n */\nexport function openSessionAttributionSpan(attribution: SessionAttribution): () => void {\n\tconst ambient = attributionStore.getStore();\n\tconst merged: SessionAttribution = {\n\t\tsessionId: attribution.sessionId ?? ambient?.sessionId,\n\t\ttool: attribution.tool,\n\t};\n\tconst mark = ++sequence;\n\topenActivities.set(mark, merged);\n\treturn () => {\n\t\tif (!openActivities.delete(mark)) return;\n\t\tlastFinished = { sequence: ++sequence, attribution: merged };\n\t};\n}\n\n/** Current activity counter; the watchdog samples it once per tick. */\nexport function sessionActivityMark(): number {\n\treturn sequence;\n}\n\n/**\n * The activity to blame for work that ran after `mark`. A synchronous body that\n * finished inside the window wins (it is the code that just held the loop); otherwise\n * the newest open span - a tool mid-execution - takes the blame. Neither means the\n * stall belongs to the host itself, and the record is emitted without a session.\n */\nexport function sessionActivitySince(mark: number): SessionAttribution | undefined {\n\tif (lastFinished !== undefined && lastFinished.sequence > mark) return lastFinished.attribution;\n\tlet newest: SessionAttribution | undefined;\n\tfor (const attribution of openActivities.values()) newest = attribution;\n\treturn newest;\n}\n\n/** Tool spans of one session, driven by the records its runtime emits. */\nexport interface ToolAttributionSpans {\n\t/** Inspect one session record; tool execution records open and close spans. */\n\tobserve(record: object): void;\n\t/** Close every span of this session (turn settled, or the binding went away). */\n\tcloseAll(): void;\n}\n\nfunction toolRecord(record: object): { type: string; toolCallId?: string; toolName?: string } | undefined {\n\tif (!(\"type\" in record) || typeof record.type !== \"string\") return undefined;\n\treturn {\n\t\ttype: record.type,\n\t\ttoolCallId: \"toolCallId\" in record && typeof record.toolCallId === \"string\" ? record.toolCallId : undefined,\n\t\ttoolName: \"toolName\" in record && typeof record.toolName === \"string\" ? record.toolName : undefined,\n\t};\n}\n\n/**\n * Track which tool a session is executing, from the record stream its runtime already\n * produces. In-process sessions run their tools ON the host loop, so this is where a\n * blocking tool becomes attributable; worker sessions block their own isolate and\n * deliberately have no spans here.\n */\nexport function createToolAttributionSpans(sessionId: string): ToolAttributionSpans {\n\tconst spans = new Map<string, () => void>();\n\tconst closeAll = (): void => {\n\t\tfor (const close of spans.values()) close();\n\t\tspans.clear();\n\t};\n\treturn {\n\t\tcloseAll,\n\t\tobserve: (record) => {\n\t\t\tconst parsed = toolRecord(record);\n\t\t\tif (parsed === undefined) return;\n\t\t\tif (parsed.type === \"tool_execution_start\" && parsed.toolCallId !== undefined) {\n\t\t\t\tspans.get(parsed.toolCallId)?.();\n\t\t\t\tspans.set(parsed.toolCallId, openSessionAttributionSpan({ sessionId, tool: parsed.toolName }));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (parsed.type === \"tool_execution_end\" && parsed.toolCallId !== undefined) {\n\t\t\t\tspans.get(parsed.toolCallId)?.();\n\t\t\t\tspans.delete(parsed.toolCallId);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// A settled turn cannot still be inside a tool: release whatever is left, so a\n\t\t\t// tool whose end record never arrived cannot keep taking the blame forever.\n\t\t\tif (parsed.type === \"agent_settled\" || parsed.type === \"agent_idle\") closeAll();\n\t\t},\n\t};\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"session-binding.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/session-binding.ts"],"names":[],"mappings":"AAEA,OAAO,EAGN,KAAK,oBAAoB,EAEzB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IACjC,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,gCAAgC,CAAC,IAAI,IAAI,CAAC;IAC1C,kBAAkB,CAAC,IAAI,IAAI,CAAC;IAC5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAQD;;;GAGG;AACH,wBAAsB,uBAAuB,CAC5C,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,EACtB,MAAM,EAAE,kBAAkB,EAC1B,YAAY,EAAE,MAAM,IAAI,EACxB,OAAO,GAAE,IAAI,CAAC,oBAAoB,EAAE,cAAc,GAAG,aAAa,CAAM,GACtE,OAAO,CAAC,iBAAiB,CAAC,CAyC5B"}
1
+ {"version":3,"file":"session-binding.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/session-binding.ts"],"names":[],"mappings":"AAEA,OAAO,EAGN,KAAK,oBAAoB,EAEzB,MAAM,yBAAyB,CAAC;AAEjC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IACjC,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,gCAAgC,CAAC,IAAI,IAAI,CAAC;IAC1C,kBAAkB,CAAC,IAAI,IAAI,CAAC;IAC5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAC5C,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,EACtB,MAAM,EAAE,kBAAkB,EAC1B,YAAY,EAAE,MAAM,IAAI,EACxB,OAAO,GAAE,IAAI,CAAC,oBAAoB,EAAE,cAAc,GAAG,aAAa,CAAM,GACtE,OAAO,CAAC,iBAAiB,CAAC,CAwD5B"}
@@ -1,11 +1,6 @@
1
1
  import { bindToProviderScope, runWithProviderScope } from "@earendil-works/pi-ai/node/provider-scope";
2
2
  import { createRpcConnectionHandler, } from "./connection-handler.js";
3
- function enqueueRecords(writer, sessionId, chunk) {
4
- for (const line of chunk.split("\n")) {
5
- if (line)
6
- writer.enqueue(sessionId, JSON.parse(line));
7
- }
8
- }
3
+ import { createToolAttributionSpans } from "./session-attribution.js";
9
4
  /**
10
5
  * Creates the extension UI bridge and subscriptions within the entry's provider
11
6
  * scope. The classic handler remains the single source of command semantics.
@@ -15,6 +10,19 @@ export async function createRpcSessionBinding(sessionId, entry, writer, requestC
15
10
  return entry.worker.bind(sessionId, writer, requestClose, options);
16
11
  if (!entry.runtime)
17
12
  throw new Error("Session runtime was not created");
13
+ // An in-process session executes its tools ON the host loop, so the tool this
14
+ // session is inside is what the loop-lag watchdog blames for a stall. The record
15
+ // stream already carries that transition; no extra subscription is needed.
16
+ const toolSpans = createToolAttributionSpans(sessionId);
17
+ const enqueueRecords = (chunk) => {
18
+ for (const line of chunk.split("\n")) {
19
+ if (!line)
20
+ continue;
21
+ const record = JSON.parse(line);
22
+ toolSpans.observe(record);
23
+ writer.enqueue(sessionId, record);
24
+ }
25
+ };
18
26
  // Attachments share one entry, so resolve the host from the live runtime. In
19
27
  // particular, switch_session must use the entry's replacement-aware method
20
28
  // instead of a runtime captured during open_session.
@@ -36,7 +44,7 @@ export async function createRpcSessionBinding(sessionId, entry, writer, requestC
36
44
  });
37
45
  const handler = await runWithProviderScope(entry.scope, async () => {
38
46
  const taggedSink = {
39
- writeRaw: bindToProviderScope((chunk) => enqueueRecords(writer, sessionId, chunk)),
47
+ writeRaw: bindToProviderScope(enqueueRecords),
40
48
  waitForBackpressure: bindToProviderScope(async () => { }),
41
49
  };
42
50
  return createRpcConnectionHandler(runtimeHost, taggedSink, {
@@ -52,7 +60,10 @@ export async function createRpcSessionBinding(sessionId, entry, writer, requestC
52
60
  handle: (command) => runWithProviderScope(entry.scope, () => handler.handleInputLine(JSON.stringify(command))),
53
61
  cancelPendingExtensionUiRequests: () => runWithProviderScope(entry.scope, () => handler.cancelPendingExtensionUiRequests()),
54
62
  rerenderComponents: () => runWithProviderScope(entry.scope, () => handler.rerenderComponents()),
55
- dispose: () => runWithProviderScope(entry.scope, () => handler.dispose()),
63
+ dispose: () => {
64
+ toolSpans.closeAll();
65
+ return runWithProviderScope(entry.scope, () => handler.dispose());
66
+ },
56
67
  };
57
68
  }
58
69
  //# sourceMappingURL=session-binding.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-binding.js","sourceRoot":"","sources":["../../../src/modes/rpc/session-binding.ts"],"names":[],"mappings":"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","sourcesContent":["import { bindToProviderScope, runWithProviderScope } from \"@earendil-works/pi-ai/node/provider-scope\";\nimport type { AgentSessionRuntime } from \"../../core/agent-session-runtime.ts\";\nimport {\n\tcreateRpcConnectionHandler,\n\ttype RpcConnectionHandler,\n\ttype RpcConnectionOptions,\n\ttype RpcConnectionSink,\n} from \"./connection-handler.ts\";\nimport type { SessionEventWriter } from \"./session-event-writer.ts\";\nimport type { RpcSessionEntry } from \"./session-registry.ts\";\n\n/** A session-owned adapter around the classic command and extension-UI wiring. */\nexport interface RpcSessionBinding {\n\thandle(command: object): Promise<void>;\n\tcancelPendingExtensionUiRequests?(): void;\n\trerenderComponents?(): void;\n\tdispose(): Promise<void>;\n}\n\nfunction enqueueRecords(writer: SessionEventWriter, sessionId: string, chunk: string): void {\n\tfor (const line of chunk.split(\"\\n\")) {\n\t\tif (line) writer.enqueue(sessionId, JSON.parse(line) as object);\n\t}\n}\n\n/**\n * Creates the extension UI bridge and subscriptions within the entry's provider\n * scope. The classic handler remains the single source of command semantics.\n */\nexport async function createRpcSessionBinding(\n\tsessionId: string,\n\tentry: RpcSessionEntry,\n\twriter: SessionEventWriter,\n\trequestClose: () => void,\n\toptions: Pick<RpcConnectionOptions, \"capabilities\" | \"sharedWidth\"> = {},\n): Promise<RpcSessionBinding> {\n\tif (entry.worker) return entry.worker.bind(sessionId, writer, requestClose, options);\n\tif (!entry.runtime) throw new Error(\"Session runtime was not created\");\n\t// Attachments share one entry, so resolve the host from the live runtime. In\n\t// particular, switch_session must use the entry's replacement-aware method\n\t// instead of a runtime captured during open_session.\n\tconst runtimeHost = new Proxy({} as AgentSessionRuntime, {\n\t\tget(_target, property) {\n\t\t\tif (property === \"switchSession\" && entry.switchSession) return entry.switchSession;\n\t\t\tif (property === \"setRebindSession\")\n\t\t\t\treturn (callback?: Parameters<AgentSessionRuntime[\"setRebindSession\"]>[0]) => {\n\t\t\t\t\tentry.rebindSession = callback;\n\t\t\t\t\tentry.runtime?.setRebindSession(callback);\n\t\t\t\t};\n\t\t\tconst runtime = entry.runtime;\n\t\t\tif (!runtime) throw new Error(\"Session runtime was not created\");\n\t\t\tconst value = Reflect.get(runtime, property, runtime);\n\t\t\treturn typeof value === \"function\" ? value.bind(runtime) : value;\n\t\t},\n\t});\n\tconst handler: RpcConnectionHandler = await runWithProviderScope(entry.scope, async () => {\n\t\tconst taggedSink: RpcConnectionSink = {\n\t\t\twriteRaw: bindToProviderScope((chunk: string) => enqueueRecords(writer, sessionId, chunk)),\n\t\t\twaitForBackpressure: bindToProviderScope(async () => {}),\n\t\t};\n\t\treturn createRpcConnectionHandler(runtimeHost, taggedSink, {\n\t\t\tsessionId,\n\t\t\tshutdownHandler: bindToProviderScope(requestClose),\n\t\t\tdisposeRuntime: false,\n\t\t\teventFlushScheduler: (flush) => flush(),\n\t\t\t...options,\n\t\t});\n\t});\n\tawait handler.ready;\n\treturn {\n\t\thandle: (command) => runWithProviderScope(entry.scope, () => handler.handleInputLine(JSON.stringify(command))),\n\t\tcancelPendingExtensionUiRequests: () =>\n\t\t\trunWithProviderScope(entry.scope, () => handler.cancelPendingExtensionUiRequests()),\n\t\trerenderComponents: () => runWithProviderScope(entry.scope, () => handler.rerenderComponents()),\n\t\tdispose: () => runWithProviderScope(entry.scope, () => handler.dispose()),\n\t};\n}\n"]}
1
+ {"version":3,"file":"session-binding.js","sourceRoot":"","sources":["../../../src/modes/rpc/session-binding.ts"],"names":[],"mappings":"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","sourcesContent":["import { bindToProviderScope, runWithProviderScope } from \"@earendil-works/pi-ai/node/provider-scope\";\nimport type { AgentSessionRuntime } from \"../../core/agent-session-runtime.ts\";\nimport {\n\tcreateRpcConnectionHandler,\n\ttype RpcConnectionHandler,\n\ttype RpcConnectionOptions,\n\ttype RpcConnectionSink,\n} from \"./connection-handler.ts\";\nimport { createToolAttributionSpans } from \"./session-attribution.ts\";\nimport type { SessionEventWriter } from \"./session-event-writer.ts\";\nimport type { RpcSessionEntry } from \"./session-registry.ts\";\n\n/** A session-owned adapter around the classic command and extension-UI wiring. */\nexport interface RpcSessionBinding {\n\thandle(command: object): Promise<void>;\n\tcancelPendingExtensionUiRequests?(): void;\n\trerenderComponents?(): void;\n\tdispose(): Promise<void>;\n}\n\n/**\n * Creates the extension UI bridge and subscriptions within the entry's provider\n * scope. The classic handler remains the single source of command semantics.\n */\nexport async function createRpcSessionBinding(\n\tsessionId: string,\n\tentry: RpcSessionEntry,\n\twriter: SessionEventWriter,\n\trequestClose: () => void,\n\toptions: Pick<RpcConnectionOptions, \"capabilities\" | \"sharedWidth\"> = {},\n): Promise<RpcSessionBinding> {\n\tif (entry.worker) return entry.worker.bind(sessionId, writer, requestClose, options);\n\tif (!entry.runtime) throw new Error(\"Session runtime was not created\");\n\t// An in-process session executes its tools ON the host loop, so the tool this\n\t// session is inside is what the loop-lag watchdog blames for a stall. The record\n\t// stream already carries that transition; no extra subscription is needed.\n\tconst toolSpans = createToolAttributionSpans(sessionId);\n\tconst enqueueRecords = (chunk: string): void => {\n\t\tfor (const line of chunk.split(\"\\n\")) {\n\t\t\tif (!line) continue;\n\t\t\tconst record = JSON.parse(line) as object;\n\t\t\ttoolSpans.observe(record);\n\t\t\twriter.enqueue(sessionId, record);\n\t\t}\n\t};\n\t// Attachments share one entry, so resolve the host from the live runtime. In\n\t// particular, switch_session must use the entry's replacement-aware method\n\t// instead of a runtime captured during open_session.\n\tconst runtimeHost = new Proxy({} as AgentSessionRuntime, {\n\t\tget(_target, property) {\n\t\t\tif (property === \"switchSession\" && entry.switchSession) return entry.switchSession;\n\t\t\tif (property === \"setRebindSession\")\n\t\t\t\treturn (callback?: Parameters<AgentSessionRuntime[\"setRebindSession\"]>[0]) => {\n\t\t\t\t\tentry.rebindSession = callback;\n\t\t\t\t\tentry.runtime?.setRebindSession(callback);\n\t\t\t\t};\n\t\t\tconst runtime = entry.runtime;\n\t\t\tif (!runtime) throw new Error(\"Session runtime was not created\");\n\t\t\tconst value = Reflect.get(runtime, property, runtime);\n\t\t\treturn typeof value === \"function\" ? value.bind(runtime) : value;\n\t\t},\n\t});\n\tconst handler: RpcConnectionHandler = await runWithProviderScope(entry.scope, async () => {\n\t\tconst taggedSink: RpcConnectionSink = {\n\t\t\twriteRaw: bindToProviderScope(enqueueRecords),\n\t\t\twaitForBackpressure: bindToProviderScope(async () => {}),\n\t\t};\n\t\treturn createRpcConnectionHandler(runtimeHost, taggedSink, {\n\t\t\tsessionId,\n\t\t\tshutdownHandler: bindToProviderScope(requestClose),\n\t\t\tdisposeRuntime: false,\n\t\t\teventFlushScheduler: (flush) => flush(),\n\t\t\t...options,\n\t\t});\n\t});\n\tawait handler.ready;\n\treturn {\n\t\thandle: (command) => runWithProviderScope(entry.scope, () => handler.handleInputLine(JSON.stringify(command))),\n\t\tcancelPendingExtensionUiRequests: () =>\n\t\t\trunWithProviderScope(entry.scope, () => handler.cancelPendingExtensionUiRequests()),\n\t\trerenderComponents: () => runWithProviderScope(entry.scope, () => handler.rerenderComponents()),\n\t\tdispose: () => {\n\t\t\ttoolSpans.closeAll();\n\t\t\treturn runWithProviderScope(entry.scope, () => handler.dispose());\n\t\t},\n\t};\n}\n"]}
@@ -49,8 +49,24 @@ export declare class SessionCommandRouter {
49
49
  private readonly canExitWhenEmpty?;
50
50
  private sweepTimer;
51
51
  private emptySince;
52
+ /** Halves the idle window while the host reports memory pressure; never refuses work. */
53
+ private memoryPressure;
52
54
  constructor(registry: Pick<RpcSessionRegistry, "openSession" | "peek" | "getForCommand" | "beginClose" | "close" | "closeMarked" | "list" | "size">, writer: SessionEventWriter, defaults: Pick<RpcSessionLaunchProfile, "cwd" | "permissionPreset" | "creationModel" | "initialThinkingLevel">, createBinding?: typeof createRpcSessionBinding, connectionOptions?: Parameters<typeof createRpcSessionBinding>[4], idle?: RpcSessionIdlePolicy);
55
+ /** Live sessions the host holds, including ones opening or closing. */
56
+ get sessionCount(): number;
57
+ /**
58
+ * Raised by the host's memory sampler. Under pressure idle sessions are parked at
59
+ * HALF the configured window, which returns their memory to the process sooner.
60
+ * Deliberately the only lever: the host never refuses or kills a session for memory.
61
+ */
62
+ setMemoryPressure(pressure: boolean): void;
63
+ /**
64
+ * Every routed command runs inside its session's attribution scope, so a stall the
65
+ * loop-lag watchdog observes right after this dispatch can name the session that
66
+ * caused it - and anything the command starts inherits the attribution.
67
+ */
53
68
  handle(command: RpcCommand): Promise<RpcResponse | undefined>;
69
+ private dispatch;
54
70
  dispose(): Promise<void>;
55
71
  /**
56
72
  * One occupancy sweep. Evicts open sessions idle longer than idleEvictionMs,
@@ -58,9 +74,11 @@ export declare class SessionCommandRouter {
58
74
  * (`AgentSession.isSessionBusy`: agent run, bash, background terminal jobs and
59
75
  * other published wake sources, compaction, barrier-held session work) - busy
60
76
  * sessions restart their idle clock instead, so work that outlives a turn is
61
- * never killed. Fires onEmptyExit once the registry has STAYED empty for
62
- * emptyExitMs with the exit permitted; any live session or connected client
63
- * resets that window. Runs on an unref'd interval and is safe to call directly.
77
+ * never killed. While the host reports memory pressure the window is HALVED, so an
78
+ * idle session's memory returns to the process sooner. Fires onEmptyExit once the
79
+ * registry has STAYED empty for emptyExitMs with the exit permitted; any live session
80
+ * or connected client resets that window. Runs on an unref'd interval and is safe to
81
+ * call directly.
64
82
  */
65
83
  sweepIdleSessions(): void;
66
84
  /**
@@ -68,6 +86,11 @@ export declare class SessionCommandRouter {
68
86
  * close_session, draining every attachment because eviction ends the shared
69
87
  * session, not one client's claim. Races with concurrent lifecycle paths are
70
88
  * tolerated the same way releaseOwnedSession tolerates them.
89
+ *
90
+ * Evicting a RETAINED session is a PARK, not a close: that session was opened to
91
+ * outlive its clients, so the terminal record names the file it reopens by
92
+ * (`session_parked`) instead of reporting a close nobody requested. The teardown
93
+ * itself is identical - retention never outlives the idle window.
71
94
  */
72
95
  private evictIdleSession;
73
96
  /** Drops per-connection records for a handle the host closed on its own. */
@@ -1 +1 @@
1
- {"version":3,"file":"session-command-router.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/session-command-router.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"session-command-router.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/session-command-router.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,9 @@
1
1
  import { VERSION } from "../../config.js";
2
2
  import { buildRpcSessionState } from "./connection-handler.js";
3
- import { AUTO_TITLE_SESSIONS_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, RETAIN_ON_DISCONNECT_CAPABILITY, } from "./custom-capability.js";
4
- import { RPC_ERROR_MISSING_SESSION_ID, RPC_ERROR_OPEN_FAILED, RPC_ERROR_SESSION_CLOSING, RPC_ERROR_UNKNOWN_SESSION, } from "./rpc-types.js";
3
+ import { AUTO_TITLE_PER_SESSION_CAPABILITY, AUTO_TITLE_SESSIONS_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, RETAIN_ON_DISCONNECT_CAPABILITY, SESSION_CONTEXT_CAPABILITY, SESSION_KIND_CAPABILITY, } from "./custom-capability.js";
4
+ import { sessionAutoTitleError, sessionContextError, sessionKindError } from "./rpc-input-validation.js";
5
+ import { RPC_ERROR_INVALID_LAUNCH_PROFILE, RPC_ERROR_INVALID_SESSION_CONTEXT, RPC_ERROR_INVALID_SESSION_KIND, RPC_ERROR_MISSING_SESSION_ID, RPC_ERROR_OPEN_FAILED, RPC_ERROR_SESSION_CLOSING, RPC_ERROR_UNKNOWN_SESSION, } from "./rpc-types.js";
6
+ import { runWithSessionAttribution } from "./session-attribution.js";
5
7
  import { createRpcSessionBinding } from "./session-binding.js";
6
8
  import { RpcSessionRegistryError } from "./session-registry.js";
7
9
  const controls = new Set(["get_protocol_info", "open_session", "close_session", "list_sessions"]);
@@ -21,6 +23,8 @@ export class SessionCommandRouter {
21
23
  this.widths = new Map();
22
24
  this.pendingCapabilities = new Map();
23
25
  this.finalizations = new Map();
26
+ /** Halves the idle window while the host reports memory pressure; never refuses work. */
27
+ this.memoryPressure = false;
24
28
  this.registry = registry;
25
29
  this.writer = writer;
26
30
  this.defaults = defaults;
@@ -38,15 +42,41 @@ export class SessionCommandRouter {
38
42
  this.sweepTimer.unref?.();
39
43
  }
40
44
  }
41
- async handle(command) {
45
+ /** Live sessions the host holds, including ones opening or closing. */
46
+ get sessionCount() {
47
+ return this.registry.size;
48
+ }
49
+ /**
50
+ * Raised by the host's memory sampler. Under pressure idle sessions are parked at
51
+ * HALF the configured window, which returns their memory to the process sooner.
52
+ * Deliberately the only lever: the host never refuses or kills a session for memory.
53
+ */
54
+ setMemoryPressure(pressure) {
55
+ this.memoryPressure = pressure;
56
+ }
57
+ /**
58
+ * Every routed command runs inside its session's attribution scope, so a stall the
59
+ * loop-lag watchdog observes right after this dispatch can name the session that
60
+ * caused it - and anything the command starts inherits the attribution.
61
+ */
62
+ handle(command) {
63
+ const sessionId = "sessionId" in command ? command.sessionId : undefined;
64
+ return runWithSessionAttribution({ sessionId }, () => this.dispatch(command));
65
+ }
66
+ async dispatch(command) {
42
67
  if (command.type === "get_protocol_info") {
43
68
  const capabilities = new Set([
44
69
  "multi_session",
45
70
  AUTO_TITLE_SESSIONS_CAPABILITY,
46
71
  MEDIA_PLACEHOLDERS_CAPABILITY,
47
- // Host capability, not a client opt-in: only a multi-session host owns the
48
- // attachment refcount `open_session.retain_on_disconnect` detaches from.
72
+ // Host capabilities, not client opt-ins: only a multi-session host owns the
73
+ // attachment refcount `open_session.retain_on_disconnect` detaches from, the
74
+ // per-session launch profile `context`/`auto_title` travel on, and the session
75
+ // listing `kind` filters.
49
76
  RETAIN_ON_DISCONNECT_CAPABILITY,
77
+ SESSION_CONTEXT_CAPABILITY,
78
+ SESSION_KIND_CAPABILITY,
79
+ AUTO_TITLE_PER_SESSION_CAPABILITY,
50
80
  ...(this.connectionOptions?.capabilities ?? []),
51
81
  ]);
52
82
  return {
@@ -62,14 +92,21 @@ export class SessionCommandRouter {
62
92
  },
63
93
  };
64
94
  }
65
- if (command.type === "list_sessions")
95
+ if (command.type === "list_sessions") {
96
+ // Worker sessions are machine-driven work: a client sees them only by asking, and
97
+ // the opaque context blob travels only on that listing, never to every connection.
98
+ const rows = this.registry.list();
99
+ const sessions = command.include_workers === true
100
+ ? rows
101
+ : rows.filter((row) => row.kind !== "worker").map(({ context: _context, ...row }) => row);
66
102
  return {
67
103
  id: command.id,
68
104
  type: "response",
69
105
  command: "list_sessions",
70
106
  success: true,
71
- data: { sessions: this.registry.list() },
107
+ data: { sessions },
72
108
  };
109
+ }
73
110
  if (command.type === "open_session")
74
111
  return this.openWithBarrier(command);
75
112
  if (command.type === "close_session")
@@ -117,13 +154,16 @@ export class SessionCommandRouter {
117
154
  * (`AgentSession.isSessionBusy`: agent run, bash, background terminal jobs and
118
155
  * other published wake sources, compaction, barrier-held session work) - busy
119
156
  * sessions restart their idle clock instead, so work that outlives a turn is
120
- * never killed. Fires onEmptyExit once the registry has STAYED empty for
121
- * emptyExitMs with the exit permitted; any live session or connected client
122
- * resets that window. Runs on an unref'd interval and is safe to call directly.
157
+ * never killed. While the host reports memory pressure the window is HALVED, so an
158
+ * idle session's memory returns to the process sooner. Fires onEmptyExit once the
159
+ * registry has STAYED empty for emptyExitMs with the exit permitted; any live session
160
+ * or connected client resets that window. Runs on an unref'd interval and is safe to
161
+ * call directly.
123
162
  */
124
163
  sweepIdleSessions() {
125
164
  const now = this.idleNow();
126
- if (Number.isFinite(this.idleEvictionMs)) {
165
+ const idleEvictionMs = this.memoryPressure ? this.idleEvictionMs / 2 : this.idleEvictionMs;
166
+ if (Number.isFinite(idleEvictionMs)) {
127
167
  for (const { sessionId, status } of this.registry.list()) {
128
168
  if (status !== "open")
129
169
  continue;
@@ -135,7 +175,7 @@ export class SessionCommandRouter {
135
175
  entry.lastCommandAt = now;
136
176
  continue;
137
177
  }
138
- if (now - entry.lastCommandAt >= this.idleEvictionMs)
178
+ if (now - entry.lastCommandAt >= idleEvictionMs)
139
179
  void this.evictIdleSession(sessionId);
140
180
  }
141
181
  }
@@ -158,8 +198,18 @@ export class SessionCommandRouter {
158
198
  * close_session, draining every attachment because eviction ends the shared
159
199
  * session, not one client's claim. Races with concurrent lifecycle paths are
160
200
  * tolerated the same way releaseOwnedSession tolerates them.
201
+ *
202
+ * Evicting a RETAINED session is a PARK, not a close: that session was opened to
203
+ * outlive its clients, so the terminal record names the file it reopens by
204
+ * (`session_parked`) instead of reporting a close nobody requested. The teardown
205
+ * itself is identical - retention never outlives the idle window.
161
206
  */
162
207
  async evictIdleSession(sessionId) {
208
+ // Read before the claim: the entry is gone once the teardown completes, and a park
209
+ // record without the session's path would not be actionable (so a retained session
210
+ // with no file to reopen by ends as an ordinary close).
211
+ const retained = this.registry.peek(sessionId);
212
+ const parkedPath = retained?.retainOnDisconnect === true ? retained.sessionPath : undefined;
163
213
  // A failed claim means an explicit close raced us and owns the entry now.
164
214
  const claim = this.tryClaimClose(sessionId, { drainAttachments: true });
165
215
  if (!claim)
@@ -168,12 +218,14 @@ export class SessionCommandRouter {
168
218
  binding?.cancelPendingExtensionUiRequests?.();
169
219
  this.forgetSessionOwnership(sessionId);
170
220
  if (claim.finalizer) {
171
- await this.finalizeClose(sessionId, binding, () => this.writer.closeSession(sessionId, {
172
- type: "response",
173
- command: "close_session",
174
- success: true,
175
- data: {},
176
- }));
221
+ await this.finalizeClose(sessionId, binding, () => parkedPath === undefined
222
+ ? this.writer.closeSession(sessionId, {
223
+ type: "response",
224
+ command: "close_session",
225
+ success: true,
226
+ data: {},
227
+ })
228
+ : this.writer.parkSession(sessionId, parkedPath));
177
229
  }
178
230
  else {
179
231
  await this.finalizations.get(sessionId)?.promise;
@@ -217,6 +269,18 @@ export class SessionCommandRouter {
217
269
  });
218
270
  }
219
271
  async open(command, owner) {
272
+ // The opaque per-session inputs are parsed HERE, at the wire boundary, so the
273
+ // registry, the runtime and the extensions downstream receive values already
274
+ // proven to satisfy every documented cap.
275
+ const kindError = sessionKindError(command.kind);
276
+ if (kindError)
277
+ return error(command.id, "open_session", `${RPC_ERROR_INVALID_SESSION_KIND}: ${kindError}`);
278
+ const contextError = sessionContextError(command.context);
279
+ if (contextError)
280
+ return error(command.id, "open_session", `${RPC_ERROR_INVALID_SESSION_CONTEXT}: ${contextError}`);
281
+ const autoTitleError = sessionAutoTitleError(command.auto_title);
282
+ if (autoTitleError)
283
+ return error(command.id, "open_session", `${RPC_ERROR_INVALID_LAUNCH_PROFILE}: ${autoTitleError}`);
220
284
  let opened;
221
285
  try {
222
286
  opened = await this.registry.openSession({
@@ -227,11 +291,15 @@ export class SessionCommandRouter {
227
291
  ? { provider: command.provider, modelId: command.modelId }
228
292
  : this.defaults.creationModel,
229
293
  initialThinkingLevel: command.thinkingLevel ?? this.defaults.initialThinkingLevel,
294
+ sessionKind: command.kind,
295
+ sessionContext: command.context,
296
+ ...(typeof command.auto_title === "boolean" ? { autoTitle: command.auto_title } : {}),
230
297
  },
231
298
  // Host lifecycle policy, deliberately outside the immutable launch profile.
232
299
  { retainOnDisconnect: command.retain_on_disconnect === true });
233
300
  const openedSession = opened;
234
301
  const entry = this.registry.getForCommand(openedSession.sessionId, "open_session");
302
+ this.writer.setSessionKind(openedSession.sessionId, entry.kind);
235
303
  if (owner !== undefined) {
236
304
  if (!this.writer.hasRegisteredConnectionCapabilities(owner))
237
305
  this.writer.setConnectionCapabilities(owner, this.pendingCapabilities.get(owner) ?? this.connectionOptions?.capabilities ?? []);
@@ -461,6 +529,15 @@ export class SessionCommandRouter {
461
529
  success: true,
462
530
  data: {},
463
531
  };
532
+ // A close releases the CALLER's attachment, and routing handles are public on a
533
+ // shared host (`list_sessions` publishes every one of them), so ownership - not
534
+ // knowledge of the handle - authorizes it: a connection that never attached holds
535
+ // no attachment, and answering it would release another client's claim and could
536
+ // tear down a session it never opened. A host with no per-connection identity
537
+ // (stdio) has no ownership to check and keeps answering every close as before.
538
+ const owner = this.writer.currentConnection();
539
+ if (owner !== undefined && this.sessionsByConnection.get(owner)?.has(command.sessionId) !== true)
540
+ return error(command.id, "close_session", RPC_ERROR_UNKNOWN_SESSION);
464
541
  const reply = this.writer.reserveCloseResponse(command.sessionId, response);
465
542
  if (!reply)
466
543
  return undefined;
@@ -475,7 +552,6 @@ export class SessionCommandRouter {
475
552
  return error(command.id, "close_session", this.code(cause));
476
553
  }
477
554
  try {
478
- const owner = this.writer.currentConnection();
479
555
  if (owner !== undefined)
480
556
  this.releaseOwnerAttachment(owner, command.sessionId);
481
557
  if (claim.finalizer) {