@code-yeongyu/senpi 2026.10.10-10 → 2026.10.10-12

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 (187) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/bundle/chunks/{anthropic-messages-2B35OVB7.js → anthropic-messages-I4Z6VKS3.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-CV6N7JDD.js → app-server-command-EGTBOL5W.js} +2 -2
  4. package/dist/bundle/chunks/bun-extension-importer-QSQHKXVG.js +20 -0
  5. package/dist/bundle/chunks/{chunk-4WZF7G6N.js → chunk-2AYAOU6W.js} +12 -10
  6. package/dist/bundle/chunks/chunk-2ZGQRUGM.js +2506 -0
  7. package/dist/bundle/chunks/{chunk-ZPJHS64R.js → chunk-5BRVRFJI.js} +1 -1
  8. package/dist/bundle/chunks/chunk-6SBY4ZWC.js +2 -0
  9. package/dist/bundle/chunks/chunk-DRVT7RIO.js +2 -0
  10. package/dist/bundle/chunks/chunk-E75RO54O.js +14 -0
  11. package/dist/bundle/chunks/{chunk-ZCYTYHDN.js → chunk-IAYFQEXI.js} +1 -1
  12. package/dist/bundle/chunks/chunk-M4C6Z7UT.js +288 -0
  13. package/dist/bundle/chunks/chunk-N6HU6BEY.js +9 -0
  14. package/dist/bundle/chunks/chunk-NEFNNQSV.js +9 -0
  15. package/dist/bundle/chunks/{chunk-YI7SPFDN.js → chunk-Q3R44CGT.js} +2 -2
  16. package/dist/bundle/chunks/chunk-QKU6JOA4.js +2 -0
  17. package/dist/bundle/chunks/{chunk-JRGNL6WM.js → chunk-QXREUA5F.js} +1 -1
  18. package/dist/bundle/chunks/chunk-SIIACSBE.js +30 -0
  19. package/dist/bundle/chunks/{chunk-QQZREV6B.js → chunk-SSH4CLHJ.js} +1 -1
  20. package/dist/bundle/chunks/chunk-TOZBUPZP.js +3 -0
  21. package/dist/bundle/chunks/chunk-WASCMT6Y.js +2 -0
  22. package/dist/bundle/chunks/chunk-YU4AYH4C.js +2 -0
  23. package/dist/bundle/chunks/chunk-ZGCPUGLT.js +6 -0
  24. package/dist/bundle/chunks/{chunk-NTH33AXY.js → chunk-ZJOYLQXK.js} +1 -1
  25. package/dist/bundle/chunks/chunk-ZRWOLP4E.js +2 -0
  26. package/dist/bundle/chunks/cli-main-PANLBILV.js +3 -0
  27. package/dist/bundle/chunks/{help-fast-path-5P73752B.js → help-fast-path-IQAJFO2G.js} +1 -1
  28. package/dist/bundle/chunks/{host-command-MWQNTWFA.js → host-command-MWSVX37K.js} +1 -1
  29. package/dist/bundle/chunks/host-lifecycle-X2J5FTJL.js +3 -0
  30. package/dist/bundle/chunks/host-lifecycle.js +8 -8
  31. package/dist/bundle/chunks/interactive-mode-TQJU6K27.js +2 -0
  32. package/dist/bundle/chunks/multi-session-host-GBSMDUMG.js +26 -0
  33. package/dist/bundle/chunks/{package-manager-cli-PXP63ZPT.js → package-manager-cli-IBPABDGR.js} +1 -1
  34. package/dist/bundle/chunks/rotation-stream-JXT3JYW5.js +2 -0
  35. package/dist/bundle/chunks/rpc-mode-PFIAS2E5.js +2 -0
  36. package/dist/bundle/chunks/runtime-session-I4NDNGC5.js +2 -0
  37. package/dist/bundle/chunks/{schedule-command-MGZLHARF.js → schedule-command-Q4W37FAP.js} +1 -1
  38. package/dist/bundle/chunks/session-control-endpoint-YYFCJZXZ.js +3 -0
  39. package/dist/bundle/chunks/session-picker-YLDBGXTR.js +2 -0
  40. package/dist/bundle/chunks/session-worker.js +455 -448
  41. package/dist/bundle/cli.js +1 -1
  42. package/dist/bundle/index.js +1 -1
  43. package/dist/bundle/rpc-entry.js +1 -1
  44. package/dist/bundle/runtime-manifest.json +1 -1
  45. package/dist/cli/session-opening.d.ts +5 -0
  46. package/dist/cli/session-opening.js +11 -0
  47. package/dist/core/agent-session.d.ts +5 -0
  48. package/dist/core/agent-session.js +56 -33
  49. package/dist/core/engine-paused.d.ts +6 -0
  50. package/dist/core/engine-paused.js +50 -0
  51. package/dist/core/extensions/builtin/compaction/deterministic-fallback.d.ts +9 -2
  52. package/dist/core/extensions/builtin/compaction/deterministic-fallback.js +22 -45
  53. package/dist/core/extensions/builtin/compaction/index.js +55 -9
  54. package/dist/core/extensions/builtin/compaction/rejected-recovery.d.ts +11 -0
  55. package/dist/core/extensions/builtin/compaction/rejected-recovery.js +73 -0
  56. package/dist/core/extensions/builtin/compaction/retained-message-projection.d.ts +10 -0
  57. package/dist/core/extensions/builtin/compaction/retained-message-projection.js +102 -0
  58. package/dist/core/extensions/builtin/goal/continuation.js +2 -0
  59. package/dist/core/extensions/builtin/goal/direct-input-lifecycle.js +3 -1
  60. package/dist/core/extensions/builtin/goal/elapsed-ticker.js +2 -0
  61. package/dist/core/extensions/builtin/goal/format.d.ts +1 -1
  62. package/dist/core/extensions/builtin/goal/format.js +19 -4
  63. package/dist/core/extensions/builtin/goal/index.js +16 -9
  64. package/dist/core/extensions/builtin/goal/lifecycle-helpers.d.ts +1 -0
  65. package/dist/core/extensions/builtin/goal/lifecycle-helpers.js +57 -13
  66. package/dist/core/extensions/builtin/goal/monitor-continuation.js +2 -1
  67. package/dist/core/extensions/builtin/goal/persistence.js +2 -0
  68. package/dist/core/extensions/builtin/goal/renderers.js +9 -4
  69. package/dist/core/extensions/builtin/goal/store.d.ts +5 -0
  70. package/dist/core/extensions/builtin/goal/store.js +44 -0
  71. package/dist/core/extensions/builtin/goal/types.d.ts +10 -0
  72. package/dist/core/extensions/builtin/goal/types.js +4 -0
  73. package/dist/core/extensions/builtin/goal/ui.js +6 -3
  74. package/dist/core/extensions/builtin/mcp/index.js +4 -2
  75. package/dist/core/extensions/builtin/mcp/service-connection.js +1 -0
  76. package/dist/core/extensions/builtin/mcp/service-types.d.ts +2 -0
  77. package/dist/core/extensions/builtin/mcp/service.js +257 -74
  78. package/dist/core/extensions/builtin/terminal/process-identity.d.ts +1 -3
  79. package/dist/core/extensions/builtin/terminal/process-identity.js +1 -7
  80. package/dist/core/extensions/builtin/terminal/process-start-probe.js +11 -7
  81. package/dist/core/extensions/builtin/ttsr/index.js +19 -0
  82. package/dist/core/extensions/bun-extension-importer.d.ts +1 -1
  83. package/dist/core/extensions/bun-extension-importer.js +7 -3
  84. package/dist/core/extensions/extension-module-cache.d.ts +4 -3
  85. package/dist/core/extensions/extension-module-cache.js +8 -22
  86. package/dist/core/foreign-session-holders.d.ts +7 -0
  87. package/dist/core/foreign-session-holders.js +63 -0
  88. package/dist/core/moved-session-cwd.d.ts +7 -0
  89. package/dist/core/moved-session-cwd.js +23 -0
  90. package/dist/core/moved-sessions.d.ts +6 -0
  91. package/dist/core/moved-sessions.js +83 -24
  92. package/dist/core/resource-loader.js +5 -6
  93. package/dist/core/resume-target.js +3 -1
  94. package/dist/core/session-holders.d.ts +2 -1
  95. package/dist/core/session-holders.js +4 -4
  96. package/dist/core/session-manager.js +29 -10
  97. package/dist/core/webview/webview-readiness.d.ts +2 -0
  98. package/dist/core/webview/webview-readiness.js +9 -0
  99. package/dist/core/webview/webview-service.d.ts +4 -0
  100. package/dist/core/webview/webview-service.js +30 -5
  101. package/dist/main.js +14 -3
  102. package/dist/modes/app-server/daemon/process.d.ts +7 -1
  103. package/dist/modes/app-server/daemon/process.js +58 -6
  104. package/dist/modes/app-server/threads/goal-wire.js +2 -1
  105. package/dist/modes/interactive/interactive-mode.js +7 -0
  106. package/dist/modes/rpc/connection-handler.js +3 -2
  107. package/dist/modes/rpc/custom-capability.d.ts +2 -0
  108. package/dist/modes/rpc/custom-capability.js +2 -0
  109. package/dist/modes/rpc/host-capabilities.js +3 -1
  110. package/dist/modes/rpc/host-client-occupancy.d.ts +2 -1
  111. package/dist/modes/rpc/host-client-occupancy.js +3 -0
  112. package/dist/modes/rpc/host-daemon-registration.js +20 -6
  113. package/dist/modes/rpc/host-daemon-state.d.ts +17 -0
  114. package/dist/modes/rpc/host-daemon-state.js +60 -0
  115. package/dist/modes/rpc/host-ensure-start.js +13 -3
  116. package/dist/modes/rpc/host-ensure-stop.d.ts +1 -1
  117. package/dist/modes/rpc/host-ensure-stop.js +17 -4
  118. package/dist/modes/rpc/host-ensure-types.d.ts +2 -0
  119. package/dist/modes/rpc/host-ensure.js +20 -2
  120. package/dist/modes/rpc/host-gc-evidence.js +3 -0
  121. package/dist/modes/rpc/host-generations.js +4 -1
  122. package/dist/modes/rpc/host-legacy.d.ts +1 -1
  123. package/dist/modes/rpc/host-legacy.js +11 -2
  124. package/dist/modes/rpc/host-lifecycle-activity.d.ts +30 -0
  125. package/dist/modes/rpc/host-lifecycle-activity.js +136 -4
  126. package/dist/modes/rpc/host-lifecycle-launch.d.ts +4 -0
  127. package/dist/modes/rpc/host-lifecycle-launch.js +24 -1
  128. package/dist/modes/rpc/host-lifecycle-proxy.d.ts +2 -0
  129. package/dist/modes/rpc/host-lifecycle-proxy.js +4 -1
  130. package/dist/modes/rpc/host-lifecycle-scratch.js +5 -2
  131. package/dist/modes/rpc/host-lifecycle-shutdown.d.ts +1 -1
  132. package/dist/modes/rpc/host-lifecycle-shutdown.js +81 -37
  133. package/dist/modes/rpc/host-lifecycle.js +23 -1
  134. package/dist/modes/rpc/host-reservations.d.ts +2 -0
  135. package/dist/modes/rpc/host-reservations.js +61 -3
  136. package/dist/modes/rpc/host-stalled-evidence.js +17 -3
  137. package/dist/modes/rpc/host-stop-intent.d.ts +3 -0
  138. package/dist/modes/rpc/host-stop-intent.js +3 -0
  139. package/dist/modes/rpc/host-stop.js +4 -9
  140. package/dist/modes/rpc/host-successor.js +10 -3
  141. package/dist/modes/rpc/host-supervisor-log.d.ts +1 -0
  142. package/dist/modes/rpc/host-supervisor-log.js +9 -0
  143. package/dist/modes/rpc/multi-session-host.js +8 -6
  144. package/dist/modes/rpc/rpc-mode.d.ts +3 -1
  145. package/dist/modes/rpc/rpc-mode.js +3 -1
  146. package/dist/modes/rpc/rpc-types.d.ts +3 -1
  147. package/dist/modes/rpc/rpc-types.js +2 -0
  148. package/dist/modes/rpc/session-command-router.d.ts +1 -1
  149. package/dist/modes/rpc/session-command-router.js +18 -14
  150. package/dist/modes/rpc/session-held.d.ts +8 -0
  151. package/dist/modes/rpc/session-held.js +127 -0
  152. package/dist/modes/rpc/session-registry-types.d.ts +1 -1
  153. package/dist/modes/rpc/session-registry.d.ts +2 -0
  154. package/dist/modes/rpc/session-registry.js +4 -0
  155. package/dist/modes/rpc/session-worker-client.d.ts +3 -3
  156. package/dist/modes/rpc/session-worker-client.js +24 -12
  157. package/dist/modes/rpc/worker-session-claims.d.ts +15 -0
  158. package/dist/modes/rpc/worker-session-claims.js +61 -0
  159. package/dist/modes/rpc/worker-session-registry.d.ts +4 -0
  160. package/dist/modes/rpc/worker-session-registry.js +51 -8
  161. package/dist/utils/shell.d.ts +6 -2
  162. package/dist/utils/shell.js +20 -6
  163. package/docs/app-server.md +1 -1
  164. package/docs/rpc.md +75 -2
  165. package/docs/session-format.md +19 -0
  166. package/package.json +6 -6
  167. package/dist/bundle/chunks/bun-extension-importer-3TEDTSKJ.js +0 -20
  168. package/dist/bundle/chunks/chunk-5AKP2VFB.js +0 -16
  169. package/dist/bundle/chunks/chunk-6Y7VGPJB.js +0 -9
  170. package/dist/bundle/chunks/chunk-CIRZTP7W.js +0 -2
  171. package/dist/bundle/chunks/chunk-HKRW6HT5.js +0 -30
  172. package/dist/bundle/chunks/chunk-JEZWV7JW.js +0 -17
  173. package/dist/bundle/chunks/chunk-LR5EN45D.js +0 -7
  174. package/dist/bundle/chunks/chunk-MYVBK47X.js +0 -2
  175. package/dist/bundle/chunks/chunk-O6YOQUVX.js +0 -2773
  176. package/dist/bundle/chunks/chunk-TJPMB4VH.js +0 -2
  177. package/dist/bundle/chunks/chunk-U5KVIAHJ.js +0 -2
  178. package/dist/bundle/chunks/chunk-XMHO4QQN.js +0 -7
  179. package/dist/bundle/chunks/cli-main-TM4UFZVC.js +0 -3
  180. package/dist/bundle/chunks/host-lifecycle-B24G65GD.js +0 -3
  181. package/dist/bundle/chunks/interactive-mode-CBQW7WRK.js +0 -2
  182. package/dist/bundle/chunks/multi-session-host-4KXWGRNS.js +0 -25
  183. package/dist/bundle/chunks/rotation-stream-FQH63DMY.js +0 -2
  184. package/dist/bundle/chunks/rpc-mode-7WHJHMUP.js +0 -2
  185. package/dist/bundle/chunks/runtime-session-CWJNFRHY.js +0 -2
  186. package/dist/bundle/chunks/session-control-endpoint-OH7XV2FN.js +0 -3
  187. package/dist/bundle/chunks/session-picker-UUSW6YSU.js +0 -2
@@ -8,6 +8,7 @@ import { RPC_ERROR_INVALID_LAUNCH_PROFILE, RPC_ERROR_INVALID_SESSION_CONTEXT, RP
8
8
  import { runWithSessionAttribution } from "./session-attribution.js";
9
9
  import { createRpcSessionBinding } from "./session-binding.js";
10
10
  import { readDrainVerdicts } from "./session-drain.js";
11
+ import { dispatchSessionBinding, sessionHeldCheck } from "./session-held.js";
11
12
  import { RpcSessionRegistryError } from "./session-registry.js";
12
13
  import { releaseSession } from "./session-release.js";
13
14
  import { selectSweepEvictions } from "./session-sweep.js";
@@ -250,20 +251,14 @@ export class SessionCommandRouter {
250
251
  }
251
252
  if (!command.sessionId)
252
253
  return error(command.id, command.type, RPC_ERROR_MISSING_SESSION_ID);
254
+ const sessionId = command.sessionId;
253
255
  try {
254
- this.registry.getForCommand(command.sessionId, command.type);
255
- const binding = this.bindings.get(command.sessionId);
256
- if (!binding)
257
- return error(command.id, command.type, RPC_ERROR_UNKNOWN_SESSION);
258
- // A prompt's preflight runs on the session's loop and can outlive the client's request deadline on a
259
- // starved host; acknowledging receipt first lets the client wait it out (senpi#2871).
260
- if (command.type === "prompt")
261
- this.acknowledgePrompt(command.id, command.sessionId);
262
- await binding.handle(command);
256
+ const entry = this.registry.getForCommand(sessionId, command.type);
257
+ await dispatchSessionBinding(command, entry, this.bindings.get(sessionId), () => this.acknowledgePrompt(command.id, sessionId), (observedStarts) => this.registry.holderPids?.(observedStarts) ?? Promise.resolve([]));
263
258
  return undefined;
264
259
  }
265
260
  catch (cause) {
266
- return error(command.id, command.type, this.code(cause));
261
+ return error(command.id, command.type, this.code(cause), this.detail(cause));
267
262
  }
268
263
  }
269
264
  async dispose() {
@@ -483,7 +478,9 @@ export class SessionCommandRouter {
483
478
  if (retryFallbackError)
484
479
  return error(command.id, "open_session", `${RPC_ERROR_INVALID_LAUNCH_PROFILE}: ${retryFallbackError}`);
485
480
  let opened;
481
+ const assertSessionNotHeld = sessionHeldCheck((observedStarts) => this.registry.holderPids?.(observedStarts) ?? Promise.resolve([]));
486
482
  try {
483
+ await assertSessionNotHeld(command.sessionPath);
487
484
  opened = await this.registry.openSession({
488
485
  cwd: command.cwd ?? this.defaults.cwd,
489
486
  sessionPath: command.sessionPath,
@@ -506,6 +503,10 @@ export class SessionCommandRouter {
506
503
  { retainOnDisconnect: command.retain_on_disconnect === true });
507
504
  const openedSession = opened;
508
505
  const entry = this.registry.getForCommand(openedSession.sessionId, "open_session");
506
+ const state = entry.worker?.snapshot?.state;
507
+ // The runtime has now published its own lease. Recheck before attaching or replying,
508
+ // so an opener racing the initial lookup is rolled back rather than admitted.
509
+ await assertSessionNotHeld(entry.runtime?.session.sessionFile ?? state?.sessionFile ?? entry.sessionPath, entry.runtime?.session.sessionId ?? state?.sessionId ?? entry.durableSessionId);
509
510
  this.writer.setSessionKind(openedSession.sessionId, entry.kind);
510
511
  this.writer.setSessionMedia(openedSession.sessionId, toolMediaPersister(() => liveToolMediaScope(entry)));
511
512
  if (owner !== undefined) {
@@ -567,8 +568,8 @@ export class SessionCommandRouter {
567
568
  this.releaseOwnerAttachment(owner, openedSession.sessionId);
568
569
  await this.releaseOwnedSession(openedSession.sessionId);
569
570
  }
570
- const state = entry.worker?.snapshot?.state ?? (entry.runtime ? buildRpcSessionState(entry.runtime.session) : undefined);
571
- if (!state)
571
+ const replyState = entry.worker?.snapshot?.state ?? (entry.runtime ? buildRpcSessionState(entry.runtime.session) : undefined);
572
+ if (!replyState)
572
573
  throw new Error("Session state was not created");
573
574
  this.writer.enqueue(opened.sessionId, {
574
575
  id: command.id,
@@ -577,7 +578,7 @@ export class SessionCommandRouter {
577
578
  success: true,
578
579
  data: {
579
580
  sessionId: opened.sessionId,
580
- state,
581
+ state: replyState,
581
582
  ...(opened.attached ? { attached: true } : {}),
582
583
  },
583
584
  });
@@ -586,7 +587,10 @@ export class SessionCommandRouter {
586
587
  catch (cause) {
587
588
  if (opened) {
588
589
  try {
589
- await this.registry.close(opened.sessionId);
590
+ if (opened.attached)
591
+ await this.releaseOwnedSession(opened.sessionId);
592
+ else
593
+ await this.registry.close(opened.sessionId);
590
594
  }
591
595
  catch {
592
596
  /* The open rollback has already removed the entry. */
@@ -0,0 +1,8 @@
1
+ import type { RpcInboundRecord } from "./rpc-types.ts";
2
+ import type { RpcSessionBinding } from "./session-binding.ts";
3
+ import { type RpcSessionEntry } from "./session-registry-types.ts";
4
+ /** Fresh leases at each boundary; daemon identity resolution is lazy and scoped to one command. */
5
+ export declare function sessionHeldCheck(resolveDaemonPids?: (observedStarts: ReadonlyMap<number, number | undefined>) => Promise<readonly number[]>): (sessionFile: string | undefined, sessionId?: string) => Promise<void>;
6
+ /** The session-command admission boundary shared by in-process and worker hosts. */
7
+ export declare function dispatchSessionBinding(command: RpcInboundRecord, entry: RpcSessionEntry, binding: RpcSessionBinding | undefined, acknowledgePrompt: () => void, daemonPids?: (observedStarts: ReadonlyMap<number, number | undefined>) => Promise<readonly number[]>): Promise<void>;
8
+ //# sourceMappingURL=session-held.d.ts.map
@@ -0,0 +1,127 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readProcessStartMs } from "../../core/extensions/builtin/terminal/process-start-probe.js";
3
+ import { foreignSessionHolders } from "../../core/foreign-session-holders.js";
4
+ import { processStartTimeMs } from "../app-server/daemon/process.js";
5
+ import { RPC_ERROR_SESSION_HELD } from "./rpc-types.js";
6
+ import { RpcSessionRegistryError } from "./session-registry-types.js";
7
+ // Admission is conservative: a new command must pass the guard unless it is explicitly
8
+ // a read or a control needed to finish/cancel an already-admitted turn.
9
+ const READ_ONLY_OR_CONTROL = new Set([
10
+ "get_state",
11
+ "get_steering_messages",
12
+ "get_follow_up_messages",
13
+ "get_available_models",
14
+ "get_available_thinking_levels",
15
+ "get_fast_mode",
16
+ "get_session_stats",
17
+ "get_fork_messages",
18
+ "get_entries",
19
+ "get_tree",
20
+ "get_last_assistant_text",
21
+ "get_messages",
22
+ "get_media",
23
+ "get_commands",
24
+ "get_loaded_surfaces",
25
+ "get_auth_providers",
26
+ "get_provider_accounts",
27
+ "memory_report",
28
+ "export_html",
29
+ "export_jsonl",
30
+ "abort",
31
+ "interrupt",
32
+ "abort_compaction",
33
+ "abort_branch_summary",
34
+ "abort_retry",
35
+ "abort_bash",
36
+ "clear_queue",
37
+ "check_reload_veto",
38
+ "cleanup_bash_output",
39
+ "set_client_info",
40
+ "extension_ui_response",
41
+ "extension_ui_progress",
42
+ ]);
43
+ /** One bounded identity snapshot for all foreign PIDs, never one subprocess per lease. */
44
+ function readProcessStarts() {
45
+ const windows = process.platform === "win32";
46
+ const command = windows ? "powershell.exe" : "ps";
47
+ const args = windows
48
+ ? [
49
+ "-NoProfile",
50
+ "-NonInteractive",
51
+ "-Command",
52
+ 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { if ($null -ne $_.CreationDate) { "{0} {1}" -f $_.ProcessId, $_.CreationDate.ToUniversalTime().ToString("o") } }',
53
+ ]
54
+ : ["-axo", "pid=,lstart="];
55
+ return new Promise((resolve) => {
56
+ execFile(command, args, {
57
+ timeout: windows ? 5_000 : 1_000,
58
+ maxBuffer: 4 * 1024 * 1024,
59
+ windowsHide: true,
60
+ ...(windows ? {} : { env: { ...process.env, LC_ALL: "C", LANG: "C" } }),
61
+ }, (error, stdout) => {
62
+ if (error)
63
+ return resolve(undefined);
64
+ const starts = new Map();
65
+ for (const line of stdout.split("\n")) {
66
+ const row = /^\s*(\d+)\s+(.+)$/.exec(line);
67
+ if (!row)
68
+ continue;
69
+ const start = processStartTimeMs(row[2]);
70
+ if (start !== undefined)
71
+ starts.set(Number(row[1]), start);
72
+ }
73
+ resolve(starts);
74
+ });
75
+ });
76
+ }
77
+ /** Fresh leases at each boundary; daemon identity resolution is lazy and scoped to one command. */
78
+ export function sessionHeldCheck(resolveDaemonPids) {
79
+ let daemonPids;
80
+ let processStarts;
81
+ const pidStarts = new Map();
82
+ const observedStarts = new Map();
83
+ return async (sessionFile, sessionId) => {
84
+ const holders = await foreignSessionHolders(sessionFile, sessionId, {
85
+ readProcessStartMs: (pid) => {
86
+ let probe = pidStarts.get(pid);
87
+ if (!probe) {
88
+ probe = (async () => {
89
+ if (process.platform !== "linux")
90
+ processStarts ??= readProcessStarts();
91
+ const starts = await processStarts;
92
+ const start = starts?.get(pid) ?? (await readProcessStartMs(pid));
93
+ observedStarts.set(pid, start);
94
+ return start;
95
+ })();
96
+ pidStarts.set(pid, probe);
97
+ }
98
+ return probe;
99
+ },
100
+ });
101
+ if (holders.length === 0)
102
+ return;
103
+ daemonPids ??= resolveDaemonPids?.(observedStarts) ?? Promise.resolve([]);
104
+ const family = await daemonPids;
105
+ const foreign = holders.filter((holder) => !family.includes(holder.pid));
106
+ if (foreign.length > 0)
107
+ throw new RpcSessionRegistryError(RPC_ERROR_SESSION_HELD, undefined, {
108
+ holders: foreign.map(({ pid, cwd }) => ({ pid, ...(cwd === undefined ? {} : { cwd }) })),
109
+ });
110
+ };
111
+ }
112
+ /** The session-command admission boundary shared by in-process and worker hosts. */
113
+ export async function dispatchSessionBinding(command, entry, binding, acknowledgePrompt, daemonPids) {
114
+ if (!binding)
115
+ throw new RpcSessionRegistryError("unknown_session");
116
+ if (!READ_ONLY_OR_CONTROL.has(command.type)) {
117
+ const assertSessionNotHeld = sessionHeldCheck(daemonPids);
118
+ const state = entry.worker?.snapshot?.state;
119
+ await assertSessionNotHeld(entry.runtime?.session.sessionFile ?? state?.sessionFile ?? entry.sessionPath, entry.runtime?.session.sessionId ?? state?.sessionId ?? entry.durableSessionId);
120
+ if ("sessionPath" in command)
121
+ await assertSessionNotHeld(command.sessionPath);
122
+ }
123
+ if (command.type === "prompt")
124
+ acknowledgePrompt();
125
+ await binding.handle(command);
126
+ }
127
+ //# sourceMappingURL=session-held.js.map
@@ -63,7 +63,7 @@ export interface RpcSessionEntry {
63
63
  closeStarted?: boolean;
64
64
  }
65
65
  export declare class RpcSessionRegistryError extends Error {
66
- readonly code: "unknown_session" | "session_closing" | "session_path_in_use" | "session_id_in_use" | "session_reservation_limit" | "invalid_path" | "invalid_session_id" | "open_failed";
66
+ readonly code: "unknown_session" | "session_closing" | "session_path_in_use" | "session_held" | "session_id_in_use" | "session_reservation_limit" | "invalid_path" | "invalid_session_id" | "open_failed";
67
67
  /** Machine-readable context for the wire (`errorData`): who holds a path, when to retry. */
68
68
  readonly detail?: Readonly<Record<string, unknown>>;
69
69
  constructor(code: RpcSessionRegistryError["code"], reason?: string, detail?: Readonly<Record<string, unknown>>);
@@ -15,6 +15,8 @@ export declare class RpcSessionRegistry {
15
15
  constructor(options: RpcSessionRegistryOptions);
16
16
  /** Number of live entries, including ones still opening or closing. */
17
17
  get size(): number;
18
+ /** Daemon-family identities come from the endpoint's claims and generation registry. */
19
+ holderPids(observedStarts?: ReadonlyMap<number, number | undefined>): Promise<readonly number[]>;
18
20
  openSession(requested: RpcSessionLaunchProfile, options?: RpcSessionOpenOptions): Promise<OpenRpcSession>;
19
21
  /**
20
22
  * Read-only lookup with no state transitions or attachment accounting.
@@ -56,6 +56,10 @@ export class RpcSessionRegistry {
56
56
  get size() {
57
57
  return this.entries.size;
58
58
  }
59
+ /** Daemon-family identities come from the endpoint's claims and generation registry. */
60
+ holderPids(observedStarts) {
61
+ return this.options.pathReservations?.holderPids?.(observedStarts) ?? Promise.resolve([]);
62
+ }
59
63
  async openSession(requested, options) {
60
64
  this.validateProfile(requested);
61
65
  const profile = resolveMovedProfile(requested);
@@ -9,10 +9,10 @@ import type { RpcSessionLaunchProfile } from "./session-registry.ts";
9
9
  import type { SessionWriteGrant, WorkerSnapshot } from "./session-worker-protocol.ts";
10
10
  /** Lifecycle hooks the owning registry installs on every worker it allocates. */
11
11
  export interface SessionWorkerCallbacks {
12
- reserve: (path: string) => SessionWriteGrant;
12
+ reserve: (path: string) => SessionWriteGrant | Promise<SessionWriteGrant>;
13
13
  /** Every snapshot republishes which paths this worker still writes. */
14
- reconcile: (livePaths: readonly string[]) => void;
15
- exit: () => void;
14
+ reconcile: (livePaths: readonly string[]) => void | Promise<void>;
15
+ exit: () => void | Promise<void>;
16
16
  failure: (error: string) => void;
17
17
  }
18
18
  export declare class SessionWorkerClient {
@@ -24,7 +24,7 @@ export class SessionWorkerClient {
24
24
  this.listeners = new Set();
25
25
  this.cancelUiPending = false;
26
26
  this.callbacks = callbacks;
27
- this.exited = new Promise((resolve) => {
27
+ this.exited = new Promise((resolve, reject) => {
28
28
  this.worker.once("exit", () => {
29
29
  if (!this.stopped && !this.closeTimer)
30
30
  this.fail("session_worker_exited");
@@ -34,9 +34,16 @@ export class SessionWorkerClient {
34
34
  this.requests.close(new Error("session_worker_exited"));
35
35
  void this.webviewBroker?.dispose();
36
36
  this.listeners.clear();
37
- callbacks.exit();
38
- this.publishTerminalFailure();
39
- resolve();
37
+ const released = callbacks.exit();
38
+ if (released)
39
+ void released.then(() => {
40
+ this.publishTerminalFailure();
41
+ resolve();
42
+ }, reject);
43
+ else {
44
+ this.publishTerminalFailure();
45
+ resolve();
46
+ }
40
47
  });
41
48
  });
42
49
  this.worker.on("message", (message) => this.receive(message));
@@ -141,18 +148,24 @@ export class SessionWorkerClient {
141
148
  case "ready":
142
149
  case "result": {
143
150
  this.requests.receive(message);
151
+ if (this.snapshot)
152
+ void Promise.resolve(this.callbacks.reconcile(this.snapshot.liveSessionPaths)).catch(() => this.fail("session_claim_reconcile_failed"));
144
153
  return;
145
154
  }
146
155
  case "reserve":
147
- acknowledgeGrant(message.signal, this.callbacks.reserve(message.path));
156
+ void Promise.resolve(this.callbacks.reserve(message.path)).then((grant) => acknowledgeGrant(message.signal, this.stopped ? "conflict" : grant), () => acknowledgeGrant(message.signal, "conflict"));
148
157
  return;
149
158
  case "snapshot":
150
159
  this.snapshot = message.snapshot;
151
- this.callbacks.reconcile(message.snapshot.liveSessionPaths);
152
- acknowledge(message.signal, true);
153
- if (message.settled)
154
- for (const listener of [...this.listeners])
155
- listener();
160
+ void Promise.resolve(this.callbacks.reconcile(message.snapshot.liveSessionPaths)).then(() => {
161
+ acknowledge(message.signal, !this.stopped);
162
+ if (message.settled)
163
+ for (const listener of [...this.listeners])
164
+ listener();
165
+ }, () => {
166
+ acknowledge(message.signal, false);
167
+ this.fail("session_claim_reconcile_failed");
168
+ });
156
169
  return;
157
170
  case "control_done":
158
171
  this.cancelUiPending = false;
@@ -167,7 +180,6 @@ export class SessionWorkerClient {
167
180
  // Identity and activity commit before publication; clients may attach or disconnect on that event.
168
181
  if (message.snapshot) {
169
182
  this.snapshot = message.snapshot;
170
- this.callbacks.reconcile(message.snapshot.liveSessionPaths);
171
183
  }
172
184
  else if (this.snapshot)
173
185
  this.snapshot = {
@@ -183,7 +195,7 @@ export class SessionWorkerClient {
183
195
  }
184
196
  return writer.waitForSessionBackpressure(sessionId);
185
197
  };
186
- const consumed = message.connection === undefined ? enqueue() : writer.withConnection(message.connection, enqueue);
198
+ const consumed = Promise.resolve(message.snapshot ? this.callbacks.reconcile(message.snapshot.liveSessionPaths) : undefined).then(() => message.connection === undefined ? enqueue() : writer.withConnection(message.connection, enqueue));
187
199
  void consumed.then(() => acknowledge(message.signal, true), (cause) => {
188
200
  acknowledge(message.signal, false);
189
201
  this.fail(cause instanceof Error ? cause.message : String(cause));
@@ -0,0 +1,15 @@
1
+ import type { SessionPathOwner, SessionPathReservations } from "./host-reservations.ts";
2
+ /** Claims precede write grants; native exit also removes claims whose publication is still pending. */
3
+ export declare class WorkerSessionClaims {
4
+ private readonly reservations;
5
+ private readonly paths;
6
+ private readonly releases;
7
+ private closed;
8
+ constructor(reservations: SessionPathReservations | undefined);
9
+ claim(path: string, attached?: boolean): Promise<SessionPathOwner | undefined>;
10
+ setAttached(attached: boolean): void;
11
+ reconcile(livePaths: readonly string[], attached: boolean): void;
12
+ private release;
13
+ close(): Promise<void>;
14
+ }
15
+ //# sourceMappingURL=worker-session-claims.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { RpcSessionRegistryError } from "./session-registry-types.js";
2
+ /** Claims precede write grants; native exit also removes claims whose publication is still pending. */
3
+ export class WorkerSessionClaims {
4
+ constructor(reservations) {
5
+ this.paths = new Map();
6
+ this.releases = new Set();
7
+ this.closed = false;
8
+ this.reservations = reservations;
9
+ }
10
+ async claim(path, attached = true) {
11
+ if (this.closed)
12
+ throw new RpcSessionRegistryError("session_closing");
13
+ if (!this.reservations)
14
+ return undefined;
15
+ const previous = this.paths.get(path);
16
+ if (previous)
17
+ return previous.ownership;
18
+ const record = { ownership: this.reservations.claim(path, attached), attached };
19
+ this.paths.set(path, record);
20
+ const owner = await record.ownership;
21
+ if (owner && this.paths.get(path) === record)
22
+ this.paths.delete(path);
23
+ return owner;
24
+ }
25
+ setAttached(attached) {
26
+ for (const [path, record] of this.paths) {
27
+ if (record.attached === attached)
28
+ continue;
29
+ record.attached = attached;
30
+ void record.ownership.then((owner) => {
31
+ if (!owner && this.paths.get(path) === record)
32
+ this.reservations?.setAttached(path, record.attached);
33
+ });
34
+ }
35
+ }
36
+ reconcile(livePaths, attached) {
37
+ const live = new Set(livePaths);
38
+ for (const path of this.paths.keys())
39
+ if (!live.has(path))
40
+ void this.release(path);
41
+ this.setAttached(attached);
42
+ }
43
+ release(path) {
44
+ const record = this.paths.get(path);
45
+ if (!record)
46
+ return Promise.resolve();
47
+ this.paths.delete(path);
48
+ const removal = record.ownership.then(async (owner) => {
49
+ if (!owner)
50
+ await this.reservations?.release(path);
51
+ });
52
+ this.releases.add(removal);
53
+ void removal.then(() => this.releases.delete(removal), () => this.releases.delete(removal));
54
+ return removal;
55
+ }
56
+ async close() {
57
+ this.closed = true;
58
+ await Promise.all([...this.paths.keys()].map((path) => this.release(path)).concat([...this.releases]));
59
+ }
60
+ }
61
+ //# sourceMappingURL=worker-session-claims.js.map
@@ -1,4 +1,5 @@
1
1
  import type { CliRuntimeConfiguration } from "../../main.ts";
2
+ import { type SessionPathReservations as DaemonPathReservations } from "./host-reservations.ts";
2
3
  import { type OpenRpcSession, type RpcSessionEntry, type RpcSessionLaunchProfile, type RpcSessionOpenOptions, type RpcSessionRow } from "./session-registry.ts";
3
4
  import { SessionWorkerClient } from "./session-worker-client.ts";
4
5
  type SessionWorkerCallbacks = ConstructorParameters<typeof SessionWorkerClient>[0];
@@ -8,11 +9,13 @@ export interface WorkerSessionRegistryOptions {
8
9
  readonly now: () => number;
9
10
  /** Production builds the real worker; a caller may supply one to drive a lifecycle path deterministically. */
10
11
  readonly createWorker?: (callbacks: SessionWorkerCallbacks) => SessionWorkerClient;
12
+ readonly pathReservations?: DaemonPathReservations;
11
13
  }
12
14
  /** Transport-side lifecycle owner. Caller paths are never inspected on this event loop. */
13
15
  export declare class WorkerSessionRegistry {
14
16
  private readonly entries;
15
17
  private readonly reservations;
18
+ private readonly claims;
16
19
  private serial;
17
20
  readonly closeGraceMs: number;
18
21
  private readonly now;
@@ -20,6 +23,7 @@ export declare class WorkerSessionRegistry {
20
23
  private readonly createWorker;
21
24
  constructor(options: WorkerSessionRegistryOptions);
22
25
  get size(): number;
26
+ holderPids(observedStarts?: ReadonlyMap<number, number | undefined>): Promise<readonly number[]>;
23
27
  openSession(profile: RpcSessionLaunchProfile, options?: RpcSessionOpenOptions): Promise<OpenRpcSession>;
24
28
  peek(handle: string): RpcSessionEntry | undefined;
25
29
  getForCommand(handle: string, command: string): RpcSessionEntry;
@@ -1,16 +1,20 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  import { ProviderScope } from "@earendil-works/pi-ai/node/provider-scope";
3
3
  import { assertValidSessionId } from "../../core/session-manager.js";
4
+ import { SESSION_PATH_RETRY_AFTER_MS, } from "./host-reservations.js";
4
5
  import { refreshesSessionActivity } from "./session-command-activity.js";
5
6
  import { RESERVATION_DENIAL_CODES, SessionPathReservations, } from "./session-path-reservations.js";
6
7
  import { frozenProfile, RpcSessionRegistryError, sessionIdentity, } from "./session-registry.js";
8
+ import { releaseWithinGrace } from "./session-teardown.js";
7
9
  import { SessionWorkerClient } from "./session-worker-client.js";
8
10
  import { SESSION_WORKER_LIMITS, typedWorkerRefusal } from "./session-worker-protocol.js";
11
+ import { WorkerSessionClaims } from "./worker-session-claims.js";
9
12
  /** Transport-side lifecycle owner. Caller paths are never inspected on this event loop. */
10
13
  export class WorkerSessionRegistry {
11
14
  constructor(options) {
12
15
  this.entries = new Map();
13
16
  this.reservations = new SessionPathReservations();
17
+ this.claims = new Map();
14
18
  this.serial = 0;
15
19
  this.options = options;
16
20
  this.closeGraceMs = options.closeGraceMs;
@@ -20,6 +24,9 @@ export class WorkerSessionRegistry {
20
24
  get size() {
21
25
  return this.entries.size;
22
26
  }
27
+ holderPids(observedStarts) {
28
+ return this.options.pathReservations?.holderPids?.(observedStarts) ?? Promise.resolve([]);
29
+ }
23
30
  async openSession(profile, options) {
24
31
  if (!isAbsolute(profile.cwd) || (profile.sessionPath !== undefined && !isAbsolute(profile.sessionPath)))
25
32
  throw new RpcSessionRegistryError("invalid_path");
@@ -57,6 +64,8 @@ export class WorkerSessionRegistry {
57
64
  if (this.size >= SESSION_WORKER_LIMITS.workers)
58
65
  throw new Error("too_many_sessions");
59
66
  const handle = `rpc-${++this.serial}`;
67
+ const claims = new WorkerSessionClaims(this.options.pathReservations);
68
+ this.claims.set(handle, claims);
60
69
  const storedProfile = frozenProfile(profile);
61
70
  const entry = {
62
71
  state: "opening",
@@ -80,10 +89,16 @@ export class WorkerSessionRegistry {
80
89
  exit: () => {
81
90
  if (this.entries.get(handle) !== entry)
82
91
  return;
83
- entry.state = "closed";
84
- this.entries.delete(handle);
85
- this.reservations.releaseAll(handle);
86
- entry.closeResolve?.();
92
+ const finish = () => {
93
+ if (this.entries.get(handle) !== entry)
94
+ return;
95
+ entry.state = "closed";
96
+ this.entries.delete(handle);
97
+ this.claims.delete(handle);
98
+ this.reservations.releaseAll(handle);
99
+ entry.closeResolve?.();
100
+ };
101
+ return releaseWithinGrace({ closeGraceMs: this.closeGraceMs, releaseReservation: () => claims.close() }, handle, entry.reservationKey ?? handle).then(finish);
87
102
  },
88
103
  failure: (error) => {
89
104
  // The open below only learns that its entry left `opening`, never why. Without this the
@@ -105,7 +120,13 @@ export class WorkerSessionRegistry {
105
120
  worker.quarantine();
106
121
  return attached;
107
122
  }
108
- const grant = this.reserve(handle, path);
123
+ const predecessor = await claims.claim(path);
124
+ if (predecessor)
125
+ throw new RpcSessionRegistryError("session_path_in_use", undefined, {
126
+ owner: predecessor,
127
+ retry_after_ms: SESSION_PATH_RETRY_AFTER_MS,
128
+ });
129
+ const grant = await this.reserve(handle, path);
109
130
  if (grant !== "granted")
110
131
  throw new RpcSessionRegistryError(RESERVATION_DENIAL_CODES[grant]);
111
132
  entry.reservationKey = path;
@@ -163,6 +184,9 @@ export class WorkerSessionRegistry {
163
184
  // attachments; only an explicit close or eviction reaches the worker.
164
185
  if (options?.detach && entry.retainOnDisconnect) {
165
186
  entry.attachments = 0;
187
+ entry.detachedAt ??= this.now();
188
+ if (!entry.worker?.busy)
189
+ this.claims.get(handle)?.setAttached(false);
166
190
  return entry;
167
191
  }
168
192
  entry.state = "closing";
@@ -253,6 +277,7 @@ export class WorkerSessionRegistry {
253
277
  }
254
278
  const result = this.openResult(owner, entry);
255
279
  entry.attachments++;
280
+ this.claims.get(owner)?.setAttached(true);
256
281
  // Retention is a property of the live session: any attach may ask for it, and
257
282
  // no attach may revoke it for the clients that already rely on it.
258
283
  if (options?.retainOnDisconnect)
@@ -264,7 +289,7 @@ export class WorkerSessionRegistry {
264
289
  reservationCount(handle) {
265
290
  return this.reservations.count(handle);
266
291
  }
267
- reserve(handle, path) {
292
+ async reserve(handle, path) {
268
293
  const entry = this.entries.get(handle);
269
294
  if (!entry)
270
295
  return "conflict";
@@ -272,7 +297,16 @@ export class WorkerSessionRegistry {
272
297
  return "granted";
273
298
  if (entry.state !== "opening" && entry.state !== "open")
274
299
  return "conflict";
275
- return this.reservations.reserve(handle, path, this.liveWorkerPaths(entry));
300
+ const grant = this.reservations.reserve(handle, path, this.liveWorkerPaths(entry));
301
+ if (grant !== "granted")
302
+ return grant;
303
+ const owner = await this.claims.get(handle)?.claim(path, entry.attachments > 0 || entry.worker?.busy === true);
304
+ if (owner || this.entries.get(handle) !== entry || (entry.state !== "opening" && entry.state !== "open")) {
305
+ const live = this.liveWorkerPaths(entry);
306
+ this.reservations.reconcile(handle, live ?? { livePaths: [], sessionPath: entry.reservationKey });
307
+ return "conflict";
308
+ }
309
+ return "granted";
276
310
  }
277
311
  /**
278
312
  * Releases the grants a worker's latest snapshot no longer claims.
@@ -281,12 +315,21 @@ export class WorkerSessionRegistry {
281
315
  * quarantined keeps every grant until its real exit, because a worker stuck in a
282
316
  * syscall can still be writing a path it can no longer report.
283
317
  */
284
- reconcile(handle, livePaths) {
318
+ async reconcile(handle, livePaths) {
285
319
  const entry = this.entries.get(handle);
286
320
  if (entry?.state !== "open")
287
321
  return;
288
322
  const sessionPath = entry.worker?.snapshot?.sessionPath;
323
+ const attached = entry.attachments > 0 || entry.worker?.busy === true;
324
+ if (sessionPath) {
325
+ const owner = await this.claims.get(handle)?.claim(sessionPath, attached);
326
+ if (owner)
327
+ throw new RpcSessionRegistryError("session_path_in_use");
328
+ if (this.entries.get(handle) !== entry || entry.state !== "open")
329
+ return;
330
+ }
289
331
  this.reservations.reconcile(handle, { livePaths, sessionPath });
332
+ this.claims.get(handle)?.reconcile(sessionPath ? [...livePaths, sessionPath] : livePaths, attached);
290
333
  if (!sessionPath)
291
334
  return;
292
335
  entry.reservationKey = sessionPath;
@@ -1,3 +1,4 @@
1
+ import { type WindowsProcessRow } from "@earendil-works/pi-agent-core/node";
1
2
  /** Family of a resolved shell executable, used to pick invocation arguments. */
2
3
  export type ShellKind = "bash" | "sh" | "cmd" | "powershell";
3
4
  export interface ShellConfig {
@@ -63,7 +64,10 @@ export declare function killTrackedDetachedChildren(): void;
63
64
  */
64
65
  export declare function windowsTaskkillCandidates(env?: NodeJS.ProcessEnv): string[];
65
66
  /**
66
- * Kill a process and all its children on Windows via `taskkill /T`.
67
+ * Kill a process and its descendants on Windows: one process listing (bounded by `TASKKILL_TIMEOUT_MS`)
68
+ * decides the tree, a process counting as a child only when it started at or after the parent it names,
69
+ * and `taskkill /F` ends each pid by name. `/T` would also adopt an unrelated older process through a
70
+ * recycled parent pid (senpi#2999); it is used only when no listing can be read.
67
71
  *
68
72
  * Synchronous on purpose. Shutdown paths call `killTrackedDetachedChildren()` and then
69
73
  * `process.exit()` in the same tick (`emergencyTerminalExit()`), so neither an
@@ -78,7 +82,7 @@ export declare function windowsTaskkillCandidates(env?: NodeJS.ProcessEnv): stri
78
82
  * Nothing in-process can walk a Windows process tree without an external tool, so this
79
83
  * still beats leaving the whole tree running.
80
84
  */
81
- export declare function killWindowsProcessTree(pid: number, taskkillPaths?: string[]): void;
85
+ export declare function killWindowsProcessTree(pid: number, taskkillPaths?: string[], listProcesses?: () => readonly WindowsProcessRow[] | undefined): void;
82
86
  /**
83
87
  * Kill a process and all its children (cross-platform)
84
88
  */