@code-yeongyu/senpi 2026.11.10-1 → 2026.11.10-2

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 (129) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/bundle/chunks/{chunk-GPXZTSEX.js → chunk-2M54EAAY.js} +3 -3
  3. package/dist/bundle/chunks/{chunk-YPAQXRP3.js → chunk-47T6YMI2.js} +2 -2
  4. package/dist/bundle/chunks/{chunk-I5ASHSLZ.js → chunk-4JMGWVRH.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-HI5JSLG5.js → chunk-74IB3JPP.js} +2 -2
  6. package/dist/bundle/chunks/{chunk-NH7SYIGN.js → chunk-DLPV4B3R.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-Y7Z7XPUF.js → chunk-DZQA5LVN.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-2CNSCMYC.js → chunk-E5O4SP5I.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-3IYWH6RO.js → chunk-IKJCA5SH.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-H33A33M4.js → chunk-M2VJVJMK.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-L6NRDUWJ.js → chunk-OF3XVGG4.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-THYEZBUG.js → chunk-Q4G5DECT.js} +2 -2
  13. package/dist/bundle/chunks/{chunk-ARUV3US4.js → chunk-QATU6BBC.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-FAFIGBUD.js → chunk-TUEZNIBK.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-BELNOPAI.js → chunk-UOZDDYBN.js} +2 -2
  16. package/dist/bundle/chunks/{chunk-DBQBJTMF.js → chunk-VUY4BZDI.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-TGW4R7SI.js → chunk-XH24X5B2.js} +1 -1
  18. package/dist/bundle/chunks/chunk-XI6THO4F.js +2 -0
  19. package/dist/bundle/chunks/{chunk-X4XWAB3J.js → chunk-YCPURLPU.js} +38 -38
  20. package/dist/bundle/chunks/{chunk-LHEP3ACI.js → chunk-YNGEZBTP.js} +1 -1
  21. package/dist/bundle/chunks/{cli-main-VG3C2EXI.js → cli-main-Y6ALA5OI.js} +1 -1
  22. package/dist/bundle/chunks/{hand-entry-3LQKUTYS.js → hand-entry-IPDAIOGI.js} +1 -1
  23. package/dist/bundle/chunks/{host-command-CCASS7W4.js → host-command-2326GCUP.js} +1 -1
  24. package/dist/bundle/chunks/{host-lifecycle-WWC7HXQ6.js → host-lifecycle-TNHASSFJ.js} +1 -1
  25. package/dist/bundle/chunks/host-lifecycle.js +1 -1
  26. package/dist/bundle/chunks/{host-session-command-YJB2FLKU.js → host-session-command-BZYBDOIE.js} +1 -1
  27. package/dist/bundle/chunks/{host-socket-request-DMYXC56U.js → host-socket-request-QN4Q2P5E.js} +1 -1
  28. package/dist/bundle/chunks/{interactive-mode-CUN3WMUF.js → interactive-mode-LBQB7DDR.js} +1 -1
  29. package/dist/bundle/chunks/{main-P7XRC2OI.js → main-FFRFP34W.js} +1 -1
  30. package/dist/bundle/chunks/{multi-session-host-244X3P6L.js → multi-session-host-NDIHHJ2L.js} +11 -8
  31. package/dist/bundle/chunks/{package-manager-cli-UVWJOQA3.js → package-manager-cli-XCPAMOYI.js} +1 -1
  32. package/dist/bundle/chunks/{rpc-mode-G4I3HHPB.js → rpc-mode-TKNZM4PZ.js} +1 -1
  33. package/dist/bundle/chunks/{rpc-monitor-source-C6VQ3BC4.js → rpc-monitor-source-PGTDSGWP.js} +1 -1
  34. package/dist/bundle/chunks/{run-attached-tui-R3RB5P7G.js → run-attached-tui-JDUKXDI2.js} +1 -1
  35. package/dist/bundle/chunks/{schedule-command-5FG2OKSR.js → schedule-command-HF2DHABB.js} +1 -1
  36. package/dist/bundle/chunks/{session-control-endpoint-XWCFLJYC.js → session-control-endpoint-A6R6W2JQ.js} +1 -1
  37. package/dist/bundle/chunks/{session-picker-2FZSNFS7.js → session-picker-GWA4S2PO.js} +1 -1
  38. package/dist/bundle/chunks/session-worker.js +119 -116
  39. package/dist/bundle/cli.js +2 -2
  40. package/dist/bundle/host-supervisor.js +1 -1
  41. package/dist/bundle/index.js +1 -1
  42. package/dist/bundle/rpc-entry.js +1 -1
  43. package/dist/bundle/runtime-manifest.json +1 -1
  44. package/dist/core/agent-session-runtime.d.ts +2 -0
  45. package/dist/core/agent-session-runtime.js +5 -0
  46. package/dist/core/agent-session.d.ts +1 -1
  47. package/dist/core/agent-session.js +30 -10
  48. package/dist/core/extensions/builtin/config-reload/change-groups.js +6 -2
  49. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.js +32 -8
  50. package/dist/core/extensions/builtin/mcp/auth/oauth-grant.d.ts +43 -0
  51. package/dist/core/extensions/builtin/mcp/auth/oauth-grant.js +196 -0
  52. package/dist/core/extensions/builtin/mcp/auth/oauth-provider.d.ts +4 -17
  53. package/dist/core/extensions/builtin/mcp/auth/oauth-provider.js +64 -75
  54. package/dist/core/extensions/builtin/mcp/auth/oauth-refresh.d.ts +1 -1
  55. package/dist/core/extensions/builtin/mcp/auth/oauth-refresh.js +25 -4
  56. package/dist/core/extensions/builtin/mcp/auth/oauth.js +10 -7
  57. package/dist/core/extensions/builtin/mcp/auth/token-store.d.ts +18 -0
  58. package/dist/core/extensions/builtin/mcp/auth/token-store.js +3 -0
  59. package/dist/core/extensions/builtin/mcp/catalog-cache.js +7 -6
  60. package/dist/core/extensions/builtin/mcp/catalog.js +3 -2
  61. package/dist/core/extensions/builtin/mcp/invocation.js +6 -0
  62. package/dist/core/extensions/builtin/mcp/service-register.js +16 -2
  63. package/dist/core/extensions/builtin/mcp/service-tools-changed.js +61 -54
  64. package/dist/core/extensions/builtin/mcp/startup-race.d.ts +2 -0
  65. package/dist/core/extensions/builtin/mcp/startup-race.js +25 -7
  66. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5-2.js +6 -6
  67. package/dist/core/open-timing.d.ts +2 -0
  68. package/dist/core/open-timing.js +7 -1
  69. package/dist/core/session-manager.d.ts +2 -0
  70. package/dist/core/session-manager.js +15 -1
  71. package/dist/modes/interactive/session-control-rpc-commands.d.ts +1 -1
  72. package/dist/modes/interactive/session-control-rpc-commands.js +5 -0
  73. package/dist/modes/rpc/compact-admission-validation.js +4 -1
  74. package/dist/modes/rpc/connection-handler.d.ts +2 -0
  75. package/dist/modes/rpc/connection-handler.js +10 -2
  76. package/dist/modes/rpc/custom-capability.d.ts +2 -0
  77. package/dist/modes/rpc/custom-capability.js +2 -0
  78. package/dist/modes/rpc/effect-commands.d.ts +21 -0
  79. package/dist/modes/rpc/host-capabilities.d.ts +1 -0
  80. package/dist/modes/rpc/host-capabilities.js +3 -1
  81. package/dist/modes/rpc/host-observe-request.d.ts +2 -0
  82. package/dist/modes/rpc/host-observe-request.js +6 -1
  83. package/dist/modes/rpc/host-park-ledger.d.ts +19 -0
  84. package/dist/modes/rpc/host-park-ledger.js +110 -0
  85. package/dist/modes/rpc/lifecycle-delivery.js +4 -1
  86. package/dist/modes/rpc/media-placeholders.js +1 -0
  87. package/dist/modes/rpc/method-classes-schema-host.d.ts +42 -1
  88. package/dist/modes/rpc/method-classes-schema-host.js +24 -21
  89. package/dist/modes/rpc/method-classes-schema.d.ts +42 -1
  90. package/dist/modes/rpc/method-classes-schema.js +5 -2
  91. package/dist/modes/rpc/method-classes.d.ts +2 -0
  92. package/dist/modes/rpc/method-classes.js +3 -0
  93. package/dist/modes/rpc/multi-session-host.js +1 -1
  94. package/dist/modes/rpc/rpc-host-command-types.d.ts +14 -1
  95. package/dist/modes/rpc/rpc-host-response-types.d.ts +21 -0
  96. package/dist/modes/rpc/session-attachment-gate.js +2 -0
  97. package/dist/modes/rpc/session-binding.d.ts +1 -0
  98. package/dist/modes/rpc/session-binding.js +6 -1
  99. package/dist/modes/rpc/session-connection-context.d.ts +2 -0
  100. package/dist/modes/rpc/session-connection-context.js +4 -0
  101. package/dist/modes/rpc/session-registry-attach.d.ts +2 -0
  102. package/dist/modes/rpc/session-registry-attach.js +15 -11
  103. package/dist/modes/rpc/session-registry-claims.js +2 -0
  104. package/dist/modes/rpc/session-registry-open.js +13 -7
  105. package/dist/modes/rpc/session-registry-opening.js +2 -1
  106. package/dist/modes/rpc/session-registry-types.d.ts +4 -0
  107. package/dist/modes/rpc/session-registry.d.ts +3 -0
  108. package/dist/modes/rpc/session-registry.js +27 -3
  109. package/dist/modes/rpc/session-router-close.js +1 -0
  110. package/dist/modes/rpc/session-router-context.d.ts +4 -2
  111. package/dist/modes/rpc/session-router-context.js +3 -0
  112. package/dist/modes/rpc/session-router-host-reads.js +1 -0
  113. package/dist/modes/rpc/session-router-occupancy.js +10 -1
  114. package/dist/modes/rpc/session-router-open-result.d.ts +12 -0
  115. package/dist/modes/rpc/session-router-open-result.js +63 -0
  116. package/dist/modes/rpc/session-router-open.d.ts +3 -0
  117. package/dist/modes/rpc/session-router-open.js +15 -63
  118. package/dist/modes/rpc/session-router-park.d.ts +15 -0
  119. package/dist/modes/rpc/session-router-park.js +176 -0
  120. package/dist/modes/rpc/session-router-routing.d.ts +1 -1
  121. package/dist/modes/rpc/session-router-routing.js +7 -0
  122. package/dist/modes/rpc/session-status-source.js +2 -2
  123. package/docs/engine-socket/fixtures/admission-routes/h-legacy.json +1 -1
  124. package/docs/engine-socket/fixtures/rpc-inbound-samples.json +22 -2
  125. package/docs/rpc.md +50 -0
  126. package/native/socket-secret/prebuilds/win32-arm64/senpi_socket_secret.win32-arm64.node +0 -0
  127. package/native/socket-secret/prebuilds/win32-x64/senpi_socket_secret.win32-x64.node +0 -0
  128. package/package.json +6 -6
  129. package/dist/bundle/chunks/chunk-436DNN4Z.js +0 -2
@@ -24,6 +24,8 @@ export type SessionRuntime = AgentSessionRuntime;
24
24
  export type RpcSessionState = "opening" | "open" | "closing" | "quarantined" | "closed";
25
25
  export interface RpcSessionEntry {
26
26
  state: RpcSessionState;
27
+ /** Built speculatively: no public identity, attachment, listing, or disk persistence until claimed. */
28
+ speculative?: boolean;
27
29
  runtime?: SessionRuntime;
28
30
  worker?: SessionWorkerClient;
29
31
  /** The in-process session's ordered acceptance lane, present only while the compact-updates flag is on. */
@@ -116,6 +118,8 @@ export interface RpcSessionRegistryOptions {
116
118
  }
117
119
  /** Host-side lifecycle policy for one `open_session`, distinct from the session's launch profile. */
118
120
  export interface RpcSessionOpenOptions {
121
+ /** Router-owned park construction; never accepted directly from the wire. */
122
+ speculative?: boolean;
119
123
  /** Refuse unless the registry already owns the path; never prepare or construct a writer. */
120
124
  attachOnly?: boolean;
121
125
  /** Keep the session alive when its last client disconnects (`open_session.retain_on_disconnect`). */
@@ -4,6 +4,7 @@ export { frozenProfile, type OpenRpcSession, type RpcSessionEntry, type RpcSessi
4
4
  /** Process-local lifecycle owner for multi-session RPC runtimes. */
5
5
  export declare class RpcSessionRegistry {
6
6
  readonly supportsCompact: (profile?: RpcSessionLaunchProfile) => boolean;
7
+ readonly supportsParkClaim = true;
7
8
  private readonly entries;
8
9
  private readonly reservations;
9
10
  private readonly pendingClaims;
@@ -20,6 +21,8 @@ export declare class RpcSessionRegistry {
20
21
  constructor(options: RpcSessionRegistryOptions);
21
22
  /** Number of live entries, including ones still opening or closing. */
22
23
  get size(): number;
24
+ /** Commit the ledger's exclusive claim after its existing runtime has acquired a persistent file. */
25
+ claimPark(handle: string, retainOnDisconnect: boolean): Promise<OpenRpcSession>;
23
26
  /** Daemon-family identities come from the endpoint's claims and generation registry. */
24
27
  holderPids(observedStarts?: ReadonlyMap<number, number | undefined>): Promise<readonly number[]>;
25
28
  openSession(requested: RpcSessionLaunchProfile, options?: RpcSessionOpenOptions): Promise<OpenRpcSession>;
@@ -16,6 +16,7 @@ export class RpcSessionRegistry {
16
16
  return this.options.agentDir;
17
17
  }
18
18
  constructor(options) {
19
+ this.supportsParkClaim = true;
19
20
  this.entries = new Map();
20
21
  this.reservations = new Set();
21
22
  this.pendingClaims = new Map();
@@ -42,8 +43,10 @@ export class RpcSessionRegistry {
42
43
  closeGraceMs: this.closeGraceMs,
43
44
  get: (handle) => this.entries.get(handle),
44
45
  delete: (handle) => {
46
+ const speculative = this.entries.get(handle)?.speculative;
45
47
  const deleted = this.entries.delete(handle);
46
- this.options.onSizeChange?.(this.entries.size);
48
+ if (!speculative)
49
+ this.options.onSizeChange?.(this.size);
47
50
  return deleted;
48
51
  },
49
52
  releaseReservation: async (key) => {
@@ -66,7 +69,26 @@ export class RpcSessionRegistry {
66
69
  }
67
70
  /** Number of live entries, including ones still opening or closing. */
68
71
  get size() {
69
- return this.entries.size;
72
+ return [...this.entries.values()].filter((entry) => !entry.speculative).length;
73
+ }
74
+ /** Commit the ledger's exclusive claim after its existing runtime has acquired a persistent file. */
75
+ async claimPark(handle, retainOnDisconnect) {
76
+ const entry = this.entries.get(handle);
77
+ if (!entry?.speculative || entry.state !== "open" || !entry.runtime)
78
+ throw new RpcSessionRegistryError("unknown_session");
79
+ await entry.runtime.persistSession();
80
+ // No await between the duplicate decision and publication: an ordinary open may have
81
+ // reserved this identity while persistence was being enabled.
82
+ for (const other of this.entries.values())
83
+ if (!other.speculative && other.durableSessionId === entry.runtime.session.sessionId)
84
+ throw new RpcSessionRegistryError("session_id_in_use");
85
+ entry.speculative = false;
86
+ entry.attachments = 1;
87
+ entry.retainOnDisconnect = retainOnDisconnect;
88
+ entry.lastCommandAt = this.now();
89
+ this.syncRuntimeMetadata();
90
+ this.options.onSizeChange?.(this.size);
91
+ return { sessionId: handle, durableSessionId: entry.runtime.session.sessionId, sessionPath: entry.sessionPath };
70
92
  }
71
93
  /** Daemon-family identities come from the endpoint's claims and generation registry. */
72
94
  holderPids(observedStarts) {
@@ -143,7 +165,9 @@ export class RpcSessionRegistry {
143
165
  list() {
144
166
  this.syncRuntimeMetadata();
145
167
  const now = this.now();
146
- return [...this.entries].map(([sessionId, entry]) => ({
168
+ return [...this.entries]
169
+ .filter(([, entry]) => !entry.speculative)
170
+ .map(([sessionId, entry]) => ({
147
171
  sessionId,
148
172
  durableSessionId: entry.durableSessionId,
149
173
  sessionPath: entry.sessionPath,
@@ -208,6 +208,7 @@ export async function dispose(host) {
208
208
  stopSweep(host);
209
209
  if (host.drainTimer !== undefined)
210
210
  clearInterval(host.drainTimer);
211
+ await host.parks.stop();
211
212
  if (host.admissions)
212
213
  await host.admissions.dispose();
213
214
  await Promise.all([...host.bindings.entries()].map(async ([sessionId, binding]) => {
@@ -6,7 +6,9 @@ import type { SessionEventWriter } from "./session-event-writer.ts";
6
6
  import type { RpcSessionRegistry } from "./session-registry.ts";
7
7
  import { SessionAttachOrder } from "./session-attach-order.ts";
8
8
  import type { RpcHostSessionDefaults, RpcSessionIdlePolicy } from "./session-router-types.ts";
9
+ import { HostParkLedger } from "./host-park-ledger.ts";
9
10
  export declare class RouterContext {
11
+ readonly parks: HostParkLedger;
10
12
  readonly admissions: CompactAdmissions | undefined;
11
13
  readonly bindings: Map<string, RpcSessionBinding>;
12
14
  readonly stopLimitBroadcast: () => void;
@@ -18,7 +20,7 @@ export declare class RouterContext {
18
20
  readonly opensByConnection: Map<string, Set<Promise<void>>>;
19
21
  /** Connections whose socket has already been detached. */
20
22
  readonly releasedConnections: Set<string>;
21
- readonly registry: Pick<RpcSessionRegistry, "openSession" | "peek" | "getForCommand" | "beginClose" | "close" | "closeMarked" | "list" | "size" | "warm" | "closeGraceMs"> & Partial<Pick<RpcSessionRegistry, "supportsCompact" | "holderPids" | "attachSession" | "agentDir">>;
23
+ readonly registry: Pick<RpcSessionRegistry, "openSession" | "peek" | "getForCommand" | "beginClose" | "close" | "closeMarked" | "list" | "size" | "warm" | "closeGraceMs"> & Partial<Pick<RpcSessionRegistry, "supportsCompact" | "supportsParkClaim" | "claimPark" | "holderPids" | "attachSession" | "agentDir">>;
22
24
  readonly writer: SessionEventWriter;
23
25
  readonly defaults: RpcHostSessionDefaults;
24
26
  readonly createBinding: typeof createRpcSessionBinding;
@@ -59,6 +61,6 @@ export declare class RouterContext {
59
61
  readonly mainHeapBytes: () => number;
60
62
  readonly kernels: () => readonly RpcHostKernelMemory[];
61
63
  } | undefined;
62
- constructor(registry: Pick<RpcSessionRegistry, "openSession" | "peek" | "getForCommand" | "beginClose" | "close" | "closeMarked" | "list" | "size" | "warm" | "closeGraceMs"> & Partial<Pick<RpcSessionRegistry, "supportsCompact" | "holderPids" | "attachSession" | "agentDir">>, writer: SessionEventWriter, defaults: RpcHostSessionDefaults, createBinding?: typeof createRpcSessionBinding, connectionOptions?: Parameters<typeof createRpcSessionBinding>[4], idle?: RpcSessionIdlePolicy);
64
+ constructor(registry: Pick<RpcSessionRegistry, "openSession" | "peek" | "getForCommand" | "beginClose" | "close" | "closeMarked" | "list" | "size" | "warm" | "closeGraceMs"> & Partial<Pick<RpcSessionRegistry, "supportsCompact" | "supportsParkClaim" | "claimPark" | "holderPids" | "attachSession" | "agentDir">>, writer: SessionEventWriter, defaults: RpcHostSessionDefaults, createBinding?: typeof createRpcSessionBinding, connectionOptions?: Parameters<typeof createRpcSessionBinding>[4], idle?: RpcSessionIdlePolicy);
63
65
  }
64
66
  //# sourceMappingURL=session-router-context.d.ts.map
@@ -12,8 +12,11 @@ import { routingIdOf } from "./session-router-errors.js";
12
12
  import { sweepIdleSessions } from "./session-router-occupancy.js";
13
13
  import { followRetainedRecord } from "./session-router-residency.js";
14
14
  import { registryStatusSource } from "./session-status-source.js";
15
+ import { HostParkLedger } from "./host-park-ledger.js";
16
+ import { closePark } from "./session-router-park.js";
15
17
  export class RouterContext {
16
18
  constructor(registry, writer, defaults, createBinding = createRpcSessionBinding, connectionOptions = {}, idle) {
19
+ this.parks = new HostParkLedger((opened) => closePark(this, opened));
17
20
  this.admissions = compactUpdatesEnabled()
18
21
  ? new CompactAdmissions(ADMISSION_LIMITS, (socketEpoch, admissionId) => this.writer.routesAdmission(socketEpoch, admissionId))
19
22
  : undefined;
@@ -14,6 +14,7 @@ import { sessionMemory } from "./session-router-observation.js";
14
14
  export function answerProtocolInfo(host, command) {
15
15
  const hostCapabilities = multiSessionHostCapabilities({
16
16
  warm: host.registry.warm !== undefined,
17
+ parkClaim: host.registry.supportsParkClaim === true,
17
18
  toolRuntime: host.defaults.toolRuntime ?? "in-process",
18
19
  negotiated: host.connectionOptions?.capabilities ?? [],
19
20
  mcpPresenters: host.writer.mcpPresenters !== undefined,
@@ -12,6 +12,15 @@ const DRAIN_SWEEP_MS = 50;
12
12
  */
13
13
  export function beginDrain(host) {
14
14
  host.draining = true;
15
+ void host.parks
16
+ .stop()
17
+ .catch((cause) => {
18
+ process.stderr.write(`senpi rpc park close failed during drain: ${String(cause)}\n`);
19
+ })
20
+ .then(() => sweepDrain(host))
21
+ .catch((cause) => {
22
+ process.stderr.write(`senpi rpc drain sweep failed after park close: ${String(cause)}\n`);
23
+ });
15
24
  stopSweep(host);
16
25
  // Armed BEFORE the first pass, so a pass that fails is retried instead of ending the drain
17
26
  // (senpi#2285). Unref'd: a draining host must never be the reason the event loop stays open.
@@ -35,7 +44,7 @@ export function sweepDrain(host) {
35
44
  parkForHandoff(host, sessionId, "session_dir_removed");
36
45
  for (const sessionId of verdicts.park)
37
46
  parkForHandoff(host, sessionId, "handoff_parked");
38
- if (host.registry.size > 0 || host.handoffParks > 0 || host.finalizations.size > 0)
47
+ if (host.registry.size > 0 || host.parks.size > 0 || host.handoffParks > 0 || host.finalizations.size > 0)
39
48
  return;
40
49
  if (host.drainTimer !== undefined) {
41
50
  clearInterval(host.drainTimer);
@@ -0,0 +1,12 @@
1
+ import type { OpenRpcSession, RpcSessionEntry } from "./session-registry.ts";
2
+ import type { RouterContext } from "./session-router-context.ts";
3
+ /** A dropped opener waits until its open or claim has either transferred or released ownership. */
4
+ export declare function holdOpenConnection(host: RouterContext, owner: string): () => void;
5
+ /** One ownership transition for both a freshly built open and a claimed speculative open. */
6
+ export declare function ownOpen(host: RouterContext, opened: OpenRpcSession, owner: string | undefined, compact?: boolean): void;
7
+ export declare function publishOpen(host: RouterContext, opened: OpenRpcSession, entry: RpcSessionEntry): void;
8
+ export declare function replyOpen(host: RouterContext, opened: OpenRpcSession, entry: RpcSessionEntry, command: {
9
+ readonly type: "open_session" | "claim_session";
10
+ readonly id?: string;
11
+ }, reply?: boolean): void;
12
+ //# sourceMappingURL=session-router-open-result.d.ts.map
@@ -0,0 +1,63 @@
1
+ import { openReplyPermissionMode } from "../../core/permission-mode.js";
2
+ import { buildRpcSessionState } from "./connection-handler.js";
3
+ /** A dropped opener waits until its open or claim has either transferred or released ownership. */
4
+ export function holdOpenConnection(host, owner) {
5
+ const barrier = Promise.withResolvers();
6
+ const opens = host.opensByConnection.get(owner) ?? new Set();
7
+ opens.add(barrier.promise);
8
+ host.opensByConnection.set(owner, opens);
9
+ return () => {
10
+ barrier.resolve();
11
+ opens.delete(barrier.promise);
12
+ if (opens.size === 0)
13
+ host.opensByConnection.delete(owner);
14
+ };
15
+ }
16
+ /** One ownership transition for both a freshly built open and a claimed speculative open. */
17
+ export function ownOpen(host, opened, owner, compact = false) {
18
+ if (owner === undefined)
19
+ return;
20
+ if (!host.writer.hasRegisteredConnectionCapabilities(owner))
21
+ host.writer.setConnectionCapabilities(owner, host.pendingCapabilities.get(owner) ?? host.connectionOptions?.capabilities ?? []);
22
+ host.writer.attachConnectionToSession(owner, opened.sessionId, compact);
23
+ const owned = host.sessionsByConnection.get(owner) ?? new Map();
24
+ owned.set(opened.sessionId, (owned.get(opened.sessionId) ?? 0) + 1);
25
+ host.sessionsByConnection.set(owner, owned);
26
+ host.attachOrder.attached(opened.sessionId, owner);
27
+ }
28
+ export function publishOpen(host, opened, entry) {
29
+ if (opened.attached)
30
+ host.writer.status.publish(opened.sessionId, "attach", "client");
31
+ else {
32
+ host.writer.publishSessionOpened({
33
+ type: "session_opened",
34
+ sessionId: opened.sessionId,
35
+ durableSessionId: opened.durableSessionId,
36
+ ...(opened.sessionPath !== undefined ? { sessionPath: opened.sessionPath } : {}),
37
+ kind: entry.kind,
38
+ cwd: entry.cwd,
39
+ context: entry.context,
40
+ });
41
+ host.writer.status.publish(opened.sessionId, "open", "client");
42
+ }
43
+ }
44
+ export function replyOpen(host, opened, entry, command, reply = true) {
45
+ const state = entry.worker?.snapshot?.state ?? (entry.runtime ? buildRpcSessionState(entry.runtime.session) : undefined);
46
+ if (!state)
47
+ throw new Error("Session state was not created");
48
+ if (reply)
49
+ host.writer.enqueue(opened.sessionId, {
50
+ id: command.id,
51
+ type: "response",
52
+ command: command.type,
53
+ success: true,
54
+ data: {
55
+ sessionId: opened.sessionId,
56
+ state,
57
+ ...openReplyPermissionMode(state),
58
+ ...(opened.attached ? { attached: true } : {}),
59
+ ...(host.defaults.sessionPresence ? { presence: host.defaults.sessionPresence.view(state.sessionId) } : {}),
60
+ },
61
+ });
62
+ }
63
+ //# sourceMappingURL=session-router-open-result.js.map
@@ -1,6 +1,7 @@
1
1
  import { type OpenAcceptance } from "./compact-open-admission.ts";
2
2
  import type { HandoverParkedEntry } from "./host-handover-parked.ts";
3
3
  import type { RpcCommand, RpcResponse } from "./rpc-types.ts";
4
+ import type { OpenRpcSession } from "./session-registry.ts";
4
5
  import type { RouterContext } from "./session-router-context.ts";
5
6
  export declare function openWithBarrier(host: RouterContext, command: Extract<RpcCommand, {
6
7
  type: "open_session";
@@ -19,5 +20,7 @@ export declare function open(host: RouterContext, command: Extract<RpcCommand, {
19
20
  readonly signal?: AbortSignal;
20
21
  readonly defaults?: boolean;
21
22
  readonly contextOverlay?: HandoverParkedEntry["contextOverlay"];
23
+ /** An invisible speculative open, published only when its ledger hands it to a caller. */
24
+ readonly park?: (opened: OpenRpcSession) => void;
22
25
  }): Promise<RpcResponse | undefined>;
23
26
  //# sourceMappingURL=session-router-open.d.ts.map
@@ -1,7 +1,5 @@
1
- import { buildRpcSessionState } from "./connection-handler.js";
2
1
  import { compactUpdatesEnabled } from "../../core/engine-socket-flag.js";
3
2
  import { startOpenTimer } from "../../core/open-timing.js";
4
- import { openReplyPermissionMode } from "../../core/permission-mode.js";
5
3
  import { compactOpen } from "./compact-open-admission.js";
6
4
  import { CompactAdmissionError } from "./compact-admission-error.js";
7
5
  import { runAdmissionProducer } from "./compact-admission-producer.js";
@@ -12,6 +10,8 @@ import { close } from "./session-router-close.js";
12
10
  import { code, detail, error } from "./session-router-errors.js";
13
11
  import { sweepDrain } from "./session-router-occupancy.js";
14
12
  import { invalidSpawnedBy, validatedOpenProfile } from "./session-router-open-profile.js";
13
+ import { holdOpenConnection, ownOpen, publishOpen, replyOpen } from "./session-router-open-result.js";
14
+ import { closePark } from "./session-router-park.js";
15
15
  import { relocationConnection } from "./session-router-relocation.js";
16
16
  import { syncRetainedRecord } from "./session-router-residency.js";
17
17
  import { liveToolMediaScope, toolMediaPersister } from "./tool-media-store.js";
@@ -24,18 +24,12 @@ function openPhysicalWithBarrier(host, command, accept, signal) {
24
24
  const owner = host.writer.currentConnection();
25
25
  if (owner === undefined)
26
26
  return open(host, command);
27
- let resolveBarrier;
28
- const barrier = new Promise((resolve) => {
29
- resolveBarrier = resolve;
30
- });
31
- const opens = host.opensByConnection.get(owner) ?? new Set();
32
27
  // Every open already accepted anywhere on this host, not just on this connection: the
33
28
  // in-process runtime serves them on ONE loop, so the wait this caller faces is the total.
34
29
  let inFlight = 0;
35
30
  for (const pending of host.opensByConnection.values())
36
31
  inFlight += pending.size;
37
- opens.add(barrier);
38
- host.opensByConnection.set(owner, opens);
32
+ const releaseBarrier = holdOpenConnection(host, owner);
39
33
  // Before the open reaches the loop: a client that later hits its deadline can then say
40
34
  // where it was queued instead of reporting a bare timeout (senpi#1844).
41
35
  if (command.id !== undefined)
@@ -49,12 +43,7 @@ function openPhysicalWithBarrier(host, command, accept, signal) {
49
43
  // A late open's physical session is released by its admission (the accept/rollback path, 4272-admission-
50
44
  // abandonment), so the late response itself owns nothing to release here.
51
45
  const opened = signal === undefined ? start() : runAdmissionProducer(signal, start, async () => { });
52
- return opened.finally(() => {
53
- resolveBarrier();
54
- opens.delete(barrier);
55
- if (opens.size === 0)
56
- host.opensByConnection.delete(owner);
57
- });
46
+ return opened.finally(releaseBarrier);
58
47
  }
59
48
  /**
60
49
  * The successor's reopen of a handoff-parked session: the same open as an `open_session`, with no
@@ -87,6 +76,7 @@ export async function open(host, command, owner, options) {
87
76
  {
88
77
  retainOnDisconnect: command.retain_on_disconnect === true,
89
78
  attachOnly: command.expectedHostInstance !== undefined,
79
+ ...(options?.park ? { speculative: true } : {}),
90
80
  });
91
81
  timer.mark("registry_open");
92
82
  options?.signal?.throwIfAborted();
@@ -119,22 +109,11 @@ export async function open(host, command, owner, options) {
119
109
  host.writer.registerAcceptance(openedSession.sessionId, lane);
120
110
  if (options?.accept)
121
111
  await options.accept(openedSession);
122
- if (owner !== undefined) {
123
- if (!host.writer.hasRegisteredConnectionCapabilities(owner))
124
- host.writer.setConnectionCapabilities(owner, host.pendingCapabilities.get(owner) ?? host.connectionOptions?.capabilities ?? []);
125
- host.writer.attachConnectionToSession(owner, openedSession.sessionId, options?.accept !== undefined);
126
- }
127
- // A client that dies without close_session (terminal closed, SIGKILL, dropped
128
- // SSH) still holds this handle's attachment and its path reservation. Remember
129
- // which connection owns it so releaseConnection() can close exactly that.
130
- if (owner !== undefined) {
131
- const owned = host.sessionsByConnection.get(owner) ?? new Map();
132
- owned.set(openedSession.sessionId, (owned.get(openedSession.sessionId) ?? 0) + 1);
133
- host.sessionsByConnection.set(owner, owned);
134
- host.attachOrder.attached(openedSession.sessionId, owner);
135
- }
112
+ ownOpen(host, openedSession, owner, options?.accept !== undefined);
136
113
  if (!host.bindings.has(openedSession.sessionId)) {
137
- host.bindings.set(openedSession.sessionId, await host.createBinding(openedSession.sessionId, entry, host.writer, () => void close(host, { type: "close_session", sessionId: openedSession.sessionId }), {
114
+ host.bindings.set(openedSession.sessionId, await host.createBinding(openedSession.sessionId, entry, host.writer, () => void (entry.speculative
115
+ ? closePark(host, openedSession)
116
+ : close(host, { type: "close_session", sessionId: openedSession.sessionId })), {
138
117
  ...host.connectionOptions,
139
118
  capabilities: owner !== undefined
140
119
  ? (host.pendingCapabilities.get(owner) ?? host.connectionOptions?.capabilities ?? [])
@@ -181,20 +160,11 @@ export async function open(host, command, owner, options) {
181
160
  });
182
161
  }
183
162
  options?.signal?.throwIfAborted();
184
- if (opened.attached)
185
- host.writer.status.publish(openedSession.sessionId, "attach", "client");
186
- else {
187
- host.writer.publishSessionOpened({
188
- type: "session_opened",
189
- sessionId: openedSession.sessionId,
190
- durableSessionId: openedSession.durableSessionId,
191
- ...(openedSession.sessionPath !== undefined ? { sessionPath: openedSession.sessionPath } : {}),
192
- kind: entry.kind,
193
- cwd: entry.cwd,
194
- context: entry.context,
195
- });
196
- host.writer.status.publish(openedSession.sessionId, "open", "client");
163
+ if (options?.park) {
164
+ options.park(openedSession);
165
+ return undefined;
197
166
  }
167
+ publishOpen(host, openedSession, entry);
198
168
  if (!options?.accept && !host.draining && owner !== undefined && host.releasedConnections.has(owner)) {
199
169
  releaseOwnerAttachment(host, owner, openedSession.sessionId);
200
170
  await releaseOwnedSession(host, openedSession.sessionId);
@@ -205,26 +175,8 @@ export async function open(host, command, owner, options) {
205
175
  if (options?.accept) {
206
176
  return undefined;
207
177
  }
208
- const replyState = entry.worker?.snapshot?.state ?? (entry.runtime ? buildRpcSessionState(entry.runtime.session) : undefined);
209
- if (!replyState)
210
- throw new Error("Session state was not created");
211
178
  timer.mark("reply");
212
- if (options?.reply !== false)
213
- host.writer.enqueue(opened.sessionId, {
214
- id: command.id,
215
- type: "response",
216
- command: "open_session",
217
- success: true,
218
- data: {
219
- sessionId: opened.sessionId,
220
- state: replyState,
221
- ...openReplyPermissionMode(replyState),
222
- ...(opened.attached ? { attached: true } : {}),
223
- ...(host.defaults.sessionPresence
224
- ? { presence: host.defaults.sessionPresence.view(replyState.sessionId) }
225
- : {}),
226
- },
227
- });
179
+ replyOpen(host, opened, entry, command, options?.reply !== false);
228
180
  return undefined;
229
181
  }
230
182
  catch (cause) {
@@ -233,7 +185,7 @@ export async function open(host, command, owner, options) {
233
185
  // rollback, and only while the registry still holds the live entry, so only that
234
186
  // rollback owes its subscribers the terminal record; an attach rollback, or one whose
235
187
  // entry is already gone, never announced the session in the first place (#3198 review, L1).
236
- if (!opened.attached && !options?.signal?.aborted) {
188
+ if (!options?.park && !opened.attached && !options?.signal?.aborted) {
237
189
  const entry = host.registry.peek(opened.sessionId);
238
190
  if (entry?.runtime !== undefined || entry?.worker !== undefined)
239
191
  host.writer.sealRolledBackOpen(opened.sessionId, entry.sessionPath);
@@ -0,0 +1,15 @@
1
+ import type { RpcCommand, RpcResponse } from "./rpc-types.ts";
2
+ import type { OpenRpcSession } from "./session-registry.ts";
3
+ import type { RouterContext } from "./session-router-context.ts";
4
+ type ParkCommand = Extract<RpcCommand, {
5
+ type: "park_session";
6
+ }>;
7
+ type ClaimCommand = Extract<RpcCommand, {
8
+ type: "claim_session";
9
+ }>;
10
+ /** Quiet teardown: speculation never owes a public terminal record or a handover park. */
11
+ export declare function closePark(host: RouterContext, opened: OpenRpcSession): Promise<void>;
12
+ export declare function parkSession(host: RouterContext, command: ParkCommand): Promise<RpcResponse>;
13
+ export declare function claimSession(host: RouterContext, command: ClaimCommand): Promise<RpcResponse | undefined>;
14
+ export {};
15
+ //# sourceMappingURL=session-router-park.d.ts.map
@@ -0,0 +1,176 @@
1
+ import { runWithProviderScope } from "@earendil-works/pi-ai/node/provider-scope";
2
+ import { withoutOpenTiming } from "../../core/open-timing.js";
3
+ import { outsideCommand } from "./command-authority.js";
4
+ import { runSetThinkingLevel } from "./effect-commands.js";
5
+ import { runSetModel } from "./handlers-model.js";
6
+ import { runSetPermissionPreset } from "./permission-preset-command.js";
7
+ import { applyAttachedProfile } from "./session-registry-attach.js";
8
+ import { releaseOwnedSession, releaseOwnerAttachment } from "./session-router-attachments.js";
9
+ import { finalizeClose, tryClaimClose } from "./session-router-close.js";
10
+ import { code, error } from "./session-router-errors.js";
11
+ import { open } from "./session-router-open.js";
12
+ import { validatedOpenProfile } from "./session-router-open-profile.js";
13
+ import { holdOpenConnection, ownOpen, publishOpen, replyOpen } from "./session-router-open-result.js";
14
+ import { syncRetainedRecord } from "./session-router-residency.js";
15
+ function profileKey(profile) {
16
+ return JSON.stringify([
17
+ profile.cwd,
18
+ profile.sessionKind ?? "interactive",
19
+ Object.entries(profile.sessionContext ?? {}).sort(([a], [b]) => a.localeCompare(b)),
20
+ ]);
21
+ }
22
+ /** Same open, host-context merge, kind, media and binding; no owner and no publication. */
23
+ async function buildPark(host, command) {
24
+ let opened;
25
+ const response = await outsideCommand(() => host.writer.connectionContext.outside(() => withoutOpenTiming(() => open(host, { ...command, type: "open_session" }, undefined, {
26
+ reply: false,
27
+ park: (result) => {
28
+ opened = result;
29
+ },
30
+ }))));
31
+ if (response && !response.success)
32
+ throw new Error(response.error);
33
+ if (!opened)
34
+ throw new Error("park_open_failed");
35
+ return opened;
36
+ }
37
+ /** Quiet teardown: speculation never owes a public terminal record or a handover park. */
38
+ export async function closePark(host, opened) {
39
+ const claim = tryClaimClose(host, opened.sessionId, { drainAttachments: true });
40
+ if (claim?.finalizer)
41
+ await finalizeClose(host, opened.sessionId, host.bindings.get(opened.sessionId));
42
+ else
43
+ await host.finalizations.get(opened.sessionId)?.promise;
44
+ host.writer.forgetSession(opened.sessionId);
45
+ }
46
+ export async function parkSession(host, command) {
47
+ if (!host.registry.supportsParkClaim)
48
+ return error(command.id, command.type, "park_claim_unsupported");
49
+ if (host.draining || host.drainExitRequested)
50
+ return error(command.id, command.type, "host_draining");
51
+ const profile = validatedOpenProfile(host, { ...command, type: "open_session" });
52
+ if ("type" in profile)
53
+ return { ...profile, command: command.type };
54
+ try {
55
+ const state = await host.parks.park(profileKey(profile), () => buildPark(host, command));
56
+ if (host.draining || host.drainExitRequested)
57
+ return error(command.id, command.type, "host_draining");
58
+ return { id: command.id, type: "response", command: command.type, success: true, data: { state } };
59
+ }
60
+ catch (cause) {
61
+ return error(command.id, command.type, code(cause));
62
+ }
63
+ }
64
+ export function claimSession(host, command) {
65
+ const owner = host.writer.currentConnection();
66
+ const release = owner === undefined ? undefined : holdOpenConnection(host, owner);
67
+ return claimReady(host, command, owner).finally(release);
68
+ }
69
+ async function claimReady(host, command, owner) {
70
+ if (!host.registry.supportsParkClaim || !host.registry.claimPark)
71
+ return error(command.id, command.type, "park_claim_unsupported");
72
+ if (host.draining || host.drainExitRequested)
73
+ return error(command.id, command.type, "host_draining");
74
+ const miss = () => ({
75
+ id: command.id,
76
+ type: "response",
77
+ command: "claim_session",
78
+ success: true,
79
+ data: { claimed: false },
80
+ });
81
+ // These require construction or a distinct admission contract. A miss leaves the park untouched.
82
+ if (command.sessionPath !== undefined ||
83
+ command.durableSessionId !== undefined ||
84
+ command.expectedHostInstance !== undefined ||
85
+ command.admissionId !== undefined ||
86
+ command.attended === false ||
87
+ command.retry !== undefined ||
88
+ (command.provider !== undefined) !== (command.modelId !== undefined) ||
89
+ command.spawnedBy !== undefined ||
90
+ command.env !== undefined ||
91
+ command.auto_title !== undefined ||
92
+ command.retryFallback !== undefined ||
93
+ (owner !== undefined && host.writer.compactSelection(owner)?.selected))
94
+ return miss();
95
+ const profile = validatedOpenProfile(host, { ...command, type: "open_session" });
96
+ if ("type" in profile)
97
+ return { ...profile, command: command.type };
98
+ const key = profileKey(profile);
99
+ const opened = host.parks.take(key);
100
+ if (!opened)
101
+ return miss();
102
+ let success = false;
103
+ try {
104
+ const entry = host.registry.peek(opened.sessionId);
105
+ if (entry?.state !== "open" || !entry.runtime)
106
+ return miss();
107
+ const runtime = entry.runtime;
108
+ const ctx = { session: runtime.session, sessionId: opened.sessionId, by: { connectionId: owner ?? "host" } };
109
+ await runWithProviderScope(entry.scope, async () => {
110
+ const responses = [];
111
+ if (command.provider !== undefined && command.modelId !== undefined)
112
+ responses.push(await runSetModel(ctx, {
113
+ type: "set_model",
114
+ provider: command.provider,
115
+ modelId: command.modelId,
116
+ scope: "session",
117
+ }));
118
+ if (command.thinkingLevel !== undefined)
119
+ responses.push(await runSetThinkingLevel(ctx, {
120
+ type: "set_thinking_level",
121
+ level: command.thinkingLevel,
122
+ scope: "turn",
123
+ }));
124
+ if (command.permissionPreset !== undefined)
125
+ responses.push(runSetPermissionPreset(runtime, {
126
+ type: "set_permission_preset",
127
+ permissionPreset: command.permissionPreset,
128
+ }));
129
+ const refused = responses.find((response) => !response.success);
130
+ if (refused && !refused.success)
131
+ throw new Error(refused.error);
132
+ applyAttachedProfile(entry, { ...profile, permissionPreset: undefined, permissionPresetSource: undefined });
133
+ if (runtime.launchProfile)
134
+ entry.profile = runtime.launchProfile;
135
+ });
136
+ if (host.draining || host.drainExitRequested || (owner !== undefined && host.releasedConnections.has(owner)))
137
+ return miss();
138
+ const claimed = await host.registry.claimPark(opened.sessionId, command.retain_on_disconnect === true);
139
+ if (host.draining || host.drainExitRequested)
140
+ return miss();
141
+ entry.acceptance?.applyTranscript(runtime.session.sessionManager.drainTranscript());
142
+ ownOpen(host, claimed, owner);
143
+ host.bindings
144
+ .get(claimed.sessionId)
145
+ ?.setClientCapabilities?.((owner === undefined ? undefined : host.pendingCapabilities.get(owner)) ??
146
+ host.connectionOptions?.capabilities ??
147
+ []);
148
+ publishOpen(host, claimed, entry);
149
+ await syncRetainedRecord(host, claimed.sessionId, undefined);
150
+ replyOpen(host, claimed, entry, command);
151
+ success = true;
152
+ if (owner !== undefined && host.releasedConnections.has(owner)) {
153
+ releaseOwnerAttachment(host, owner, claimed.sessionId);
154
+ await releaseOwnedSession(host, claimed.sessionId);
155
+ }
156
+ return undefined;
157
+ }
158
+ catch (cause) {
159
+ return error(command.id, command.type, code(cause));
160
+ }
161
+ finally {
162
+ if (!success)
163
+ await closePark(host, opened);
164
+ host.parks.finish(key);
165
+ if (success) {
166
+ const replacement = {
167
+ type: "park_session",
168
+ cwd: command.cwd,
169
+ kind: command.kind,
170
+ context: command.context,
171
+ };
172
+ host.parks.replace(key, () => buildPark(host, replacement));
173
+ }
174
+ }
175
+ }
176
+ //# sourceMappingURL=session-router-park.js.map
@@ -7,7 +7,7 @@ import type { RouterContext } from "./session-router-context.ts";
7
7
  */
8
8
  export declare function handle(host: RouterContext, command: RpcCommand): Promise<RpcResponse | undefined>;
9
9
  export declare function dispatch(host: RouterContext, command: Exclude<RpcCommand, {
10
- type: "warm";
10
+ type: "warm" | "park_session";
11
11
  }>): Promise<RpcResponse | undefined>;
12
12
  /** Any attached connection may patch; a host with no per-connection identity (stdio) owns every session. */
13
13
  export declare function setSessionContext(host: RouterContext, command: Extract<RpcCommand, {
@@ -24,6 +24,7 @@ import { answerListSessions, answerProtocolInfo, answerSubscriptionLimits } from
24
24
  import { acknowledgePrompt, otherRequestsSettled } from "./session-router-observation.js";
25
25
  import { sweepDrain } from "./session-router-occupancy.js";
26
26
  import { openWithBarrier } from "./session-router-open.js";
27
+ import { claimSession, parkSession } from "./session-router-park.js";
27
28
  import { syncRetainedRecord } from "./session-router-residency.js";
28
29
  import { answerSetSessionContext, answerSubscribeSessions, answerUnsubscribeSessions, notAttachedRefusal, } from "./session-status-commands.js";
29
30
  import { unloadSession } from "./session-unload.js";
@@ -42,6 +43,10 @@ export function handle(host, command) {
42
43
  return ownership ? Promise.resolve(ownership) : admitted(host, command);
43
44
  }
44
45
  function admitted(host, command) {
46
+ if ("sessionId" in command && command.sessionId !== undefined && host.registry.peek(command.sessionId)?.speculative)
47
+ return Promise.resolve(error(command.id, command.type, "unknown_session"));
48
+ if (command.type === "park_session")
49
+ return parkSession(host, command);
45
50
  const refusal = attachmentGate(host, command) ?? compactAdmissionGate(host, command);
46
51
  if (refusal)
47
52
  return Promise.resolve(refusal);
@@ -117,6 +122,8 @@ export async function dispatch(host, command) {
117
122
  return detachConnection(command, host.writer.currentConnection(), (id) => releaseConnection(host, id));
118
123
  if (command.type === "open_session")
119
124
  return openWithBarrier(host, command);
125
+ if (command.type === "claim_session")
126
+ return claimSession(host, command);
120
127
  if (command.type === "attach_session")
121
128
  return attachSession(host, command);
122
129
  if (command.type === "release_attachment")