@code-yeongyu/senpi 2026.9.17-2 → 2026.9.17-4
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.
- package/CHANGELOG.md +49 -0
- package/dist/bundle/chunks/{app-server-command-XSPV6BRF.js → app-server-command-RTISWAJY.js} +1 -1
- package/dist/bundle/chunks/{chunk-PLUQUK3T.js → chunk-IM4UIHPI.js} +15 -15
- package/dist/bundle/chunks/{chunk-ESZHT7IE.js → chunk-IMTGM3RJ.js} +1 -1
- package/dist/bundle/chunks/chunk-OQGI752B.js +2 -0
- package/dist/bundle/chunks/{chunk-Z56RXBL6.js → chunk-QDR3GME3.js} +2 -2
- package/dist/bundle/chunks/{chunk-ZPO5FWHM.js → chunk-QILSJ5PQ.js} +2 -2
- package/dist/bundle/chunks/{chunk-IGKWKAMN.js → chunk-XHTF6VKZ.js} +4 -3
- package/dist/bundle/chunks/chunk-YD5QZXAK.js +8 -0
- package/dist/bundle/chunks/{cli-main-L7WQP5V6.js → cli-main-TO3O4TUE.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-O6MBSNMK.js → help-fast-path-LFGHCIWO.js} +1 -1
- package/dist/bundle/chunks/{host-lifecycle-AI7NH6ZO.js → host-lifecycle-YPVGQSGS.js} +1 -1
- package/dist/bundle/chunks/{interactive-host-runtime-DKJKH5DU.js → interactive-host-runtime-KZLF3AZD.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-FUW5LNKL.js → interactive-mode-ARGRH6QZ.js} +1 -1
- package/dist/bundle/chunks/multi-session-host-HIZZTSYU.js +20 -0
- package/dist/bundle/chunks/{package-manager-cli-J26DB6GM.js → package-manager-cli-544JIWNJ.js} +1 -1
- package/dist/bundle/chunks/rotation-stream-FSZVBG2T.js +2 -0
- package/dist/bundle/chunks/{rpc-mode-A3U63XX7.js → rpc-mode-S3IEBFEB.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +71 -68
- package/dist/bundle/cli.js +1 -1
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/cli/args.d.ts +19 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +27 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-session-runtime.d.ts +14 -1
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/auth-storage.js +2 -2
- package/dist/core/auth-storage.js.map +1 -1
- package/dist/core/credential-pool/rotation-stream.d.ts.map +1 -1
- package/dist/core/credential-pool/rotation-stream.js +10 -5
- package/dist/core/credential-pool/rotation-stream.js.map +1 -1
- package/dist/core/extensions/builtin/mcp/index.d.ts.map +1 -1
- package/dist/core/extensions/builtin/mcp/index.js +11 -0
- package/dist/core/extensions/builtin/mcp/index.js.map +1 -1
- package/dist/core/extensions/builtin/mcp/service.d.ts +9 -0
- package/dist/core/extensions/builtin/mcp/service.d.ts.map +1 -1
- package/dist/core/extensions/builtin/mcp/service.js +14 -1
- package/dist/core/extensions/builtin/mcp/service.js.map +1 -1
- package/dist/core/extensions/builtin/mcp/startup-race.d.ts +20 -0
- package/dist/core/extensions/builtin/mcp/startup-race.d.ts.map +1 -1
- package/dist/core/extensions/builtin/mcp/startup-race.js +38 -1
- package/dist/core/extensions/builtin/mcp/startup-race.js.map +1 -1
- package/dist/core/extensions/index.d.ts +2 -2
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/loader.d.ts +9 -4
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +20 -9
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/types.d.ts +37 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js +8 -0
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/footer-data-provider.d.ts +2 -1
- package/dist/core/footer-data-provider.d.ts.map +1 -1
- package/dist/core/footer-data-provider.js +21 -35
- package/dist/core/footer-data-provider.js.map +1 -1
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +3 -2
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/model-runtime.d.ts +4 -2
- package/dist/core/model-runtime.d.ts.map +1 -1
- package/dist/core/model-runtime.js +9 -5
- package/dist/core/model-runtime.js.map +1 -1
- package/dist/core/provider-api-key-auth.js +3 -3
- package/dist/core/provider-api-key-auth.js.map +1 -1
- package/dist/core/provider-composer.d.ts +9 -3
- package/dist/core/provider-composer.d.ts.map +1 -1
- package/dist/core/provider-composer.js +7 -4
- package/dist/core/provider-composer.js.map +1 -1
- package/dist/core/resolve-config-command.d.ts +10 -0
- package/dist/core/resolve-config-command.d.ts.map +1 -0
- package/dist/core/resolve-config-command.js +106 -0
- package/dist/core/resolve-config-command.js.map +1 -0
- package/dist/core/resolve-config-value.d.ts +4 -8
- package/dist/core/resolve-config-value.d.ts.map +1 -1
- package/dist/core/resolve-config-value.js +16 -105
- package/dist/core/resolve-config-value.js.map +1 -1
- package/dist/core/resource-loader.d.ts +7 -1
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +13 -9
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/main.d.ts +5 -3
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +25 -11
- package/dist/main.js.map +1 -1
- package/dist/modes/rpc/child-reaper-syscalls.d.ts +18 -0
- package/dist/modes/rpc/child-reaper-syscalls.d.ts.map +1 -0
- package/dist/modes/rpc/child-reaper-syscalls.js +136 -0
- package/dist/modes/rpc/child-reaper-syscalls.js.map +1 -0
- package/dist/modes/rpc/child-reaper.d.ts +56 -0
- package/dist/modes/rpc/child-reaper.d.ts.map +1 -0
- package/dist/modes/rpc/child-reaper.js +140 -0
- package/dist/modes/rpc/child-reaper.js.map +1 -0
- package/dist/modes/rpc/custom-capability.d.ts +16 -0
- package/dist/modes/rpc/custom-capability.d.ts.map +1 -1
- package/dist/modes/rpc/custom-capability.js +16 -0
- package/dist/modes/rpc/custom-capability.js.map +1 -1
- package/dist/modes/rpc/host-memory-sampler.d.ts +48 -0
- package/dist/modes/rpc/host-memory-sampler.d.ts.map +1 -0
- package/dist/modes/rpc/host-memory-sampler.js +72 -0
- package/dist/modes/rpc/host-memory-sampler.js.map +1 -0
- package/dist/modes/rpc/loop-lag-watchdog.d.ts +51 -0
- package/dist/modes/rpc/loop-lag-watchdog.d.ts.map +1 -0
- package/dist/modes/rpc/loop-lag-watchdog.js +78 -0
- package/dist/modes/rpc/loop-lag-watchdog.js.map +1 -0
- package/dist/modes/rpc/multi-session-host.d.ts.map +1 -1
- package/dist/modes/rpc/multi-session-host.js +34 -0
- package/dist/modes/rpc/multi-session-host.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +4 -2
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +2 -1
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-input-validation.d.ts +28 -0
- package/dist/modes/rpc/rpc-input-validation.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-input-validation.js +63 -0
- package/dist/modes/rpc/rpc-input-validation.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts +18 -5
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +18 -5
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +79 -2
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js +6 -0
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/modes/rpc/session-attribution.d.ts +57 -0
- package/dist/modes/rpc/session-attribution.d.ts.map +1 -0
- package/dist/modes/rpc/session-attribution.js +132 -0
- package/dist/modes/rpc/session-attribution.js.map +1 -0
- package/dist/modes/rpc/session-binding.d.ts.map +1 -1
- package/dist/modes/rpc/session-binding.js +19 -8
- package/dist/modes/rpc/session-binding.js.map +1 -1
- package/dist/modes/rpc/session-command-router.d.ts +26 -3
- package/dist/modes/rpc/session-command-router.d.ts.map +1 -1
- package/dist/modes/rpc/session-command-router.js +95 -19
- package/dist/modes/rpc/session-command-router.js.map +1 -1
- package/dist/modes/rpc/session-event-fanout.d.ts +2 -0
- package/dist/modes/rpc/session-event-fanout.d.ts.map +1 -1
- package/dist/modes/rpc/session-event-fanout.js +6 -0
- package/dist/modes/rpc/session-event-fanout.js.map +1 -1
- package/dist/modes/rpc/session-event-writer.d.ts +29 -0
- package/dist/modes/rpc/session-event-writer.d.ts.map +1 -1
- package/dist/modes/rpc/session-event-writer.js +57 -0
- package/dist/modes/rpc/session-event-writer.js.map +1 -1
- package/dist/modes/rpc/session-registry.d.ts +42 -9
- package/dist/modes/rpc/session-registry.d.ts.map +1 -1
- package/dist/modes/rpc/session-registry.js +49 -3
- package/dist/modes/rpc/session-registry.js.map +1 -1
- package/dist/modes/rpc/worker-session-registry.d.ts +2 -10
- package/dist/modes/rpc/worker-session-registry.d.ts.map +1 -1
- package/dist/modes/rpc/worker-session-registry.js +6 -2
- package/dist/modes/rpc/worker-session-registry.js.map +1 -1
- package/docs/rpc.md +126 -20
- package/node_modules/@code-yeongyu/senpi-codemode/CHANGELOG.md +24 -0
- package/node_modules/@code-yeongyu/senpi-codemode/package.json +4 -4
- package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/javascript.wasm +0 -0
- package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/provenance.json +22 -0
- package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/web-tree-sitter.wasm +0 -0
- package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai-coding-cn.json +1 -1
- package/node_modules/@earendil-works/pi-ai/package.json +2 -2
- package/node_modules/@earendil-works/pi-pty/package.json +1 -1
- package/node_modules/@earendil-works/pi-telemetry/package.json +1 -1
- package/node_modules/@earendil-works/pi-tui/package.json +1 -1
- package/package.json +7 -7
- package/dist/bundle/chunks/chunk-3Q5PD4KW.js +0 -8
- package/dist/bundle/chunks/chunk-YLYLKL7X.js +0 -2
- package/dist/bundle/chunks/multi-session-host-SMNZXFPT.js +0 -18
- 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;
|
|
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
|
-
|
|
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(
|
|
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: () =>
|
|
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":"AAAA,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEtG,OAAO,EACN,0BAA0B,GAI1B,MAAM,yBAAyB,CAAC;
|
|
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.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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":"
|
|
1
|
+
{"version":3,"file":"session-command-router.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/session-command-router.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAW9D,OAAO,EAAE,uBAAuB,EAA0B,MAAM,sBAAsB,CAAC;AACvF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAkB,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAKzG,gGAAgG;AAChG,MAAM,MAAM,iBAAiB,GAAG,OAAO,uBAAuB,CAAC;AAE/D;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACpC,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,wGAAwG;IACxG,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC;CACjC;AAaD,uFAAuF;AACvF,qBAAa,oBAAoB;IAChC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAwC;IACjE,gFAAgF;IAChF,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAA0C;IAC/E,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAyC;IAC3E,0DAA0D;IAC1D,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IACzD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAGvB;IACF,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqB;IAC5C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAGvB;IACF,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAiC;IAC/D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAgD;IAClF,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0C;IACjE,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA+B;IACnE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAsE;IACpG,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAa;IAC1C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAgB;IAClD,OAAO,CAAC,UAAU,CAA6C;IAC/D,OAAO,CAAC,UAAU,CAAqB;IACvC,yFAAyF;IACzF,OAAO,CAAC,cAAc,CAAS;IAE/B,YACC,QAAQ,EAAE,IAAI,CACb,kBAAkB,EAClB,aAAa,GAAG,MAAM,GAAG,eAAe,GAAG,YAAY,GAAG,OAAO,GAAG,aAAa,GAAG,MAAM,GAAG,MAAM,CACnG,EACD,MAAM,EAAE,kBAAkB,EAC1B,QAAQ,EAAE,IAAI,CAAC,uBAAuB,EAAE,KAAK,GAAG,kBAAkB,GAAG,eAAe,GAAG,sBAAsB,CAAC,EAC9G,aAAa,GAAE,OAAO,uBAAiD,EACvE,iBAAiB,GAAE,UAAU,CAAC,OAAO,uBAAuB,CAAC,CAAC,CAAC,CAAM,EACrE,IAAI,CAAC,EAAE,oBAAoB,EAkB3B;IAED,uEAAuE;IACvE,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED;;;;OAIG;IACH,iBAAiB,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI,CAEzC;IAED;;;;OAIG;IACH,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAG5D;YAEa,QAAQ;IAoEhB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAW7B;IAED;;;;;;;;;;;OAWG;IACH,iBAAiB,IAAI,IAAI,CA2BxB;IAED;;;;;;;;;;OAUG;YACW,gBAAgB;IAgC9B,4EAA4E;IAC5E,OAAO,CAAC,sBAAsB;IAQ9B,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,eAAe;YAiBT,IAAI;IA4IlB;;;;OAIG;IACG,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgE3D;IAED;;;;;;;;;;OAUG;YACW,mBAAmB;IAkBjC;;;;;;OAMG;IACH,OAAO,CAAC,UAAU;IAYlB,kFAAkF;IAClF,OAAO,CAAC,aAAa;YAQP,KAAK;IA4CnB,qGAAqG;IACrG,OAAO,CAAC,sBAAsB;IAkB9B;;;;;;OAMG;YACW,aAAa;IA6B3B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,IAAI;CAWZ"}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
|
|
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 >=
|
|
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, () =>
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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) {
|