@code-yeongyu/senpi 2026.10.6 → 2026.10.8

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 (72) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/bundle/chunks/{anthropic-messages-4MNFPQ66.js → anthropic-messages-CVXI6CLT.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-GNTX4MZT.js → app-server-command-MJOYE5ZS.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-BE66QJ3Z.js → chunk-4GUQVY4Z.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-3EE6G4G7.js → chunk-5MG76BCM.js} +13 -13
  6. package/dist/bundle/chunks/{chunk-ESSQUSRC.js → chunk-BTYFICUC.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-4MJKBXI3.js → chunk-CB77NQMN.js} +8 -4
  8. package/dist/bundle/chunks/{chunk-6JXPZACL.js → chunk-CM5QTRC2.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-D2W3MXRF.js → chunk-LSE7KE2C.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-22DAHAHO.js → chunk-LUWWM7GA.js} +2 -2
  11. package/dist/bundle/chunks/{chunk-HKG7XL4H.js → chunk-MJGHZT4N.js} +4 -4
  12. package/dist/bundle/chunks/{chunk-7A3WRAKP.js → chunk-Q2ZTPMWD.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-6KQGOVQG.js → chunk-VJJ53ORU.js} +1 -1
  14. package/dist/bundle/chunks/chunk-X6UDRO7J.js +14 -0
  15. package/dist/bundle/chunks/{chunk-XYDZ3LIE.js → chunk-YI7SPFDN.js} +2 -2
  16. package/dist/bundle/chunks/chunk-ZJHRDQCH.js +2 -0
  17. package/dist/bundle/chunks/cli-main-GWVLFOIP.js +3 -0
  18. package/dist/bundle/chunks/{help-fast-path-FUMH5NSA.js → help-fast-path-JBQLYO7W.js} +1 -1
  19. package/dist/bundle/chunks/host-command-HTYYQBSG.js +2 -0
  20. package/dist/bundle/chunks/{host-lifecycle-7OVWGVIZ.js → host-lifecycle-XZF6IKKO.js} +1 -1
  21. package/dist/bundle/chunks/host-lifecycle.js +3 -3
  22. package/dist/bundle/chunks/interactive-mode-YLM2U7IX.js +2 -0
  23. package/dist/bundle/chunks/{multi-session-host-QLLTPWIM.js → multi-session-host-KMCBBPTV.js} +1 -1
  24. package/dist/bundle/chunks/{package-manager-cli-VRCAO7RV.js → package-manager-cli-3UIJ4RXY.js} +1 -1
  25. package/dist/bundle/chunks/{rotation-stream-7E37KOCX.js → rotation-stream-ZJQ6I2RZ.js} +1 -1
  26. package/dist/bundle/chunks/rpc-mode-PUYBZG27.js +2 -0
  27. package/dist/bundle/chunks/{schedule-command-QTYSUYO2.js → schedule-command-CVAPK2WQ.js} +1 -1
  28. package/dist/bundle/chunks/{sdk-EO76I33H.js → sdk-U52J75K2.js} +50 -50
  29. package/dist/bundle/chunks/session-control-endpoint-THEF3K7M.js +3 -0
  30. package/dist/bundle/chunks/{session-picker-VTTRFGE7.js → session-picker-N463VCDI.js} +1 -1
  31. package/dist/bundle/chunks/session-worker.js +79 -75
  32. package/dist/bundle/cli.js +1 -1
  33. package/dist/bundle/index.js +1 -1
  34. package/dist/bundle/rpc-entry.js +1 -1
  35. package/dist/bundle/runtime-manifest.json +1 -1
  36. package/dist/core/agent-session.d.ts +7 -2
  37. package/dist/core/agent-session.js +31 -5
  38. package/dist/core/continue-from-leaf.d.ts +21 -0
  39. package/dist/core/continue-from-leaf.js +39 -0
  40. package/dist/core/extensions/eval-handle-host.d.ts +82 -0
  41. package/dist/core/extensions/eval-handle-host.js +28 -0
  42. package/dist/core/extensions/loader.js +5 -0
  43. package/dist/core/extensions/runner.js +4 -0
  44. package/dist/core/extensions/types.d.ts +15 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.js +1 -0
  47. package/dist/modes/interactive/session-control-registry.js +2 -0
  48. package/dist/modes/rpc/host-daemon-registration.d.ts +9 -0
  49. package/dist/modes/rpc/host-daemon-registration.js +2 -4
  50. package/dist/modes/rpc/host-ensure-start.js +2 -0
  51. package/dist/modes/rpc/host-handoff-unregistered.d.ts +24 -0
  52. package/dist/modes/rpc/host-handoff-unregistered.js +50 -0
  53. package/dist/modes/rpc/host-handoff.d.ts +7 -2
  54. package/dist/modes/rpc/host-handoff.js +7 -3
  55. package/dist/modes/rpc/host-legacy.d.ts +3 -0
  56. package/dist/modes/rpc/host-legacy.js +10 -5
  57. package/dist/modes/rpc/host-lifecycle.js +5 -28
  58. package/dist/modes/rpc/host-probe.d.ts +12 -0
  59. package/dist/modes/rpc/host-probe.js +53 -5
  60. package/dist/modes/rpc/host-run-activity.d.ts +13 -0
  61. package/dist/modes/rpc/host-run-activity.js +39 -0
  62. package/dist/modes/rpc/host-runner.js +1 -1
  63. package/dist/modes/rpc/host-successor.d.ts +5 -4
  64. package/dist/modes/rpc/host-successor.js +11 -4
  65. package/docs/rpc.md +10 -2
  66. package/package.json +7 -7
  67. package/dist/bundle/chunks/chunk-QLIW3YMI.js +0 -14
  68. package/dist/bundle/chunks/cli-main-2C3YPHRE.js +0 -3
  69. package/dist/bundle/chunks/host-command-N6XS5O67.js +0 -2
  70. package/dist/bundle/chunks/interactive-mode-CM4C4ZFY.js +0 -2
  71. package/dist/bundle/chunks/rpc-mode-2OPE6J7P.js +0 -2
  72. package/dist/bundle/chunks/session-control-endpoint-SOG4XFF7.js +0 -3
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A handoff away from a host that no layout-2 registration proves (#2701).
3
+ *
4
+ * After an upgrade the old host is often still serving the very socket the updated client uses, and the
5
+ * only evidence of who it is may be a flat pre-layout-2 record - or nothing provable at all. Refusing
6
+ * such a host with `unknown_owner` left the client unable to replace it until somebody drained it by
7
+ * hand. An IDLE one is replaced instead; one that holds work never is.
8
+ *
9
+ * - The sessions are counted over a connection that stays open, and counted again over that same
10
+ * connection once the successor owns the socket: the recount reaches the old process, and a session
11
+ * it accepted in between is seen there.
12
+ * - A flat record that proves the process is the only licence to signal it, and the signal is a DRAIN
13
+ * (SIGUSR1), sent only when the recount still finds no session. A drain ends no work either way.
14
+ * - Without a provable owner nothing is signalled: the old host notices that it lost the public entry
15
+ * and drains itself (`host-supersession.ts`). That predecessor is named in one warning line, so an
16
+ * old host that outlives the handoff can still be identified.
17
+ */
18
+ import type { HostDaemonPaths } from "./host-daemon-paths.ts";
19
+ import type { HostProtocolInfo } from "./host-decision.ts";
20
+ import type { HandoffHostOptions, HandoffResult } from "./host-handoff.ts";
21
+ /** One session count; an unregistered host is counted twice (before the swap and over the held connection after). */
22
+ export declare const SESSION_COUNT_TIMEOUT_MS = 10000;
23
+ export declare function handoffUnregisteredHost(options: HandoffHostOptions, paths: HostDaemonPaths, host: HostProtocolInfo): Promise<HandoffResult>;
24
+ //# sourceMappingURL=host-handoff-unregistered.d.ts.map
@@ -0,0 +1,50 @@
1
+ import { busyLegacyHostDetail, describeSessions, provenLegacyOwner } from "./host-legacy.js";
2
+ import { holdSessionCount } from "./host-probe.js";
3
+ import { startSuccessor } from "./host-successor.js";
4
+ /** One session count; an unregistered host is counted twice (before the swap and over the held connection after). */
5
+ export const SESSION_COUNT_TIMEOUT_MS = 10_000;
6
+ export async function handoffUnregisteredHost(options, paths, host) {
7
+ const counted = await holdSessionCount(options.socket, SESSION_COUNT_TIMEOUT_MS);
8
+ try {
9
+ const legacy = await provenLegacyOwner(paths, options.socket);
10
+ const sessions = counted?.sessions;
11
+ if (sessions !== 0) {
12
+ if (legacy !== undefined) {
13
+ return {
14
+ action: "refuse",
15
+ reason: "legacy_host",
16
+ upgradeable: true,
17
+ detail: busyLegacyHostDetail(legacy.pid, options.socket, sessions),
18
+ };
19
+ }
20
+ return {
21
+ action: "refuse",
22
+ reason: "unknown_owner",
23
+ upgradeable: true,
24
+ detail: `${options.socket} has no provable owner and holds ${describeSessions(sessions)}; it is never signalled - let that work finish, then retry`,
25
+ };
26
+ }
27
+ const result = await startSuccessor({
28
+ options,
29
+ paths,
30
+ host,
31
+ owner: legacy,
32
+ drainGate: async () => (await counted?.recount(SESSION_COUNT_TIMEOUT_MS)) === 0,
33
+ });
34
+ if (result.action === "handoff" && legacy === undefined)
35
+ warnUnsignalledPredecessor(options.socket, host);
36
+ return result;
37
+ }
38
+ finally {
39
+ counted?.close();
40
+ }
41
+ }
42
+ /**
43
+ * The one record of a predecessor nobody could prove, on the caller's stderr: the endpoint's log belongs
44
+ * to the generations writing it. Its pid is not known: no record proves it, and a Unix socket's peer
45
+ * credentials are not readable from this runtime.
46
+ */
47
+ function warnUnsignalledPredecessor(socket, host) {
48
+ process.stderr.write(`generation handoff: no provable owner for ${socket}; the host it replaced (instance ${host.instanceId ?? "unknown"}, engine ${host.engineVersion ?? "unknown"}) listed 0 sessions and was not signalled; it drains itself on losing the public socket\n`);
49
+ }
50
+ //# sourceMappingURL=host-handoff-unregistered.js.map
@@ -1,5 +1,8 @@
1
1
  import type { HostLifecyclePolicyInput } from "./host-lifecycle.ts";
2
- /** The longest a handoff holds the ensure lock: its probe of the running host, then the successor's start. */
2
+ /**
3
+ * The longest a handoff holds the ensure lock: its probe of the running host, the two session counts
4
+ * an unregistered host gets (before the swap and the recount after it), then the successor's start.
5
+ */
3
6
  export declare const HANDOFF_LOCK_HOLD_MS: number;
4
7
  export interface HandoffHostOptions {
5
8
  readonly socket: string;
@@ -43,8 +46,10 @@ export type HandoffRefusal =
43
46
  | "handoff_unsupported"
44
47
  /** win32: a named pipe can be neither renamed nor drained. */
45
48
  | "upgrade_unsupported"
46
- /** The pidfile cannot prove which process serves this socket, so it may not be signalled. */
49
+ /** No record proves which process serves this socket, and that host holds sessions (or will not say). */
47
50
  | "unknown_owner"
51
+ /** A host from before layout 2, proven by its flat record, holds sessions; `detail` says how to retire it. */
52
+ | "legacy_host"
48
53
  /** The public socket stopped being the one this handoff was decided against. */
49
54
  | "socket_replaced"
50
55
  /** `<public>.next-<gen>` would exceed the platform's socket path limit. */
@@ -31,11 +31,15 @@ import { createDaemonDirectories, createHostDaemonPaths } from "./host-daemon-pa
31
31
  import { provenOwner, readHostRegistration } from "./host-daemon-registration.js";
32
32
  import { GENERATION_HANDOFF_CAPABILITY } from "./host-decision.js";
33
33
  import { acquireHostEnsureLock } from "./host-ensure-lock.js";
34
+ import { handoffUnregisteredHost, SESSION_COUNT_TIMEOUT_MS } from "./host-handoff-unregistered.js";
34
35
  import { probeProtocolInfo } from "./host-probe.js";
35
36
  import { SUCCESSOR_START_BUDGET_MS, startSuccessor } from "./host-successor.js";
36
37
  const HANDOFF_PROBE_TIMEOUT_MS = 10_000;
37
- /** The longest a handoff holds the ensure lock: its probe of the running host, then the successor's start. */
38
- export const HANDOFF_LOCK_HOLD_MS = HANDOFF_PROBE_TIMEOUT_MS + SUCCESSOR_START_BUDGET_MS;
38
+ /**
39
+ * The longest a handoff holds the ensure lock: its probe of the running host, the two session counts
40
+ * an unregistered host gets (before the swap and the recount after it), then the successor's start.
41
+ */
42
+ export const HANDOFF_LOCK_HOLD_MS = HANDOFF_PROBE_TIMEOUT_MS + 2 * SESSION_COUNT_TIMEOUT_MS + SUCCESSOR_START_BUDGET_MS;
39
43
  /**
40
44
  * How long a handoff waits for the lock. The longest holder is an ensure that probes the running host
41
45
  * and then hands it off itself (its upgrade path); 10 s of headroom for a slow runner.
@@ -74,7 +78,7 @@ export async function handoffHostLocked(options) {
74
78
  const registered = await readHostRegistration(paths);
75
79
  const owner = await provenOwner(registered, options.socket);
76
80
  if (!owner)
77
- return { action: "refuse", reason: "unknown_owner", upgradeable: true };
81
+ return handoffUnregisteredHost(options, paths, host);
78
82
  return startSuccessor({ options, paths, host, owner });
79
83
  }
80
84
  function handoffUnsupported(options) {
@@ -15,4 +15,7 @@ export declare function provenLegacyOwner(paths: HostDaemonPaths, socket: string
15
15
  pid: number;
16
16
  processStartTime: string;
17
17
  } | undefined>;
18
+ /** Why a proven legacy host that holds work is left alone, and the command that retires it. */
19
+ export declare function busyLegacyHostDetail(pid: number, socket: string, sessions: number | undefined): string;
20
+ export declare function describeSessions(sessions: number | undefined): string;
18
21
  //# sourceMappingURL=host-legacy.d.ts.map
@@ -96,11 +96,16 @@ async function judgeLegacyHost(paths, probe, platform = process.platform) {
96
96
  const sessions = await probeSessionCount(host.socket, LEGACY_PROBE_TIMEOUT_MS);
97
97
  if (sessions === 0)
98
98
  return { verdict: "idle", host };
99
- const held = sessions === undefined ? "an unknown number of sessions" : `${sessions} open session${sessions === 1 ? "" : "s"}`;
100
- return {
101
- verdict: "held",
102
- detail: `${where} holds ${held}; run \`${APP_NAME} host stop --drain --socket ${host.socket}\` to let it finish that work and exit, then retry`,
103
- };
99
+ return { verdict: "held", detail: busyLegacyHostDetail(pid, host.socket, sessions) };
100
+ }
101
+ /** Why a proven legacy host that holds work is left alone, and the command that retires it. */
102
+ export function busyLegacyHostDetail(pid, socket, sessions) {
103
+ return `pid ${pid} (${socket}) holds ${describeSessions(sessions)}; run \`${APP_NAME} host stop --drain --socket ${socket}\` to let it finish that work and exit, then retry`;
104
+ }
105
+ export function describeSessions(sessions) {
106
+ return sessions === undefined
107
+ ? "an unknown number of sessions"
108
+ : `${sessions} open session${sessions === 1 ? "" : "s"}`;
104
109
  }
105
110
  /**
106
111
  * SIGUSR1, then wait for the proven process to leave. A host that exits before the signal lands has
@@ -63,6 +63,7 @@ import { hostCrashCleanupPaths } from "./host-cleanup-paths.js";
63
63
  import { createHostDaemonPaths, generationPaths, HOST_DAEMON_DIR_ENV } from "./host-daemon-paths.js";
64
64
  import { releaseGeneration } from "./host-daemon-registration.js";
65
65
  import { HOST_INSTANCE_ID_ENV } from "./host-identity-env.js";
66
+ import { SessionRunActivity } from "./host-run-activity.js";
66
67
  import { watchForSupersession } from "./host-supersession.js";
67
68
  import { HOST_CLEANUP_PATHS_ENV, HOST_PUBLIC_SOCKET_ENV, HOST_SCRATCH_DIR_ENV, HOST_WATCH_FD_ENV, HOST_WATCH_PPID_ENV, } from "./host-watchdog.js";
68
69
  import { attachJsonlLineReader, MAX_RPC_LINE_CHARACTERS } from "./jsonl.js";
@@ -319,7 +320,7 @@ export async function runHostSupervisor(launch) {
319
320
  const internalSecretPath = internal.secretPath ?? socketSecretPath(internalSocket);
320
321
  const internalSecret = process.platform === "win32" ? await createSocketSecret(internalSecretPath) : undefined;
321
322
  const clients = new ClientOccupancy(() => decider.update(currentActivity()));
322
- const busySessions = new Map();
323
+ const runs = new SessionRunActivity();
323
324
  // Declared before anything that can reach `currentActivity()`. A client accepted during startup
324
325
  // asks for the activity snapshot, and a `const` read before its initializer runs is a
325
326
  // ReferenceError that fails the connection - which is how a successor's first `open_session`
@@ -461,37 +462,13 @@ export async function runHostSupervisor(launch) {
461
462
  unhealthySince: observerLink.unhealthySince(),
462
463
  now: Date.now(),
463
464
  unknownGraceMs: decider.idleExitMs,
464
- observedBusy: countBusySessions(),
465
+ observedBusy: runs.busySessions,
465
466
  }),
466
467
  };
467
468
  }
468
- function countBusySessions() {
469
- let busy = 0;
470
- for (const count of busySessions.values())
471
- if (count > 0)
472
- busy++;
473
- return busy;
474
- }
475
469
  function observeHostEvent(line) {
476
- let event;
477
- try {
478
- event = JSON.parse(line);
479
- }
480
- catch {
481
- return;
482
- }
483
- if (typeof event !== "object" || event === null)
484
- return;
485
- const { type, sessionId } = event;
486
- if (typeof sessionId !== "string")
487
- return;
488
- if (type === "agent_start")
489
- busySessions.set(sessionId, (busySessions.get(sessionId) ?? 0) + 1);
490
- else if (type === "agent_settled")
491
- busySessions.set(sessionId, Math.max(0, (busySessions.get(sessionId) ?? 1) - 1));
492
- else
493
- return;
494
- decider.update(currentActivity());
470
+ if (runs.observe(line))
471
+ decider.update(currentActivity());
495
472
  }
496
473
  async function shutdown(reason, exitCode) {
497
474
  // Single-flight: concurrent triggers (listener error, child exit, signals)
@@ -32,6 +32,18 @@ export declare function probeSocketReachable(socketPath: string, timeoutMs: numb
32
32
  * does not answer. A stop decision needs the number the host reports, not the one a client remembers.
33
33
  */
34
34
  export declare function probeSessionCount(socketPath: string, timeoutMs: number): Promise<number | undefined>;
35
+ /**
36
+ * The session count together with the connection that answered it, kept open so the SAME host can be
37
+ * asked again after its public path was handed to somebody else: the recount reaches the process that
38
+ * was counted, never whoever serves the path by then. `undefined` when the host does not answer.
39
+ */
40
+ export interface HeldSessionCount {
41
+ readonly sessions: number;
42
+ /** Asks the counted host again over the held connection; `undefined` when it no longer answers. */
43
+ recount(timeoutMs: number): Promise<number | undefined>;
44
+ close(): void;
45
+ }
46
+ export declare function holdSessionCount(socketPath: string, timeoutMs: number): Promise<HeldSessionCount | undefined>;
35
47
  /** Sends one command and returns its `data`, or `undefined` for any failure to get a usable answer. */
36
48
  export declare function requestOnSocket(socketPath: string, command: Readonly<Record<string, unknown>>, timeoutMs: number): Promise<unknown>;
37
49
  //# sourceMappingURL=host-probe.d.ts.map
@@ -59,10 +59,58 @@ export async function probeSocketReachable(socketPath, timeoutMs) {
59
59
  * does not answer. A stop decision needs the number the host reports, not the one a client remembers.
60
60
  */
61
61
  export async function probeSessionCount(socketPath, timeoutMs) {
62
- const reply = await requestOnSocket(socketPath, { type: "list_sessions", include_workers: true }, timeoutMs);
63
- if (!isRecord(reply) || !Array.isArray(reply.sessions))
62
+ return sessionCountOf(await requestOnSocket(socketPath, { type: "list_sessions", include_workers: true }, timeoutMs));
63
+ }
64
+ export async function holdSessionCount(socketPath, timeoutMs) {
65
+ const outcome = await connectAndAsk(socketPath, { id: PROBE_REQUEST_ID, type: "list_sessions", include_workers: true }, timeoutMs, true);
66
+ const kept = outcome.kept;
67
+ if (kept === undefined)
68
+ return undefined;
69
+ const sessions = sessionCountOf(outcome.answer);
70
+ if (sessions === undefined) {
71
+ kept.destroy();
64
72
  return undefined;
65
- return reply.sessions.length;
73
+ }
74
+ let asked = 0;
75
+ return {
76
+ sessions,
77
+ recount: (recountTimeoutMs) => askAgain(kept, `${PROBE_REQUEST_ID}-recount-${++asked}`, recountTimeoutMs),
78
+ close: () => kept.destroy(),
79
+ };
80
+ }
81
+ function askAgain(socket, id, timeoutMs) {
82
+ return new Promise((resolveCount) => {
83
+ let buffer = "";
84
+ const finish = (count) => {
85
+ clearTimeout(timeout);
86
+ socket.off("data", onData);
87
+ socket.off("close", onClose);
88
+ resolveCount(count);
89
+ };
90
+ const onClose = () => finish(undefined);
91
+ const onData = (chunk) => {
92
+ buffer += chunk.toString("utf8");
93
+ for (let newline = buffer.indexOf("\n"); newline !== -1; newline = buffer.indexOf("\n")) {
94
+ const answer = readAnswer(buffer.slice(0, newline), id);
95
+ buffer = buffer.slice(newline + 1);
96
+ if (answer !== undefined) {
97
+ finish(sessionCountOf(answer));
98
+ return;
99
+ }
100
+ }
101
+ };
102
+ const timeout = setTimeout(() => finish(undefined), timeoutMs);
103
+ if (socket.destroyed) {
104
+ finish(undefined);
105
+ return;
106
+ }
107
+ socket.on("data", onData);
108
+ socket.once("close", onClose);
109
+ socket.write(`${JSON.stringify({ id, type: "list_sessions", include_workers: true })}\n`);
110
+ });
111
+ }
112
+ function sessionCountOf(reply) {
113
+ return isRecord(reply) && Array.isArray(reply.sessions) ? reply.sessions.length : undefined;
66
114
  }
67
115
  /** Sends one command and returns its `data`, or `undefined` for any failure to get a usable answer. */
68
116
  export async function requestOnSocket(socketPath, command, timeoutMs) {
@@ -125,7 +173,7 @@ async function connectAndAsk(socketPath, request, timeoutMs, keep = false) {
125
173
  sendSocketHandshake(socket, secret);
126
174
  });
127
175
  }
128
- function readAnswer(text) {
176
+ function readAnswer(text, id = PROBE_REQUEST_ID) {
129
177
  let parsed;
130
178
  try {
131
179
  parsed = JSON.parse(text);
@@ -133,7 +181,7 @@ function readAnswer(text) {
133
181
  catch {
134
182
  return undefined;
135
183
  }
136
- if (!isRecord(parsed) || parsed.id !== PROBE_REQUEST_ID || parsed.success !== true)
184
+ if (!isRecord(parsed) || parsed.id !== id || parsed.success !== true)
137
185
  return undefined;
138
186
  return parsed.data ?? {};
139
187
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Which sessions have an agent run in progress, from the host child's events. A run emits
3
+ * `agent_start` on every loop iteration (a provider retry, a post-compaction continue, a follow-up)
4
+ * but `agent_settled` once, so this tracks runs, not starts: a repeated start for a running session
5
+ * changes nothing. A session that closes is dropped even if its settle never arrives (#2713).
6
+ */
7
+ export declare class SessionRunActivity {
8
+ #private;
9
+ /** Applies one host event line; true when it is a run or session event the idle decision must re-read. */
10
+ observe(line: string): boolean;
11
+ get busySessions(): number;
12
+ }
13
+ //# sourceMappingURL=host-run-activity.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Which sessions have an agent run in progress, from the host child's events. A run emits
3
+ * `agent_start` on every loop iteration (a provider retry, a post-compaction continue, a follow-up)
4
+ * but `agent_settled` once, so this tracks runs, not starts: a repeated start for a running session
5
+ * changes nothing. A session that closes is dropped even if its settle never arrives (#2713).
6
+ */
7
+ export class SessionRunActivity {
8
+ #running = new Set();
9
+ /** Applies one host event line; true when it is a run or session event the idle decision must re-read. */
10
+ observe(line) {
11
+ const event = parseHostEvent(line);
12
+ if (event === undefined)
13
+ return false;
14
+ if (event.type === "agent_start")
15
+ this.#running.add(event.sessionId);
16
+ else if (event.type === "agent_settled" || event.type === "session_closed")
17
+ this.#running.delete(event.sessionId);
18
+ else
19
+ return false;
20
+ return true;
21
+ }
22
+ get busySessions() {
23
+ return this.#running.size;
24
+ }
25
+ }
26
+ function parseHostEvent(line) {
27
+ let event;
28
+ try {
29
+ event = JSON.parse(line);
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ if (typeof event !== "object" || event === null)
35
+ return undefined;
36
+ const { type, sessionId } = event;
37
+ return typeof sessionId === "string" ? { type, sessionId } : undefined;
38
+ }
39
+ //# sourceMappingURL=host-run-activity.js.map
@@ -164,7 +164,7 @@ async function handoffOutcome(target, spec) {
164
164
  return refusal("refuse", before, {
165
165
  reason: upgradeRefusal(result.reason),
166
166
  socket: target.socket,
167
- detail: result.reason,
167
+ detail: result.detail ?? result.reason,
168
168
  upgradeable: result.upgradeable,
169
169
  });
170
170
  }
@@ -7,10 +7,11 @@ export declare function startSuccessor(context: {
7
7
  options: HandoffHostOptions;
8
8
  paths: HostDaemonPaths;
9
9
  host: HostProtocolInfo;
10
+ /** The predecessor's proven identity; `undefined` when nothing proves it, and then it is never signalled. */
10
11
  owner: {
11
- pid: number;
12
- processStartTime: string;
13
- instanceId: string;
14
- };
12
+ readonly pid: number;
13
+ } | undefined;
14
+ /** Asked once the successor owns the socket: the drain request is sent only when it answers true. */
15
+ drainGate?: () => Promise<boolean>;
15
16
  }): Promise<HandoffResult>;
16
17
  //# sourceMappingURL=host-successor.d.ts.map
@@ -21,7 +21,7 @@ import { randomUUID } from "node:crypto";
21
21
  import { open, rm, writeFile } from "node:fs/promises";
22
22
  import { waitForStartTime } from "../app-server/daemon/process.js";
23
23
  import { generationPaths, HOST_STATE_FILE_MODE } from "./host-daemon-paths.js";
24
- import { releaseGeneration, writeGenerationRecord, writeHostRegistration } from "./host-daemon-registration.js";
24
+ import { releaseGeneration, writeGenerationRecord, writeHostRegistration, } from "./host-daemon-registration.js";
25
25
  import { readFileOrUndefined, readHostSettings, writeHostSettings } from "./host-daemon-state.js";
26
26
  import { defaultHostLaunch, PINNED_HOST_CLIENT_CAPABILITIES } from "./host-launch.js";
27
27
  import { DEFAULT_HOST_IDLE_EXIT_MS } from "./host-lifecycle.js";
@@ -37,7 +37,7 @@ const SUCCESSOR_EXIT_WAIT_MS = 5_000;
37
37
  /** The longest `startSuccessor` runs: its start-time read, the readiness window, one last probe and a refusal's exit wait. */
38
38
  export const SUCCESSOR_START_BUDGET_MS = 10_000 + DEFAULT_HANDOFF_READINESS_MS + 2_000 + SUCCESSOR_EXIT_WAIT_MS;
39
39
  export async function startSuccessor(context) {
40
- const { options, paths, host, owner } = context;
40
+ const { options, paths, host, owner, drainGate } = context;
41
41
  const generation = (host.generation ?? 0) + 1;
42
42
  // The successor's identity, chosen here so its generation directory holds its settings before it
43
43
  // boots and the pointer can name it the instant it answers on the public socket.
@@ -119,13 +119,14 @@ export async function startSuccessor(context) {
119
119
  // The pointer moves to the successor only now: until the rename landed, the generation the
120
120
  // clients reach is still the predecessor, and the pointer has to name whoever owns the socket.
121
121
  await options._test?.beforeRegistration?.();
122
- await writeHostRegistration(paths, registration);
122
+ await writeHostRegistration(paths, { ...registration, ...successorBuild(answer) });
123
123
  child.unref();
124
124
  // The successor owns the socket now: the predecessor may drain. SIGUSR1 is sent only here,
125
125
  // to a pid the record proved and a host that advertised it can survive the signal. A
126
126
  // predecessor that exited on its own in the meantime is already drained, and the handoff it
127
127
  // was being asked to make room for has already happened.
128
- signalGeneration(owner.pid, "SIGUSR1");
128
+ if (owner !== undefined && (drainGate === undefined || (await drainGate())))
129
+ signalGeneration(owner.pid, "SIGUSR1");
129
130
  return {
130
131
  action: "handoff",
131
132
  pid: child.pid,
@@ -185,6 +186,12 @@ async function exitedWithin(exited, ms) {
185
186
  clearTimeout(timer);
186
187
  }
187
188
  }
189
+ /** The successor's build as the successor itself reported it on the socket; never this process's build. */
190
+ function successorBuild(answer) {
191
+ if (answer.engineVersion === undefined || answer.engineOrdinal === undefined)
192
+ return {};
193
+ return { build: { text: answer.engineVersion, ordinal: answer.engineOrdinal } };
194
+ }
188
195
  /**
189
196
  * The successor's own answer on the PUBLIC socket is the only proof the rename landed: it
190
197
  * reports a different `instanceId` than the generation being replaced. An exit instead means
package/docs/rpc.md CHANGED
@@ -288,8 +288,9 @@ Two guards decide whether a handoff is attempted at all, and both fail closed:
288
288
  handler for it, so a host from before the drain existed is never signalled - `handoffHost` answers
289
289
  `{ action: "refuse", reason: "handoff_unsupported" }` and `decideHostAction` reports `upgradeable: false`.
290
290
  - The registration must prove which process serves the socket (pid + start time, and the record's `socket`
291
- must be this endpoint). An unprovable owner refuses with `unknown_owner` rather than signalling a
292
- stranger (I1).
291
+ must be this endpoint) before that process is signalled. Without such a registration a handoff never
292
+ signals a stranger (I1): it refuses while that host holds a session, and replaces an idle one without
293
+ a signal unless a flat pre-layout-2 record proves it (see "Daemon state directory (layout 2)").
293
294
 
294
295
  On win32 a named pipe can be neither renamed nor drained: `handoffHost` refuses with `upgrade_unsupported`
295
296
  and `decideHostAction` never yields `handoff` there. Upgrades apply after `stopHost({ drain: true })` or an
@@ -430,6 +431,13 @@ pid and start time must match the live process, so a recycled pid proves nothing
430
431
  its endpoint answers with `generation_handoff` and `list_sessions` (workers included) lists no session.
431
432
  Otherwise it refuses `legacy_host`, and the CLI refusal carries `detail`: the pid, its endpoint, how many
432
433
  sessions it holds and the `host stop --drain --socket <endpoint>` that retires it.
434
+ - A handoff (`host handoff`, and an ensure's upgrade) on an endpoint no layout-2 record proves counts the
435
+ running host's sessions over a connection it keeps open. With any session, or no answer, it refuses:
436
+ `legacy_host` with the same `detail` when a flat record proves the process, `unknown_owner` with the
437
+ session count otherwise. With none, the successor takes the socket; a proven legacy process is sent the
438
+ drain only if a recount over that held connection still lists no session, and a host no record proves
439
+ is never signalled - it drains itself once another generation owns the public entry, and the handoff
440
+ writes one stderr warning naming the socket, that host's instance and its engine.
433
441
 
434
442
  `ensureHost` fails with a typed `HostDaemonStateError` naming the directory it could not create or write,
435
443
  and starts no host in that case.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.6",
3
+ "version": "2026.10.8",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@earendil-works/chord": "0.99.1",
60
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.6",
61
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.6",
62
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.6",
60
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.8",
61
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.8",
62
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.8",
63
63
  "@silvia-odwyer/photon-node": "0.3.4",
64
64
  "chalk": "6.0.0",
65
65
  "cross-spawn": "7.0.6",
@@ -76,10 +76,10 @@
76
76
  "typebox": "1.3.34",
77
77
  "undici": "8.10.2",
78
78
  "yaml": "2.9.1",
79
- "@anthropic-ai/claude-agent-sdk": "0.3.288",
79
+ "@anthropic-ai/claude-agent-sdk": "0.3.289",
80
80
  "@anthropic-ai/sdk": "0.127.0",
81
- "@code-yeongyu/senpi-codemode": "2026.10.6",
82
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.6",
81
+ "@code-yeongyu/senpi-codemode": "2026.10.8",
82
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.8",
83
83
  "@modelcontextprotocol/sdk": "1.30.0",
84
84
  "@mozilla/readability": "0.6.0",
85
85
  "linkedom": "0.18.13",