@code-yeongyu/senpi 2026.9.8 → 2026.9.9

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 (114) hide show
  1. package/CHANGELOG.md +23 -4
  2. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  3. package/dist/core/agent-session-runtime.js +2 -0
  4. package/dist/core/agent-session-runtime.js.map +1 -1
  5. package/dist/core/compaction/compaction-execution.d.ts.map +1 -1
  6. package/dist/core/compaction/compaction-execution.js +5 -5
  7. package/dist/core/compaction/compaction-execution.js.map +1 -1
  8. package/dist/core/compaction/compaction-settings.d.ts +2 -0
  9. package/dist/core/compaction/compaction-settings.d.ts.map +1 -1
  10. package/dist/core/compaction/compaction-settings.js.map +1 -1
  11. package/dist/core/compaction/compaction.d.ts +3 -3
  12. package/dist/core/compaction/compaction.d.ts.map +1 -1
  13. package/dist/core/compaction/compaction.js +12 -7
  14. package/dist/core/compaction/compaction.js.map +1 -1
  15. package/dist/core/compaction/stream-watchdog.d.ts +29 -0
  16. package/dist/core/compaction/stream-watchdog.d.ts.map +1 -1
  17. package/dist/core/compaction/stream-watchdog.js +35 -0
  18. package/dist/core/compaction/stream-watchdog.js.map +1 -1
  19. package/dist/core/compaction-settings-access.d.ts +6 -0
  20. package/dist/core/compaction-settings-access.d.ts.map +1 -1
  21. package/dist/core/compaction-settings-access.js.map +1 -1
  22. package/dist/core/compaction-settings-resolver.d.ts +2 -0
  23. package/dist/core/compaction-settings-resolver.d.ts.map +1 -1
  24. package/dist/core/compaction-settings-resolver.js +5 -0
  25. package/dist/core/compaction-settings-resolver.js.map +1 -1
  26. package/dist/core/extensions/builtin/compaction/model-usability-budget.d.ts +1 -1
  27. package/dist/core/extensions/builtin/compaction/model-usability-budget.d.ts.map +1 -1
  28. package/dist/core/extensions/builtin/compaction/model-usability-budget.js +23 -3
  29. package/dist/core/extensions/builtin/compaction/model-usability-budget.js.map +1 -1
  30. package/dist/core/extensions/builtin/compaction/speculative-summary.d.ts +2 -0
  31. package/dist/core/extensions/builtin/compaction/speculative-summary.d.ts.map +1 -1
  32. package/dist/core/extensions/builtin/compaction/speculative-summary.js +5 -2
  33. package/dist/core/extensions/builtin/compaction/speculative-summary.js.map +1 -1
  34. package/dist/core/extensions/builtin/compaction/speculative.d.ts.map +1 -1
  35. package/dist/core/extensions/builtin/compaction/speculative.js +9 -3
  36. package/dist/core/extensions/builtin/compaction/speculative.js.map +1 -1
  37. package/dist/core/extensions/builtin/compaction/summarization-retry.d.ts +10 -4
  38. package/dist/core/extensions/builtin/compaction/summarization-retry.d.ts.map +1 -1
  39. package/dist/core/extensions/builtin/compaction/summarization-retry.js +14 -5
  40. package/dist/core/extensions/builtin/compaction/summarization-retry.js.map +1 -1
  41. package/dist/core/session-manager.d.ts.map +1 -1
  42. package/dist/core/session-manager.js +11 -1
  43. package/dist/core/session-manager.js.map +1 -1
  44. package/dist/core/session-write-reservation.d.ts +4 -0
  45. package/dist/core/session-write-reservation.d.ts.map +1 -0
  46. package/dist/core/session-write-reservation.js +12 -0
  47. package/dist/core/session-write-reservation.js.map +1 -0
  48. package/dist/main.d.ts +14 -0
  49. package/dist/main.d.ts.map +1 -1
  50. package/dist/main.js +162 -147
  51. package/dist/main.js.map +1 -1
  52. package/dist/modes/rpc/connection-handler.d.ts +2 -0
  53. package/dist/modes/rpc/connection-handler.d.ts.map +1 -1
  54. package/dist/modes/rpc/connection-handler.js +1 -1
  55. package/dist/modes/rpc/connection-handler.js.map +1 -1
  56. package/dist/modes/rpc/multi-session-host.d.ts +2 -0
  57. package/dist/modes/rpc/multi-session-host.d.ts.map +1 -1
  58. package/dist/modes/rpc/multi-session-host.js +18 -8
  59. package/dist/modes/rpc/multi-session-host.js.map +1 -1
  60. package/dist/modes/rpc/session-binding.d.ts.map +1 -1
  61. package/dist/modes/rpc/session-binding.js +3 -0
  62. package/dist/modes/rpc/session-binding.js.map +1 -1
  63. package/dist/modes/rpc/session-command-router.d.ts +1 -1
  64. package/dist/modes/rpc/session-command-router.d.ts.map +1 -1
  65. package/dist/modes/rpc/session-command-router.js +57 -25
  66. package/dist/modes/rpc/session-command-router.js.map +1 -1
  67. package/dist/modes/rpc/session-event-writer.d.ts +23 -1
  68. package/dist/modes/rpc/session-event-writer.d.ts.map +1 -1
  69. package/dist/modes/rpc/session-event-writer.js +108 -1
  70. package/dist/modes/rpc/session-event-writer.js.map +1 -1
  71. package/dist/modes/rpc/session-registry.d.ts +4 -2
  72. package/dist/modes/rpc/session-registry.d.ts.map +1 -1
  73. package/dist/modes/rpc/session-registry.js +1 -1
  74. package/dist/modes/rpc/session-registry.js.map +1 -1
  75. package/dist/modes/rpc/session-worker-client.d.ts +47 -0
  76. package/dist/modes/rpc/session-worker-client.d.ts.map +1 -0
  77. package/dist/modes/rpc/session-worker-client.js +226 -0
  78. package/dist/modes/rpc/session-worker-client.js.map +1 -0
  79. package/dist/modes/rpc/session-worker-protocol.d.ts +103 -0
  80. package/dist/modes/rpc/session-worker-protocol.d.ts.map +1 -0
  81. package/dist/modes/rpc/session-worker-protocol.js +12 -0
  82. package/dist/modes/rpc/session-worker-protocol.js.map +1 -0
  83. package/dist/modes/rpc/session-worker-requests.d.ts +24 -0
  84. package/dist/modes/rpc/session-worker-requests.d.ts.map +1 -0
  85. package/dist/modes/rpc/session-worker-requests.js +71 -0
  86. package/dist/modes/rpc/session-worker-requests.js.map +1 -0
  87. package/dist/modes/rpc/session-worker.d.ts +2 -0
  88. package/dist/modes/rpc/session-worker.d.ts.map +1 -0
  89. package/dist/modes/rpc/session-worker.js +229 -0
  90. package/dist/modes/rpc/session-worker.js.map +1 -0
  91. package/dist/modes/rpc/worker-session-registry.d.ts +37 -0
  92. package/dist/modes/rpc/worker-session-registry.d.ts.map +1 -0
  93. package/dist/modes/rpc/worker-session-registry.js +207 -0
  94. package/dist/modes/rpc/worker-session-registry.js.map +1 -0
  95. package/docs/rpc.md +70 -9
  96. package/docs/settings.md +1 -0
  97. package/node_modules/@code-yeongyu/senpi-codemode/CHANGELOG.md +12 -0
  98. package/node_modules/@code-yeongyu/senpi-codemode/package.json +4 -4
  99. package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
  100. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js +23 -5
  101. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js.map +1 -1
  102. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.d.ts +30 -0
  103. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.d.ts.map +1 -1
  104. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.js +30 -0
  105. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.js.map +1 -1
  106. package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
  107. package/node_modules/@earendil-works/pi-ai/dist/providers/data/amazon-bedrock.json +1 -1
  108. package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
  109. package/node_modules/@earendil-works/pi-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
  110. package/node_modules/@earendil-works/pi-ai/package.json +2 -2
  111. package/node_modules/@earendil-works/pi-pty/package.json +1 -1
  112. package/node_modules/@earendil-works/pi-telemetry/package.json +1 -1
  113. package/node_modules/@earendil-works/pi-tui/package.json +1 -1
  114. package/package.json +7 -7
@@ -0,0 +1,207 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { ProviderScope } from "@earendil-works/pi-ai/node/provider-scope";
3
+ import { RpcSessionRegistryError, } from "./session-registry.js";
4
+ import { SessionWorkerClient } from "./session-worker-client.js";
5
+ import { SESSION_WORKER_LIMITS } from "./session-worker-protocol.js";
6
+ /** Transport-side lifecycle owner. Caller paths are never inspected on this event loop. */
7
+ export class WorkerSessionRegistry {
8
+ constructor(options) {
9
+ this.entries = new Map();
10
+ this.reservations = new Map();
11
+ this.serial = 0;
12
+ this.options = options;
13
+ this.closeGraceMs = options.closeGraceMs;
14
+ this.now = options.now;
15
+ }
16
+ get size() {
17
+ return this.entries.size;
18
+ }
19
+ async openSession(profile) {
20
+ if (!isAbsolute(profile.cwd) || (profile.sessionPath !== undefined && !isAbsolute(profile.sessionPath)))
21
+ throw new RpcSessionRegistryError("invalid_path");
22
+ if (profile.sessionPath) {
23
+ const key = this.knownReservationKey(profile.sessionPath);
24
+ const owner = key ? this.reservations.get(key) : undefined;
25
+ if (key && owner)
26
+ return this.attach(owner, key);
27
+ }
28
+ if (this.size >= SESSION_WORKER_LIMITS.workers)
29
+ throw new Error("too_many_sessions");
30
+ const handle = `rpc-${++this.serial}`;
31
+ const entry = {
32
+ state: "opening",
33
+ scope: new ProviderScope(),
34
+ profile: Object.freeze({ ...profile }),
35
+ cwd: profile.cwd,
36
+ attachments: 1,
37
+ lastCommandAt: this.now(),
38
+ lifecycleMutex: Promise.resolve(),
39
+ };
40
+ const worker = new SessionWorkerClient({
41
+ reserve: (path) => this.reserve(handle, path),
42
+ exit: () => {
43
+ if (this.entries.get(handle) !== entry)
44
+ return;
45
+ entry.state = "closed";
46
+ this.entries.delete(handle);
47
+ for (const [path, owner] of this.reservations)
48
+ if (owner === handle)
49
+ this.reservations.delete(path);
50
+ entry.closeResolve?.();
51
+ },
52
+ failure: (error) => {
53
+ entry.state = "quarantined";
54
+ process.stderr.write(`senpi rpc session ${handle} quarantined: ${error}\n`);
55
+ },
56
+ });
57
+ entry.worker = worker;
58
+ this.entries.set(handle, entry);
59
+ try {
60
+ const path = await worker.prepare(this.options.configuration, profile);
61
+ const owner = this.reservations.get(path);
62
+ if (owner) {
63
+ const attached = this.attach(owner, path);
64
+ entry.state = "quarantined";
65
+ worker.quarantine();
66
+ return attached;
67
+ }
68
+ if (!this.reserve(handle, path))
69
+ throw new RpcSessionRegistryError("session_path_in_use");
70
+ entry.reservationKey = path;
71
+ entry.sessionPath = path;
72
+ const snapshot = await worker.commit();
73
+ if (entry.state !== "opening")
74
+ throw new RpcSessionRegistryError("session_closing");
75
+ entry.durableSessionId = snapshot.state.sessionId;
76
+ entry.cwd = snapshot.state.cwd;
77
+ entry.state = "open";
78
+ return this.openResult(handle, entry);
79
+ }
80
+ catch (cause) {
81
+ entry.state = "quarantined";
82
+ worker.quarantine();
83
+ throw cause;
84
+ }
85
+ }
86
+ peek(handle) {
87
+ return this.entries.get(handle);
88
+ }
89
+ getForCommand(handle, command) {
90
+ const entry = this.entries.get(handle);
91
+ if (!entry)
92
+ throw new RpcSessionRegistryError("unknown_session");
93
+ if (entry.state === "quarantined" ||
94
+ (entry.state === "closing" && !["abort", "abort_bash", "extension_ui_response"].includes(command)))
95
+ throw new RpcSessionRegistryError("session_closing");
96
+ if (entry.state !== "open" && entry.state !== "closing")
97
+ throw new RpcSessionRegistryError("unknown_session");
98
+ entry.lastCommandAt = this.now();
99
+ return entry;
100
+ }
101
+ beginClose(handle, onRole) {
102
+ const entry = this.entries.get(handle);
103
+ if (!entry)
104
+ throw new RpcSessionRegistryError("unknown_session");
105
+ if (entry.state === "closing" || entry.state === "quarantined") {
106
+ onRole?.(false);
107
+ return entry;
108
+ }
109
+ if (entry.state !== "open" && entry.state !== "opening")
110
+ throw new RpcSessionRegistryError("unknown_session");
111
+ entry.attachments--;
112
+ if (entry.attachments > 0)
113
+ return entry;
114
+ entry.state = "closing";
115
+ entry.closeCompletion = new Promise((resolve) => {
116
+ entry.closeResolve = resolve;
117
+ });
118
+ onRole?.(true);
119
+ return entry;
120
+ }
121
+ close(handle) {
122
+ const entry = this.beginClose(handle);
123
+ return entry.state === "closing" ? this.closeMarked(handle) : Promise.resolve();
124
+ }
125
+ async closeMarked(handle) {
126
+ const entry = this.entries.get(handle);
127
+ if (entry?.state !== "closing" || !entry.worker)
128
+ throw new RpcSessionRegistryError("unknown_session");
129
+ if (entry.closeStarted)
130
+ return entry.closeCompletion;
131
+ entry.closeStarted = true;
132
+ // Reply on a bounded deadline, but keep entry, attachments and reservations until exit.
133
+ let timer;
134
+ await Promise.race([
135
+ entry.worker.close(this.closeGraceMs),
136
+ new Promise((resolve) => {
137
+ timer = setTimeout(() => {
138
+ if (entry.state !== "closed")
139
+ entry.state = "quarantined";
140
+ resolve();
141
+ }, this.closeGraceMs);
142
+ }),
143
+ ]);
144
+ if (timer)
145
+ clearTimeout(timer);
146
+ }
147
+ list() {
148
+ return [...this.entries].map(([sessionId, entry]) => {
149
+ const state = entry.worker?.snapshot?.state;
150
+ return {
151
+ sessionId,
152
+ durableSessionId: state?.sessionId ?? entry.durableSessionId,
153
+ sessionPath: state?.sessionFile ?? entry.sessionPath,
154
+ cwd: state?.cwd ?? entry.cwd,
155
+ name: state?.sessionName,
156
+ status: entry.state === "quarantined" ? "closing" : entry.state,
157
+ };
158
+ });
159
+ }
160
+ /** Only compare spellings already tied to a granted identity; do not inspect caller paths here. */
161
+ knownReservationKey(path) {
162
+ if (this.reservations.has(path))
163
+ return path;
164
+ for (const entry of this.entries.values()) {
165
+ if (entry.profile.sessionPath === path && entry.reservationKey)
166
+ return entry.reservationKey;
167
+ const snapshot = entry.worker?.snapshot;
168
+ if (snapshot?.state.sessionFile === path)
169
+ return snapshot.sessionPath;
170
+ }
171
+ return undefined;
172
+ }
173
+ attach(owner, path) {
174
+ const entry = this.entries.get(owner);
175
+ if (entry?.state !== "open" || !entry.worker?.bindingReady || entry.worker.snapshot?.sessionPath !== path)
176
+ throw new RpcSessionRegistryError("session_path_in_use");
177
+ const result = this.openResult(owner, entry);
178
+ entry.attachments++;
179
+ entry.lastCommandAt = this.now();
180
+ return { ...result, attached: true };
181
+ }
182
+ reserve(handle, path) {
183
+ const entry = this.entries.get(handle);
184
+ if (!entry)
185
+ return false;
186
+ const owner = this.reservations.get(path);
187
+ if (owner)
188
+ return owner === handle;
189
+ if (entry.state !== "opening" && entry.state !== "open")
190
+ return false;
191
+ let count = 0;
192
+ for (const current of this.reservations.values())
193
+ if (current === handle)
194
+ count++;
195
+ if (count >= SESSION_WORKER_LIMITS.reservations)
196
+ return false;
197
+ this.reservations.set(path, handle);
198
+ return true;
199
+ }
200
+ openResult(handle, entry) {
201
+ const state = entry.worker?.snapshot?.state;
202
+ if (!state)
203
+ throw new RpcSessionRegistryError("open_failed");
204
+ return { sessionId: handle, durableSessionId: state.sessionId, sessionPath: state.sessionFile };
205
+ }
206
+ }
207
+ //# sourceMappingURL=worker-session-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-session-registry.js","sourceRoot":"","sources":["../../../src/modes/rpc/worker-session-registry.ts"],"names":[],"mappings":"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","sourcesContent":["import { isAbsolute } from \"node:path\";\nimport { ProviderScope } from \"@earendil-works/pi-ai/node/provider-scope\";\nimport type { CliRuntimeConfiguration } from \"../../main.ts\";\nimport {\n\ttype OpenRpcSession,\n\ttype RpcSessionEntry,\n\ttype RpcSessionLaunchProfile,\n\tRpcSessionRegistryError,\n} from \"./session-registry.ts\";\nimport { SessionWorkerClient } from \"./session-worker-client.ts\";\nimport { SESSION_WORKER_LIMITS } from \"./session-worker-protocol.ts\";\n\n/** Transport-side lifecycle owner. Caller paths are never inspected on this event loop. */\nexport class WorkerSessionRegistry {\n\tprivate readonly entries = new Map<string, RpcSessionEntry>();\n\tprivate readonly reservations = new Map<string, string>();\n\tprivate serial = 0;\n\treadonly closeGraceMs: number;\n\tprivate readonly now: () => number;\n\n\tprivate readonly options: { configuration: CliRuntimeConfiguration; closeGraceMs: number; now: () => number };\n\n\tconstructor(options: { configuration: CliRuntimeConfiguration; closeGraceMs: number; now: () => number }) {\n\t\tthis.options = options;\n\t\tthis.closeGraceMs = options.closeGraceMs;\n\t\tthis.now = options.now;\n\t}\n\n\tget size(): number {\n\t\treturn this.entries.size;\n\t}\n\n\tasync openSession(profile: RpcSessionLaunchProfile): Promise<OpenRpcSession> {\n\t\tif (!isAbsolute(profile.cwd) || (profile.sessionPath !== undefined && !isAbsolute(profile.sessionPath)))\n\t\t\tthrow new RpcSessionRegistryError(\"invalid_path\");\n\t\tif (profile.sessionPath) {\n\t\t\tconst key = this.knownReservationKey(profile.sessionPath);\n\t\t\tconst owner = key ? this.reservations.get(key) : undefined;\n\t\t\tif (key && owner) return this.attach(owner, key);\n\t\t}\n\t\tif (this.size >= SESSION_WORKER_LIMITS.workers) throw new Error(\"too_many_sessions\");\n\t\tconst handle = `rpc-${++this.serial}`;\n\t\tconst entry: RpcSessionEntry = {\n\t\t\tstate: \"opening\",\n\t\t\tscope: new ProviderScope(),\n\t\t\tprofile: Object.freeze({ ...profile }),\n\t\t\tcwd: profile.cwd,\n\t\t\tattachments: 1,\n\t\t\tlastCommandAt: this.now(),\n\t\t\tlifecycleMutex: Promise.resolve(),\n\t\t};\n\t\tconst worker = new SessionWorkerClient({\n\t\t\treserve: (path) => this.reserve(handle, path),\n\t\t\texit: () => {\n\t\t\t\tif (this.entries.get(handle) !== entry) return;\n\t\t\t\tentry.state = \"closed\";\n\t\t\t\tthis.entries.delete(handle);\n\t\t\t\tfor (const [path, owner] of this.reservations) if (owner === handle) this.reservations.delete(path);\n\t\t\t\tentry.closeResolve?.();\n\t\t\t},\n\t\t\tfailure: (error) => {\n\t\t\t\tentry.state = \"quarantined\";\n\t\t\t\tprocess.stderr.write(`senpi rpc session ${handle} quarantined: ${error}\\n`);\n\t\t\t},\n\t\t});\n\t\tentry.worker = worker;\n\t\tthis.entries.set(handle, entry);\n\t\ttry {\n\t\t\tconst path = await worker.prepare(this.options.configuration, profile);\n\t\t\tconst owner = this.reservations.get(path);\n\t\t\tif (owner) {\n\t\t\t\tconst attached = this.attach(owner, path);\n\t\t\t\tentry.state = \"quarantined\";\n\t\t\t\tworker.quarantine();\n\t\t\t\treturn attached;\n\t\t\t}\n\t\t\tif (!this.reserve(handle, path)) throw new RpcSessionRegistryError(\"session_path_in_use\");\n\t\t\tentry.reservationKey = path;\n\t\t\tentry.sessionPath = path;\n\t\t\tconst snapshot = await worker.commit();\n\t\t\tif (entry.state !== \"opening\") throw new RpcSessionRegistryError(\"session_closing\");\n\t\t\tentry.durableSessionId = snapshot.state.sessionId;\n\t\t\tentry.cwd = snapshot.state.cwd;\n\t\t\tentry.state = \"open\";\n\t\t\treturn this.openResult(handle, entry);\n\t\t} catch (cause) {\n\t\t\tentry.state = \"quarantined\";\n\t\t\tworker.quarantine();\n\t\t\tthrow cause;\n\t\t}\n\t}\n\n\tpeek(handle: string): RpcSessionEntry | undefined {\n\t\treturn this.entries.get(handle);\n\t}\n\n\tgetForCommand(handle: string, command: string): RpcSessionEntry {\n\t\tconst entry = this.entries.get(handle);\n\t\tif (!entry) throw new RpcSessionRegistryError(\"unknown_session\");\n\t\tif (\n\t\t\tentry.state === \"quarantined\" ||\n\t\t\t(entry.state === \"closing\" && ![\"abort\", \"abort_bash\", \"extension_ui_response\"].includes(command))\n\t\t)\n\t\t\tthrow new RpcSessionRegistryError(\"session_closing\");\n\t\tif (entry.state !== \"open\" && entry.state !== \"closing\") throw new RpcSessionRegistryError(\"unknown_session\");\n\t\tentry.lastCommandAt = this.now();\n\t\treturn entry;\n\t}\n\n\tbeginClose(handle: string, onRole?: (finalizer: boolean) => void): RpcSessionEntry {\n\t\tconst entry = this.entries.get(handle);\n\t\tif (!entry) throw new RpcSessionRegistryError(\"unknown_session\");\n\t\tif (entry.state === \"closing\" || entry.state === \"quarantined\") {\n\t\t\tonRole?.(false);\n\t\t\treturn entry;\n\t\t}\n\t\tif (entry.state !== \"open\" && entry.state !== \"opening\") throw new RpcSessionRegistryError(\"unknown_session\");\n\t\tentry.attachments--;\n\t\tif (entry.attachments > 0) return entry;\n\t\tentry.state = \"closing\";\n\t\tentry.closeCompletion = new Promise((resolve) => {\n\t\t\tentry.closeResolve = resolve;\n\t\t});\n\t\tonRole?.(true);\n\t\treturn entry;\n\t}\n\n\tclose(handle: string): Promise<void> {\n\t\tconst entry = this.beginClose(handle);\n\t\treturn entry.state === \"closing\" ? this.closeMarked(handle) : Promise.resolve();\n\t}\n\n\tasync closeMarked(handle: string): Promise<void> {\n\t\tconst entry = this.entries.get(handle);\n\t\tif (entry?.state !== \"closing\" || !entry.worker) throw new RpcSessionRegistryError(\"unknown_session\");\n\t\tif (entry.closeStarted) return entry.closeCompletion;\n\t\tentry.closeStarted = true;\n\t\t// Reply on a bounded deadline, but keep entry, attachments and reservations until exit.\n\t\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\t\tawait Promise.race([\n\t\t\tentry.worker.close(this.closeGraceMs),\n\t\t\tnew Promise<void>((resolve) => {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (entry.state !== \"closed\") entry.state = \"quarantined\";\n\t\t\t\t\tresolve();\n\t\t\t\t}, this.closeGraceMs);\n\t\t\t}),\n\t\t]);\n\t\tif (timer) clearTimeout(timer);\n\t}\n\n\tlist(): Array<{\n\t\tsessionId: string;\n\t\tdurableSessionId?: string;\n\t\tsessionPath?: string;\n\t\tcwd: string;\n\t\tname?: string;\n\t\tstatus: Exclude<RpcSessionEntry[\"state\"], \"quarantined\">;\n\t}> {\n\t\treturn [...this.entries].map(([sessionId, entry]) => {\n\t\t\tconst state = entry.worker?.snapshot?.state;\n\t\t\treturn {\n\t\t\t\tsessionId,\n\t\t\t\tdurableSessionId: state?.sessionId ?? entry.durableSessionId,\n\t\t\t\tsessionPath: state?.sessionFile ?? entry.sessionPath,\n\t\t\t\tcwd: state?.cwd ?? entry.cwd,\n\t\t\t\tname: state?.sessionName,\n\t\t\t\tstatus: entry.state === \"quarantined\" ? \"closing\" : entry.state,\n\t\t\t};\n\t\t});\n\t}\n\n\t/** Only compare spellings already tied to a granted identity; do not inspect caller paths here. */\n\tprivate knownReservationKey(path: string): string | undefined {\n\t\tif (this.reservations.has(path)) return path;\n\t\tfor (const entry of this.entries.values()) {\n\t\t\tif (entry.profile.sessionPath === path && entry.reservationKey) return entry.reservationKey;\n\t\t\tconst snapshot = entry.worker?.snapshot;\n\t\t\tif (snapshot?.state.sessionFile === path) return snapshot.sessionPath;\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate attach(owner: string, path: string): OpenRpcSession {\n\t\tconst entry = this.entries.get(owner);\n\t\tif (entry?.state !== \"open\" || !entry.worker?.bindingReady || entry.worker.snapshot?.sessionPath !== path)\n\t\t\tthrow new RpcSessionRegistryError(\"session_path_in_use\");\n\t\tconst result = this.openResult(owner, entry);\n\t\tentry.attachments++;\n\t\tentry.lastCommandAt = this.now();\n\t\treturn { ...result, attached: true };\n\t}\n\n\tprivate reserve(handle: string, path: string): boolean {\n\t\tconst entry = this.entries.get(handle);\n\t\tif (!entry) return false;\n\t\tconst owner = this.reservations.get(path);\n\t\tif (owner) return owner === handle;\n\t\tif (entry.state !== \"opening\" && entry.state !== \"open\") return false;\n\t\tlet count = 0;\n\t\tfor (const current of this.reservations.values()) if (current === handle) count++;\n\t\tif (count >= SESSION_WORKER_LIMITS.reservations) return false;\n\t\tthis.reservations.set(path, handle);\n\t\treturn true;\n\t}\n\n\tprivate openResult(handle: string, entry: RpcSessionEntry): OpenRpcSession {\n\t\tconst state = entry.worker?.snapshot?.state;\n\t\tif (!state) throw new RpcSessionRegistryError(\"open_failed\");\n\t\treturn { sessionId: handle, durableSessionId: state.sessionId, sessionPath: state.sessionFile };\n\t}\n}\n"]}
package/docs/rpc.md CHANGED
@@ -177,8 +177,8 @@ hosts) sees neither variable and is unaffected. A host whose supervisor is alive
177
177
 
178
178
  ### Shared host occupancy (idle eviction, session cap, empty-host exit)
179
179
 
180
- Every open session owns a complete runtime (a lone idle session measures 340-510 MB RSS), so the host enforces three
181
- occupancy bounds itself, independent of the supervisor and of client cooperation:
180
+ Every CLI shared-host session owns a worker isolate and a complete runtime. Memory depends on its extensions and
181
+ session contents; isolates do not provide process-fatal OOM containment. The host enforces these occupancy bounds:
182
182
 
183
183
  - **Idle eviction**: a session with no routed command and no session-owned work for
184
184
  `SENPI_RPC_SESSION_IDLE_EVICTION_MS` (default 30 minutes) is closed through the exact `close_session` sequence
@@ -188,9 +188,13 @@ occupancy bounds itself, independent of the supervisor and of client cooperation
188
188
  background terminal jobs and any other published wake source (terminal monitors, loop-guard holds), compaction,
189
189
  and barrier-held session work all defer eviction, and the idle clock restarts when that work settles. An evicted
190
190
  session resumes like any other: the next `open_session` with the same `sessionPath` reopens it.
191
- - **Logical sessions**: `open_session` has no session-count admission limit. Every logical session remains isolated
192
- by its routing handle and provider scope inside this one shared daemon; idle eviction and empty-host shutdown
193
- reclaim resident resources without rejecting a new session.
191
+ - **Worker capacity**: at most 20 workers may be preparing, open, closing, or quarantined together. Admission beyond
192
+ this bound fails explicitly with `open_failed: too_many_sessions`; it never evicts another session or starts an OS
193
+ process as a fallback. This is a new externally visible bound for CLI shared mode, which previously admitted
194
+ unlimited logical sessions. It applies to new worker allocation, not attachments: a known canonical path or
195
+ original opening spelling joins its already-bound owner without allocating a worker, even at capacity. An
196
+ unknown path/alias still needs a preparation worker slot. In-process SDK registries using an injected runtime
197
+ factory retain their existing behavior.
194
198
  - **Empty-host exit**: when the registry holds zero sessions AND no client is connected, continuously for
195
199
  `SENPI_RPC_HOST_EMPTY_EXIT_MS` (default 15 minutes), the host exits through its clean shutdown path (flush, socket
196
200
  removal), for stdio and `--listen` hosts alike. A connected client counts as occupancy even with no session open,
@@ -200,14 +204,71 @@ occupancy bounds itself, independent of the supervisor and of client cooperation
200
204
  Values are positive integers; invalid values fall through to the defaults. These lifecycle windows run inside the host process,
201
205
  so they hold even for embedders and hand-started hosts that have no supervisor.
202
206
 
207
+ ### Worker ownership and flow control
208
+
209
+ The main host owns transport, attachments and canonical reservations. Workers canonicalize caller-supplied paths;
210
+ main does not synchronously traverse those paths. An open prepares a path, obtains the main host's exclusive grant,
211
+ and only then constructs its session writer and runtime. A conflicting alias attaches to an open owner or fails
212
+ explicitly before opening another writer.
213
+
214
+ SessionManager writes, switches, forks, new sessions and imports obtain the same grant before writer creation or
215
+ append-side normalization. Acquired paths are conservatively retained for that worker's entire lifetime, including
216
+ superseded paths after a switch. Each worker may reserve at most 64 paths; an exhausted reservation budget fails
217
+ explicitly. Close or an opening deadline requests worker termination, but does not release reservations or worker
218
+ capacity until the actual exit event. A syscall that cannot yet be interrupted can therefore keep an entry
219
+ internally quarantined after the routing handle has closed. `list_sessions` continues to publish `closing`, not a
220
+ new status: existing clients must not mistake a quarantined worker for a live reattach target. Retry the path only
221
+ after that entry disappears.
222
+
223
+ Each worker accepts at most 64 ordinary pending requests totaling 16 MiB, plus four reserved interrupt/UI-response
224
+ slots totaling 1 MiB. Prepare, commit and bind share one 30-second opening budget starting with worker allocation;
225
+ they do not each reset that budget. Interrupt requests have a five-second deadline. Ordinary commands remain bounded
226
+ by count and bytes without imposing a new timeout on long-running commands. The desktop's 60-second open/readmission
227
+ window is a separate client-side wait: the host normally reports its earlier 30-second failure within that window,
228
+ but a slow transport can delay delivery. Neither timeout proves worker exit or permits concurrent reopening.
229
+ Display updates coalesce to one pending update and one latest value; UI cancellation and close have separate control
230
+ messages. IPC output and snapshots are limited to 16 MiB per record, with one acknowledged record at a time. Credit
231
+ returns after the session's destinations consume their output, not merely on IPC receipt. A five-second credit
232
+ failure closes that session visibly (`session_error` followed by `session_closed`), rather than retaining an
233
+ unbounded queue. Socket queues retain their existing independent overflow/disconnect behavior. The default stdio
234
+ queue is bounded at 64 MiB or 4096 records, with reserved terminal-failure records and one control-overflow notice.
235
+ Close admission counts both queued output and pending close replies (including their serialized bytes) before
236
+ releasing an attachment or waiting for teardown. An admitted first closer reserves its lifecycle and terminal reply;
237
+ admitted joiners follow those records in FIFO order. Excess closes are not admitted and do not release ownership.
238
+ One bounded `overflow` record with `command: "close_session"` and
239
+ `error: "rpc_close_output_overflow, resync required"` reports the saturation episode instead of retaining a reply
240
+ or promise for each rejected request. Over sockets, this notice goes only to the requester whose close was rejected;
241
+ each connection may have at most one outstanding notice, released when its sink consumes it. After socket-only drain,
242
+ a subsequent saturation episode on the same connection can report a new notice without reconnecting. A healthy peer is not
243
+ told to resynchronize, and a newly affected or reconnected peer receives its own notice. Stdio retains one notice per
244
+ episode. Clients receiving a notice must stop issuing closes, drain output, and resynchronize unacknowledged requests; the notice is not a successful close acknowledgment. Admission resumes as capacity becomes available.
245
+ Canonical reservations and worker capacity remain held until native exit, including after close-output overflow.
246
+
247
+ The classic handler, extension UI bridge, renderer callbacks and provider scope run inside the owning worker; only
248
+ plain data crosses IPC. Inline `main()` extension factories cannot be cloned and are rejected in shared mode: use
249
+ file-backed extensions. Classic single-session RPC remains in-process. Standalone Bun builds must embed
250
+ `src/modes/rpc/session-worker.ts` as an explicit entrypoint; Node bundles must ship `session-worker.js` beside the
251
+ chunk containing its worker client. Third-party/rebranded Bun wrappers must pass the published
252
+ `dist/modes/rpc/session-worker.js` as an additional compile entry, set an explicit `--root`, and set
253
+ `--define=SENPI_RPC_SESSION_WORKER_ENTRY='"./<worker-path-relative-to-root>"'`. The define is a build-time
254
+ contract, not an environment variable. Its path must match Bun's embedded entry name, not the source machine's
255
+ absolute path or the runtime working directory. The client resolves it against its compiled `import.meta.url`
256
+ before constructing the Worker: Bun 1.4.0 resolves a bare relative Worker string against the real cwd instead
257
+ of the embedded filesystem. Verify the relocated wrapper on the wrapper's supported Bun version and platform
258
+ by opening two shared sessions;
259
+ a standalone Senpi smoke does not verify a wrapper's different compile graph.
260
+
261
+ Workers isolate JavaScript event loops, not OS processes: they do not promise syscall cancellation, process-fatal OOM
262
+ containment, or containment of arbitrary native code. They are not an extension sandbox.
263
+
203
264
  ### D1 normative table (multi-session mode)
204
265
 
205
266
  | Command | Params | Success data | Notes |
206
267
  | --- | --- | --- | --- |
207
268
  | `get_protocol_info` | - | `{ protocolVersion: 1, serverVersion: string, capabilities: string[], mode: "classic"\|"multi" }` | Answered in BOTH modes; side-effect-free; the capability probe. Multi-session hosts include `multi_session` plus the negotiated launch capabilities. |
208
269
  | `open_session` | `sessionPath?`, `cwd?`, `provider?`, `modelId?`, `thinkingLevel?`, `permissionPreset?` (all optional; paths MUST be absolute) | `{ sessionId, state: RpcSessionState, attached?: true }` | `sessionPath` = today's `--session` semantics (open-if-exists else create persisting there, `session-manager.ts:926-940`); `provider`/`modelId` applied only on create (resume restores the session's model — mirrors `SenpiSessionRuntime.ts:198-200`); params form the immutable launch profile (D8). When the path is already held by a fully-open session, the open ATTACHES to it: same routing handle, `attached: true`, one more attachment counted; the runtime is torn down only when the last attachment closes. Idle sessions past the eviction window are closed by the host itself. |
209
- | `close_session` | `sessionId` | `{}` | Aborts active work, awaits agent idle + settled persistence for up to the host grace window (default 10s), then force-releases the session; its response is the LAST record tagged with that handle for the first closer — no events after (test-pinned). A concurrent close joins the same teardown and receives its own successful response. |
210
- | `list_sessions` | - | `{ sessions: [{ sessionId, durableSessionId, sessionPath, cwd, name, status }] }` | Includes `opening`/`closing` entries with their status. |
270
+ | `close_session` | `sessionId` | `{}` | Aborts active work, awaits agent idle + settled persistence for up to the host grace window (default 10s), then quarantines any worker that has not exited without releasing its path reservation; its response is the LAST record tagged with that handle for the first closer — no events after (test-pinned). An admitted concurrent close joins the same teardown and receives its own successful response; output saturation rejects admission with the bounded close-overflow/resync notice described above. |
271
+ | `list_sessions` | - | `{ sessions: [{ sessionId, durableSessionId, sessionPath, cwd, name, status }] }` | Includes `opening`/`closing` entries. Internally quarantined workers remain externally `closing` until exit. |
211
272
  | every existing command | + `sessionId` (REQUIRED in multi mode) | unchanged | Routed to that session. |
212
273
 
213
274
  ### Identities (D6)
@@ -220,7 +281,7 @@ In the response `error` field, machine-matchable:
220
281
 
221
282
  - `unknown_session`
222
283
  - `session_closing`
223
- - `session_path_in_use` (path held by a session still `opening` or already `closing`; a fully-open session is attached instead)
284
+ - `session_path_in_use` (path held by an opening, closing, quarantined, or superseded owner; a fully-open current owner is attached instead)
224
285
  - `missing_session_id` (session-scoped command without `sessionId` in multi mode)
225
286
  - `multi_session_disabled` (`open_session` in classic mode)
226
287
  - `invalid_path` (relative `sessionPath`/`cwd`)
@@ -237,7 +298,7 @@ Strict FIFO per session; one total stdout order; cross-session order unspecified
237
298
 
238
299
  ### Duplicate/idempotency
239
300
 
240
- Duplicate `open_session` while a path reservation is held by a fully-open session → ATTACH (`attached: true`, same handle); while held by an `opening`/`closing` entry → `session_path_in_use`. `close_session` releases one attachment; the runtime is disposed only when the last attachment closes. A close for an entry already `closing` joins its in-flight teardown. `close_session` on unknown/already-closed → `unknown_session` error. The grace window is configurable by the host through `SENPI_RPC_CLOSE_GRACE_MS`. Request `id`s are client-owned; the server echoes them without dedup.
301
+ Duplicate `open_session` while a path reservation is held by a fully-open session → ATTACH (`attached: true`, same handle); while held by an `opening`/`closing` entry (including internal quarantine) → `session_path_in_use`. `close_session` releases one attachment; the runtime is disposed only when the last attachment closes. A close for an entry already `closing` joins its in-flight teardown. `close_session` on unknown/already-closed → `unknown_session` error. The grace window is configurable by the host through `SENPI_RPC_CLOSE_GRACE_MS`. Request `id`s are client-owned; the server echoes them without dedup.
241
302
 
242
303
  ## Protocol Overview
243
304
 
package/docs/settings.md CHANGED
@@ -191,6 +191,7 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the senpi version update check. Use `--
191
191
  | `compaction.enabled` | boolean | `true` | Enable auto-compaction |
192
192
  | `compaction.reserveTokens` | number | `16384` | Tokens reserved for LLM response |
193
193
  | `compaction.keepRecentTokens` | number | `20000` | Recent tokens to keep (not summarized) |
194
+ | `compaction.summarizationMaxDurationMs` | number | adaptive | Wall-clock budget for one summarization attempt: larger of 120s and 2ms per estimated input token, capped at 30min. Set a positive value to override |
194
195
 
195
196
  ```json
196
197
  {
@@ -12,6 +12,18 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.9] - 2026-09-09
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ ### Removed
26
+
15
27
  ## [2026.9.8] - 2026-09-08
16
28
 
17
29
  ### Breaking Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.9.8",
3
+ "version": "2026.9.9",
4
4
  "private": true,
5
5
  "description": "Source-only senpi extension package for codemode evaluation tools",
6
6
  "type": "module",
@@ -31,14 +31,14 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@babel/parser": "8.0.4",
34
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.8",
34
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.9",
35
35
  "typebox": "1.3.18"
36
36
  },
37
37
  "peerDependencies": {
38
- "@code-yeongyu/senpi": "2026.9.8"
38
+ "@code-yeongyu/senpi": "2026.9.9"
39
39
  },
40
40
  "devDependencies": {
41
- "@code-yeongyu/senpi": "2026.9.8"
41
+ "@code-yeongyu/senpi": "2026.9.9"
42
42
  },
43
43
  "keywords": [
44
44
  "senpi",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@earendil-works/pi-agent-core",
3
- "version": "2026.9.8",
3
+ "version": "2026.9.9",
4
4
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -35,8 +35,8 @@
35
35
  "prepublishOnly": "npm run build"
36
36
  },
37
37
  "dependencies": {
38
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.8",
39
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.9.8",
38
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.9",
39
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.9.9",
40
40
  "diff": "9.0.0",
41
41
  "ignore": "7.0.6",
42
42
  "typebox": "1.3.18",
@@ -1618,11 +1618,19 @@ function buildParams(model, context, isOAuthToken, options, unsignedThinkingRepl
1618
1618
  disableThinkingForRequest(params, model, compat);
1619
1619
  }
1620
1620
  }
1621
- // Anthropic rejects a thinking-enabled request whose final assistant turn
1622
- // contains tool_use but does not begin with a thinking block. Cross-model
1623
- // histories lose their signed thinking blocks (demoted to text or dropped),
1624
- // so degrade thinking for this request instead of failing every turn.
1625
- if (params.thinking && params.thinking.type !== "disabled" && finalAssistantTurnStartsWithToolUse(params.messages))
1621
+ // A budget-thinking request whose final assistant turn came from another API
1622
+ // and starts with tool_use (its thinking demoted to text or dropped) is the
1623
+ // history shape Anthropic has rejected with "final assistant message must
1624
+ // start with a thinking block", so that request degrades thinking instead of
1625
+ // failing every turn. Nothing else degrades: adaptive families accept a
1626
+ // tool_use-first final turn (verified live 2026-09-09 on claude-opus-5,
1627
+ // claude-opus-4-6 and claude-fable-5), the model itself skips thinking before
1628
+ // trivial tool calls, and every change to `thinking` re-keys the prompt
1629
+ // cache - degrading here re-wrote the whole cached prefix on each tool
1630
+ // continuation (the "cache misses every second prompt" report).
1631
+ if (params.thinking?.type === "enabled" &&
1632
+ finalAssistantTurnIsForeign(context.messages) &&
1633
+ finalAssistantTurnStartsWithToolUse(params.messages))
1626
1634
  disableThinkingForRequest(params, model, compat);
1627
1635
  if (options?.metadata) {
1628
1636
  const userId = options.metadata.user_id;
@@ -1663,6 +1671,16 @@ function applyExtraBodyToAnthropicParams(params, extraBody) {
1663
1671
  * thinking/redacted_thinking block. Anthropic requires thinking-enabled
1664
1672
  * requests to start that turn with a thinking block.
1665
1673
  */
1674
+ /** True when the last assistant message was produced through a different wire API. */
1675
+ function finalAssistantTurnIsForeign(messages) {
1676
+ for (let index = messages.length - 1; index >= 0; index--) {
1677
+ const message = messages[index];
1678
+ if (message.role !== "assistant")
1679
+ continue;
1680
+ return message.api !== "anthropic-messages";
1681
+ }
1682
+ return false;
1683
+ }
1666
1684
  function finalAssistantTurnStartsWithToolUse(messages) {
1667
1685
  for (let index = messages.length - 1; index >= 0; index--) {
1668
1686
  const message = messages[index];