@code-yeongyu/senpi 2026.10.6 → 2026.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/bundle/chunks/{app-server-command-GNTX4MZT.js → app-server-command-SO5RJ2N7.js} +1 -1
- package/dist/bundle/chunks/chunk-7SMFF2QJ.js +14 -0
- package/dist/bundle/chunks/{chunk-6JXPZACL.js → chunk-CM5QTRC2.js} +1 -1
- package/dist/bundle/chunks/{chunk-3EE6G4G7.js → chunk-EUU6W5B3.js} +10 -10
- package/dist/bundle/chunks/{chunk-HKG7XL4H.js → chunk-HY4AOLON.js} +1 -1
- package/dist/bundle/chunks/{chunk-4MJKBXI3.js → chunk-JJNIU2A2.js} +8 -4
- package/dist/bundle/chunks/{chunk-22DAHAHO.js → chunk-LUWWM7GA.js} +2 -2
- package/dist/bundle/chunks/{chunk-6KQGOVQG.js → chunk-VJJ53ORU.js} +1 -1
- package/dist/bundle/chunks/{chunk-XYDZ3LIE.js → chunk-YI7SPFDN.js} +2 -2
- package/dist/bundle/chunks/chunk-ZJHRDQCH.js +2 -0
- package/dist/bundle/chunks/cli-main-XBQN73O2.js +3 -0
- package/dist/bundle/chunks/host-command-YXQAXNKK.js +2 -0
- package/dist/bundle/chunks/{host-lifecycle-7OVWGVIZ.js → host-lifecycle-L4XQIATP.js} +1 -1
- package/dist/bundle/chunks/host-lifecycle.js +1 -1
- package/dist/bundle/chunks/interactive-mode-ROO5W56P.js +2 -0
- package/dist/bundle/chunks/{multi-session-host-QLLTPWIM.js → multi-session-host-5WTKAQJM.js} +1 -1
- package/dist/bundle/chunks/{package-manager-cli-VRCAO7RV.js → package-manager-cli-NCD5FQGV.js} +1 -1
- package/dist/bundle/chunks/rpc-mode-MWURKCP2.js +2 -0
- package/dist/bundle/chunks/{schedule-command-QTYSUYO2.js → schedule-command-UTXB465F.js} +1 -1
- package/dist/bundle/chunks/session-control-endpoint-J6RHZXWB.js +3 -0
- package/dist/bundle/chunks/session-worker.js +16 -12
- package/dist/bundle/cli.js +1 -1
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/extensions/eval-handle-host.d.ts +82 -0
- package/dist/core/extensions/eval-handle-host.js +28 -0
- package/dist/core/extensions/loader.js +5 -0
- package/dist/core/extensions/runner.js +4 -0
- package/dist/core/extensions/types.d.ts +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/modes/interactive/session-control-registry.js +2 -0
- package/dist/modes/rpc/host-daemon-registration.d.ts +9 -0
- package/dist/modes/rpc/host-daemon-registration.js +2 -4
- package/dist/modes/rpc/host-ensure-start.js +2 -0
- package/dist/modes/rpc/host-handoff-unregistered.d.ts +24 -0
- package/dist/modes/rpc/host-handoff-unregistered.js +50 -0
- package/dist/modes/rpc/host-handoff.d.ts +7 -2
- package/dist/modes/rpc/host-handoff.js +7 -3
- package/dist/modes/rpc/host-legacy.d.ts +3 -0
- package/dist/modes/rpc/host-legacy.js +10 -5
- package/dist/modes/rpc/host-probe.d.ts +12 -0
- package/dist/modes/rpc/host-probe.js +53 -5
- package/dist/modes/rpc/host-runner.js +1 -1
- package/dist/modes/rpc/host-successor.d.ts +5 -4
- package/dist/modes/rpc/host-successor.js +11 -4
- package/docs/rpc.md +10 -2
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-QLIW3YMI.js +0 -14
- package/dist/bundle/chunks/cli-main-2C3YPHRE.js +0 -3
- package/dist/bundle/chunks/host-command-N6XS5O67.js +0 -2
- package/dist/bundle/chunks/interactive-mode-CM4C4ZFY.js +0 -2
- package/dist/bundle/chunks/rpc-mode-2OPE6J7P.js +0 -2
- package/dist/bundle/chunks/session-control-endpoint-SOG4XFF7.js +0 -3
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
@@ -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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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 !==
|
|
184
|
+
if (!isRecord(parsed) || parsed.id !== id || parsed.success !== true)
|
|
137
185
|
return undefined;
|
|
138
186
|
return parsed.data ?? {};
|
|
139
187
|
}
|
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
3
|
+
"version": "2026.10.7",
|
|
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.
|
|
61
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.
|
|
62
|
-
"@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.
|
|
60
|
+
"@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.7",
|
|
61
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.7",
|
|
62
|
+
"@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.7",
|
|
63
63
|
"@silvia-odwyer/photon-node": "0.3.4",
|
|
64
64
|
"chalk": "6.0.0",
|
|
65
65
|
"cross-spawn": "7.0.6",
|
|
@@ -78,8 +78,8 @@
|
|
|
78
78
|
"yaml": "2.9.1",
|
|
79
79
|
"@anthropic-ai/claude-agent-sdk": "0.3.288",
|
|
80
80
|
"@anthropic-ai/sdk": "0.127.0",
|
|
81
|
-
"@code-yeongyu/senpi-codemode": "2026.10.
|
|
82
|
-
"@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.
|
|
81
|
+
"@code-yeongyu/senpi-codemode": "2026.10.7",
|
|
82
|
+
"@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.7",
|
|
83
83
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
84
84
|
"@mozilla/readability": "0.6.0",
|
|
85
85
|
"linkedom": "0.18.13",
|