@mnemom/mnemom 0.17.34-next.1 → 0.17.34-next.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.
- package/dist/base-url-proxy.mjs +18 -4
- package/dist/commands/agent-config.js +5 -1
- package/dist/commands/agent-doctor.d.ts +9 -0
- package/dist/commands/agent-doctor.js +4 -0
- package/dist/commands/agent-session.d.ts +1 -2
- package/dist/commands/agent-session.js +1 -2
- package/dist/commands/agent-setup.d.ts +6 -0
- package/dist/commands/agent-setup.js +12 -1
- package/dist/commands/agent.d.ts +11 -0
- package/dist/commands/agent.js +145 -46
- package/dist/commands/durable-doctor.d.ts +50 -0
- package/dist/commands/durable-doctor.js +186 -0
- package/dist/commands/durable-lock.d.ts +254 -0
- package/dist/commands/durable-lock.js +595 -0
- package/dist/commands/durable-sessions.d.ts +87 -0
- package/dist/commands/durable-sessions.js +303 -0
- package/dist/commands/durable-verbs.d.ts +116 -0
- package/dist/commands/durable-verbs.js +393 -0
- package/dist/durable/flock.d.ts +26 -0
- package/dist/durable/flock.js +80 -0
- package/dist/durable/launch-spec.d.ts +7 -1
- package/dist/durable/launch-spec.js +10 -3
- package/dist/durable/seat-proxy-entry.d.ts +11 -0
- package/dist/durable/seat-proxy-entry.js +25 -0
- package/dist/durable/seat-proxy.d.ts +107 -0
- package/dist/durable/seat-proxy.js +599 -0
- package/dist/durable/seat-socket.d.ts +46 -0
- package/dist/durable/seat-socket.js +179 -0
- package/dist/durable/seat-spec-validator.d.ts +28 -0
- package/dist/durable/seat-spec-validator.js +59 -0
- package/dist/durable/test-mode.mjs +81 -0
- package/dist/durable/turn-chain.d.ts +55 -0
- package/dist/durable/turn-chain.js +178 -0
- package/dist/host/auto-continue.d.ts +67 -0
- package/dist/host/auto-continue.js +118 -0
- package/dist/host/bootid.d.ts +13 -0
- package/dist/host/bootid.js +41 -0
- package/dist/host/client.d.ts +123 -0
- package/dist/host/client.js +171 -0
- package/dist/host/commands.d.ts +2 -0
- package/dist/host/commands.js +122 -0
- package/dist/host/control.d.ts +33 -0
- package/dist/host/control.js +144 -0
- package/dist/host/daemon.d.ts +217 -0
- package/dist/host/daemon.js +606 -0
- package/dist/host/gate.d.ts +46 -0
- package/dist/host/gate.js +82 -0
- package/dist/host/grant.d.ts +24 -0
- package/dist/host/grant.js +24 -0
- package/dist/host/instance.d.ts +35 -0
- package/dist/host/instance.js +62 -0
- package/dist/host/integrity.d.ts +35 -0
- package/dist/host/integrity.js +115 -0
- package/dist/host/launchd.d.ts +16 -0
- package/dist/host/launchd.js +45 -0
- package/dist/host/lock.d.ts +20 -0
- package/dist/host/lock.js +63 -0
- package/dist/host/owner.d.ts +55 -0
- package/dist/host/owner.js +121 -0
- package/dist/host/plist.d.ts +16 -0
- package/dist/host/plist.js +85 -0
- package/dist/host/procs.d.ts +27 -0
- package/dist/host/procs.js +105 -0
- package/dist/host/run.d.ts +66 -0
- package/dist/host/run.js +314 -0
- package/dist/host/runtime.d.ts +8 -0
- package/dist/host/runtime.js +22 -0
- package/dist/host/s1-names.d.ts +22 -0
- package/dist/host/s1-names.js +21 -0
- package/dist/host/seat-credential.d.ts +27 -0
- package/dist/host/seat-credential.js +41 -0
- package/dist/host/seat-launch-main.d.ts +1 -0
- package/dist/host/seat-launch-main.js +59 -0
- package/dist/host/seat-launch.d.ts +116 -0
- package/dist/host/seat-launch.js +302 -0
- package/dist/host/seat.d.ts +48 -0
- package/dist/host/seat.js +149 -0
- package/dist/host/state.d.ts +33 -0
- package/dist/host/state.js +117 -0
- package/dist/host/status.d.ts +101 -0
- package/dist/host/status.js +307 -0
- package/dist/host/tmux.d.ts +56 -0
- package/dist/host/tmux.js +182 -0
- package/dist/host/transcript.d.ts +30 -0
- package/dist/host/transcript.js +130 -0
- package/dist/host/verbs.d.ts +50 -0
- package/dist/host/verbs.js +271 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.js +197 -35
- package/dist/lib/agent.d.ts +8 -26
- package/dist/lib/agent.js +11 -26
- package/dist/lib/claude-plugin.d.ts +11 -2
- package/dist/lib/claude-plugin.js +10 -1
- package/dist/lib/durable/api.d.ts +136 -0
- package/dist/lib/durable/api.js +220 -0
- package/dist/lib/durable/codes.d.ts +8 -0
- package/dist/lib/durable/codes.js +2 -0
- package/dist/lib/durable/first-turn.d.ts +50 -0
- package/dist/lib/durable/first-turn.js +102 -0
- package/dist/lib/durable/gate.d.ts +48 -0
- package/dist/lib/durable/gate.js +122 -0
- package/dist/lib/durable/handover.d.ts +101 -0
- package/dist/lib/durable/handover.js +175 -0
- package/dist/lib/durable/launch.d.ts +182 -0
- package/dist/lib/durable/launch.js +443 -0
- package/dist/lib/durable/launching.d.ts +15 -0
- package/dist/lib/durable/launching.js +70 -0
- package/dist/lib/durable/live.d.ts +194 -0
- package/dist/lib/durable/live.js +635 -0
- package/dist/lib/durable/refusal.d.ts +57 -0
- package/dist/lib/durable/refusal.js +78 -0
- package/dist/lib/durable/view.d.ts +83 -0
- package/dist/lib/durable/view.js +244 -0
- package/dist/lib/experimental.d.ts +0 -9
- package/dist/lib/experimental.js +18 -17
- package/dist/lib/launcher-config.d.ts +7 -0
- package/dist/lib/launcher-config.js +21 -0
- package/dist/lib/scan/report-email.d.ts +1 -1
- package/dist/lib/scan/report-email.js +9 -1
- package/dist/lib/subscription-login.d.ts +15 -0
- package/dist/lib/subscription-login.js +28 -1
- package/dist/vendor/durable-contract/index.js +240 -18
- package/dist/vendor/shared/durable-contract/admission.d.ts +240 -0
- package/dist/vendor/shared/durable-contract/admission.js +533 -0
- package/dist/vendor/shared/durable-contract/arm.d.ts +51 -0
- package/dist/vendor/shared/durable-contract/arm.js +133 -0
- package/dist/vendor/shared/durable-contract/canonical.d.ts +29 -0
- package/dist/vendor/shared/durable-contract/canonical.js +98 -0
- package/dist/vendor/shared/durable-contract/cell-id.d.ts +22 -0
- package/dist/vendor/shared/durable-contract/cell-id.js +38 -0
- package/dist/vendor/shared/durable-contract/cli-output.d.ts +342 -0
- package/dist/vendor/shared/durable-contract/cli-output.js +201 -0
- package/dist/vendor/shared/durable-contract/cli-surface.d.ts +119 -0
- package/dist/vendor/shared/durable-contract/cli-surface.js +270 -0
- package/dist/vendor/shared/durable-contract/codes.d.ts +537 -0
- package/dist/vendor/shared/durable-contract/codes.js +475 -0
- package/dist/vendor/shared/durable-contract/constants.d.ts +153 -0
- package/dist/vendor/shared/durable-contract/constants.js +179 -0
- package/dist/vendor/shared/durable-contract/doors.d.ts +119 -0
- package/dist/vendor/shared/durable-contract/doors.js +249 -0
- package/dist/vendor/shared/durable-contract/envelope.d.ts +103 -0
- package/dist/vendor/shared/durable-contract/envelope.js +317 -0
- package/dist/vendor/shared/durable-contract/events.d.ts +147 -0
- package/dist/vendor/shared/durable-contract/events.js +630 -0
- package/dist/vendor/shared/durable-contract/executor-state.d.ts +41 -0
- package/dist/vendor/shared/durable-contract/executor-state.js +32 -0
- package/dist/vendor/shared/durable-contract/fixlines.d.ts +318 -0
- package/dist/vendor/shared/durable-contract/fixlines.js +483 -0
- package/dist/vendor/shared/durable-contract/freeze-record.d.ts +787 -0
- package/dist/vendor/shared/durable-contract/freeze-record.js +361 -0
- package/dist/vendor/shared/durable-contract/index.d.ts +36 -0
- package/dist/vendor/shared/durable-contract/index.js +37 -0
- package/dist/vendor/shared/durable-contract/journal-approvals.d.ts +29 -0
- package/dist/vendor/shared/durable-contract/journal-approvals.js +60 -0
- package/dist/vendor/shared/durable-contract/keys.d.ts +24 -0
- package/dist/vendor/shared/durable-contract/keys.js +66 -0
- package/dist/vendor/shared/durable-contract/lock.d.ts +128 -0
- package/dist/vendor/shared/durable-contract/lock.js +151 -0
- package/dist/vendor/shared/durable-contract/names.d.ts +25 -0
- package/dist/vendor/shared/durable-contract/names.js +78 -0
- package/dist/vendor/shared/durable-contract/owner-verbs.d.ts +175 -0
- package/dist/vendor/shared/durable-contract/owner-verbs.js +403 -0
- package/dist/vendor/shared/durable-contract/paths.d.ts +23 -0
- package/dist/vendor/shared/durable-contract/paths.js +35 -0
- package/dist/vendor/shared/durable-contract/pure-fn.d.ts +48 -0
- package/dist/vendor/shared/durable-contract/pure-fn.js +89 -0
- package/dist/vendor/shared/durable-contract/routes.d.ts +205 -0
- package/dist/vendor/shared/durable-contract/routes.js +315 -0
- package/dist/vendor/shared/durable-contract/schema.d.ts +126 -0
- package/dist/vendor/shared/durable-contract/schema.js +441 -0
- package/dist/vendor/shared/durable-contract/seal.d.ts +54 -0
- package/dist/vendor/shared/durable-contract/seal.js +99 -0
- package/dist/vendor/shared/durable-contract/secret-scope.d.ts +28 -0
- package/dist/vendor/shared/durable-contract/secret-scope.js +76 -0
- package/dist/vendor/shared/durable-contract/session-id.d.ts +21 -0
- package/dist/vendor/shared/durable-contract/session-id.js +58 -0
- package/dist/vendor/shared/durable-contract/spec.d.ts +162 -0
- package/dist/vendor/shared/durable-contract/spec.js +286 -0
- package/dist/vendor/shared/durable-contract/states.d.ts +96 -0
- package/dist/vendor/shared/durable-contract/states.js +218 -0
- package/dist/vendor/shared/durable-contract/telemetry.d.ts +94 -0
- package/dist/vendor/shared/durable-contract/telemetry.js +180 -0
- package/dist/vendor/shared/durable-contract/test-mode.d.ts +62 -0
- package/dist/vendor/shared/durable-contract/test-mode.js +140 -0
- package/dist/vendor/shared/durable-contract/turn.d.ts +57 -0
- package/dist/vendor/shared/durable-contract/turn.js +81 -0
- package/dist/vendor/shared/durable-contract/vectors.d.ts +191 -0
- package/dist/vendor/shared/durable-contract/vectors.js +208 -0
- package/package.json +3 -2
- package/plugins/integrity-manifest.json +35 -0
- package/plugins/mnemom/hooks/hooks.json +15 -1
- package/plugins/mnemom/hooks/register.tsx +60 -2
- package/plugins/mnemom/hooks/seat-hello.mjs +116 -0
- package/dist/commands/agent-implicit-notice.d.ts +0 -31
- package/dist/commands/agent-implicit-notice.js +0 -73
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The per-seat socket of the durable seat proxy (C13-R10, C13-R14; BACKBONE C3 "per-seat 0600
|
|
2
|
+
// socket serving only hello").
|
|
3
|
+
//
|
|
4
|
+
// Framing (the plugin SessionStart hook's and the eval's): one JSON object per line,
|
|
5
|
+
// {op, session_id, ...payload}, answered by exactly one JSON line, then the server closes.
|
|
6
|
+
//
|
|
7
|
+
// In M1 it serves exactly one op: `hello` naming the seat's own session id and its registered spawn
|
|
8
|
+
// id (and, when given, the seat's epoch). That hello is relayed to the host daemon, which holds the
|
|
9
|
+
// executor key and nonce the API hello route needs, and the daemon's one-line answer goes back to
|
|
10
|
+
// the hook unchanged; a repeat hello is relayed like the first. Every other op, and any op naming
|
|
11
|
+
// another session id or spawn id, gets the one fixed refusal line below, with no relay and no API
|
|
12
|
+
// call. The refusal echoes nothing from the request.
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import net from "node:net";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
/** The one fixed refusal line (bytes before the newline). A local literal: S1 freezes no seat-socket refusal line yet (gap c3-seat-proxy-enums-not-in-s1). */
|
|
19
|
+
export const SEAT_SOCKET_REFUSAL = '{"ok":false,"error":"seat_op_refused"}';
|
|
20
|
+
/** Our own hello cannot reach the daemon (no relay configured, or the relay failed). */
|
|
21
|
+
export const SEAT_SOCKET_UNAVAILABLE = '{"ok":false,"error":"seat_hello_unavailable"}';
|
|
22
|
+
/** A frame (the bytes before the newline) longer than this many bytes is refused without parsing. */
|
|
23
|
+
export const SEAT_SOCKET_MAX_FRAME = 64 * 1024;
|
|
24
|
+
/** macOS sun_path is 104 bytes including the NUL. */
|
|
25
|
+
export const SOCKET_PATH_MAX = 103;
|
|
26
|
+
/** Whether a parsed frame is the seat's own hello. Everything else is refused. */
|
|
27
|
+
export function isOwnHello(frame, seat) {
|
|
28
|
+
if (!seat || !frame || typeof frame !== "object" || Array.isArray(frame))
|
|
29
|
+
return false;
|
|
30
|
+
const f = frame;
|
|
31
|
+
if (f.op !== "hello")
|
|
32
|
+
return false;
|
|
33
|
+
if (f.session_id !== seat.sessionId)
|
|
34
|
+
return false;
|
|
35
|
+
if (f.spawn_id !== seat.spawnId)
|
|
36
|
+
return false;
|
|
37
|
+
// Strict: a given epoch must be the seat's own number, same type and value ("3", [3], true: refused).
|
|
38
|
+
if (f.epoch !== undefined && f.epoch !== seat.epoch)
|
|
39
|
+
return false;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
/** The reply line (without newline) for one request line. Never throws. */
|
|
43
|
+
export async function answerFrame(line, seat, relay) {
|
|
44
|
+
let frame;
|
|
45
|
+
try {
|
|
46
|
+
frame = JSON.parse(line);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { reply: SEAT_SOCKET_REFUSAL, outcome: "refused" };
|
|
50
|
+
}
|
|
51
|
+
if (!isOwnHello(frame, seat) || !seat)
|
|
52
|
+
return { reply: SEAT_SOCKET_REFUSAL, outcome: "refused" };
|
|
53
|
+
if (!relay)
|
|
54
|
+
return { reply: SEAT_SOCKET_UNAVAILABLE, outcome: "unavailable" };
|
|
55
|
+
try {
|
|
56
|
+
const out = await relay({
|
|
57
|
+
op: "hello",
|
|
58
|
+
session_id: seat.sessionId,
|
|
59
|
+
epoch: seat.epoch,
|
|
60
|
+
spawn_id: seat.spawnId,
|
|
61
|
+
});
|
|
62
|
+
const first = String(out).split("\n")[0];
|
|
63
|
+
return first
|
|
64
|
+
? { reply: first, outcome: "relayed" }
|
|
65
|
+
: { reply: SEAT_SOCKET_UNAVAILABLE, outcome: "unavailable" };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { reply: SEAT_SOCKET_UNAVAILABLE, outcome: "unavailable" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The socket path for a conversation: under ~/.mnemom/durable/seats when it fits sun_path, else a
|
|
73
|
+
* short path under /tmp/mnm-seat-<uid>/ (a 0700 directory the seat uid owns), named by a hash of
|
|
74
|
+
* HOME and the conversation id.
|
|
75
|
+
*/
|
|
76
|
+
export function seatSocketPath(home, conversationId, uid = os.userInfo().uid) {
|
|
77
|
+
const preferred = path.join(home, ".mnemom", "durable", "seats", `${conversationId}.sock`);
|
|
78
|
+
if (Buffer.byteLength(preferred) <= SOCKET_PATH_MAX)
|
|
79
|
+
return preferred;
|
|
80
|
+
const h = createHash("sha256").update(`${home}\0${conversationId}`).digest("hex").slice(0, 24);
|
|
81
|
+
return path.join("/tmp", `mnm-seat-${uid}`, `${h}.sock`);
|
|
82
|
+
}
|
|
83
|
+
/** Create (or check) the socket's directory: 0700 and owned by this uid. Throws when it is not. */
|
|
84
|
+
export function ensureSocketDir(dir, uid = os.userInfo().uid) {
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
86
|
+
const st = fs.lstatSync(dir);
|
|
87
|
+
if (!st.isDirectory())
|
|
88
|
+
throw new Error("socket dir is not a directory");
|
|
89
|
+
if (st.uid !== uid)
|
|
90
|
+
throw new Error("socket dir is owned by another user");
|
|
91
|
+
if ((st.mode & 0o777) !== 0o700)
|
|
92
|
+
fs.chmodSync(dir, 0o700);
|
|
93
|
+
}
|
|
94
|
+
/** Relay a hello to the daemon's control socket: one JSON line out, one line back. */
|
|
95
|
+
export function unixLineRelay(socketPath, timeoutMs = 10_000) {
|
|
96
|
+
return (frame) => new Promise((resolve, reject) => {
|
|
97
|
+
const sock = net.createConnection({ path: socketPath });
|
|
98
|
+
let buf = "";
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
sock.destroy();
|
|
101
|
+
reject(new Error("relay timeout"));
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
sock.on("connect", () => sock.write(`${JSON.stringify(frame)}\n`));
|
|
104
|
+
sock.on("data", (d) => {
|
|
105
|
+
buf += d.toString("utf8");
|
|
106
|
+
const i = buf.indexOf("\n");
|
|
107
|
+
if (i >= 0) {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
sock.end();
|
|
110
|
+
resolve(buf.slice(0, i));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
sock.on("error", (e) => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
reject(e);
|
|
116
|
+
});
|
|
117
|
+
sock.on("close", () => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
if (buf.indexOf("\n") < 0)
|
|
120
|
+
reject(new Error("relay closed without a reply"));
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Listen on the seat socket (file mode 0600). Resolves once listening. */
|
|
125
|
+
export function startSeatSocket(opts) {
|
|
126
|
+
ensureSocketDir(path.dirname(opts.socketPath));
|
|
127
|
+
try {
|
|
128
|
+
fs.unlinkSync(opts.socketPath); // a stale socket from a dead proxy; we hold the conversation lock
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* none */
|
|
132
|
+
}
|
|
133
|
+
const server = net.createServer((sock) => {
|
|
134
|
+
// Raw bytes, decoded once a whole line is in: the cap counts bytes (a multibyte character is
|
|
135
|
+
// not one unit), and a character split across chunks is not mangled.
|
|
136
|
+
let buf = Buffer.alloc(0);
|
|
137
|
+
sock.on("error", () => sock.destroy());
|
|
138
|
+
const onData = (d) => {
|
|
139
|
+
buf = Buffer.concat([buf, d]);
|
|
140
|
+
const i = buf.indexOf(0x0a);
|
|
141
|
+
if ((i < 0 ? buf.length : i) > SEAT_SOCKET_MAX_FRAME) {
|
|
142
|
+
sock.removeListener("data", onData);
|
|
143
|
+
opts.log("socket op refused (frame too long)");
|
|
144
|
+
sock.end(`${SEAT_SOCKET_REFUSAL}\n`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (i < 0)
|
|
148
|
+
return;
|
|
149
|
+
sock.removeListener("data", onData);
|
|
150
|
+
void answerFrame(buf.subarray(0, i).toString("utf8"), opts.seat, opts.relay).then((a) => {
|
|
151
|
+
opts.log(`socket op ${a.outcome}`);
|
|
152
|
+
sock.end(`${a.reply}\n`);
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
sock.on("data", onData);
|
|
156
|
+
});
|
|
157
|
+
const prev = process.umask(0o177);
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const onStartError = (e) => {
|
|
160
|
+
process.umask(prev);
|
|
161
|
+
reject(e);
|
|
162
|
+
};
|
|
163
|
+
server.once("error", onStartError);
|
|
164
|
+
server.listen(opts.socketPath, () => {
|
|
165
|
+
process.umask(prev);
|
|
166
|
+
// The start-up listener only answers listen(): once listening, a later server error must not
|
|
167
|
+
// reset the umask or vanish into a settled promise, and must not go unhandled either.
|
|
168
|
+
server.removeListener("error", onStartError);
|
|
169
|
+
server.on("error", (e) => opts.log(`seat socket error: ${e.message}`));
|
|
170
|
+
try {
|
|
171
|
+
fs.chmodSync(opts.socketPath, 0o600);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* best effort: umask already gave 0600 */
|
|
175
|
+
}
|
|
176
|
+
resolve(server);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type LaunchSpec, type LauncherKey } from "../vendor/durable-contract/index.js";
|
|
2
|
+
export interface SeatSpecInput {
|
|
3
|
+
raw_spec: unknown;
|
|
4
|
+
name: string;
|
|
5
|
+
launcher_key: LauncherKey;
|
|
6
|
+
}
|
|
7
|
+
export interface SeatSpecRefusal {
|
|
8
|
+
code: "durable_unsupported";
|
|
9
|
+
feature: "remote_control";
|
|
10
|
+
names: string[];
|
|
11
|
+
fix: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SeatSpecResult {
|
|
14
|
+
seat_spec_built: boolean;
|
|
15
|
+
spawn_requested: boolean;
|
|
16
|
+
refusal: SeatSpecRefusal | null;
|
|
17
|
+
/** The parsed spec when built (not part of the S1 output contract; for the daemon). */
|
|
18
|
+
spec?: LaunchSpec;
|
|
19
|
+
}
|
|
20
|
+
/** The spec's user flags for the fix line, in the seat order: --model, then --effort. */
|
|
21
|
+
export declare function specFlags(raw: unknown): string[];
|
|
22
|
+
/**
|
|
23
|
+
* The launch_spec_change fix for `name`: exactly the S1 render with <flags> the spec's flags. S1
|
|
24
|
+
* does not say how an empty <flags> renders (gap c3-rc-fix-empty-flags), so it is not
|
|
25
|
+
* post-processed here.
|
|
26
|
+
*/
|
|
27
|
+
export declare function remoteControlFix(name: string, flags: readonly string[], launcherKey: LauncherKey): string;
|
|
28
|
+
export declare function validateSeatSpec(input: SeatSpecInput): SeatSpecResult;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The daemon's seat-spec validator: defence in depth against a durable launch spec carrying
|
|
2
|
+
// remote control (C13-R13; BACKBONE line 153; S1 PURE_FN_M1_PENDING validateSeatSpec, ruling 33.01).
|
|
3
|
+
//
|
|
4
|
+
// Order (BACKBONE line 84): hasRemoteControl on the raw spec first, then the closed-schema parse.
|
|
5
|
+
// A remote-control carrier is refused with S1 durable_unsupported{feature: remote_control}, and the
|
|
6
|
+
// fix is the S1 launch_spec_change render for the session name (the done clause, since the locked
|
|
7
|
+
// session holds its name; then `--new` with the spec's model/effort flags; the S1 launcher suffix
|
|
8
|
+
// unless the launcher key is always). The validator builds nothing and spawns nothing itself: the
|
|
9
|
+
// daemon (C2) builds the seat from `spec` and spawns only when seat_spec_built is true.
|
|
10
|
+
import { hasRemoteControl, parseLaunchSpec, renderFixLine, LAUNCHER_KEYS, } from "../vendor/durable-contract/index.js";
|
|
11
|
+
/** The spec's user flags for the fix line, in the seat order: --model, then --effort. */
|
|
12
|
+
export function specFlags(raw) {
|
|
13
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
14
|
+
return [];
|
|
15
|
+
const r = raw;
|
|
16
|
+
const out = [];
|
|
17
|
+
if (typeof r.model === "string" && r.model)
|
|
18
|
+
out.push("--model", r.model);
|
|
19
|
+
if (typeof r.effort === "string" && r.effort)
|
|
20
|
+
out.push("--effort", r.effort);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The launch_spec_change fix for `name`: exactly the S1 render with <flags> the spec's flags. S1
|
|
25
|
+
* does not say how an empty <flags> renders (gap c3-rc-fix-empty-flags), so it is not
|
|
26
|
+
* post-processed here.
|
|
27
|
+
*/
|
|
28
|
+
export function remoteControlFix(name, flags, launcherKey) {
|
|
29
|
+
return renderFixLine("launch_spec_change", {
|
|
30
|
+
launcherKey,
|
|
31
|
+
nameHeld: true,
|
|
32
|
+
params: { name, flags: flags.join(" ") },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function validateSeatSpec(input) {
|
|
36
|
+
if (!input || typeof input !== "object")
|
|
37
|
+
throw new TypeError("validateSeatSpec needs {raw_spec, name, launcher_key}");
|
|
38
|
+
const { raw_spec: raw, name, launcher_key: launcherKey } = input;
|
|
39
|
+
if (typeof name !== "string" || name === "")
|
|
40
|
+
throw new TypeError("name must be a non-empty string");
|
|
41
|
+
if (!LAUNCHER_KEYS.includes(launcherKey))
|
|
42
|
+
throw new TypeError("launcher_key must be off, ask or always");
|
|
43
|
+
if (hasRemoteControl(raw)) {
|
|
44
|
+
return {
|
|
45
|
+
seat_spec_built: false,
|
|
46
|
+
spawn_requested: false,
|
|
47
|
+
refusal: {
|
|
48
|
+
code: "durable_unsupported",
|
|
49
|
+
feature: "remote_control",
|
|
50
|
+
names: ["--remote-control"],
|
|
51
|
+
fix: remoteControlFix(name, specFlags(raw), launcherKey),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const spec = parseLaunchSpec(raw);
|
|
56
|
+
if (spec === null)
|
|
57
|
+
return { seat_spec_built: false, spawn_requested: false, refusal: null };
|
|
58
|
+
return { seat_spec_built: true, spawn_requested: true, refusal: null, spec };
|
|
59
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The CLI's one TEST_MODE module (S1 TEST_MODE_MODULE.cli; C17-R16, C12-R26, C12-R29).
|
|
2
|
+
//
|
|
3
|
+
// Every CLI seam getter lives here and nowhere else reads DURABLE_SESSIONS_TEST_MODE: the static
|
|
4
|
+
// seam_module check fails the build on any other read. A seam is honoured only when
|
|
5
|
+
// DURABLE_SESSIONS_TEST_MODE is exactly "1" and every URL on the CLI guard list (S1 GUARD_LISTS.cli)
|
|
6
|
+
// is a loopback literal; an unset URL counts as non-loopback. Otherwise a seam env is ignored.
|
|
7
|
+
//
|
|
8
|
+
// The CLI seams (closed list): T1 DURABLE_BOOT_ID_FILE, T2 DURABLE_HOST_TEST_PAUSE_AT, T3 the
|
|
9
|
+
// credential proxy's http door on a loopback host.
|
|
10
|
+
|
|
11
|
+
const TEST_MODE = "DURABLE_SESSIONS_TEST_MODE";
|
|
12
|
+
/** S1 GUARD_LISTS.cli, in order. */
|
|
13
|
+
export const CLI_GUARD = Object.freeze(["MNEMOM_API_URL", "MNEMOM_GATEWAY_URL"]);
|
|
14
|
+
const LOOPBACK = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
15
|
+
|
|
16
|
+
/** A URL whose host is exactly a loopback literal (S1 isLoopbackUrl). */
|
|
17
|
+
export function isLoopbackUrl(value) {
|
|
18
|
+
if (typeof value !== "string" || value === "") return false;
|
|
19
|
+
try {
|
|
20
|
+
return LOOPBACK.has(new URL(value).hostname);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The seam guard: test mode exactly "1" and every guard URL a loopback literal. */
|
|
27
|
+
export function testSeamsHonoured(env = process.env) {
|
|
28
|
+
if (env[TEST_MODE] !== "1") return false;
|
|
29
|
+
return CLI_GUARD.every((k) => isLoopbackUrl(env[k]));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* T1: the boot id override file, or null. `warned` says the override was set but ignored (the
|
|
34
|
+
* caller logs a warning before any network call, C12-R26).
|
|
35
|
+
*/
|
|
36
|
+
export function bootIdFileOverride(env = process.env) {
|
|
37
|
+
const file = env.DURABLE_BOOT_ID_FILE;
|
|
38
|
+
if (typeof file !== "string" || file === "") return { file: null, ignored: false };
|
|
39
|
+
if (!testSeamsHonoured(env)) return { file: null, ignored: true };
|
|
40
|
+
return { file, ignored: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** S1 T2_PAUSE_POINTS. */
|
|
44
|
+
export const PAUSE_POINTS = Object.freeze([
|
|
45
|
+
"after_claim_before_spawn",
|
|
46
|
+
"after_spawn_before_register",
|
|
47
|
+
"after_register_before_hello",
|
|
48
|
+
"after_first_batch",
|
|
49
|
+
"after_verify_before_exec",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** T2: the host daemon pause point, or null (unknown values are ignored). */
|
|
53
|
+
export function hostTestPauseAt(env = process.env) {
|
|
54
|
+
const v = env.DURABLE_HOST_TEST_PAUSE_AT;
|
|
55
|
+
if (typeof v !== "string" || !PAUSE_POINTS.includes(v)) return null;
|
|
56
|
+
return testSeamsHonoured(env) ? v : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** T3: whether the credential proxy may use `door` (an http URL on a loopback host). https is always allowed. */
|
|
60
|
+
export function httpDoorAllowed(door, env = process.env) {
|
|
61
|
+
let u;
|
|
62
|
+
try {
|
|
63
|
+
u = new URL(door);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (u.protocol === "https:") return true;
|
|
68
|
+
if (u.protocol !== "http:") return false;
|
|
69
|
+
return isLoopbackUrl(door) && testSeamsHonoured(env);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Seam env the host daemon's LaunchAgent and its seats carry over, so a test-mode lane stays in test
|
|
74
|
+
* mode across launchd and tmux. Named here so no other module spells the test-mode variable.
|
|
75
|
+
*/
|
|
76
|
+
export const DAEMON_TEST_ENV_PASS = Object.freeze([
|
|
77
|
+
TEST_MODE,
|
|
78
|
+
"DURABLE_BOOT_ID_FILE",
|
|
79
|
+
"DURABLE_HOST_TEST_PAUSE_AT",
|
|
80
|
+
]);
|
|
81
|
+
export const SEAT_TEST_ENV_PASS = Object.freeze([TEST_MODE]);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type TranscriptEntry, type TurnOrigins } from "../turn-origin.mjs";
|
|
2
|
+
import { type RequestClass } from "../vendor/durable-contract/index.js";
|
|
3
|
+
export type DurableTurn =
|
|
4
|
+
/** Forward with this `x-mnemom-turn` value (it starts with `chain=`). */
|
|
5
|
+
{
|
|
6
|
+
kind: "chained";
|
|
7
|
+
value: string;
|
|
8
|
+
chain: string;
|
|
9
|
+
cls: RequestClass;
|
|
10
|
+
note: string;
|
|
11
|
+
}
|
|
12
|
+
/** Forward with no turn header (pre-first-prompt count_tokens). */
|
|
13
|
+
| {
|
|
14
|
+
kind: "unchained";
|
|
15
|
+
cls: RequestClass;
|
|
16
|
+
note: string;
|
|
17
|
+
}
|
|
18
|
+
/** Answer the request locally; nothing is forwarded. */
|
|
19
|
+
| {
|
|
20
|
+
kind: "local";
|
|
21
|
+
cls: RequestClass | "no_session";
|
|
22
|
+
reason: "turn_wait_timeout" | "pre_first_prompt" | "no_session";
|
|
23
|
+
note: string;
|
|
24
|
+
};
|
|
25
|
+
/** The S1 request class of a request, from its path and (lower-cased) Claude Code hint headers. */
|
|
26
|
+
export declare function requestClassOf(pathname: string, headers: Record<string, unknown>): RequestClass;
|
|
27
|
+
/**
|
|
28
|
+
* The entry as the durable variant states it. Every prompt entry is stated, so the newest stated
|
|
29
|
+
* entry is always the real newest one: an entry whose origin turn-origin cannot name reads as the
|
|
30
|
+
* user's prompt when it records no source either, and as meta (with its source) when its source is
|
|
31
|
+
* system or sdk. Nothing Claude Code recorded as non-human is ever promoted.
|
|
32
|
+
*/
|
|
33
|
+
export declare function statedEntry(e: TranscriptEntry): TranscriptEntry;
|
|
34
|
+
/**
|
|
35
|
+
* The producer Claude Code recorded for a task notification (`origin.producer`), stated on
|
|
36
|
+
* origin=task entries only (S1 amendment 56); undefined otherwise or when not a TURN_PRODUCER_RE token.
|
|
37
|
+
*/
|
|
38
|
+
export declare function entryProducer(e: TranscriptEntry): string | undefined;
|
|
39
|
+
/** The header value: the leading chain element, then the stated entries up to and including the root. */
|
|
40
|
+
export declare function durableHeaderValue(entries: readonly TranscriptEntry[], chain: string): string;
|
|
41
|
+
/** Entries up to and including the last one whose promptId is `promptId`; none when it is absent. */
|
|
42
|
+
export declare function upTo(entries: readonly TranscriptEntry[], promptId: string): TranscriptEntry[];
|
|
43
|
+
export interface DurableTurnOptions {
|
|
44
|
+
/** Must be exactly true: the explicit opt-in only the durable seat proxy passes. */
|
|
45
|
+
durable: boolean;
|
|
46
|
+
/** Overrides S1 TURN_WAIT_MS (tests only). */
|
|
47
|
+
waitMs?: number;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
sleep?: (ms: number) => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The durable turn decision for one request. Without `{ durable: true }` it throws: there is no
|
|
53
|
+
* implicit path to a `chain=` header.
|
|
54
|
+
*/
|
|
55
|
+
export declare function durableTurnHeaderFor(turns: TurnOrigins, pathname: string, headers: Record<string, unknown>, opts: DurableTurnOptions): Promise<DurableTurn>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The opt-in durable variant of the turn-origin builder (BACKBONE C3 and amendment 38; C13-R8).
|
|
2
|
+
//
|
|
3
|
+
// A sibling of turn-origin.mjs `turnHeaderFor`: it reads the same transcript through the same
|
|
4
|
+
// TurnOrigins / TranscriptIndex and states entries with the same `buildTurnHeader`, and adds the
|
|
5
|
+
// leading `chain=<64 hex>` element under the S1 chain derivation. It emits `chain=` only when the
|
|
6
|
+
// caller passes `{ durable: true }`, and only the durable seat proxy does. MNEMOM_AGENT_TURN_HEADER
|
|
7
|
+
// is never read here. rc-proxy.mjs and base-url-proxy.mjs do not import this module, so their
|
|
8
|
+
// output is byte-identical with durable on or off.
|
|
9
|
+
//
|
|
10
|
+
// S1 chain derivation (shared/durable-contract turn.ts chainHandling):
|
|
11
|
+
// main with a prompt id wait up to TURN_WAIT_MS for that prompt; chain = sha256(promptId),
|
|
12
|
+
// newest stated entry = that prompt entry; timeout = drop
|
|
13
|
+
// main without a prompt id, no wait; the session's current root chain: sha256 of the promptId of
|
|
14
|
+
// compaction, auxiliary the newest prompt entry (a compact-summary line never counts)
|
|
15
|
+
// count_tokens no wait; the current root chain when there is one, else forwarded
|
|
16
|
+
// with no turn header at all (the one named exception, ruling 22)
|
|
17
|
+
// no prompt entry yet generation requests are answered locally by the proxy
|
|
18
|
+
//
|
|
19
|
+
// A prompt entry that records no origin fact at all (no turnOrigin, origin.kind, promptSource, meta
|
|
20
|
+
// or summary flag) is stated as `origin=human`, with no source: it is the user's own prompt line.
|
|
21
|
+
// Every non-human fact Claude Code records still wins, by entryOrigin's precedence.
|
|
22
|
+
//
|
|
23
|
+
// S1 amendment 56: an origin=task entry also states `producer=<token>` (Claude Code's own
|
|
24
|
+
// origin.producer, e.g. session-task), here only. turn-origin.mjs is main's, so the plain proxies'
|
|
25
|
+
// header bytes never carry it.
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { buildTurnHeader, entryOrigin, entrySource, TURN_PARTS_MAX, } from "../turn-origin.mjs";
|
|
28
|
+
import { TURN_WAIT_MS, isCountTokensPath, } from "../vendor/durable-contract/index.js";
|
|
29
|
+
import { TURN_PRODUCER_RE } from "../vendor/shared/durable-contract/turn.js";
|
|
30
|
+
const ID_RE = /^[A-Za-z0-9-]{8,64}$/;
|
|
31
|
+
const POLL_MS = 20;
|
|
32
|
+
const one = (v) => String((Array.isArray(v) ? v[0] : v) ?? "").trim();
|
|
33
|
+
const sha256Hex = (s) => createHash("sha256").update(s, "utf8").digest("hex");
|
|
34
|
+
/** The S1 request class of a request, from its path and (lower-cased) Claude Code hint headers. */
|
|
35
|
+
export function requestClassOf(pathname, headers) {
|
|
36
|
+
const cls = one(headers["x-claude-code-request-class"]).toLowerCase();
|
|
37
|
+
const promptId = one(headers["x-claude-code-prompt-id"]);
|
|
38
|
+
if (isCountTokensPath(pathname))
|
|
39
|
+
return "count_tokens_pre_first_prompt";
|
|
40
|
+
if (cls === "main")
|
|
41
|
+
return ID_RE.test(promptId) ? "main_synced" : "main_no_prompt_id";
|
|
42
|
+
if (cls === "compaction")
|
|
43
|
+
return "compaction";
|
|
44
|
+
return "auxiliary";
|
|
45
|
+
}
|
|
46
|
+
/** A transcript entry that may be the root of a turn: a prompt entry with a promptId, never a summary. */
|
|
47
|
+
function isRootCandidate(e) {
|
|
48
|
+
return typeof e.promptId === "string" && e.promptId !== "" && e.isCompactSummary !== true;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* entryOrigin as S1 pinned it (turn-origin.mjs@b37bfbf1), before main's SDK rule (#2849: an SDK
|
|
52
|
+
* prompt reads as the user's turn). In a durable session the host daemon's seat drives Claude Code
|
|
53
|
+
* through the SDK, so an SDK prompt is not the owner speaking: an entry whose only origin fact is
|
|
54
|
+
* `sdk` names no origin here, and statedEntry states it as meta with its source, as before.
|
|
55
|
+
*/
|
|
56
|
+
function originWithoutSdkRule(e) {
|
|
57
|
+
return entryOrigin({
|
|
58
|
+
...e,
|
|
59
|
+
promptSource: e.promptSource === "sdk" ? undefined : e.promptSource,
|
|
60
|
+
turnOrigin: e.turnOrigin === "sdk" ? undefined : e.turnOrigin,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The entry as the durable variant states it. Every prompt entry is stated, so the newest stated
|
|
65
|
+
* entry is always the real newest one: an entry whose origin turn-origin cannot name reads as the
|
|
66
|
+
* user's prompt when it records no source either, and as meta (with its source) when its source is
|
|
67
|
+
* system or sdk. Nothing Claude Code recorded as non-human is ever promoted.
|
|
68
|
+
*/
|
|
69
|
+
export function statedEntry(e) {
|
|
70
|
+
if (originWithoutSdkRule(e) !== null)
|
|
71
|
+
return e;
|
|
72
|
+
const source = entrySource(e);
|
|
73
|
+
if (source === "system" || source === "sdk")
|
|
74
|
+
return { ...e, isMeta: true };
|
|
75
|
+
return { ...e, turnOrigin: "human" };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The producer Claude Code recorded for a task notification (`origin.producer`), stated on
|
|
79
|
+
* origin=task entries only (S1 amendment 56); undefined otherwise or when not a TURN_PRODUCER_RE token.
|
|
80
|
+
*/
|
|
81
|
+
export function entryProducer(e) {
|
|
82
|
+
if (entryOrigin(e) !== "task")
|
|
83
|
+
return undefined;
|
|
84
|
+
const p = e.origin?.producer;
|
|
85
|
+
return typeof p === "string" && TURN_PRODUCER_RE.test(p) ? p : undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* main's buildTurnHeader, entry by entry, with `producer=<p>; ` before `pastes=` on an entry that
|
|
89
|
+
* states one; the same newest TURN_PARTS_MAX parts.
|
|
90
|
+
*/
|
|
91
|
+
function statedParts(stated) {
|
|
92
|
+
const parts = [];
|
|
93
|
+
for (const e of stated) {
|
|
94
|
+
const own = buildTurnHeader([e], Number.MAX_SAFE_INTEGER);
|
|
95
|
+
if (!own)
|
|
96
|
+
continue;
|
|
97
|
+
const producer = entryProducer(e);
|
|
98
|
+
for (const part of own.split(", "))
|
|
99
|
+
parts.push(producer ? part.replace("; pastes=", `; producer=${producer}; pastes=`) : part);
|
|
100
|
+
}
|
|
101
|
+
return parts.slice(-TURN_PARTS_MAX).join(", ");
|
|
102
|
+
}
|
|
103
|
+
/** The header value: the leading chain element, then the stated entries up to and including the root. */
|
|
104
|
+
export function durableHeaderValue(entries, chain) {
|
|
105
|
+
const stated = entries.filter((e) => e.isCompactSummary !== true).map(statedEntry);
|
|
106
|
+
const parts = statedParts(stated);
|
|
107
|
+
return parts ? `chain=${chain}, ${parts}` : `chain=${chain}`;
|
|
108
|
+
}
|
|
109
|
+
/** Entries up to and including the last one whose promptId is `promptId`; none when it is absent. */
|
|
110
|
+
export function upTo(entries, promptId) {
|
|
111
|
+
for (let i = entries.length - 1; i >= 0; i--)
|
|
112
|
+
if (entries[i].promptId === promptId)
|
|
113
|
+
return entries.slice(0, i + 1);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
/** The newest root candidate, or null before the first prompt. */
|
|
117
|
+
function newestRoot(entries) {
|
|
118
|
+
for (let i = entries.length - 1; i >= 0; i--)
|
|
119
|
+
if (isRootCandidate(entries[i]))
|
|
120
|
+
return entries[i];
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The durable turn decision for one request. Without `{ durable: true }` it throws: there is no
|
|
125
|
+
* implicit path to a `chain=` header.
|
|
126
|
+
*/
|
|
127
|
+
export async function durableTurnHeaderFor(turns, pathname, headers, opts) {
|
|
128
|
+
if (!opts || opts.durable !== true)
|
|
129
|
+
throw new Error("durableTurnHeaderFor needs { durable: true }");
|
|
130
|
+
const now = opts.now ?? Date.now;
|
|
131
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
132
|
+
const sessionId = one(headers["x-claude-code-session-id"]);
|
|
133
|
+
const cls = requestClassOf(pathname, headers);
|
|
134
|
+
const isCount = cls === "count_tokens_pre_first_prompt";
|
|
135
|
+
if (!ID_RE.test(sessionId)) {
|
|
136
|
+
if (isCount)
|
|
137
|
+
return { kind: "unchained", cls, note: "no_session" };
|
|
138
|
+
return { kind: "local", cls: "no_session", reason: "no_session", note: "no_session" };
|
|
139
|
+
}
|
|
140
|
+
if (cls === "main_synced") {
|
|
141
|
+
const promptId = one(headers["x-claude-code-prompt-id"]);
|
|
142
|
+
const deadline = now() + (opts.waitMs ?? TURN_WAIT_MS);
|
|
143
|
+
for (;;) {
|
|
144
|
+
const ix = turns.index(sessionId);
|
|
145
|
+
if (ix && ix.refresh() && ix.has(promptId)) {
|
|
146
|
+
const entries = upTo(ix.entries, promptId);
|
|
147
|
+
const chain = sha256Hex(promptId);
|
|
148
|
+
return {
|
|
149
|
+
kind: "chained",
|
|
150
|
+
value: durableHeaderValue(entries, chain),
|
|
151
|
+
chain,
|
|
152
|
+
cls,
|
|
153
|
+
note: "synced",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (now() >= deadline)
|
|
157
|
+
return { kind: "local", cls, reason: "turn_wait_timeout", note: "turn_wait_timeout" };
|
|
158
|
+
await sleep(POLL_MS);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const ix = turns.index(sessionId);
|
|
162
|
+
const ok = ix ? ix.refresh() : false;
|
|
163
|
+
const root = ok && ix ? newestRoot(ix.entries) : null;
|
|
164
|
+
if (!root || !ix) {
|
|
165
|
+
if (isCount)
|
|
166
|
+
return { kind: "unchained", cls, note: "pre_first_prompt" };
|
|
167
|
+
return { kind: "local", cls, reason: "pre_first_prompt", note: "pre_first_prompt" };
|
|
168
|
+
}
|
|
169
|
+
const promptId = root.promptId;
|
|
170
|
+
const chain = sha256Hex(promptId);
|
|
171
|
+
return {
|
|
172
|
+
kind: "chained",
|
|
173
|
+
value: durableHeaderValue(upTo(ix.entries, promptId), chain),
|
|
174
|
+
chain,
|
|
175
|
+
cls,
|
|
176
|
+
note: "current_root",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seat's automatic "continue" (lead rulings, 2026-10-28: "auto-continue an interrupted turn once
|
|
3
|
+
* on takeover"): a lock that lands mid-turn leaves the resumed conversation's last turn interrupted,
|
|
4
|
+
* and `claude --resume` then idles at "What should Claude do instead?". On the seat's first accepted
|
|
5
|
+
* hello, if the transcript's last turn (as it was before the seat started) was interrupted and that
|
|
6
|
+
* turn was never continued, the daemon types one "continue" into the pane and logs it. The marker is
|
|
7
|
+
* keyed on the session and the interrupted turn's record uuid, written before the keys are sent and
|
|
8
|
+
* kept across respawns: each interrupted turn is continued at most once, so a later mid-turn lock in
|
|
9
|
+
* the same session is continued too, and a respawn never continues the same turn twice.
|
|
10
|
+
*
|
|
11
|
+
* The continue is also journaled: S1's executor.turn_continued (refs {spawn_id}, lead ruling
|
|
12
|
+
* 2026-10-28) is appended under the seat's epoch after the marker and before the keys. A refused or
|
|
13
|
+
* failed append sends nothing; the marker stays, so no restart retries that turn unjournaled. The
|
|
14
|
+
* marker remains the per-turn guard: the event's refs name the spawn, not the turn.
|
|
15
|
+
*
|
|
16
|
+
* Pure over injected deps; host/run.ts wires tmux, the marker directory and the daemon log.
|
|
17
|
+
*/
|
|
18
|
+
import type { ExecutorApi } from "./client.js";
|
|
19
|
+
export declare const CONTINUE_TEXT = "continue";
|
|
20
|
+
/** How long to wait for claude's input prompt in the pane before giving up. */
|
|
21
|
+
export declare const PANE_READY_TIMEOUT_MS = 30000;
|
|
22
|
+
export declare const PANE_READY_POLL_MS = 500;
|
|
23
|
+
/**
|
|
24
|
+
* The interrupted turn of a CC transcript: the uuid of its last conversation record when the turn was
|
|
25
|
+
* cut off, else null. The last record decides: the assistant's tool call (no result yet) or any user
|
|
26
|
+
* record (a prompt no reply followed, a tool result the model never answered, or CC's "[Request
|
|
27
|
+
* interrupted by user]") is cut off, as is an assistant record with only thinking; the assistant's own
|
|
28
|
+
* text finished the turn. Meta records and lines that are not JSON are skipped. A cut-off record with
|
|
29
|
+
* no uuid also gives null: without a turn id there is no marker to guard the continue with.
|
|
30
|
+
*/
|
|
31
|
+
export declare function interruptedTurnId(transcript: string): string | null;
|
|
32
|
+
/** The seat a continue is for: its session, pane, and the claim it was spawned under. */
|
|
33
|
+
export interface ContinueSeat {
|
|
34
|
+
readonly sessionId: string;
|
|
35
|
+
readonly tmuxSession: string;
|
|
36
|
+
readonly epoch: number;
|
|
37
|
+
readonly spawnId: string;
|
|
38
|
+
}
|
|
39
|
+
/** The turn_continued idempotency key: one continue per spawn of a session under one epoch. */
|
|
40
|
+
export declare const continueIdempotencyKey: (seat: ContinueSeat) => string;
|
|
41
|
+
/**
|
|
42
|
+
* The journal dep over the executor API: POST executor.turn_continued for the seat under its claim
|
|
43
|
+
* epoch and the boot-bound nonce. A 2xx is journaled (null); anything else answers its code.
|
|
44
|
+
*/
|
|
45
|
+
export declare function turnContinuedJournal(api: Pick<ExecutorApi, "append">, nonce: () => string): (seat: ContinueSeat) => Promise<string | null>;
|
|
46
|
+
export interface AutoContinueDeps {
|
|
47
|
+
/** This turn of this session was already continued (by any spawn). */
|
|
48
|
+
continued(sessionId: string, turnId: string): boolean;
|
|
49
|
+
/** Record the turn's one continue; throws when it cannot be recorded (then nothing is sent). */
|
|
50
|
+
markContinued(sessionId: string, turnId: string): void;
|
|
51
|
+
/** Append executor.turn_continued for `seat`; null when the API took it, else why it did not. */
|
|
52
|
+
journal(seat: ContinueSeat): Promise<string | null>;
|
|
53
|
+
/** The pane's visible text, or null when the pane is gone. */
|
|
54
|
+
capture(tmuxSession: string): Promise<string | null>;
|
|
55
|
+
/** Type `text` and press Enter in the pane. */
|
|
56
|
+
send(tmuxSession: string, text: string): Promise<void>;
|
|
57
|
+
sleep(ms: number): Promise<void>;
|
|
58
|
+
now(): number;
|
|
59
|
+
log(line: string): void;
|
|
60
|
+
readyTimeoutMs?: number;
|
|
61
|
+
}
|
|
62
|
+
export type AutoContinueOutcome = "not_interrupted" | "already_continued" | "pane_not_ready" | "marker_unwritable" | "journal_refused" | "sent";
|
|
63
|
+
/**
|
|
64
|
+
* Run once per seat after its first accepted hello. `turnId` is interruptedTurnId of the transcript
|
|
65
|
+
* as it was at spawn (null: nothing to continue).
|
|
66
|
+
*/
|
|
67
|
+
export declare function autoContinue(seat: ContinueSeat, turnId: string | null, d: AutoContinueDeps): Promise<AutoContinueOutcome>;
|