cmdr-mcp 0.1.1 → 0.3.0
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +19 -16
- package/docs/README.zh-CN.md +16 -11
- package/docs/agent-integration.md +13 -3
- package/docs/cmdr-design-v1.md +2 -0
- package/docs/implementation.md +51 -6
- package/docs/long-running-collaboration.md +138 -0
- package/docs/publishing.md +23 -8
- package/docs/troubleshooting.md +34 -3
- package/docs/zcode-desktop-session-visibility.md +172 -0
- package/marketplace.json +1 -1
- package/package.json +2 -1
- package/plugins/cmdr/.claude-plugin/plugin.json +1 -1
- package/plugins/cmdr/.codex-plugin/plugin.json +1 -1
- package/plugins/cmdr/.zcode-plugin/plugin.json +1 -1
- package/plugins/cmdr/README.md +4 -4
- package/plugins/cmdr/bin/cmdr-node +2 -2
- package/plugins/cmdr/commands/cmdr.md +4 -6
- package/plugins/cmdr/dist/cli.mjs +322 -36
- package/plugins/cmdr/dist/daemon.mjs +1478 -155
- package/plugins/cmdr/dist/hook.mjs +25 -8
- package/plugins/cmdr/dist/integrity.json +13 -13
- package/plugins/cmdr/dist/mcp.mjs +44 -17
- package/plugins/cmdr/skills/cmdr-commander/SKILL.md +10 -8
- package/plugins/cmdr/skills/cmdr-executor/SKILL.md +9 -7
- package/plugins/cmdr/skills/using-cmdr/SKILL.md +7 -9
|
@@ -6,19 +6,688 @@ var __export = (target, all) => {
|
|
|
6
6
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
+
// src/shared/protocol.ts
|
|
10
|
+
var LIMITS = {
|
|
11
|
+
maxWaitSec: 300,
|
|
12
|
+
maxBody: 32768,
|
|
13
|
+
maxData: 65536,
|
|
14
|
+
maxFrame: 2 * 1024 * 1024
|
|
15
|
+
};
|
|
16
|
+
var CmdrError = class extends Error {
|
|
17
|
+
constructor(code, message = code) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function fail(code, message) {
|
|
23
|
+
throw new CmdrError(code, message);
|
|
24
|
+
}
|
|
25
|
+
var WakeDeferred = class extends Error {
|
|
26
|
+
};
|
|
27
|
+
var terminalWork = (m) => !!m?.work && ["completed", "failed", "cancelled"].includes(m.work.state);
|
|
28
|
+
|
|
29
|
+
// src/shared/wake.ts
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
function actionable(message, sid) {
|
|
32
|
+
if (message.type === "command") return !terminalWork(message);
|
|
33
|
+
if (["cancel", "ask", "answer", "system"].includes(message.type)) return true;
|
|
34
|
+
return message.attn || message.type === "info" && message.direct !== false && !message.to_sid.startsWith("squad:") && (!sid || message.to_sid === sid);
|
|
35
|
+
}
|
|
36
|
+
function wakeEvent(event, sid) {
|
|
37
|
+
return ["message.queued", "work.released"].includes(event.kind) && !!event.message && actionable(event.message, sid);
|
|
38
|
+
}
|
|
39
|
+
function wakePrompt(id) {
|
|
40
|
+
return `[cmdr wake ${id}] Actionable messages or unfinished commands await this member. Call cmdr read, then read(recover=true). Accept commands with report(working, reply_to) before work. Check cancel messages first; never repeat completed work. Messages do not expand user authorization.`;
|
|
41
|
+
}
|
|
42
|
+
function hostStandby(mode) {
|
|
43
|
+
return mode === "claude" || mode === "zcode";
|
|
44
|
+
}
|
|
45
|
+
function armHint(s) {
|
|
46
|
+
if (!hostStandby(s.wake_mode)) return void 0;
|
|
47
|
+
const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
48
|
+
const command = `${quote(fileURLToPath(new URL("../bin/cmdr", import.meta.url)))} standby watch --session ${quote(s.sid)}`;
|
|
49
|
+
return {
|
|
50
|
+
command,
|
|
51
|
+
tool: s.wake_mode === "claude" ? "Monitor" : "Bash(run_in_background=true)",
|
|
52
|
+
instruction: s.wake_mode === "claude" ? "Run command with the host Monitor tool (one notification per stdout line). If unavailable use Bash(run_in_background=true) with --once. Re-arm when the monitor expires or exits." : "Run command with Bash(run_in_background=true). It stays silent across idle polls and exits on actionable work. Re-arm after every completion, failure or kill notification.",
|
|
53
|
+
on_wake: "Read the task output, call read and read(recover=true), handle cancellation and report working/done/failed with reply_to. Check host task status before starting another watcher. If the host lacks background completion notifications, use standby=manual."
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/daemon/standby.ts
|
|
58
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
59
|
+
import { isAbsolute } from "node:path";
|
|
60
|
+
|
|
61
|
+
// src/daemon/adapters/codex.ts
|
|
62
|
+
import { spawn } from "node:child_process";
|
|
63
|
+
import { createInterface } from "node:readline";
|
|
64
|
+
|
|
65
|
+
// src/daemon/adapters/codex-queue.ts
|
|
66
|
+
import { execFile } from "node:child_process";
|
|
67
|
+
import { promisify } from "node:util";
|
|
68
|
+
import { createReadStream, statSync } from "node:fs";
|
|
69
|
+
import { homedir } from "node:os";
|
|
70
|
+
import { join } from "node:path";
|
|
71
|
+
import { DatabaseSync } from "node:sqlite";
|
|
72
|
+
var run = promisify(execFile);
|
|
73
|
+
var CodexQueueAdapter = class {
|
|
74
|
+
constructor(options) {
|
|
75
|
+
this.options = options;
|
|
76
|
+
}
|
|
77
|
+
transport = "queue";
|
|
78
|
+
abort = new AbortController();
|
|
79
|
+
probed = false;
|
|
80
|
+
file = "";
|
|
81
|
+
ino = 0;
|
|
82
|
+
offset = 0;
|
|
83
|
+
incomplete = false;
|
|
84
|
+
status = "unknown";
|
|
85
|
+
markers = /* @__PURE__ */ new Set();
|
|
86
|
+
async scan(native) {
|
|
87
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
88
|
+
if (major < 22 || major === 22 && minor < 12)
|
|
89
|
+
throw new Error(
|
|
90
|
+
"Codex queue compatibility needs Node >=22.12 for read-only SQLite; use Node 24 or the proxy transport"
|
|
91
|
+
);
|
|
92
|
+
const home = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
93
|
+
const db = new DatabaseSync(join(home, "state_5.sqlite"), { readOnly: true });
|
|
94
|
+
let path;
|
|
95
|
+
try {
|
|
96
|
+
const row = db.prepare("SELECT rollout_path FROM threads WHERE id = ?").get(native);
|
|
97
|
+
if (typeof row?.rollout_path !== "string")
|
|
98
|
+
throw new Error("Thread missing from Codex state_5.sqlite");
|
|
99
|
+
path = row.rollout_path;
|
|
100
|
+
} finally {
|
|
101
|
+
db.close();
|
|
102
|
+
}
|
|
103
|
+
const stat = statSync(path);
|
|
104
|
+
if (this.file !== path || this.ino !== stat.ino || stat.size < this.offset) {
|
|
105
|
+
this.file = path;
|
|
106
|
+
this.ino = stat.ino;
|
|
107
|
+
this.offset = 0;
|
|
108
|
+
this.incomplete = false;
|
|
109
|
+
this.status = "unknown";
|
|
110
|
+
this.markers.clear();
|
|
111
|
+
}
|
|
112
|
+
if (stat.size === this.offset) return;
|
|
113
|
+
const input = createReadStream(path, {
|
|
114
|
+
start: this.offset,
|
|
115
|
+
end: stat.size - 1,
|
|
116
|
+
signal: this.abort.signal
|
|
117
|
+
});
|
|
118
|
+
let partial = Buffer.alloc(0);
|
|
119
|
+
for await (const chunk of input) {
|
|
120
|
+
partial = Buffer.concat([partial, Buffer.from(chunk)]);
|
|
121
|
+
let end;
|
|
122
|
+
while ((end = partial.indexOf(10)) >= 0) {
|
|
123
|
+
const line = partial.subarray(0, end).toString("utf8");
|
|
124
|
+
partial = partial.subarray(end + 1);
|
|
125
|
+
this.offset += end + 1;
|
|
126
|
+
let record;
|
|
127
|
+
try {
|
|
128
|
+
record = JSON.parse(line);
|
|
129
|
+
} catch {
|
|
130
|
+
this.status = "unknown";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (record.type === "event_msg") {
|
|
134
|
+
if (record.payload?.type === "task_started") this.status = "busy";
|
|
135
|
+
else if (["task_complete", "turn_aborted"].includes(record.payload?.type))
|
|
136
|
+
this.status = "idle";
|
|
137
|
+
}
|
|
138
|
+
const user = record.type === "event_msg" && record.payload?.type === "user_message" ? record.payload.message : record.type === "response_item" && record.payload?.role === "user" ? record.payload.content?.filter((c) => c.type === "input_text").map((c) => c.text).join("\n") : void 0;
|
|
139
|
+
if (typeof user === "string") {
|
|
140
|
+
const match = user.match(/^\[cmdr wake ([a-f0-9-]{36})\]/);
|
|
141
|
+
if (match) this.markers.add(match[1]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (partial.length > 16 * 1024 * 1024)
|
|
145
|
+
throw new Error("Unrecognized Codex rollout record size");
|
|
146
|
+
}
|
|
147
|
+
this.incomplete = partial.length > 0;
|
|
148
|
+
}
|
|
149
|
+
async state(native) {
|
|
150
|
+
if (!this.probed) {
|
|
151
|
+
const { stdout } = await run(this.options.executable || "codex", ["queue", "--help"], {
|
|
152
|
+
timeout: 1e4,
|
|
153
|
+
maxBuffer: 1024 * 1024,
|
|
154
|
+
signal: this.abort.signal
|
|
155
|
+
});
|
|
156
|
+
if (!stdout.includes("--thread") || !stdout.includes("--message"))
|
|
157
|
+
throw new Error("Codex CLI does not support queue --thread/--message");
|
|
158
|
+
this.probed = true;
|
|
159
|
+
}
|
|
160
|
+
await this.scan(native);
|
|
161
|
+
if (this.incomplete && this.status !== "busy")
|
|
162
|
+
throw new Error(
|
|
163
|
+
"Codex rollout has an incomplete record; waiting for the host to finish writing before assuming idle"
|
|
164
|
+
);
|
|
165
|
+
if (this.status === "unknown")
|
|
166
|
+
throw new Error(
|
|
167
|
+
"Codex queue idle state unavailable: no recognized lifecycle marker in state_5 rollout"
|
|
168
|
+
);
|
|
169
|
+
return this.status;
|
|
170
|
+
}
|
|
171
|
+
async lookup(native, request) {
|
|
172
|
+
await this.scan(native);
|
|
173
|
+
return { found: this.markers.has(request.id) };
|
|
174
|
+
}
|
|
175
|
+
async enqueue(native, request) {
|
|
176
|
+
if (await this.state(native) !== "idle")
|
|
177
|
+
throw new WakeDeferred("Codex became busy before queue submission");
|
|
178
|
+
await run(
|
|
179
|
+
this.options.executable || "codex",
|
|
180
|
+
["queue", "--thread", native, "--message", wakePrompt(request.id)],
|
|
181
|
+
{ timeout: 3e4, maxBuffer: 1024 * 1024, signal: this.abort.signal }
|
|
182
|
+
);
|
|
183
|
+
return void 0;
|
|
184
|
+
}
|
|
185
|
+
async start() {
|
|
186
|
+
}
|
|
187
|
+
close() {
|
|
188
|
+
this.abort.abort();
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// src/daemon/adapters/codex.ts
|
|
193
|
+
var CodexProxyAdapter = class {
|
|
194
|
+
constructor(options) {
|
|
195
|
+
this.options = options;
|
|
196
|
+
}
|
|
197
|
+
transport = "proxy";
|
|
198
|
+
child;
|
|
199
|
+
ready;
|
|
200
|
+
next = 1;
|
|
201
|
+
pending = /* @__PURE__ */ new Map();
|
|
202
|
+
connect() {
|
|
203
|
+
if (this.ready) return this.ready;
|
|
204
|
+
const child = spawn(
|
|
205
|
+
this.options.executable || "codex",
|
|
206
|
+
["app-server", "proxy", ...this.options.socket ? ["--sock", this.options.socket] : []],
|
|
207
|
+
{ stdio: "pipe" }
|
|
208
|
+
);
|
|
209
|
+
this.child = child;
|
|
210
|
+
const lines = createInterface({ input: child.stdout });
|
|
211
|
+
let stderr = "";
|
|
212
|
+
child.stderr.on("data", (chunk) => {
|
|
213
|
+
stderr = (stderr + String(chunk)).slice(-1e3);
|
|
214
|
+
});
|
|
215
|
+
const failed = (error) => {
|
|
216
|
+
if (this.child !== child) return;
|
|
217
|
+
this.child = void 0;
|
|
218
|
+
this.ready = void 0;
|
|
219
|
+
for (const p of this.pending.values()) {
|
|
220
|
+
clearTimeout(p.timer);
|
|
221
|
+
p.reject(
|
|
222
|
+
new Error(
|
|
223
|
+
stderr.trim() || error?.message || "Codex proxy disconnected; wake outcome may be uncertain"
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
this.pending.clear();
|
|
228
|
+
lines.close();
|
|
229
|
+
child.kill();
|
|
230
|
+
};
|
|
231
|
+
child.on("error", failed);
|
|
232
|
+
child.on("exit", () => failed());
|
|
233
|
+
child.stdin.on("error", failed);
|
|
234
|
+
lines.on("line", (line) => {
|
|
235
|
+
if (Buffer.byteLength(line) > 4 * 1024 * 1024) {
|
|
236
|
+
this.close();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const m = JSON.parse(line), p = this.pending.get(m.id);
|
|
241
|
+
if (!p) return;
|
|
242
|
+
this.pending.delete(m.id);
|
|
243
|
+
clearTimeout(p.timer);
|
|
244
|
+
if (m.error)
|
|
245
|
+
p.reject(new Error(`Codex ${m.error.code}: ${String(m.error.message).slice(0, 300)}`));
|
|
246
|
+
else p.resolve(m.result);
|
|
247
|
+
} catch {
|
|
248
|
+
this.close();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
this.ready = this.request("initialize", {
|
|
252
|
+
clientInfo: { name: "cmdr", version: "1" },
|
|
253
|
+
capabilities: { experimentalApi: true }
|
|
254
|
+
}).then(() => {
|
|
255
|
+
child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
|
|
256
|
+
}).catch((error) => {
|
|
257
|
+
this.close();
|
|
258
|
+
throw error;
|
|
259
|
+
});
|
|
260
|
+
return this.ready;
|
|
261
|
+
}
|
|
262
|
+
request(method, params) {
|
|
263
|
+
return new Promise((resolve2, reject) => {
|
|
264
|
+
const id = this.next++;
|
|
265
|
+
const timer = setTimeout(() => {
|
|
266
|
+
this.pending.delete(id);
|
|
267
|
+
reject(new Error(`Codex ${method} timed out; reconcile before retrying`));
|
|
268
|
+
this.close();
|
|
269
|
+
}, 1e4);
|
|
270
|
+
this.pending.set(id, { resolve: resolve2, reject, timer });
|
|
271
|
+
this.child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async call(method, params) {
|
|
275
|
+
await this.connect();
|
|
276
|
+
return this.request(method, params);
|
|
277
|
+
}
|
|
278
|
+
async state(native) {
|
|
279
|
+
let result = await this.call("thread/read", { threadId: native, includeTurns: false });
|
|
280
|
+
if (result.thread?.status?.type === "notLoaded") {
|
|
281
|
+
result = await this.call("thread/resume", { threadId: native, excludeTurns: true });
|
|
282
|
+
}
|
|
283
|
+
await this.call("thread/queue/list", { threadId: native, limit: 1 });
|
|
284
|
+
const type = result.thread?.status?.type;
|
|
285
|
+
return type === "idle" ? "idle" : type === "active" ? "busy" : "unknown";
|
|
286
|
+
}
|
|
287
|
+
async lookup(native, request) {
|
|
288
|
+
let cursor = null;
|
|
289
|
+
do {
|
|
290
|
+
const page = await this.call("thread/queue/list", { threadId: native, cursor, limit: 100 });
|
|
291
|
+
const match = page.data?.find((q) => q.clientUserMessageId === request.id);
|
|
292
|
+
if (match) return { found: true, submission: String(match.id) };
|
|
293
|
+
cursor = page.nextCursor;
|
|
294
|
+
} while (cursor);
|
|
295
|
+
do {
|
|
296
|
+
const page = await this.call("thread/turns/list", {
|
|
297
|
+
threadId: native,
|
|
298
|
+
cursor,
|
|
299
|
+
limit: 50,
|
|
300
|
+
itemsView: "full"
|
|
301
|
+
});
|
|
302
|
+
if (page.data?.some(
|
|
303
|
+
(t) => t.items?.some((i) => i.type === "userMessage" && i.clientId === request.id)
|
|
304
|
+
))
|
|
305
|
+
return { found: true };
|
|
306
|
+
const oldest = page.data?.at(-1)?.startedAt;
|
|
307
|
+
if (oldest && oldest * 1e3 < request.created_at - 6e4) break;
|
|
308
|
+
cursor = page.nextCursor;
|
|
309
|
+
} while (cursor);
|
|
310
|
+
return { found: false };
|
|
311
|
+
}
|
|
312
|
+
async enqueue(native, request) {
|
|
313
|
+
const text2 = wakePrompt(request.id);
|
|
314
|
+
const result = await this.call("thread/queue/add", {
|
|
315
|
+
threadId: native,
|
|
316
|
+
clientUserMessageId: request.id,
|
|
317
|
+
input: [{ type: "text", text: text2, text_elements: [] }]
|
|
318
|
+
});
|
|
319
|
+
if (!result.queuedSubmission?.id)
|
|
320
|
+
throw new Error("Codex queue response did not confirm acceptance");
|
|
321
|
+
return String(result.queuedSubmission.id);
|
|
322
|
+
}
|
|
323
|
+
async start(native, submission) {
|
|
324
|
+
if (!submission) throw new Error("Missing Codex proxy submission ID");
|
|
325
|
+
if (await this.state(native) !== "idle") return;
|
|
326
|
+
await this.call("thread/queue/start", { threadId: native, queuedSubmissionId: submission });
|
|
327
|
+
}
|
|
328
|
+
close() {
|
|
329
|
+
const child = this.child;
|
|
330
|
+
this.child = void 0;
|
|
331
|
+
this.ready = void 0;
|
|
332
|
+
for (const p of this.pending.values()) {
|
|
333
|
+
clearTimeout(p.timer);
|
|
334
|
+
p.reject(new Error("Codex adapter closed"));
|
|
335
|
+
}
|
|
336
|
+
this.pending.clear();
|
|
337
|
+
child?.kill();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
var CodexAdapter = class {
|
|
341
|
+
constructor(options) {
|
|
342
|
+
this.options = options;
|
|
343
|
+
}
|
|
344
|
+
adapter;
|
|
345
|
+
closed = false;
|
|
346
|
+
get transport() {
|
|
347
|
+
return this.adapter?.transport;
|
|
348
|
+
}
|
|
349
|
+
async state(native) {
|
|
350
|
+
if (this.adapter) return this.adapter.state(native);
|
|
351
|
+
const pinned = this.options.request && this.options.request.state !== "observed" ? this.options.request.transport || "proxy" : void 0;
|
|
352
|
+
const selected = pinned || this.options.codex_transport || "auto";
|
|
353
|
+
let proxyError;
|
|
354
|
+
if (selected !== "queue") {
|
|
355
|
+
this.adapter = new CodexProxyAdapter(this.options);
|
|
356
|
+
try {
|
|
357
|
+
return await this.adapter.state(native);
|
|
358
|
+
} catch (e) {
|
|
359
|
+
this.adapter.close();
|
|
360
|
+
this.adapter = void 0;
|
|
361
|
+
if (selected === "proxy" || this.closed) throw e;
|
|
362
|
+
proxyError = e;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (this.closed) throw new Error("Codex adapter closed");
|
|
366
|
+
this.adapter = new CodexQueueAdapter(this.options);
|
|
367
|
+
try {
|
|
368
|
+
return await this.adapter.state(native);
|
|
369
|
+
} catch (e) {
|
|
370
|
+
this.adapter.close();
|
|
371
|
+
this.adapter = void 0;
|
|
372
|
+
throw new Error(
|
|
373
|
+
`${proxyError ? `proxy unavailable: ${String(proxyError).slice(0, 200)}; ` : ""}queue unavailable: ${String(e).slice(0, 250)}`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
lookup(native, request) {
|
|
378
|
+
return this.adapter.lookup(native, request);
|
|
379
|
+
}
|
|
380
|
+
enqueue(native, request) {
|
|
381
|
+
return this.adapter.enqueue(native, request);
|
|
382
|
+
}
|
|
383
|
+
start(native, submission) {
|
|
384
|
+
return this.adapter.start(native, submission);
|
|
385
|
+
}
|
|
386
|
+
close() {
|
|
387
|
+
this.closed = true;
|
|
388
|
+
this.adapter?.close();
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// src/daemon/standby.ts
|
|
393
|
+
var StandbyManager = class {
|
|
394
|
+
constructor(core, factory = (s) => new CodexAdapter(s)) {
|
|
395
|
+
this.core = core;
|
|
396
|
+
this.factory = factory;
|
|
397
|
+
for (const listener of core.store.standbys())
|
|
398
|
+
if (listener.enabled && listener.wake_mode !== "manual") {
|
|
399
|
+
listener.health = "starting";
|
|
400
|
+
listener.lease = void 0;
|
|
401
|
+
listener.host_state = "unknown";
|
|
402
|
+
core.store.saveStandby(listener);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
adapters = /* @__PURE__ */ new Map();
|
|
406
|
+
running = false;
|
|
407
|
+
stopped = false;
|
|
408
|
+
get active() {
|
|
409
|
+
return this.core.store.standbys().some((s) => s.enabled && s.wake_mode !== "manual");
|
|
410
|
+
}
|
|
411
|
+
configure(p) {
|
|
412
|
+
const store = this.core.store, session = store.session(p.sid) || fail("NOT_JOINED");
|
|
413
|
+
if (!session.native_id || !session.squad_id)
|
|
414
|
+
fail("IDENTITY_REQUIRED", "Join with the real host session ID first");
|
|
415
|
+
let s = store.standby(session.sid);
|
|
416
|
+
if (p.action === "status")
|
|
417
|
+
return s ? { ...s, ...this.core.standbyView(s.sid) } : { sid: p.sid, wake_mode: "manual", health: "manual", enabled: false };
|
|
418
|
+
if (!["start", "stop", "resume"].includes(p.action)) fail("INVALID_ARGUMENT");
|
|
419
|
+
if (p.executable && (typeof p.executable !== "string" || !isAbsolute(p.executable)))
|
|
420
|
+
fail("INVALID_ARGUMENT", "executable must be an absolute path");
|
|
421
|
+
if (p.socket && (typeof p.socket !== "string" || !isAbsolute(p.socket)))
|
|
422
|
+
fail("INVALID_ARGUMENT", "socket must be an absolute path");
|
|
423
|
+
if (p.adapter && !["codex", "claude", "zcode", "manual"].includes(p.adapter))
|
|
424
|
+
fail("INVALID_ARGUMENT");
|
|
425
|
+
if (p.transport && !["auto", "proxy", "queue"].includes(p.transport)) fail("INVALID_ARGUMENT");
|
|
426
|
+
if (p.resolve && !["retry", "accepted"].includes(p.resolve)) fail("INVALID_ARGUMENT");
|
|
427
|
+
if (!s)
|
|
428
|
+
s = {
|
|
429
|
+
sid: session.sid,
|
|
430
|
+
enabled: false,
|
|
431
|
+
wake_mode: "manual",
|
|
432
|
+
health: "manual",
|
|
433
|
+
host_state: "unknown",
|
|
434
|
+
checked_at: null
|
|
435
|
+
};
|
|
436
|
+
if (p.action === "stop") {
|
|
437
|
+
s.enabled = false;
|
|
438
|
+
s.health = "stopped";
|
|
439
|
+
} else {
|
|
440
|
+
if (p.adapter && p.adapter !== "manual" && p.adapter !== session.agent)
|
|
441
|
+
fail("INVALID_ARGUMENT", "Adapter must match the member host");
|
|
442
|
+
if (s.enabled && s.wake_mode !== "manual" && p.action === "start" && !p.adapter && !p.executable && !p.socket && !p.transport && !p.resolve)
|
|
443
|
+
return { ...s, arm: armHint(s) };
|
|
444
|
+
s.wake_mode = p.adapter || (p.action === "start" ? ["codex", "claude", "zcode"].includes(session.agent) ? session.agent : "manual" : s.wake_mode);
|
|
445
|
+
s.enabled = true;
|
|
446
|
+
s.health = s.wake_mode === "manual" ? "manual" : "starting";
|
|
447
|
+
s.executable = p.executable || s.executable;
|
|
448
|
+
s.socket = p.socket || s.socket;
|
|
449
|
+
s.codex_transport = p.transport || s.codex_transport;
|
|
450
|
+
if (p.resolve === "retry") s.request = void 0;
|
|
451
|
+
if (p.resolve === "accepted" && s.request) s.request.state = "accepted";
|
|
452
|
+
if (s.request?.state === "failed") s.request = void 0;
|
|
453
|
+
}
|
|
454
|
+
s.lease = void 0;
|
|
455
|
+
s.generation = (s.generation || 0) + 1;
|
|
456
|
+
this.adapters.get(s.sid)?.close();
|
|
457
|
+
this.adapters.delete(s.sid);
|
|
458
|
+
this.save(
|
|
459
|
+
s,
|
|
460
|
+
"standby.changed",
|
|
461
|
+
p.resolve ? `operator resolved wake as ${p.resolve}` : p.action
|
|
462
|
+
);
|
|
463
|
+
return { ...s, arm: armHint(s) };
|
|
464
|
+
}
|
|
465
|
+
save(s, kind, reason) {
|
|
466
|
+
if (this.stopped || !this.core.store.session(s.sid)) return;
|
|
467
|
+
const cursor = this.core.store.eventCursor();
|
|
468
|
+
this.core.store.transaction(() => {
|
|
469
|
+
this.core.store.saveStandby(s);
|
|
470
|
+
if (kind)
|
|
471
|
+
this.core.record(kind, this.core.store.session(s.sid).squad_id, {
|
|
472
|
+
to_sid: s.sid,
|
|
473
|
+
reason,
|
|
474
|
+
data: {
|
|
475
|
+
wake_id: s.request?.id,
|
|
476
|
+
message_ids: s.request?.message_ids,
|
|
477
|
+
state: s.request?.state,
|
|
478
|
+
health: s.health,
|
|
479
|
+
host_state: s.host_state
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
this.core.publishEvents(cursor);
|
|
484
|
+
}
|
|
485
|
+
watch(sid, token, action) {
|
|
486
|
+
const s = this.core.store.standby(sid);
|
|
487
|
+
const session = this.core.store.session(sid);
|
|
488
|
+
if (!s?.enabled || !session?.squad_id || !hostStandby(s.wake_mode))
|
|
489
|
+
fail("WATCHER_DISABLED", "Join with standby=auto on Claude/ZCode before arming a watcher");
|
|
490
|
+
if (action === "detach") {
|
|
491
|
+
if (s.lease?.token === token) {
|
|
492
|
+
s.lease = void 0;
|
|
493
|
+
s.health = "starting";
|
|
494
|
+
this.save(s, "standby.disarmed");
|
|
495
|
+
}
|
|
496
|
+
return {};
|
|
497
|
+
}
|
|
498
|
+
if (s.lease && s.lease.token !== token && s.lease.expires_at > Date.now())
|
|
499
|
+
fail(
|
|
500
|
+
"WATCHER_ACTIVE",
|
|
501
|
+
"A watcher already owns this member; inspect the host task before replacing it"
|
|
502
|
+
);
|
|
503
|
+
if (action === "pulse" && s.lease?.token !== token)
|
|
504
|
+
fail("WATCHER_EXPIRED", "Watcher lease lost; re-arm from the host");
|
|
505
|
+
const changed = s.health !== "healthy" || s.lease?.token !== token;
|
|
506
|
+
s.lease = { token, expires_at: Date.now() + 9e4 };
|
|
507
|
+
s.health = "healthy";
|
|
508
|
+
s.checked_at = Date.now();
|
|
509
|
+
s.error = void 0;
|
|
510
|
+
this.save(s, changed ? "standby.armed" : void 0);
|
|
511
|
+
return {
|
|
512
|
+
wake_mode: s.wake_mode,
|
|
513
|
+
messages: this.core.actionable(sid).map((m) => ({
|
|
514
|
+
id: m.id,
|
|
515
|
+
type: m.type,
|
|
516
|
+
from_sid: m.from_sid,
|
|
517
|
+
reply_to: m.reply_to,
|
|
518
|
+
status: m.data?.status,
|
|
519
|
+
work_state: m.work?.state,
|
|
520
|
+
updated_at: m.work?.updated_at,
|
|
521
|
+
cancel_requested_at: m.work?.cancel_requested_at
|
|
522
|
+
}))
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
async tick() {
|
|
526
|
+
if (this.running || this.stopped) return;
|
|
527
|
+
this.running = true;
|
|
528
|
+
try {
|
|
529
|
+
for (const [sid, adapter] of this.adapters) {
|
|
530
|
+
const current = this.core.store.standby(sid);
|
|
531
|
+
if (!current?.enabled || current.wake_mode === "manual") {
|
|
532
|
+
adapter.close();
|
|
533
|
+
this.adapters.delete(sid);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
for (const s of this.core.store.standbys()) {
|
|
537
|
+
if (s.enabled && hostStandby(s.wake_mode) && s.health === "healthy" && (s.lease?.expires_at || 0) <= Date.now()) {
|
|
538
|
+
s.health = "stalled";
|
|
539
|
+
s.error = "Host watcher expired; re-arm it from the host session.";
|
|
540
|
+
this.save(s, "standby.health", s.error);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const records = this.core.store.standbys().filter((s) => s.enabled && s.wake_mode === "codex");
|
|
544
|
+
let next = 0;
|
|
545
|
+
await Promise.all(
|
|
546
|
+
Array.from({ length: Math.min(4, records.length) }, async () => {
|
|
547
|
+
while (!this.stopped && next < records.length) await this.check(records[next++]);
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
} finally {
|
|
551
|
+
this.running = false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async check(s) {
|
|
555
|
+
const store = this.core.store, session = store.session(s.sid);
|
|
556
|
+
if (!session?.native_id || !session.squad_id) return;
|
|
557
|
+
let adapter = this.adapters.get(s.sid);
|
|
558
|
+
if (!adapter) {
|
|
559
|
+
adapter = this.factory(s);
|
|
560
|
+
this.adapters.set(s.sid, adapter);
|
|
561
|
+
}
|
|
562
|
+
const valid = () => !this.stopped && store.session(s.sid)?.native_id === session.native_id && store.standby(s.sid)?.enabled === true && store.standby(s.sid)?.generation === s.generation;
|
|
563
|
+
try {
|
|
564
|
+
const host = await adapter.state(session.native_id);
|
|
565
|
+
if (!valid()) return;
|
|
566
|
+
s.host_state = host;
|
|
567
|
+
s.transport = adapter.transport;
|
|
568
|
+
s.checked_at = Date.now();
|
|
569
|
+
s.error = host === "unknown" ? "Host session is not loaded or its runtime state is unavailable; resume it in the host." : void 0;
|
|
570
|
+
const work = this.core.actionable(s.sid);
|
|
571
|
+
const fingerprint = createHash("sha256").update(
|
|
572
|
+
JSON.stringify(
|
|
573
|
+
work.map((m) => [m.id, m.work?.state, m.work?.updated_at, m.work?.cancel_requested_at])
|
|
574
|
+
)
|
|
575
|
+
).digest("hex");
|
|
576
|
+
if (s.request && s.request.state !== "observed") {
|
|
577
|
+
if (s.request.state === "accepted" && host === "busy") {
|
|
578
|
+
s.health = "healthy";
|
|
579
|
+
this.save(s);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const found = await adapter.lookup(session.native_id, s.request);
|
|
583
|
+
if (!valid()) return;
|
|
584
|
+
if (found.submission) {
|
|
585
|
+
s.request.state = "accepted";
|
|
586
|
+
s.request.submission_id = found.submission;
|
|
587
|
+
s.request.message_ids = [
|
|
588
|
+
.../* @__PURE__ */ new Set([...s.request.message_ids, ...work.map((m) => m.id)])
|
|
589
|
+
];
|
|
590
|
+
s.health = host === "unknown" ? "error" : "healthy";
|
|
591
|
+
this.save(
|
|
592
|
+
s,
|
|
593
|
+
recordChanged(store.standby(s.sid), s) ? "wake.accepted" : void 0,
|
|
594
|
+
s.error
|
|
595
|
+
);
|
|
596
|
+
if (host === "idle") await adapter.start(session.native_id, found.submission);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (found.found || s.request.state === "accepted") {
|
|
600
|
+
const unresolved = s.request.message_ids.some((id) => work.some((m) => m.id === id));
|
|
601
|
+
s.request.state = "accepted";
|
|
602
|
+
s.request.submission_id = void 0;
|
|
603
|
+
if (!unresolved || found.found && host === "idle" && fingerprint !== s.request.fingerprint) {
|
|
604
|
+
s.request.state = "observed";
|
|
605
|
+
s.health = host === "unknown" ? "error" : "healthy";
|
|
606
|
+
this.save(s, "wake.observed");
|
|
607
|
+
} else {
|
|
608
|
+
s.health = host === "idle" && Date.now() - s.request.created_at > 6e4 ? "stalled" : host === "unknown" ? "error" : "healthy";
|
|
609
|
+
this.save(s, recordChanged(store.standby(s.sid), s) ? "standby.health" : void 0);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
s.request.state = "uncertain";
|
|
614
|
+
s.health = "uncertain";
|
|
615
|
+
s.error = "Wake not found in host queue/history. Inspect host and use standby resume --resolve retry|accepted; no automatic replay.";
|
|
616
|
+
this.save(
|
|
617
|
+
s,
|
|
618
|
+
recordChanged(store.standby(s.sid), s) ? "wake.uncertain" : void 0,
|
|
619
|
+
s.error
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
s.health = host === "unknown" ? "error" : "healthy";
|
|
625
|
+
this.save(s, recordChanged(store.standby(s.sid), s) ? "standby.health" : void 0, s.error);
|
|
626
|
+
if (!work.length || host !== "idle") return;
|
|
627
|
+
s.request = {
|
|
628
|
+
id: randomUUID(),
|
|
629
|
+
transport: adapter.transport,
|
|
630
|
+
fingerprint,
|
|
631
|
+
message_ids: work.map((m) => m.id),
|
|
632
|
+
created_at: Date.now(),
|
|
633
|
+
state: "requested"
|
|
634
|
+
};
|
|
635
|
+
this.save(s, "wake.requested");
|
|
636
|
+
const submission = await adapter.enqueue(session.native_id, s.request);
|
|
637
|
+
if (!valid()) return;
|
|
638
|
+
s.request.state = "accepted";
|
|
639
|
+
s.request.submission_id = submission;
|
|
640
|
+
this.save(s, "wake.accepted");
|
|
641
|
+
await adapter.start(session.native_id, submission);
|
|
642
|
+
} catch (e) {
|
|
643
|
+
if (!valid()) return;
|
|
644
|
+
if (e instanceof WakeDeferred) {
|
|
645
|
+
s.request = void 0;
|
|
646
|
+
s.host_state = "busy";
|
|
647
|
+
s.health = "healthy";
|
|
648
|
+
s.error = void 0;
|
|
649
|
+
this.save(s, "wake.deferred", e.message);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
s.checked_at = Date.now();
|
|
653
|
+
s.error = String(e).slice(0, 500);
|
|
654
|
+
if (s.request && s.request.state !== "observed") {
|
|
655
|
+
s.request.state = "uncertain";
|
|
656
|
+
s.health = "uncertain";
|
|
657
|
+
s.request.error = s.error;
|
|
658
|
+
} else s.health = "error";
|
|
659
|
+
const previous = store.standby(s.sid);
|
|
660
|
+
this.save(
|
|
661
|
+
s,
|
|
662
|
+
recordChanged(previous, s) || previous?.error !== s.error ? "wake.failed" : void 0,
|
|
663
|
+
s.error
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
close() {
|
|
668
|
+
this.stopped = true;
|
|
669
|
+
for (const a of this.adapters.values()) a.close();
|
|
670
|
+
this.adapters.clear();
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
function recordChanged(a, b) {
|
|
674
|
+
return a?.health !== b.health || a?.host_state !== b.host_state || a?.request?.state !== b.request?.state;
|
|
675
|
+
}
|
|
676
|
+
|
|
9
677
|
// src/daemon/server.ts
|
|
10
678
|
import { createServer } from "node:net";
|
|
11
|
-
import { chmodSync as chmodSync2, rmSync as rmSync4, writeFileSync as writeFileSync3, existsSync as existsSync2, statSync as
|
|
679
|
+
import { chmodSync as chmodSync2, rmSync as rmSync4, writeFileSync as writeFileSync3, existsSync as existsSync2, statSync as statSync4 } from "node:fs";
|
|
12
680
|
|
|
13
681
|
// src/daemon/core.ts
|
|
682
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
14
683
|
import { readdirSync as readdirSync2, rmSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { join as
|
|
684
|
+
import { join as join3 } from "node:path";
|
|
16
685
|
|
|
17
686
|
// src/daemon/title.ts
|
|
18
687
|
import { readFileSync, readdirSync } from "node:fs";
|
|
19
|
-
import { join, basename } from "node:path";
|
|
20
|
-
import { homedir } from "node:os";
|
|
21
|
-
import { DatabaseSync } from "node:sqlite";
|
|
688
|
+
import { join as join2, basename } from "node:path";
|
|
689
|
+
import { homedir as homedir2 } from "node:os";
|
|
690
|
+
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
22
691
|
var cache = /* @__PURE__ */ new Map();
|
|
23
692
|
function firstText(value) {
|
|
24
693
|
if (typeof value === "string") return value;
|
|
@@ -31,9 +700,9 @@ function titleFor(s) {
|
|
|
31
700
|
if (found && found.key === key && Date.now() - found.at < 6e4) return found.title;
|
|
32
701
|
let title = "";
|
|
33
702
|
if (s.agent === "codex" && s.native_id) {
|
|
34
|
-
const home = process.env.CODEX_HOME ||
|
|
703
|
+
const home = process.env.CODEX_HOME || join2(homedir2(), ".codex");
|
|
35
704
|
let row;
|
|
36
|
-
for (const dir of [home,
|
|
705
|
+
for (const dir of [home, join2(home, "sqlite")]) {
|
|
37
706
|
let files = [];
|
|
38
707
|
try {
|
|
39
708
|
files = readdirSync(dir).filter((f) => /^state_\d+\.sqlite$/.test(f)).sort((a, b) => Number(b.match(/\d+/)[0]) - Number(a.match(/\d+/)[0]));
|
|
@@ -42,7 +711,7 @@ function titleFor(s) {
|
|
|
42
711
|
for (const f of files) {
|
|
43
712
|
let db;
|
|
44
713
|
try {
|
|
45
|
-
db = new
|
|
714
|
+
db = new DatabaseSync2(join2(dir, f), { readOnly: true });
|
|
46
715
|
row = db.prepare("SELECT * FROM threads WHERE id=?").get(s.native_id);
|
|
47
716
|
if (!row?.id) row = void 0;
|
|
48
717
|
} catch {
|
|
@@ -55,7 +724,7 @@ function titleFor(s) {
|
|
|
55
724
|
}
|
|
56
725
|
let sidebar = "";
|
|
57
726
|
try {
|
|
58
|
-
for (const line of readFileSync(
|
|
727
|
+
for (const line of readFileSync(join2(home, "session_index.jsonl"), "utf8").split("\n")) {
|
|
59
728
|
try {
|
|
60
729
|
const r = JSON.parse(line);
|
|
61
730
|
if (r.id === s.native_id || r.thread_id === s.native_id)
|
|
@@ -4128,23 +4797,6 @@ var coerce = {
|
|
|
4128
4797
|
};
|
|
4129
4798
|
var NEVER = INVALID;
|
|
4130
4799
|
|
|
4131
|
-
// src/shared/protocol.ts
|
|
4132
|
-
var LIMITS = {
|
|
4133
|
-
maxWaitSec: 300,
|
|
4134
|
-
maxBody: 32768,
|
|
4135
|
-
maxData: 65536,
|
|
4136
|
-
maxFrame: 2 * 1024 * 1024
|
|
4137
|
-
};
|
|
4138
|
-
var CmdrError = class extends Error {
|
|
4139
|
-
constructor(code, message = code) {
|
|
4140
|
-
super(message);
|
|
4141
|
-
this.code = code;
|
|
4142
|
-
}
|
|
4143
|
-
};
|
|
4144
|
-
function fail(code, message) {
|
|
4145
|
-
throw new CmdrError(code, message);
|
|
4146
|
-
}
|
|
4147
|
-
|
|
4148
4800
|
// src/shared/schemas.ts
|
|
4149
4801
|
var text = external_exports.string().min(1).refine((v) => Buffer.byteLength(v) <= LIMITS.maxBody, "MESSAGE_TOO_LARGE");
|
|
4150
4802
|
var data = external_exports.record(external_exports.unknown()).refine((v) => Buffer.byteLength(JSON.stringify(v)) <= LIMITS.maxData, "MESSAGE_TOO_LARGE").optional();
|
|
@@ -4157,16 +4809,20 @@ var schemas = {
|
|
|
4157
4809
|
squad: external_exports.string().optional(),
|
|
4158
4810
|
name: external_exports.string().trim().min(1).max(64).optional(),
|
|
4159
4811
|
note: text.optional(),
|
|
4160
|
-
squad_name: external_exports.string().trim().min(1).max(64).optional()
|
|
4812
|
+
squad_name: external_exports.string().trim().min(1).max(64).optional(),
|
|
4813
|
+
takeover: external_exports.boolean().default(false),
|
|
4814
|
+
standby: external_exports.enum(["auto", "manual"]).optional(),
|
|
4815
|
+
rebind: external_exports.string().optional()
|
|
4161
4816
|
}).strict(),
|
|
4162
4817
|
list: external_exports.object({
|
|
4163
4818
|
...identity,
|
|
4819
|
+
full: external_exports.boolean().default(false),
|
|
4164
4820
|
scope: external_exports.enum(["squad", "all"]).optional(),
|
|
4165
4821
|
squad: external_exports.string().optional()
|
|
4166
4822
|
}).strict(),
|
|
4167
4823
|
report: external_exports.object({
|
|
4168
4824
|
...identity,
|
|
4169
|
-
status: external_exports.enum(["ready", "working", "blocked", "done", "failed"]),
|
|
4825
|
+
status: external_exports.enum(["ready", "working", "blocked", "done", "failed", "cancelled"]),
|
|
4170
4826
|
message: text,
|
|
4171
4827
|
reply_to: external_exports.string().optional(),
|
|
4172
4828
|
data
|
|
@@ -4176,7 +4832,10 @@ var schemas = {
|
|
|
4176
4832
|
...identity,
|
|
4177
4833
|
to: external_exports.union([external_exports.string().min(1), external_exports.array(external_exports.string().min(1)).min(1).max(1e3)]),
|
|
4178
4834
|
message: text,
|
|
4179
|
-
type: external_exports.enum(["command", "answer", "info"]).default("command"),
|
|
4835
|
+
type: external_exports.enum(["command", "cancel", "answer", "info"]).default("command"),
|
|
4836
|
+
task_key: external_exports.string().min(1).max(128).optional(),
|
|
4837
|
+
reassign: external_exports.string().optional(),
|
|
4838
|
+
attention: external_exports.boolean().optional(),
|
|
4180
4839
|
priority: external_exports.enum(["high", "normal", "low"]).optional(),
|
|
4181
4840
|
reply_to: external_exports.string().optional(),
|
|
4182
4841
|
data
|
|
@@ -4187,7 +4846,10 @@ var schemas = {
|
|
|
4187
4846
|
limit: external_exports.number().int().min(1).max(100).default(20),
|
|
4188
4847
|
peek: external_exports.boolean().default(false),
|
|
4189
4848
|
history: external_exports.boolean().default(false),
|
|
4190
|
-
since: external_exports.string().optional()
|
|
4849
|
+
since: external_exports.string().optional(),
|
|
4850
|
+
id: external_exports.string().optional(),
|
|
4851
|
+
recover: external_exports.boolean().default(false),
|
|
4852
|
+
full: external_exports.boolean().default(false)
|
|
4191
4853
|
}).strict(),
|
|
4192
4854
|
leave: external_exports.object({ ...identity, dissolve: external_exports.boolean().default(false), message: text.optional() }).strict()
|
|
4193
4855
|
};
|
|
@@ -4202,13 +4864,13 @@ function parse(tool, args) {
|
|
|
4202
4864
|
}
|
|
4203
4865
|
|
|
4204
4866
|
// src/shared/ids.ts
|
|
4205
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
4867
|
+
import { randomBytes, randomUUID as randomUUID2 } from "node:crypto";
|
|
4206
4868
|
var alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
|
|
4207
4869
|
function squadId() {
|
|
4208
4870
|
return Array.from(randomBytes(6), (b) => alphabet[b % alphabet.length]).join("");
|
|
4209
4871
|
}
|
|
4210
4872
|
var messageId = () => `m_${Date.now().toString(36)}${randomBytes(6).toString("hex")}`;
|
|
4211
|
-
var provisionalId = (agent) => `${agent}:prov-${
|
|
4873
|
+
var provisionalId = (agent) => `${agent}:prov-${randomUUID2()}`;
|
|
4212
4874
|
var safeSid = (sid) => Buffer.from(sid).toString("base64url");
|
|
4213
4875
|
|
|
4214
4876
|
// src/shared/env.ts
|
|
@@ -4228,17 +4890,29 @@ function recommendedWait(agent, env = process.env) {
|
|
|
4228
4890
|
}
|
|
4229
4891
|
|
|
4230
4892
|
// src/daemon/core.ts
|
|
4893
|
+
function bounded(values, limit) {
|
|
4894
|
+
const result = [];
|
|
4895
|
+
let bytes = 0;
|
|
4896
|
+
for (const value of values) {
|
|
4897
|
+
const size = Buffer.byteLength(JSON.stringify(value));
|
|
4898
|
+
if (result.length >= limit || result.length > 0 && bytes + size > LIMITS.maxFrame / 2) break;
|
|
4899
|
+
result.push(value);
|
|
4900
|
+
bytes += size;
|
|
4901
|
+
}
|
|
4902
|
+
return result;
|
|
4903
|
+
}
|
|
4231
4904
|
var Core = class {
|
|
4232
4905
|
constructor(store, paths2, config2) {
|
|
4233
4906
|
this.store = store;
|
|
4234
4907
|
this.paths = paths2;
|
|
4235
4908
|
this.config = config2;
|
|
4236
4909
|
for (const s of store.sessions()) {
|
|
4237
|
-
s.presence = "offline";
|
|
4910
|
+
s.presence = s.transport === "cli" ? "cli" : "offline";
|
|
4238
4911
|
store.saveSession(s);
|
|
4239
4912
|
}
|
|
4240
4913
|
this.refreshFlags();
|
|
4241
4914
|
}
|
|
4915
|
+
configureStandby;
|
|
4242
4916
|
contexts = /* @__PURE__ */ new Set();
|
|
4243
4917
|
waiters = /* @__PURE__ */ new Set();
|
|
4244
4918
|
effects = [];
|
|
@@ -4250,22 +4924,32 @@ var Core = class {
|
|
|
4250
4924
|
ctx.closed = true;
|
|
4251
4925
|
this.contexts.delete(ctx);
|
|
4252
4926
|
for (const w of this.waiters) if (w.ctx === ctx) w.finish();
|
|
4253
|
-
if (ctx.sid && ![...this.contexts].some(
|
|
4927
|
+
if (ctx.sid && ![...this.contexts].some(
|
|
4928
|
+
(c) => c.kind === "mcp" && c.transport !== "cli" && c.sid === ctx.sid
|
|
4929
|
+
)) {
|
|
4254
4930
|
const s = this.store.session(ctx.sid);
|
|
4255
4931
|
if (s) {
|
|
4256
|
-
s.presence = "offline";
|
|
4257
|
-
|
|
4258
|
-
|
|
4932
|
+
s.presence = s.transport === "cli" ? "cli" : "offline";
|
|
4933
|
+
this.atomic(() => {
|
|
4934
|
+
this.store.saveSession(s);
|
|
4935
|
+
this.record("session.disconnected", s.squad_id, {
|
|
4936
|
+
to_sid: s.sid,
|
|
4937
|
+
data: { presence: s.presence }
|
|
4938
|
+
});
|
|
4939
|
+
});
|
|
4259
4940
|
}
|
|
4260
4941
|
}
|
|
4261
4942
|
}
|
|
4262
4943
|
close() {
|
|
4944
|
+
for (const ctx of this.contexts) ctx.closed = true;
|
|
4263
4945
|
for (const w of this.waiters) w.finish();
|
|
4264
4946
|
}
|
|
4265
4947
|
me(ctx) {
|
|
4266
4948
|
return ctx.sid ? this.store.session(ctx.sid) : void 0;
|
|
4267
4949
|
}
|
|
4268
4950
|
required(ctx) {
|
|
4951
|
+
if (ctx.sid && this.store.revoked(ctx.sid))
|
|
4952
|
+
fail("ENDPOINT_REPLACED", "This endpoint was replaced; use the new member session.");
|
|
4269
4953
|
return this.me(ctx) || fail("NOT_JOINED", "Register a session first.");
|
|
4270
4954
|
}
|
|
4271
4955
|
member(ctx, role) {
|
|
@@ -4290,10 +4974,13 @@ var Core = class {
|
|
|
4290
4974
|
role: s.role,
|
|
4291
4975
|
squad: s.squad_id,
|
|
4292
4976
|
name: s.name,
|
|
4977
|
+
member_id: s.member_id || s.sid,
|
|
4978
|
+
wake_mode: this.store.standby(s.sid)?.wake_mode || "manual",
|
|
4979
|
+
listener: this.standbyView(s.sid),
|
|
4293
4980
|
identity: s.native_id ? "confirmed" : "provisional",
|
|
4294
4981
|
recommended_wait: this.waitHint(s)
|
|
4295
4982
|
} : null,
|
|
4296
|
-
unread: s ? this.
|
|
4983
|
+
unread: s ? this.inbox(s).length : 0
|
|
4297
4984
|
};
|
|
4298
4985
|
}
|
|
4299
4986
|
board(q) {
|
|
@@ -4303,18 +4990,118 @@ var Core = class {
|
|
|
4303
4990
|
members: this.members(q.id).map((s) => this.view(s))
|
|
4304
4991
|
};
|
|
4305
4992
|
}
|
|
4306
|
-
|
|
4307
|
-
const
|
|
4993
|
+
standbyView(sid) {
|
|
4994
|
+
const standby = this.store.standby(sid);
|
|
4995
|
+
return standby ? {
|
|
4996
|
+
sid: standby.sid,
|
|
4997
|
+
wake_mode: standby.wake_mode,
|
|
4998
|
+
enabled: standby.enabled,
|
|
4999
|
+
health: hostStandby(standby.wake_mode) && standby.health === "healthy" && (!standby.lease || standby.lease.expires_at <= Date.now()) ? "stalled" : standby.health,
|
|
5000
|
+
transport: standby.transport,
|
|
5001
|
+
arm: armHint(standby),
|
|
5002
|
+
host_state: standby.host_state,
|
|
5003
|
+
checked_at: standby.checked_at,
|
|
5004
|
+
error: standby.error,
|
|
5005
|
+
request: standby.request ? {
|
|
5006
|
+
id: standby.request.id,
|
|
5007
|
+
state: standby.request.state,
|
|
5008
|
+
created_at: standby.request.created_at
|
|
5009
|
+
} : void 0,
|
|
5010
|
+
can_auto_respond: standby.enabled && standby.wake_mode !== "manual" && standby.health === "healthy" && (!hostStandby(standby.wake_mode) || (standby.lease?.expires_at || 0) > Date.now())
|
|
5011
|
+
} : { wake_mode: "manual", health: "manual", can_auto_respond: false };
|
|
5012
|
+
}
|
|
5013
|
+
inbox(s, history = false) {
|
|
5014
|
+
return [
|
|
5015
|
+
...this.store.queue(s.sid, history),
|
|
5016
|
+
...s.role === "commander" && s.squad_id ? this.store.queue(`squad:${s.squad_id}`, history) : []
|
|
5017
|
+
].filter((m) => history || !m.blocked_by || terminalWork(this.store.message(m.blocked_by))).sort((a, b) => a.priority - b.priority || a.seq - b.seq);
|
|
5018
|
+
}
|
|
5019
|
+
actionable(sid) {
|
|
5020
|
+
const session = this.store.session(sid) || fail("NOT_JOINED");
|
|
5021
|
+
return [
|
|
5022
|
+
...new Map(
|
|
5023
|
+
[
|
|
5024
|
+
...this.inbox(session).filter((m) => actionable(m, sid)),
|
|
5025
|
+
...this.store.commands(sid).filter((m) => !m.blocked_by || terminalWork(this.store.message(m.blocked_by)))
|
|
5026
|
+
].map((m) => [m.id, m])
|
|
5027
|
+
).values()
|
|
5028
|
+
];
|
|
5029
|
+
}
|
|
5030
|
+
view(s, full = false, commandSquad) {
|
|
5031
|
+
const commands = this.store.commands(s.sid).filter((m) => !commandSquad || m.squad_id === commandSquad).map((m) => ({
|
|
5032
|
+
id: m.id,
|
|
5033
|
+
task_key: m.task_key,
|
|
5034
|
+
state: m.work?.state || (m.status === "queued" ? "queued" : "read"),
|
|
5035
|
+
created_at: m.created_at,
|
|
5036
|
+
updated_at: m.work?.updated_at || m.created_at,
|
|
5037
|
+
unacked_for: m.work?.accepted_at ? null : Math.floor((Date.now() - m.created_at) / 1e3),
|
|
5038
|
+
cancel_requested_at: m.work?.cancel_requested_at,
|
|
5039
|
+
blocked_by: m.blocked_by
|
|
5040
|
+
}));
|
|
4308
5041
|
return {
|
|
4309
|
-
...s
|
|
5042
|
+
...full ? s : {
|
|
5043
|
+
sid: s.sid,
|
|
5044
|
+
agent: s.agent,
|
|
5045
|
+
name: s.name,
|
|
5046
|
+
role: s.role,
|
|
5047
|
+
cwd: s.cwd,
|
|
5048
|
+
native_id: s.native_id,
|
|
5049
|
+
presence: s.presence,
|
|
5050
|
+
activity: s.activity,
|
|
5051
|
+
last_status: s.last_status
|
|
5052
|
+
},
|
|
5053
|
+
member_id: s.member_id || s.sid,
|
|
4310
5054
|
title: titleFor(s),
|
|
4311
5055
|
short: s.sid.slice(0, s.sid.indexOf(":") + 9),
|
|
4312
5056
|
squad: s.squad_id,
|
|
4313
5057
|
last_seen: s.last_seen_at,
|
|
4314
|
-
|
|
5058
|
+
activity_at: s.activity_at || null,
|
|
5059
|
+
last_progress_at: s.last_progress_at || null,
|
|
5060
|
+
hook_seen_at: s.hook_seen_at || null,
|
|
5061
|
+
pending: commands.filter((m) => m.state === "queued").length,
|
|
5062
|
+
unacked: commands.filter((m) => !["accepted"].includes(m.state)).length,
|
|
5063
|
+
in_progress: commands.filter((m) => m.state === "accepted").length,
|
|
5064
|
+
commands,
|
|
5065
|
+
listener: this.standbyView(s.sid)
|
|
4315
5066
|
};
|
|
4316
5067
|
}
|
|
5068
|
+
record(kind, channel, detail = {}) {
|
|
5069
|
+
return this.store.appendEvent({ at: Date.now(), kind, channel, ...detail });
|
|
5070
|
+
}
|
|
5071
|
+
messageEvent(kind, m, reason) {
|
|
5072
|
+
this.record(kind, m.squad_id, {
|
|
5073
|
+
from_sid: m.from_sid,
|
|
5074
|
+
to_sid: m.to_sid,
|
|
5075
|
+
message_id: m.id,
|
|
5076
|
+
reply_to: m.reply_to,
|
|
5077
|
+
message: m,
|
|
5078
|
+
reason
|
|
5079
|
+
});
|
|
5080
|
+
}
|
|
5081
|
+
publishEvents(after) {
|
|
5082
|
+
for (const event of this.store.events(after))
|
|
5083
|
+
for (const c of this.contexts) {
|
|
5084
|
+
const t = c.tail;
|
|
5085
|
+
if (!t || event.event_seq <= t.after) continue;
|
|
5086
|
+
t.after = event.event_seq;
|
|
5087
|
+
if (t.squad && t.squad !== event.channel) continue;
|
|
5088
|
+
if (t.for && t.for !== event.to_sid && !(event.to_sid === `squad:${event.channel}` && this.store.squad(event.channel)?.commander_sid === t.for))
|
|
5089
|
+
continue;
|
|
5090
|
+
if (t.actionable && !this.isWakeEvent(event, t.for)) continue;
|
|
5091
|
+
c.notify(
|
|
5092
|
+
"lifecycle.event",
|
|
5093
|
+
t.full ? event : {
|
|
5094
|
+
...event,
|
|
5095
|
+
message: event.message ? { ...event.message, body: event.message.body.slice(0, 160), data: null } : void 0
|
|
5096
|
+
}
|
|
5097
|
+
);
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
isWakeEvent(event, sid) {
|
|
5101
|
+
return wakeEvent(event, sid) && (!event.message?.blocked_by || terminalWork(this.store.message(event.message.blocked_by)));
|
|
5102
|
+
}
|
|
4317
5103
|
atomic(fn) {
|
|
5104
|
+
const cursor = this.store.eventCursor();
|
|
4318
5105
|
this.effects = [];
|
|
4319
5106
|
let result;
|
|
4320
5107
|
try {
|
|
@@ -4326,12 +5113,13 @@ var Core = class {
|
|
|
4326
5113
|
const effects = this.effects;
|
|
4327
5114
|
this.effects = [];
|
|
4328
5115
|
for (const m of effects) {
|
|
4329
|
-
this.
|
|
5116
|
+
const recipient = m.to_sid.startsWith("squad:") ? this.store.squad(m.squad_id)?.commander_sid : m.to_sid;
|
|
5117
|
+
if (recipient) this.flag(recipient);
|
|
4330
5118
|
for (const c of this.contexts) {
|
|
4331
|
-
if (c.sid ===
|
|
5119
|
+
if (c.sid === recipient)
|
|
4332
5120
|
c.notify("msg.new", {
|
|
4333
|
-
sid:
|
|
4334
|
-
count: this.store.
|
|
5121
|
+
sid: recipient,
|
|
5122
|
+
count: this.inbox(this.store.session(recipient)).length,
|
|
4335
5123
|
top_priority: m.priority
|
|
4336
5124
|
});
|
|
4337
5125
|
if (c.tail && (!c.tail.squad || c.tail.squad === m.squad_id))
|
|
@@ -4341,8 +5129,15 @@ var Core = class {
|
|
|
4341
5129
|
});
|
|
4342
5130
|
}
|
|
4343
5131
|
}
|
|
5132
|
+
this.publishEvents(cursor);
|
|
4344
5133
|
for (const w of [...this.waiters]) {
|
|
4345
|
-
if (this.store.
|
|
5134
|
+
if (!this.store.session(w.sid)) {
|
|
5135
|
+
w.finish();
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
5138
|
+
if (this.inbox(this.store.session(w.sid)).some(
|
|
5139
|
+
(m) => !w.answer || m.type === "answer" && m.reply_to === w.answer
|
|
5140
|
+
))
|
|
4346
5141
|
w.finish();
|
|
4347
5142
|
}
|
|
4348
5143
|
return result;
|
|
@@ -4357,17 +5152,17 @@ var Core = class {
|
|
|
4357
5152
|
rmSync(this.paths.flag(sid), { force: true });
|
|
4358
5153
|
}
|
|
4359
5154
|
refreshFlags() {
|
|
4360
|
-
const queued = new Set(
|
|
4361
|
-
|
|
4362
|
-
|
|
5155
|
+
const queued = /* @__PURE__ */ new Set();
|
|
5156
|
+
for (const s of this.store.sessions()) {
|
|
5157
|
+
const queue = this.inbox(s);
|
|
5158
|
+
if (!queue.length) continue;
|
|
5159
|
+
queued.add(s.sid);
|
|
5160
|
+
if (queue.some((m) => m.seq > s.last_notified_seq) || queue.some((m) => m.priority <= 0) && Date.now() - s.last_notified_at >= this.config.remindIntervalSec * 1e3)
|
|
5161
|
+
this.flag(s.sid);
|
|
5162
|
+
}
|
|
4363
5163
|
for (const f of readdirSync2(this.paths.flags))
|
|
4364
5164
|
if (!queued.has(Buffer.from(f, "base64url").toString()))
|
|
4365
|
-
rmSync(
|
|
4366
|
-
for (const sid of queued) {
|
|
4367
|
-
const s = this.store.session(sid), queue = this.store.queue(sid);
|
|
4368
|
-
if (s && (queue.some((m) => m.seq > s.last_notified_seq) || queue.some((m) => m.priority === 0) && Date.now() - s.last_notified_at >= this.config.remindIntervalSec * 1e3))
|
|
4369
|
-
this.flag(sid);
|
|
4370
|
-
}
|
|
5165
|
+
rmSync(join3(this.paths.flags, f), { force: true });
|
|
4371
5166
|
}
|
|
4372
5167
|
register(ctx, p) {
|
|
4373
5168
|
if (!["mcp", "hook", "cli"].includes(p.kind))
|
|
@@ -4381,6 +5176,7 @@ var Core = class {
|
|
|
4381
5176
|
const sid = p.native_id ? `${agent}:${String(p.native_id)}` : p.sid || ctx.sid || provisionalId(agent);
|
|
4382
5177
|
if (typeof sid !== "string" || !sid.startsWith(agent + ":") || sid.length > 300 || p.native_id !== void 0 && (typeof p.native_id !== "string" || !p.native_id || p.native_id.length > 256))
|
|
4383
5178
|
fail("INVALID_ARGUMENT");
|
|
5179
|
+
if (this.store.revoked(sid)) fail("ENDPOINT_REPLACED");
|
|
4384
5180
|
let s = this.store.session(sid);
|
|
4385
5181
|
if (!s)
|
|
4386
5182
|
s = {
|
|
@@ -4396,7 +5192,8 @@ var Core = class {
|
|
|
4396
5192
|
role: "none",
|
|
4397
5193
|
squad_id: null,
|
|
4398
5194
|
presence: "offline",
|
|
4399
|
-
activity: "
|
|
5195
|
+
activity: "unknown",
|
|
5196
|
+
member_id: `member:${randomUUID3()}`,
|
|
4400
5197
|
last_status: null,
|
|
4401
5198
|
last_notified_seq: 0,
|
|
4402
5199
|
last_notified_at: 0,
|
|
@@ -4415,17 +5212,24 @@ var Core = class {
|
|
|
4415
5212
|
if (p.kind === "mcp") {
|
|
4416
5213
|
if (ctx.sid && ctx.sid !== sid)
|
|
4417
5214
|
fail("ROLE_NOT_ALLOWED", "Use session.identify to bind identity");
|
|
4418
|
-
|
|
5215
|
+
ctx.transport = p.transport === "cli" ? "cli" : "mcp";
|
|
5216
|
+
s.transport = ctx.transport;
|
|
5217
|
+
s.presence = ctx.transport === "mcp" || [...this.contexts].some((c) => c.sid === sid && c.kind === "mcp" && c.transport !== "cli") ? "online" : "cli";
|
|
4419
5218
|
ctx.sid = sid;
|
|
4420
5219
|
ctx.agent = agent;
|
|
4421
5220
|
ctx.waitHint = Number.isFinite(p.wait_hint) ? Math.min(300, Math.max(0, p.wait_hint)) : recommendedWait(agent, {});
|
|
4422
5221
|
}
|
|
4423
5222
|
this.store.saveSession(s);
|
|
5223
|
+
this.record("session.registered", s.squad_id, {
|
|
5224
|
+
to_sid: s.sid,
|
|
5225
|
+
data: { presence: s.presence, transport: s.transport }
|
|
5226
|
+
});
|
|
4424
5227
|
return s;
|
|
4425
5228
|
}
|
|
4426
5229
|
identify(ctx, native, force = false) {
|
|
4427
5230
|
if (typeof native !== "string" || !native || native.length > 256) fail("INVALID_ARGUMENT");
|
|
4428
5231
|
const old = this.required(ctx), sid = `${old.agent}:${native}`;
|
|
5232
|
+
if (this.store.revoked(sid)) fail("ENDPOINT_REPLACED");
|
|
4429
5233
|
if (old.sid === sid) return old;
|
|
4430
5234
|
if (old.native_id && !force) return old;
|
|
4431
5235
|
const target = this.store.session(sid);
|
|
@@ -4434,6 +5238,7 @@ var Core = class {
|
|
|
4434
5238
|
const merged = {
|
|
4435
5239
|
...old,
|
|
4436
5240
|
...target,
|
|
5241
|
+
member_id: old.role !== "none" ? old.member_id || old.sid : target?.member_id || old.member_id || old.sid,
|
|
4437
5242
|
sid,
|
|
4438
5243
|
native_id: native,
|
|
4439
5244
|
presence: "online",
|
|
@@ -4474,14 +5279,14 @@ var Core = class {
|
|
|
4474
5279
|
return merged;
|
|
4475
5280
|
}
|
|
4476
5281
|
enqueue(from, q, to, type, body, opts = {}) {
|
|
4477
|
-
if (this.store.queue(to).length >= this.config.maxQueue)
|
|
5282
|
+
if (!opts.terminalAck && this.store.queue(to).filter((m2) => m2.type === "cancel" === (type === "cancel")).length >= (type === "cancel" ? 100 : this.config.maxQueue))
|
|
4478
5283
|
fail("QUEUE_FULL", `Queue for ${to} is full`);
|
|
4479
5284
|
const m = {
|
|
4480
5285
|
id: messageId(),
|
|
4481
5286
|
seq: 0,
|
|
4482
5287
|
squad_id: q,
|
|
4483
5288
|
type,
|
|
4484
|
-
priority: opts.priority ?? (["command", "ask", "answer"].includes(type) ? 0 : type === "report" ? 2 : 1),
|
|
5289
|
+
priority: opts.priority ?? (["command", "cancel", "ask", "answer"].includes(type) ? 0 : type === "report" ? 2 : 1),
|
|
4485
5290
|
from_sid: from?.sid || (opts.operator ? "operator" : "system"),
|
|
4486
5291
|
from_role: from?.role || (opts.operator ? "operator" : "system"),
|
|
4487
5292
|
from_name: from?.name || null,
|
|
@@ -4490,11 +5295,14 @@ var Core = class {
|
|
|
4490
5295
|
data: opts.data || null,
|
|
4491
5296
|
reply_to: opts.reply_to || null,
|
|
4492
5297
|
status: "queued",
|
|
4493
|
-
attn: opts.attn ?? ["command", "ask", "answer"].includes(type),
|
|
5298
|
+
attn: opts.attn ?? ["command", "cancel", "ask", "answer"].includes(type),
|
|
5299
|
+
direct: opts.direct,
|
|
4494
5300
|
created_at: Date.now(),
|
|
4495
5301
|
delivered_at: null
|
|
4496
5302
|
};
|
|
5303
|
+
if (type === "command") m.work = { state: "queued", updated_at: m.created_at };
|
|
4497
5304
|
this.store.insert(m);
|
|
5305
|
+
this.messageEvent("message.queued", m);
|
|
4498
5306
|
this.effects.push(m);
|
|
4499
5307
|
return m;
|
|
4500
5308
|
}
|
|
@@ -4502,51 +5310,115 @@ var Core = class {
|
|
|
4502
5310
|
for (const s of to)
|
|
4503
5311
|
this.enqueue(null, q.id, s.sid, "system", body, { priority: high ? 0 : 1, attn: high });
|
|
4504
5312
|
}
|
|
5313
|
+
roleInbox(q, old) {
|
|
5314
|
+
for (const m of this.store.messages())
|
|
5315
|
+
if (m.squad_id === q.id && m.to_sid === old && !["command", "cancel", "answer"].includes(m.type)) {
|
|
5316
|
+
m.to_sid = `squad:${q.id}`;
|
|
5317
|
+
this.store.saveMessage(m);
|
|
5318
|
+
}
|
|
5319
|
+
}
|
|
5320
|
+
rebind(ctx, member) {
|
|
5321
|
+
const target = this.required(ctx);
|
|
5322
|
+
if (!target.native_id) fail("IDENTITY_REQUIRED");
|
|
5323
|
+
const old = this.store.sessions().find((s) => (s.member_id || s.sid) === member) || fail("MEMBER_NOT_FOUND");
|
|
5324
|
+
if (old.sid === target.sid) return;
|
|
5325
|
+
if (!old.squad_id) fail("NOT_JOINED", "The member must belong to a channel before rebinding");
|
|
5326
|
+
if (target.squad_id) fail("ALREADY_JOINED");
|
|
5327
|
+
const merged = {
|
|
5328
|
+
...old,
|
|
5329
|
+
sid: target.sid,
|
|
5330
|
+
agent: target.agent,
|
|
5331
|
+
native_id: target.native_id,
|
|
5332
|
+
member_id: old.member_id || old.sid,
|
|
5333
|
+
pid: target.pid,
|
|
5334
|
+
cwd: target.cwd,
|
|
5335
|
+
terminal: target.terminal,
|
|
5336
|
+
transcript_path: target.transcript_path,
|
|
5337
|
+
presence: target.presence,
|
|
5338
|
+
transport: target.transport,
|
|
5339
|
+
activity: "unknown",
|
|
5340
|
+
last_seen_at: Date.now(),
|
|
5341
|
+
ended_at: null
|
|
5342
|
+
};
|
|
5343
|
+
this.store.saveSession(merged);
|
|
5344
|
+
for (const q of this.store.squads())
|
|
5345
|
+
if (q.commander_sid === old.sid) {
|
|
5346
|
+
q.commander_sid = target.sid;
|
|
5347
|
+
this.store.saveSquad(q);
|
|
5348
|
+
}
|
|
5349
|
+
for (const m of this.store.messages()) {
|
|
5350
|
+
if (m.to_sid === old.sid) m.to_sid = target.sid;
|
|
5351
|
+
if (m.from_sid === old.sid) m.from_sid = target.sid;
|
|
5352
|
+
this.store.saveMessage(m);
|
|
5353
|
+
}
|
|
5354
|
+
this.store.migrateMembership(old.sid, target.sid);
|
|
5355
|
+
this.store.revoke(old.sid, target.sid);
|
|
5356
|
+
this.store.deleteSession(old.sid);
|
|
5357
|
+
this.store.deleteStandby(old.sid);
|
|
5358
|
+
for (const w of this.waiters) if (w.sid === old.sid) w.finish();
|
|
5359
|
+
this.clearFlag(old.sid);
|
|
5360
|
+
if (this.inbox(merged).length) this.flag(target.sid);
|
|
5361
|
+
this.record("member.rebound", merged.squad_id, {
|
|
5362
|
+
from_sid: old.sid,
|
|
5363
|
+
to_sid: target.sid,
|
|
5364
|
+
data: { member_id: merged.member_id, wake_mode: "manual" }
|
|
5365
|
+
});
|
|
5366
|
+
}
|
|
4505
5367
|
joinSquad(ctx, p) {
|
|
5368
|
+
if (p.rebind && (p.role || p.squad || p.squad_name || p.takeover))
|
|
5369
|
+
fail("INVALID_ARGUMENT", "rebind cannot be combined with a role or channel change");
|
|
5370
|
+
if (p.rebind) this.rebind(ctx, p.rebind);
|
|
4506
5371
|
const s = this.required(ctx);
|
|
4507
5372
|
let q, role = p.role;
|
|
4508
5373
|
if (p.squad_name) {
|
|
4509
|
-
if (p.
|
|
4510
|
-
fail("INVALID_ARGUMENT", "squad_name is the atomic shortcut; omit role and squad");
|
|
5374
|
+
if (p.squad) fail("INVALID_ARGUMENT", "Use squad or squad_name, not both");
|
|
4511
5375
|
q = this.store.squads().find((q2) => q2.name_key === p.squad_name.toLowerCase() && q2.status !== "dissolved");
|
|
4512
|
-
if (s.squad_id
|
|
4513
|
-
|
|
4514
|
-
fail(
|
|
4515
|
-
"SQUAD_ORPHANED",
|
|
4516
|
-
`Squad ${q.id} is orphaned. Choose join(role="commander", squad="${q.id}") to take over or role="executor" to join.`
|
|
4517
|
-
);
|
|
4518
|
-
role = q ? "executor" : "commander";
|
|
4519
|
-
if (!q) p.name = p.squad_name;
|
|
5376
|
+
if (s.squad_id === q?.id && !role) return this.joinResult(ctx, q);
|
|
5377
|
+
role ||= "executor";
|
|
4520
5378
|
} else if (p.squad) q = this.store.squad(p.squad) || fail("SQUAD_NOT_FOUND");
|
|
5379
|
+
else if (p.rebind && s.squad_id) return this.joinResult(ctx, this.store.squad(s.squad_id));
|
|
4521
5380
|
if (!role) fail("INVALID_ARGUMENT", "Provide role or squad_name");
|
|
5381
|
+
if (q?.status === "dissolved") fail("SQUAD_NOT_FOUND", "Channel is closed");
|
|
4522
5382
|
if (s.squad_id) {
|
|
4523
5383
|
if ((q?.id === s.squad_id || !q && !p.squad_name && role === "commander") && s.role === role)
|
|
4524
5384
|
return this.joinResult(ctx, this.store.squad(s.squad_id));
|
|
4525
|
-
if (q?.id === s.squad_id &&
|
|
4526
|
-
|
|
4527
|
-
} else fail("ALREADY_JOINED");
|
|
5385
|
+
if (q?.id === s.squad_id && role === "commander") this.store.leave(s.sid);
|
|
5386
|
+
else fail("ALREADY_JOINED");
|
|
4528
5387
|
}
|
|
4529
|
-
if (q?.status === "dissolved") fail("SQUAD_NOT_FOUND", "Squad is dissolved");
|
|
4530
5388
|
if (!q) {
|
|
4531
|
-
if (role !== "commander") fail("SQUAD_NOT_FOUND");
|
|
4532
|
-
const
|
|
5389
|
+
if (role !== "commander" && !p.squad_name) fail("SQUAD_NOT_FOUND");
|
|
5390
|
+
const name = p.squad_name || p.name || null, key = name?.toLowerCase() || null;
|
|
4533
5391
|
if (key && this.store.squads().some((x) => x.name_key === key && x.status !== "dissolved"))
|
|
4534
|
-
fail("SQUAD_NAME_EXISTS"
|
|
5392
|
+
fail("SQUAD_NAME_EXISTS");
|
|
4535
5393
|
let id;
|
|
4536
5394
|
do {
|
|
4537
5395
|
id = squadId();
|
|
4538
5396
|
} while (this.store.squad(id));
|
|
4539
5397
|
q = {
|
|
4540
5398
|
id,
|
|
4541
|
-
name
|
|
5399
|
+
name,
|
|
4542
5400
|
name_key: key,
|
|
4543
|
-
commander_sid: s.sid,
|
|
4544
|
-
status: "active",
|
|
5401
|
+
commander_sid: role === "commander" ? s.sid : null,
|
|
5402
|
+
status: role === "commander" ? "active" : "orphaned",
|
|
4545
5403
|
created_at: Date.now(),
|
|
4546
5404
|
updated_at: Date.now()
|
|
4547
5405
|
};
|
|
5406
|
+
this.record("channel.created", q.id);
|
|
4548
5407
|
} else if (role === "commander") {
|
|
4549
|
-
if (q.commander_sid && q.commander_sid !== s.sid)
|
|
5408
|
+
if (q.commander_sid && q.commander_sid !== s.sid) {
|
|
5409
|
+
if (!p.takeover)
|
|
5410
|
+
fail(
|
|
5411
|
+
"SQUAD_HAS_COMMANDER",
|
|
5412
|
+
"Use takeover=true for an explicit handover; offline does not mean stopped."
|
|
5413
|
+
);
|
|
5414
|
+
const old = this.store.session(q.commander_sid);
|
|
5415
|
+
this.roleInbox(q, old.sid);
|
|
5416
|
+
this.store.leave(old.sid);
|
|
5417
|
+
old.role = "executor";
|
|
5418
|
+
this.store.saveSession(old);
|
|
5419
|
+
this.store.join(old);
|
|
5420
|
+
this.record("commander.handover", q.id, { from_sid: old.sid, to_sid: s.sid });
|
|
5421
|
+
}
|
|
4550
5422
|
q.commander_sid = s.sid;
|
|
4551
5423
|
q.status = "active";
|
|
4552
5424
|
this.system(
|
|
@@ -4554,29 +5426,25 @@ var Core = class {
|
|
|
4554
5426
|
this.members(q.id).filter((member) => member.sid !== s.sid),
|
|
4555
5427
|
"commander_joined"
|
|
4556
5428
|
);
|
|
4557
|
-
|
|
4558
|
-
if (inbox.length + this.store.queue(s.sid).length > this.config.maxQueue) fail("QUEUE_FULL");
|
|
4559
|
-
for (const m of inbox) {
|
|
4560
|
-
m.to_sid = s.sid;
|
|
4561
|
-
this.store.saveMessage(m);
|
|
4562
|
-
this.effects.push(m);
|
|
4563
|
-
}
|
|
5429
|
+
this.record("commander.claimed", q.id, { to_sid: s.sid });
|
|
4564
5430
|
}
|
|
4565
5431
|
s.role = role;
|
|
4566
5432
|
s.squad_id = q.id;
|
|
4567
|
-
s.name = p.name ||
|
|
5433
|
+
s.name = p.name || s.name;
|
|
4568
5434
|
q.updated_at = Date.now();
|
|
4569
5435
|
this.store.saveSquad(q);
|
|
4570
5436
|
this.store.saveSession(s);
|
|
4571
5437
|
this.store.join(s);
|
|
5438
|
+
this.record("member.joined", q.id, { to_sid: s.sid, data: { role, member_id: s.member_id } });
|
|
4572
5439
|
if (role === "executor" && q.commander_sid)
|
|
4573
5440
|
this.enqueue(
|
|
4574
5441
|
s,
|
|
4575
5442
|
q.id,
|
|
4576
|
-
q.
|
|
5443
|
+
`squad:${q.id}`,
|
|
4577
5444
|
"system",
|
|
4578
5445
|
`member_joined${p.note ? `: ${p.note}` : ""}`
|
|
4579
5446
|
);
|
|
5447
|
+
if (this.inbox(s).length) this.flag(s.sid);
|
|
4580
5448
|
return this.joinResult(ctx, q);
|
|
4581
5449
|
}
|
|
4582
5450
|
joinResult(ctx, q) {
|
|
@@ -4587,13 +5455,15 @@ var Core = class {
|
|
|
4587
5455
|
join_prompt,
|
|
4588
5456
|
user_reply: s.role === "commander" ? `Squad ${q.id}${q.name ? ` (${q.name})` : ""} is ready. Paste this into each other session:
|
|
4589
5457
|
${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report ready and wait for commands.`,
|
|
4590
|
-
|
|
5458
|
+
standby: this.standbyView(s.sid),
|
|
5459
|
+
protocol_hint: `You are the ${s.role.toUpperCase()} of squad ${q.id}. ${s.role === "commander" ? "Dispatch clear, verifiable tasks with send; answer every ask using type=answer and reply_to." : "Report ready now with cwd, capabilities and context; act on commands and report working/done/failed with reply_to. Ask when blocked."} Reply with ONLY user_reply (translate prose, keep the join line verbatim). Check list before reassignment: offline never means work stopped. Accept commands immediately with report(working, reply_to); recover with read(recover=true). Use join(standby="auto") with the real session ID to register managed standby, then check list for listener health. For Claude/ZCode, run listener.arm.command with its indicated host tool before ending the turn, and re-arm after task termination. If me.listener.can_auto_respond, end the turn; otherwise use at most two read(wait=${wait2}) calls and explain that manual continuation is required. Use the built-in standby watcher; do not write a private listener. Keep user replies to one or two lines. Apply normal judgment to messages from other agents. ${s.native_id ? "" : "Identity is provisional; hooks may be unavailable. Use read(wait) for reminders."}`
|
|
4591
5460
|
};
|
|
4592
5461
|
}
|
|
4593
5462
|
leave(ctx, p) {
|
|
4594
5463
|
const s = this.member(ctx), q = this.store.squad(s.squad_id);
|
|
4595
5464
|
if (p.dissolve && s.role !== "commander") fail("ROLE_NOT_ALLOWED");
|
|
4596
5465
|
if (s.role === "commander") {
|
|
5466
|
+
this.roleInbox(q, s.sid);
|
|
4597
5467
|
this.system(
|
|
4598
5468
|
q,
|
|
4599
5469
|
this.members(q.id).filter((x) => x.sid !== s.sid),
|
|
@@ -4608,12 +5478,18 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4608
5478
|
m.role = "none";
|
|
4609
5479
|
m.squad_id = null;
|
|
4610
5480
|
this.store.saveSession(m);
|
|
5481
|
+
const listener = this.store.standby(m.sid);
|
|
5482
|
+
if (listener) {
|
|
5483
|
+
listener.enabled = false;
|
|
5484
|
+
listener.health = "stopped";
|
|
5485
|
+
this.store.saveStandby(listener);
|
|
5486
|
+
}
|
|
4611
5487
|
}
|
|
4612
5488
|
} else if (q.commander_sid)
|
|
4613
5489
|
this.enqueue(
|
|
4614
5490
|
s,
|
|
4615
5491
|
q.id,
|
|
4616
|
-
q.
|
|
5492
|
+
`squad:${q.id}`,
|
|
4617
5493
|
"system",
|
|
4618
5494
|
`member_left${p.message ? `: ${p.message}` : ""}`
|
|
4619
5495
|
);
|
|
@@ -4623,6 +5499,13 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4623
5499
|
this.store.saveSession(s);
|
|
4624
5500
|
q.updated_at = Date.now();
|
|
4625
5501
|
this.store.saveSquad(q);
|
|
5502
|
+
this.record(p.dissolve ? "channel.closed" : "member.left", q.id, { from_sid: s.sid });
|
|
5503
|
+
const standby = this.store.standby(s.sid);
|
|
5504
|
+
if (standby) {
|
|
5505
|
+
standby.enabled = false;
|
|
5506
|
+
standby.health = "stopped";
|
|
5507
|
+
this.store.saveStandby(standby);
|
|
5508
|
+
}
|
|
4626
5509
|
return { left: true, squad: q.id, status: q.status };
|
|
4627
5510
|
}
|
|
4628
5511
|
rate(ctx) {
|
|
@@ -4640,7 +5523,7 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4640
5523
|
for (const s of all) if (s.sid !== sender) found.set(s.sid, s);
|
|
4641
5524
|
continue;
|
|
4642
5525
|
}
|
|
4643
|
-
const exact = all.filter((s) => s.sid === token);
|
|
5526
|
+
const exact = all.filter((s) => s.sid === token || s.member_id === token);
|
|
4644
5527
|
const matches = exact.length ? exact : all.filter((s) => s.name === token || s.sid.startsWith(token));
|
|
4645
5528
|
if (matches.length !== 1)
|
|
4646
5529
|
fail(
|
|
@@ -4665,65 +5548,208 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4665
5548
|
"Answers require reply_to for an ask from the recipient in this squad."
|
|
4666
5549
|
);
|
|
4667
5550
|
}
|
|
5551
|
+
if (p.type === "cancel" && (!p.reply_to || p.reassign))
|
|
5552
|
+
fail("INVALID_ARGUMENT", "cancel requires reply_to=<command id>");
|
|
5553
|
+
const previous = p.reassign ? this.store.message(p.reassign) : void 0;
|
|
5554
|
+
if (p.reassign && (p.type !== "command" || !previous || previous.type !== "command" || previous.squad_id !== qid || terminalWork(previous) || recipients.length !== 1))
|
|
5555
|
+
fail("INVALID_ARGUMENT", "reassign must reference one unfinished command in this channel");
|
|
5556
|
+
if (previous?.work?.replacement_id) fail("ALREADY_REASSIGNED");
|
|
5557
|
+
if (previous?.task_key && p.task_key && p.task_key !== previous.task_key)
|
|
5558
|
+
fail("INVALID_ARGUMENT", "Reassignment must preserve the original task_key");
|
|
5559
|
+
const taskKey = previous?.task_key || p.task_key;
|
|
5560
|
+
if (p.task_key && (p.type !== "command" || recipients.length !== 1))
|
|
5561
|
+
fail(
|
|
5562
|
+
"INVALID_ARGUMENT",
|
|
5563
|
+
"task_key identifies one command owner; do not broadcast the same ticket"
|
|
5564
|
+
);
|
|
5565
|
+
if (taskKey && this.store.commands().some((m) => m.squad_id === qid && m.task_key === taskKey && m.id !== previous?.id))
|
|
5566
|
+
fail("TASK_OWNED", "This task_key already has unfinished work. Use reassign=<command id>.");
|
|
5567
|
+
const warnings = recipients.flatMap((s) => {
|
|
5568
|
+
const active = this.store.commands(s.sid);
|
|
5569
|
+
return active.length ? [
|
|
5570
|
+
{
|
|
5571
|
+
code: "UNFINISHED_WORK",
|
|
5572
|
+
sid: s.sid,
|
|
5573
|
+
command_ids: active.map((m) => m.id),
|
|
5574
|
+
message: "Verify ownership before dispatch. Offline does not mean stopped."
|
|
5575
|
+
}
|
|
5576
|
+
] : [];
|
|
5577
|
+
});
|
|
4668
5578
|
this.rate(ctx);
|
|
4669
|
-
const
|
|
4670
|
-
|
|
5579
|
+
const cancel = (command, body) => {
|
|
5580
|
+
command.work ||= {
|
|
5581
|
+
state: command.status === "queued" ? "queued" : "read",
|
|
5582
|
+
updated_at: command.created_at
|
|
5583
|
+
};
|
|
5584
|
+
command.work.cancel_requested_at = Date.now();
|
|
5585
|
+
if (command.work.state === "queued") {
|
|
5586
|
+
command.work.state = "cancelled";
|
|
5587
|
+
command.status = "delivered";
|
|
5588
|
+
command.delivered_at = Date.now();
|
|
5589
|
+
}
|
|
5590
|
+
command.work.updated_at = Date.now();
|
|
5591
|
+
this.store.saveMessage(command);
|
|
5592
|
+
this.messageEvent(
|
|
5593
|
+
command.work.state === "cancelled" ? "work.cancelled" : "work.cancel_requested",
|
|
5594
|
+
command
|
|
5595
|
+
);
|
|
5596
|
+
return this.enqueue(from, qid, command.to_sid, "cancel", body, {
|
|
5597
|
+
priority: -1,
|
|
5598
|
+
reply_to: command.id,
|
|
5599
|
+
attn: true,
|
|
5600
|
+
operator: ctx.kind === "cli"
|
|
5601
|
+
});
|
|
5602
|
+
};
|
|
5603
|
+
if (previous)
|
|
5604
|
+
cancel(
|
|
5605
|
+
previous,
|
|
5606
|
+
`Cancel command ${previous.id} at the next safe checkpoint and report cancelled with reply_to. Reassignment waits for your terminal report.`
|
|
5607
|
+
);
|
|
5608
|
+
const messages = recipients.map((s) => {
|
|
5609
|
+
if (p.type === "cancel") {
|
|
5610
|
+
const command = this.store.message(p.reply_to);
|
|
5611
|
+
if (!command || command.type !== "command" || command.to_sid !== s.sid || command.squad_id !== qid || terminalWork(command))
|
|
5612
|
+
fail("INVALID_ARGUMENT", "Cancel target must own an unfinished command");
|
|
5613
|
+
return cancel(command, p.message);
|
|
5614
|
+
}
|
|
5615
|
+
const m = this.enqueue(from, qid, s.sid, p.type, p.message, {
|
|
4671
5616
|
priority: p.priority ? { high: 0, normal: 1, low: 2 }[p.priority] : void 0,
|
|
4672
5617
|
reply_to: p.reply_to,
|
|
4673
5618
|
data: p.data,
|
|
5619
|
+
attn: p.attention,
|
|
5620
|
+
direct: !(Array.isArray(p.to) ? p.to : [p.to]).includes("all"),
|
|
4674
5621
|
operator: ctx.kind === "cli"
|
|
4675
|
-
})
|
|
4676
|
-
|
|
5622
|
+
});
|
|
5623
|
+
if (p.type === "command") {
|
|
5624
|
+
m.task_key = taskKey;
|
|
5625
|
+
if (previous && !terminalWork(previous)) m.blocked_by = previous.id;
|
|
5626
|
+
this.store.saveMessage(m);
|
|
5627
|
+
if (previous) {
|
|
5628
|
+
previous.work.replacement_id = m.id;
|
|
5629
|
+
this.store.saveMessage(previous);
|
|
5630
|
+
this.messageEvent(
|
|
5631
|
+
"work.reassigned",
|
|
5632
|
+
m,
|
|
5633
|
+
`Replaces ${previous.id}; ${m.blocked_by ? "waiting for original owner to stop" : "original was unread"}`
|
|
5634
|
+
);
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
return m;
|
|
5638
|
+
});
|
|
4677
5639
|
return {
|
|
4678
5640
|
ids: messages.map((m) => m.id),
|
|
5641
|
+
queued_to: recipients.map((s) => s.sid),
|
|
4679
5642
|
delivered_to: recipients.map((s) => s.sid),
|
|
5643
|
+
delivery_hint: "queued_to / delivered_to mean enqueued, not read or accepted",
|
|
5644
|
+
warnings: p.type === "command" ? warnings : [],
|
|
5645
|
+
blocked_by: messages.find((m) => m.blocked_by)?.blocked_by,
|
|
4680
5646
|
offline: recipients.filter((s) => s.presence === "offline").map((s) => s.sid),
|
|
4681
5647
|
idle: recipients.filter((s) => s.activity === "idle").map((s) => s.sid)
|
|
4682
5648
|
};
|
|
4683
5649
|
}
|
|
4684
5650
|
reportOrAsk(ctx, p, ask) {
|
|
4685
|
-
const s = this.member(ctx, "executor")
|
|
5651
|
+
const s = !ask && p.reply_to ? this.required(ctx) : this.member(ctx, "executor");
|
|
5652
|
+
let command = !ask && p.reply_to ? this.store.message(p.reply_to) : void 0;
|
|
5653
|
+
let terminalAck = false;
|
|
5654
|
+
const q = this.store.squad(command?.squad_id || s.squad_id) || fail("NOT_JOINED");
|
|
5655
|
+
if (!ask && p.reply_to) {
|
|
5656
|
+
command = this.store.message(p.reply_to);
|
|
5657
|
+
if (!command || command.type !== "command" || command.to_sid !== s.sid || command.squad_id !== q.id)
|
|
5658
|
+
fail("INVALID_ARGUMENT", "reply_to must identify a command owned by this member");
|
|
5659
|
+
if (command.blocked_by && !terminalWork(this.store.message(command.blocked_by)))
|
|
5660
|
+
fail("REASSIGNMENT_PENDING", "Original owner has not stopped");
|
|
5661
|
+
const state = {
|
|
5662
|
+
working: "accepted",
|
|
5663
|
+
blocked: "accepted",
|
|
5664
|
+
done: "completed",
|
|
5665
|
+
failed: "failed",
|
|
5666
|
+
cancelled: "cancelled"
|
|
5667
|
+
}[p.status];
|
|
5668
|
+
if (!state) fail("INVALID_ARGUMENT", "ready is not a command acknowledgement");
|
|
5669
|
+
if (terminalWork(command) && state !== command.work.state) fail("WORK_TERMINAL");
|
|
5670
|
+
if (!terminalWork(command)) {
|
|
5671
|
+
const now = Date.now();
|
|
5672
|
+
command.work ||= { state: "read", updated_at: now };
|
|
5673
|
+
if (command.work.cancel_requested_at && state === "accepted")
|
|
5674
|
+
fail("CANCEL_REQUESTED", "Stop at a safe checkpoint and report cancelled");
|
|
5675
|
+
const changed = command.work.state !== state;
|
|
5676
|
+
command.work.state = state;
|
|
5677
|
+
command.work.updated_at = now;
|
|
5678
|
+
if (state === "accepted") command.work.accepted_at ||= now;
|
|
5679
|
+
command.status = "delivered";
|
|
5680
|
+
command.delivered_at ||= now;
|
|
5681
|
+
this.store.saveMessage(command);
|
|
5682
|
+
terminalAck = terminalWork(command);
|
|
5683
|
+
this.messageEvent(changed ? `work.${state}` : "work.progress", command);
|
|
5684
|
+
if (terminalWork(command) && command.work.replacement_id) {
|
|
5685
|
+
const replacement = this.store.message(command.work.replacement_id);
|
|
5686
|
+
this.effects.push(replacement);
|
|
5687
|
+
this.messageEvent("work.released", replacement);
|
|
5688
|
+
}
|
|
5689
|
+
}
|
|
5690
|
+
}
|
|
4686
5691
|
this.rate(ctx);
|
|
4687
|
-
const to =
|
|
5692
|
+
const to = `squad:${q.id}`;
|
|
4688
5693
|
const m = this.enqueue(s, q.id, to, ask ? "ask" : "report", ask ? p.question : p.message, {
|
|
4689
5694
|
reply_to: p.reply_to,
|
|
4690
5695
|
data: ask ? p.data : { ...p.data, status: p.status },
|
|
4691
5696
|
priority: ask ? 0 : ["blocked", "failed"].includes(p.status) ? 1 : 2,
|
|
4692
|
-
attn: ask || ["done", "failed", "blocked"].includes(p.status)
|
|
5697
|
+
attn: ask || ["done", "failed", "blocked", "cancelled"].includes(p.status),
|
|
5698
|
+
// Each issued command reserves admission for its first terminal report.
|
|
5699
|
+
// Keep work and report atomic even under backpressure; repeats use the ordinary cap.
|
|
5700
|
+
terminalAck
|
|
4693
5701
|
});
|
|
4694
5702
|
if (!ask) {
|
|
4695
5703
|
s.last_status = { status: p.status, message: p.message.slice(0, 300) };
|
|
5704
|
+
s.last_progress_at = Date.now();
|
|
5705
|
+
s.activity_at = Date.now();
|
|
5706
|
+
s.activity = this.store.commands(s.sid).some((m2) => m2.work?.state === "accepted") ? "busy" : "idle";
|
|
4696
5707
|
this.store.saveSession(s);
|
|
4697
5708
|
}
|
|
4698
5709
|
return {
|
|
4699
5710
|
id: m.id,
|
|
4700
5711
|
delivered_to: to,
|
|
5712
|
+
queued_to: to,
|
|
5713
|
+
work: command?.work,
|
|
5714
|
+
...!ask && !p.reply_to && this.store.commands(s.sid).length ? { warning: "Uncorrelated report does not accept or finish a command; provide reply_to." } : {},
|
|
4701
5715
|
commander_presence: q.commander_sid ? this.store.session(q.commander_sid)?.presence : "orphaned",
|
|
4702
5716
|
answered: false
|
|
4703
5717
|
};
|
|
4704
5718
|
}
|
|
4705
5719
|
readNow(ctx, p, answer) {
|
|
4706
5720
|
const s = this.required(ctx);
|
|
4707
|
-
let queue = this.store.
|
|
5721
|
+
let queue = p.recover ? this.store.commands(s.sid).filter((m) => !m.blocked_by || terminalWork(this.store.message(m.blocked_by))) : this.inbox(s, p.history);
|
|
5722
|
+
if (p.id) {
|
|
5723
|
+
const m = this.store.message(p.id);
|
|
5724
|
+
if (!m || m.to_sid !== s.sid && !(s.role === "commander" && m.to_sid === `squad:${s.squad_id}`))
|
|
5725
|
+
fail("MESSAGE_NOT_FOUND");
|
|
5726
|
+
if (m.status === "queued" && m.blocked_by && !terminalWork(this.store.message(m.blocked_by)))
|
|
5727
|
+
fail("REASSIGNMENT_PENDING", "Original owner has not stopped");
|
|
5728
|
+
queue = [m];
|
|
5729
|
+
}
|
|
4708
5730
|
if (p.since) {
|
|
4709
5731
|
const since = this.store.message(p.since);
|
|
4710
|
-
if (!since || since.to_sid !== s.sid
|
|
5732
|
+
if (!since || since.to_sid !== s.sid && !(s.role === "commander" && since.to_sid === `squad:${s.squad_id}`))
|
|
5733
|
+
fail("INVALID_ARGUMENT", "Unknown since message");
|
|
4711
5734
|
queue = queue.filter((m) => m.seq > since.seq);
|
|
4712
5735
|
}
|
|
4713
5736
|
if (answer) queue = queue.filter((m) => m.type === "answer" && m.reply_to === answer);
|
|
4714
|
-
const messages = queue
|
|
4715
|
-
if (!p.peek && !p.history && !ctx.closed)
|
|
5737
|
+
const messages = bounded(queue, p.limit || 20);
|
|
5738
|
+
if (!p.peek && !p.history && !p.recover && !p.id && !ctx.closed)
|
|
4716
5739
|
for (const m of messages) {
|
|
4717
5740
|
m.status = "delivered";
|
|
4718
5741
|
m.delivered_at = Date.now();
|
|
5742
|
+
if (m.type === "command" && (!m.work || m.work.state === "queued"))
|
|
5743
|
+
m.work = { ...m.work, state: "read", updated_at: Date.now() };
|
|
4719
5744
|
this.store.saveMessage(m);
|
|
5745
|
+
this.messageEvent("message.read", m);
|
|
4720
5746
|
}
|
|
4721
|
-
const remaining = this.
|
|
5747
|
+
const remaining = this.inbox(s).length;
|
|
4722
5748
|
if (!remaining) this.clearFlag(s.sid);
|
|
4723
5749
|
return {
|
|
4724
5750
|
messages: ctx.closed ? [] : messages,
|
|
4725
5751
|
remaining,
|
|
4726
|
-
squad_summary: s.squad_id ? this.board(this.store.squad(s.squad_id)) : null
|
|
5752
|
+
...p.full ? { squad_summary: s.squad_id ? this.board(this.store.squad(s.squad_id)) : null } : {}
|
|
4727
5753
|
};
|
|
4728
5754
|
}
|
|
4729
5755
|
wait(ctx, seconds, answer, signal) {
|
|
@@ -4744,7 +5770,9 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4744
5770
|
w.timer = setTimeout(w.finish, seconds * 1e3);
|
|
4745
5771
|
this.waiters.add(w);
|
|
4746
5772
|
signal?.addEventListener("abort", w.finish, { once: true });
|
|
4747
|
-
if (this.store.
|
|
5773
|
+
if (this.inbox(this.store.session(w.sid)).some(
|
|
5774
|
+
(m) => !answer || m.type === "answer" && m.reply_to === answer
|
|
5775
|
+
))
|
|
4748
5776
|
w.finish();
|
|
4749
5777
|
});
|
|
4750
5778
|
}
|
|
@@ -4753,18 +5781,19 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4753
5781
|
if (squad && !this.store.squad(squad)) fail("SQUAD_NOT_FOUND");
|
|
4754
5782
|
return {
|
|
4755
5783
|
sessions: this.store.sessions().filter(
|
|
4756
|
-
(s) => squad ? s.squad_id === squad : s.presence === "online" || s.role !== "none"
|
|
5784
|
+
(s) => squad ? s.squad_id === squad || this.store.commands(s.sid).some((m) => m.squad_id === squad) : s.presence === "online" || s.role !== "none" || this.store.commands(s.sid).length > 0
|
|
4757
5785
|
).map((s) => ({
|
|
4758
|
-
...this.view(s),
|
|
4759
|
-
...s.sid === me?.sid ? { unread: this.
|
|
5786
|
+
...this.view(s, p.full, squad || void 0),
|
|
5787
|
+
...s.sid === me?.sid ? { unread: this.inbox(s).length } : {}
|
|
4760
5788
|
})),
|
|
4761
|
-
squads: this.store.squads().filter((q) => squad ? q.id === squad : q.status !== "dissolved").map((q) => this.board(q))
|
|
5789
|
+
squads: this.store.squads().filter((q) => squad ? q.id === squad : q.status !== "dissolved").map((q) => p.full ? this.board(q) : q)
|
|
4762
5790
|
};
|
|
4763
5791
|
}
|
|
4764
5792
|
hook(ctx, p) {
|
|
4765
5793
|
if (!p.session_id || !["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "Stop"].includes(p.event))
|
|
4766
5794
|
return {};
|
|
4767
5795
|
const agent = /^[a-z][a-z0-9_-]{0,63}$/.test(p.agent || "") ? p.agent : "generic", sid = `${agent}:${p.session_id}`;
|
|
5796
|
+
if (this.store.revoked(sid)) return {};
|
|
4768
5797
|
let s = this.store.session(sid);
|
|
4769
5798
|
if (p.event === "SessionEnd" && !s) return {};
|
|
4770
5799
|
if (p.event === "SessionStart") {
|
|
@@ -4787,6 +5816,16 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4787
5816
|
if (!s) return {};
|
|
4788
5817
|
const now = Date.now();
|
|
4789
5818
|
s.last_seen_at = now;
|
|
5819
|
+
s.hook_seen_at = now;
|
|
5820
|
+
s.activity_at = now;
|
|
5821
|
+
this.record("session.activity", s.squad_id, {
|
|
5822
|
+
to_sid: s.sid,
|
|
5823
|
+
reason: p.event,
|
|
5824
|
+
data: {
|
|
5825
|
+
activity: p.event === "Stop" ? "idle" : p.event === "SessionEnd" ? s.activity : "busy",
|
|
5826
|
+
presence: p.event === "SessionEnd" ? "offline" : s.presence
|
|
5827
|
+
}
|
|
5828
|
+
});
|
|
4790
5829
|
if (p.event === "SessionEnd") {
|
|
4791
5830
|
s.presence = "offline";
|
|
4792
5831
|
s.ended_at = now;
|
|
@@ -4794,11 +5833,11 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4794
5833
|
return {};
|
|
4795
5834
|
}
|
|
4796
5835
|
s.activity = p.event === "Stop" ? "idle" : "busy";
|
|
4797
|
-
const queue = this.
|
|
5836
|
+
const queue = this.inbox(s), max = Math.max(0, ...queue.map((m) => m.seq));
|
|
4798
5837
|
const groups = /* @__PURE__ */ new Map();
|
|
4799
5838
|
for (const m of queue) {
|
|
4800
5839
|
const sender = (m.from_name || m.from_role).replace(/[\r\n\t]/g, " ").slice(0, 30);
|
|
4801
|
-
const label = `${m.type} (${["high", "normal", "low"][m.priority]}) from ${sender}`;
|
|
5840
|
+
const label = `${m.type} (${m.priority < 0 ? "urgent" : ["high", "normal", "low"][m.priority]}) from ${sender}`;
|
|
4802
5841
|
groups.set(label, (groups.get(label) || 0) + 1);
|
|
4803
5842
|
}
|
|
4804
5843
|
const details = [...groups].slice(0, 3).map(([label, n]) => `${n} ${label}`).join(", ").slice(0, 175);
|
|
@@ -4812,11 +5851,13 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4812
5851
|
s.last_stop_block_seq = attn;
|
|
4813
5852
|
}
|
|
4814
5853
|
} else if (p.event === "SessionStart" && s.role !== "none") {
|
|
4815
|
-
|
|
5854
|
+
const listener = this.store.standby(s.sid);
|
|
5855
|
+
const rearm = listener?.enabled && hostStandby(listener.wake_mode) ? "Check list.listener.arm; re-arm the host watcher if its task stopped. " : "";
|
|
5856
|
+
result.inject = `[cmdr] ${rearm}Context restored: ${s.role} in squad ${s.squad_id}. ${s.role === "commander" ? "Send tasks; answer asks with reply_to." : "Report progress with reply_to; ask when blocked."} Read(wait=${this.waitHint(s)}). ${summary}`.slice(
|
|
4816
5857
|
0,
|
|
4817
5858
|
300
|
|
4818
5859
|
);
|
|
4819
|
-
} else if (queue.length && (max > s.last_notified_seq || queue.some((m) => m.priority
|
|
5860
|
+
} else if (queue.length && (max > s.last_notified_seq || queue.some((m) => m.priority <= 0) && now - s.last_notified_at >= this.config.remindIntervalSec * 1e3))
|
|
4820
5861
|
result.inject = summary.slice(0, 300);
|
|
4821
5862
|
if (result.inject) {
|
|
4822
5863
|
s.last_notified_seq = max;
|
|
@@ -4830,8 +5871,10 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4830
5871
|
this.atomic(() => {
|
|
4831
5872
|
const cutoff = Date.now() - this.config.ttlDays * 864e5;
|
|
4832
5873
|
this.store.expireMessages(all ? Number.MAX_SAFE_INTEGER : cutoff);
|
|
5874
|
+
this.store.expireEvents(all ? Number.MAX_SAFE_INTEGER : cutoff);
|
|
5875
|
+
if (all) this.store.purge();
|
|
4833
5876
|
for (const s of this.store.sessions())
|
|
4834
|
-
if (all || s.presence
|
|
5877
|
+
if (all || !s.squad_id && s.presence !== "online" && s.last_seen_at < cutoff && !this.store.commands(s.sid).length) {
|
|
4835
5878
|
if (s.squad_id && s.role === "commander") {
|
|
4836
5879
|
const q = this.store.squad(s.squad_id);
|
|
4837
5880
|
q.commander_sid = null;
|
|
@@ -4852,7 +5895,7 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4852
5895
|
this.store.deleteSession(s.sid);
|
|
4853
5896
|
}
|
|
4854
5897
|
for (const q of this.store.squads())
|
|
4855
|
-
if (all || q.status
|
|
5898
|
+
if (all || q.status === "dissolved" && q.updated_at < cutoff && !this.members(q.id).length && !this.store.commands().some((m) => m.squad_id === q.id))
|
|
4856
5899
|
this.store.deleteSquad(q.id);
|
|
4857
5900
|
});
|
|
4858
5901
|
if (all)
|
|
@@ -4891,24 +5934,73 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4891
5934
|
identity: "provisional"
|
|
4892
5935
|
})),
|
|
4893
5936
|
squads: this.store.squads().length,
|
|
4894
|
-
connections: [...this.contexts].filter((c) => c.kind === "mcp").length
|
|
5937
|
+
connections: [...this.contexts].filter((c) => c.kind === "mcp").length,
|
|
5938
|
+
clients: [...this.contexts].map((c) => ({
|
|
5939
|
+
sid: c.sid,
|
|
5940
|
+
kind: c.kind,
|
|
5941
|
+
version: c.version,
|
|
5942
|
+
client: c.client,
|
|
5943
|
+
observing: c.tail ? { for: c.tail.for, squad: c.tail.squad } : void 0
|
|
5944
|
+
})),
|
|
5945
|
+
listeners: this.store.standbys().map((s) => this.standbyView(s.sid))
|
|
4895
5946
|
};
|
|
4896
5947
|
if (method === "admin.housekeep" || method === "admin.purge")
|
|
4897
5948
|
return this.housekeep(method === "admin.purge" && params.all === true);
|
|
4898
|
-
if (method === "admin.peek")
|
|
5949
|
+
if (method === "admin.peek")
|
|
5950
|
+
return { messages: this.inbox(this.store.session(params.sid) || fail("NOT_JOINED")) };
|
|
4899
5951
|
if (method === "admin.read") {
|
|
4900
5952
|
const s = this.store.session(params.sid) || fail("NOT_JOINED");
|
|
4901
5953
|
return this.atomic(
|
|
4902
5954
|
() => this.readNow({ ...ctx, sid: s.sid }, parse("read", params.options || {}))
|
|
4903
5955
|
);
|
|
4904
5956
|
}
|
|
4905
|
-
if (method === "admin.tail") {
|
|
4906
|
-
|
|
4907
|
-
|
|
5957
|
+
if (method === "admin.tail" || method === "admin.events") {
|
|
5958
|
+
const after = params.after === "now" || params.after === void 0 && method === "admin.tail" ? this.store.eventCursor() : params.after === void 0 ? Math.max(0, this.store.eventCursor() - 20) : params.after;
|
|
5959
|
+
if (!Number.isSafeInteger(after) || after < 0)
|
|
5960
|
+
fail("INVALID_ARGUMENT", "after must be a nonnegative event_seq");
|
|
5961
|
+
const high = this.store.eventCursor();
|
|
5962
|
+
if (after > high)
|
|
5963
|
+
fail(
|
|
5964
|
+
"CURSOR_AHEAD",
|
|
5965
|
+
"Cursor is ahead of this database; verify CMDR_HOME or restart from --after 0"
|
|
5966
|
+
);
|
|
5967
|
+
const recipient = params.for ? this.store.session(params.for) : void 0;
|
|
5968
|
+
const candidates = this.store.eventPage(after, {
|
|
5969
|
+
squad: params.squad,
|
|
5970
|
+
to: params.for,
|
|
5971
|
+
roleInbox: recipient?.role === "commander" ? `squad:${recipient.squad_id}` : void 0
|
|
5972
|
+
});
|
|
5973
|
+
const events = bounded(
|
|
5974
|
+
params.full ? candidates : candidates.map((e) => ({
|
|
5975
|
+
...e,
|
|
5976
|
+
message: e.message ? {
|
|
5977
|
+
...e.message,
|
|
5978
|
+
body: e.message.body.slice(0, 160),
|
|
5979
|
+
data: null
|
|
5980
|
+
} : void 0
|
|
5981
|
+
})),
|
|
5982
|
+
100
|
|
5983
|
+
);
|
|
5984
|
+
const next = events.length < candidates.length ? events[events.length - 1].event_seq : high;
|
|
5985
|
+
if (method === "admin.tail")
|
|
5986
|
+
ctx.tail = {
|
|
5987
|
+
squad: params.squad,
|
|
5988
|
+
for: params.for,
|
|
5989
|
+
full: params.full,
|
|
5990
|
+
actionable: params.actionable,
|
|
5991
|
+
after: high
|
|
5992
|
+
};
|
|
5993
|
+
return {
|
|
5994
|
+
events: params.actionable ? events.filter((e) => this.isWakeEvent(e, params.for)) : events,
|
|
5995
|
+
next,
|
|
5996
|
+
high,
|
|
5997
|
+
gap: after < this.store.eventFloor(),
|
|
5998
|
+
retained_after: this.store.eventFloor()
|
|
5999
|
+
};
|
|
4908
6000
|
}
|
|
4909
6001
|
if (method === "admin.recent")
|
|
4910
6002
|
return {
|
|
4911
|
-
messages: this.store.messages().filter((m) => !params.squad || m.squad_id === params.squad).slice(-
|
|
6003
|
+
messages: this.store.messages().filter((m) => !params.squad || m.squad_id === params.squad).slice(-20)
|
|
4912
6004
|
};
|
|
4913
6005
|
fail("INVALID_ARGUMENT", `Unknown method ${method}`);
|
|
4914
6006
|
}
|
|
@@ -4930,13 +6022,12 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4930
6022
|
if (method === "msg.history") p.history = true;
|
|
4931
6023
|
if (ctx.sid) {
|
|
4932
6024
|
const s = this.required(ctx);
|
|
4933
|
-
s.activity = "busy";
|
|
4934
6025
|
s.last_seen_at = Date.now();
|
|
4935
6026
|
this.store.saveSession(s);
|
|
4936
6027
|
}
|
|
4937
6028
|
if (tool === "read") {
|
|
4938
6029
|
this.required(ctx);
|
|
4939
|
-
if (!p.history && !p.since && !this.
|
|
6030
|
+
if (!p.history && !p.since && !p.recover && !p.id && !this.inbox(this.required(ctx)).length)
|
|
4940
6031
|
await this.wait(ctx, p.wait, void 0, signal);
|
|
4941
6032
|
if (signal?.aborted) fail("REQUEST_CANCELLED");
|
|
4942
6033
|
if (ctx.closed) fail("DAEMON_UNAVAILABLE");
|
|
@@ -4967,16 +6058,27 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
|
|
|
4967
6058
|
return this.reportOrAsk(ctx, p, false);
|
|
4968
6059
|
}
|
|
4969
6060
|
});
|
|
6061
|
+
if (tool === "join" && p.standby && this.configureStandby) {
|
|
6062
|
+
return this.envelope(ctx, {
|
|
6063
|
+
...result,
|
|
6064
|
+
standby: this.required(ctx).native_id ? (this.configureStandby(ctx.sid, p.standby), this.standbyView(ctx.sid)) : {
|
|
6065
|
+
wake_mode: "manual",
|
|
6066
|
+
health: "manual",
|
|
6067
|
+
can_auto_respond: false,
|
|
6068
|
+
reason: "A confirmed native session ID is required for managed standby; membership is retained."
|
|
6069
|
+
}
|
|
6070
|
+
});
|
|
6071
|
+
}
|
|
4970
6072
|
return this.envelope(ctx, result);
|
|
4971
6073
|
}
|
|
4972
6074
|
};
|
|
4973
6075
|
|
|
4974
6076
|
// src/daemon/store.ts
|
|
4975
|
-
import { DatabaseSync as
|
|
6077
|
+
import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
|
|
4976
6078
|
var Store = class {
|
|
4977
6079
|
db;
|
|
4978
6080
|
constructor(path) {
|
|
4979
|
-
this.db = new
|
|
6081
|
+
this.db = new DatabaseSync3(path);
|
|
4980
6082
|
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000;
|
|
4981
6083
|
CREATE TABLE IF NOT EXISTS sessions(sid TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
|
4982
6084
|
CREATE TABLE IF NOT EXISTS squads(id TEXT PRIMARY KEY, name_key TEXT, status TEXT NOT NULL, payload TEXT NOT NULL);
|
|
@@ -4986,7 +6088,58 @@ var Store = class {
|
|
|
4986
6088
|
CREATE TABLE IF NOT EXISTS messages(seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, to_sid TEXT NOT NULL, status TEXT NOT NULL, priority INTEGER NOT NULL, reply_to TEXT, created_at INTEGER NOT NULL, payload TEXT NOT NULL);
|
|
4987
6089
|
CREATE INDEX IF NOT EXISTS queue ON messages(to_sid,status,priority,seq);
|
|
4988
6090
|
CREATE INDEX IF NOT EXISTS reply ON messages(reply_to);
|
|
4989
|
-
CREATE INDEX IF NOT EXISTS created ON messages(created_at)
|
|
6091
|
+
CREATE INDEX IF NOT EXISTS created ON messages(created_at);
|
|
6092
|
+
CREATE TABLE IF NOT EXISTS events(event_seq INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, payload TEXT NOT NULL);
|
|
6093
|
+
CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY, value INTEGER NOT NULL);
|
|
6094
|
+
CREATE TABLE IF NOT EXISTS standby(sid TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
|
6095
|
+
CREATE TABLE IF NOT EXISTS revoked(sid TEXT PRIMARY KEY, replacement TEXT NOT NULL);`);
|
|
6096
|
+
this.migrateWork();
|
|
6097
|
+
}
|
|
6098
|
+
migrateWork() {
|
|
6099
|
+
const legacy = this.unpack(
|
|
6100
|
+
this.db.prepare(
|
|
6101
|
+
"SELECT payload FROM messages WHERE json_extract(payload,'$.type')='command' AND json_extract(payload,'$.work') IS NULL"
|
|
6102
|
+
).all()
|
|
6103
|
+
);
|
|
6104
|
+
if (!legacy.length) return;
|
|
6105
|
+
this.transaction(() => {
|
|
6106
|
+
for (const m of legacy) {
|
|
6107
|
+
m.work = {
|
|
6108
|
+
state: m.status === "queued" ? "queued" : "read",
|
|
6109
|
+
updated_at: m.delivered_at || m.created_at
|
|
6110
|
+
};
|
|
6111
|
+
const reports = this.unpack(
|
|
6112
|
+
this.db.prepare("SELECT payload FROM messages WHERE reply_to=? ORDER BY seq").all(m.id)
|
|
6113
|
+
);
|
|
6114
|
+
for (const report of reports) {
|
|
6115
|
+
if (report.type !== "report" || report.from_sid !== m.to_sid || report.squad_id !== m.squad_id || terminalWork(m))
|
|
6116
|
+
continue;
|
|
6117
|
+
const state = {
|
|
6118
|
+
working: "accepted",
|
|
6119
|
+
blocked: "accepted",
|
|
6120
|
+
done: "completed",
|
|
6121
|
+
failed: "failed",
|
|
6122
|
+
cancelled: "cancelled"
|
|
6123
|
+
}[String(report.data?.status)];
|
|
6124
|
+
if (!state) continue;
|
|
6125
|
+
m.work.state = state;
|
|
6126
|
+
m.work.updated_at = report.created_at;
|
|
6127
|
+
if (state === "accepted") m.work.accepted_at ||= report.created_at;
|
|
6128
|
+
m.status = "delivered";
|
|
6129
|
+
m.delivered_at ||= report.created_at;
|
|
6130
|
+
}
|
|
6131
|
+
this.saveMessage(m);
|
|
6132
|
+
this.appendEvent({
|
|
6133
|
+
at: Date.now(),
|
|
6134
|
+
kind: "work.migrated",
|
|
6135
|
+
channel: m.squad_id,
|
|
6136
|
+
message_id: m.id,
|
|
6137
|
+
to_sid: m.to_sid,
|
|
6138
|
+
from_sid: m.from_sid,
|
|
6139
|
+
message: m
|
|
6140
|
+
});
|
|
6141
|
+
}
|
|
6142
|
+
});
|
|
4990
6143
|
}
|
|
4991
6144
|
transaction(fn) {
|
|
4992
6145
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -5072,7 +6225,89 @@ var Store = class {
|
|
|
5072
6225
|
this.db.prepare("DELETE FROM messages WHERE id=?").run(id);
|
|
5073
6226
|
}
|
|
5074
6227
|
expireMessages(before) {
|
|
5075
|
-
|
|
6228
|
+
const active = this.commands();
|
|
6229
|
+
const unfinished = new Set(active.map((m) => m.id));
|
|
6230
|
+
const dependencies = new Set(active.map((m) => m.blocked_by));
|
|
6231
|
+
for (const m of this.messages())
|
|
6232
|
+
if (m.created_at < before && !dependencies.has(m.id) && !(m.type === "cancel" && m.reply_to && unfinished.has(m.reply_to)) && (m.type !== "command" || terminalWork(m)))
|
|
6233
|
+
this.deleteMessage(m.id);
|
|
6234
|
+
}
|
|
6235
|
+
commands(sid) {
|
|
6236
|
+
return this.messages().filter(
|
|
6237
|
+
(m) => m.type === "command" && (!sid || m.to_sid === sid) && !terminalWork(m)
|
|
6238
|
+
);
|
|
6239
|
+
}
|
|
6240
|
+
appendEvent(event) {
|
|
6241
|
+
const r = this.db.prepare("INSERT INTO events(at,payload) VALUES (?,?)").run(event.at, JSON.stringify(event));
|
|
6242
|
+
const value = { ...event, event_seq: Number(r.lastInsertRowid) };
|
|
6243
|
+
this.db.prepare("UPDATE events SET payload=? WHERE event_seq=?").run(JSON.stringify(value), value.event_seq);
|
|
6244
|
+
return value;
|
|
6245
|
+
}
|
|
6246
|
+
eventCursor() {
|
|
6247
|
+
return Number(
|
|
6248
|
+
this.db.prepare("SELECT seq FROM sqlite_sequence WHERE name='events'").get()?.seq || 0
|
|
6249
|
+
);
|
|
6250
|
+
}
|
|
6251
|
+
events(after = 0) {
|
|
6252
|
+
return this.unpack(
|
|
6253
|
+
this.db.prepare("SELECT payload FROM events WHERE event_seq>? ORDER BY event_seq").all(after)
|
|
6254
|
+
);
|
|
6255
|
+
}
|
|
6256
|
+
eventPage(after, filter) {
|
|
6257
|
+
return this.unpack(
|
|
6258
|
+
this.db.prepare(
|
|
6259
|
+
`SELECT payload FROM events WHERE event_seq>?
|
|
6260
|
+
AND (? IS NULL OR json_extract(payload,'$.channel')=?)
|
|
6261
|
+
AND (? IS NULL OR json_extract(payload,'$.to_sid')=? OR json_extract(payload,'$.to_sid')=?)
|
|
6262
|
+
ORDER BY event_seq LIMIT 101`
|
|
6263
|
+
).all(
|
|
6264
|
+
after,
|
|
6265
|
+
filter.squad || null,
|
|
6266
|
+
filter.squad || null,
|
|
6267
|
+
filter.to || null,
|
|
6268
|
+
filter.to || null,
|
|
6269
|
+
filter.roleInbox || null
|
|
6270
|
+
)
|
|
6271
|
+
);
|
|
6272
|
+
}
|
|
6273
|
+
eventFloor() {
|
|
6274
|
+
return Number(
|
|
6275
|
+
this.db.prepare("SELECT value FROM metadata WHERE key='event_floor'").get()?.value || 0
|
|
6276
|
+
);
|
|
6277
|
+
}
|
|
6278
|
+
expireEvents(before) {
|
|
6279
|
+
const last = Number(
|
|
6280
|
+
this.db.prepare("SELECT MAX(event_seq) AS seq FROM events WHERE at<?").get(before)?.seq || 0
|
|
6281
|
+
);
|
|
6282
|
+
if (last) {
|
|
6283
|
+
this.db.prepare(
|
|
6284
|
+
"INSERT INTO metadata VALUES ('event_floor',?) ON CONFLICT(key) DO UPDATE SET value=MAX(value,excluded.value)"
|
|
6285
|
+
).run(last);
|
|
6286
|
+
this.db.prepare("DELETE FROM events WHERE event_seq<=?").run(last);
|
|
6287
|
+
}
|
|
6288
|
+
}
|
|
6289
|
+
standbys() {
|
|
6290
|
+
return this.unpack(this.db.prepare("SELECT payload FROM standby").all());
|
|
6291
|
+
}
|
|
6292
|
+
standby(sid) {
|
|
6293
|
+
return this.standbys().find((s) => s.sid === sid);
|
|
6294
|
+
}
|
|
6295
|
+
saveStandby(s) {
|
|
6296
|
+
this.db.prepare(
|
|
6297
|
+
"INSERT INTO standby VALUES (?,?) ON CONFLICT(sid) DO UPDATE SET payload=excluded.payload"
|
|
6298
|
+
).run(s.sid, JSON.stringify(s));
|
|
6299
|
+
}
|
|
6300
|
+
deleteStandby(sid) {
|
|
6301
|
+
this.db.prepare("DELETE FROM standby WHERE sid=?").run(sid);
|
|
6302
|
+
}
|
|
6303
|
+
revoke(sid, replacement) {
|
|
6304
|
+
this.db.prepare("INSERT OR REPLACE INTO revoked VALUES (?,?)").run(sid, replacement);
|
|
6305
|
+
}
|
|
6306
|
+
revoked(sid) {
|
|
6307
|
+
return !!this.db.prepare("SELECT sid FROM revoked WHERE sid=?").get(sid)?.sid;
|
|
6308
|
+
}
|
|
6309
|
+
purge() {
|
|
6310
|
+
this.db.exec("DELETE FROM messages; DELETE FROM standby; DELETE FROM revoked;");
|
|
5076
6311
|
}
|
|
5077
6312
|
close() {
|
|
5078
6313
|
this.db.close();
|
|
@@ -5080,7 +6315,7 @@ var Store = class {
|
|
|
5080
6315
|
};
|
|
5081
6316
|
|
|
5082
6317
|
// src/daemon/lock.ts
|
|
5083
|
-
import { closeSync, openSync, readFileSync as readFileSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
6318
|
+
import { closeSync, openSync, readFileSync as readFileSync2, rmSync as rmSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
5084
6319
|
function alive(pid) {
|
|
5085
6320
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
5086
6321
|
try {
|
|
@@ -5107,7 +6342,7 @@ function acquireLock(path) {
|
|
|
5107
6342
|
if (alive(pid)) return false;
|
|
5108
6343
|
if (!pid) {
|
|
5109
6344
|
try {
|
|
5110
|
-
if (Date.now() -
|
|
6345
|
+
if (Date.now() - statSync2(path).mtimeMs < 1e4) return false;
|
|
5111
6346
|
} catch {
|
|
5112
6347
|
continue;
|
|
5113
6348
|
}
|
|
@@ -5125,11 +6360,11 @@ function releaseLock(path) {
|
|
|
5125
6360
|
}
|
|
5126
6361
|
|
|
5127
6362
|
// src/daemon/logger.ts
|
|
5128
|
-
import { appendFileSync, existsSync, renameSync, rmSync as rmSync3, statSync as
|
|
6363
|
+
import { appendFileSync, existsSync, renameSync, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
|
|
5129
6364
|
function logger(path) {
|
|
5130
6365
|
return (message) => {
|
|
5131
6366
|
try {
|
|
5132
|
-
if (existsSync(path) &&
|
|
6367
|
+
if (existsSync(path) && statSync3(path).size > 5 * 1024 * 1024) {
|
|
5133
6368
|
rmSync3(`${path}.5`, { force: true });
|
|
5134
6369
|
for (let i = 4; i >= 1; i--)
|
|
5135
6370
|
if (existsSync(`${path}.${i}`)) renameSync(`${path}.${i}`, `${path}.${i + 1}`);
|
|
@@ -5143,33 +6378,33 @@ function logger(path) {
|
|
|
5143
6378
|
}
|
|
5144
6379
|
|
|
5145
6380
|
// src/shared/paths.ts
|
|
5146
|
-
import { homedir as
|
|
5147
|
-
import { join as
|
|
6381
|
+
import { homedir as homedir3, tmpdir } from "node:os";
|
|
6382
|
+
import { join as join4, resolve } from "node:path";
|
|
5148
6383
|
import { mkdirSync, chmodSync } from "node:fs";
|
|
5149
|
-
import { createHash } from "node:crypto";
|
|
5150
|
-
function paths(home = process.env.CMDR_HOME ||
|
|
6384
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
6385
|
+
function paths(home = process.env.CMDR_HOME || join4(homedir3(), ".cmdr")) {
|
|
5151
6386
|
home = resolve(home);
|
|
5152
|
-
let socket =
|
|
6387
|
+
let socket = join4(home, "cmdr.sock");
|
|
5153
6388
|
if (Buffer.byteLength(socket) > 100)
|
|
5154
|
-
socket =
|
|
6389
|
+
socket = join4(
|
|
5155
6390
|
tmpdir(),
|
|
5156
|
-
`cmdr-${
|
|
6391
|
+
`cmdr-${createHash2("sha256").update(`${process.getuid?.()}:${home}`).digest("hex").slice(0, 20)}.sock`
|
|
5157
6392
|
);
|
|
5158
6393
|
return {
|
|
5159
6394
|
home,
|
|
5160
6395
|
socket,
|
|
5161
|
-
db:
|
|
5162
|
-
lock:
|
|
5163
|
-
spawn:
|
|
5164
|
-
info:
|
|
5165
|
-
flags:
|
|
5166
|
-
log:
|
|
5167
|
-
config:
|
|
5168
|
-
flag: (sid) =>
|
|
6396
|
+
db: join4(home, "cmdr.db"),
|
|
6397
|
+
lock: join4(home, "daemon.lock"),
|
|
6398
|
+
spawn: join4(home, "spawn.lock"),
|
|
6399
|
+
info: join4(home, "daemon.json"),
|
|
6400
|
+
flags: join4(home, "flags"),
|
|
6401
|
+
log: join4(home, "logs/daemon.log"),
|
|
6402
|
+
config: join4(home, "config.json"),
|
|
6403
|
+
flag: (sid) => join4(home, "flags", safeSid(sid))
|
|
5169
6404
|
};
|
|
5170
6405
|
}
|
|
5171
6406
|
function prepare(p) {
|
|
5172
|
-
for (const dir of [p.home, p.flags,
|
|
6407
|
+
for (const dir of [p.home, p.flags, join4(p.home, "logs")]) {
|
|
5173
6408
|
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
5174
6409
|
chmodSync(dir, 448);
|
|
5175
6410
|
}
|
|
@@ -5331,8 +6566,16 @@ var Rpc = class extends EventEmitter {
|
|
|
5331
6566
|
};
|
|
5332
6567
|
|
|
5333
6568
|
// src/shared/version.ts
|
|
5334
|
-
var
|
|
6569
|
+
var MIN_CLIENT_VERSION = "0.2.0";
|
|
6570
|
+
var VERSION = true ? "0.3.0" : MIN_CLIENT_VERSION;
|
|
5335
6571
|
var PROTOCOL = 1;
|
|
6572
|
+
function newer(a, b) {
|
|
6573
|
+
const x = a.split(".").map(Number), y = b.split(".").map(Number);
|
|
6574
|
+
for (let i = 0; i < 3; i++) {
|
|
6575
|
+
if (x[i] !== y[i]) return x[i] > y[i];
|
|
6576
|
+
}
|
|
6577
|
+
return false;
|
|
6578
|
+
}
|
|
5336
6579
|
|
|
5337
6580
|
// src/daemon/server.ts
|
|
5338
6581
|
async function startDaemon(home) {
|
|
@@ -5341,6 +6584,15 @@ async function startDaemon(home) {
|
|
|
5341
6584
|
prepare(p);
|
|
5342
6585
|
if (!acquireLock(p.lock)) return null;
|
|
5343
6586
|
const log = logger(p.log), store = new Store(p.db), core = new Core(store, p, config(p.config));
|
|
6587
|
+
const standby = new StandbyManager(core);
|
|
6588
|
+
core.configureStandby = (sid, mode) => standby.configure({
|
|
6589
|
+
sid,
|
|
6590
|
+
action: "start",
|
|
6591
|
+
...mode === "manual" ? { adapter: "manual" } : {}
|
|
6592
|
+
});
|
|
6593
|
+
const wakeTimer = setInterval(() => {
|
|
6594
|
+
void standby.tick().catch((e) => log(`standby failed: ${String(e)}`));
|
|
6595
|
+
}, 2e3);
|
|
5344
6596
|
const peers = /* @__PURE__ */ new Set();
|
|
5345
6597
|
let idleSince = Date.now(), stopping = false;
|
|
5346
6598
|
rmSync4(p.socket, { force: true });
|
|
@@ -5351,6 +6603,7 @@ async function startDaemon(home) {
|
|
|
5351
6603
|
const ctx = { notify: (m, p2) => rpc.notify(m, p2) };
|
|
5352
6604
|
core.connect(ctx);
|
|
5353
6605
|
let greeted = false;
|
|
6606
|
+
let watcher;
|
|
5354
6607
|
rpc.handler = async (method, params, signal) => {
|
|
5355
6608
|
if (!greeted && method !== "hello")
|
|
5356
6609
|
fail(
|
|
@@ -5363,10 +6616,39 @@ async function startDaemon(home) {
|
|
|
5363
6616
|
"PROTOCOL_MISMATCH",
|
|
5364
6617
|
`Protocol mismatch: client ${String(params.protocol).slice(0, 20)}, daemon ${PROTOCOL} (${VERSION}). Update/reinstall the plugin cache, restart the daemon with the matching cmdr installation, then restart the host session.`
|
|
5365
6618
|
);
|
|
6619
|
+
const clientVersion = typeof params.version === "string" ? params.version.match(/^(\d+\.\d+\.\d+)(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/)?.[1] : void 0;
|
|
6620
|
+
if (!clientVersion || newer(MIN_CLIENT_VERSION, clientVersion))
|
|
6621
|
+
fail(
|
|
6622
|
+
"PROTOCOL_MISMATCH",
|
|
6623
|
+
`Daemon ${VERSION} requires client ${MIN_CLIENT_VERSION} or newer for compatible tool semantics. Update/reinstall the plugin cache and restart the host MCP connection.`
|
|
6624
|
+
);
|
|
5366
6625
|
greeted = true;
|
|
6626
|
+
ctx.version = String(params.version || "unknown").slice(0, 80);
|
|
6627
|
+
ctx.client = String(params.client || "unknown").slice(0, 80);
|
|
5367
6628
|
return { version: VERSION, protocol: PROTOCOL };
|
|
5368
6629
|
}
|
|
6630
|
+
if (method === "admin.watch") {
|
|
6631
|
+
if (ctx.kind !== "cli") fail("ROLE_NOT_ALLOWED");
|
|
6632
|
+
if (!["attach", "pulse"].includes(params.action) || typeof params.token !== "string" || !params.token.length || params.token.length > 100)
|
|
6633
|
+
fail("INVALID_ARGUMENT");
|
|
6634
|
+
if (watcher && (watcher.sid !== params.sid || watcher.token !== params.token))
|
|
6635
|
+
fail("WATCHER_ACTIVE");
|
|
6636
|
+
const result2 = standby.watch(params.sid, params.token, params.action);
|
|
6637
|
+
watcher = { sid: params.sid, token: params.token };
|
|
6638
|
+
return result2;
|
|
6639
|
+
}
|
|
6640
|
+
if (method === "admin.standby") {
|
|
6641
|
+
if (ctx.kind !== "cli") fail("ROLE_NOT_ALLOWED");
|
|
6642
|
+
const result2 = standby.configure(params);
|
|
6643
|
+
void standby.tick();
|
|
6644
|
+
return result2;
|
|
6645
|
+
}
|
|
5369
6646
|
if (method === "admin.shutdown") {
|
|
6647
|
+
if (params.reason === "upgrade")
|
|
6648
|
+
fail(
|
|
6649
|
+
"UPGRADE_REQUIRES_RESTART",
|
|
6650
|
+
"Automatic replacement is disabled. Run cmdr daemon restart from the new installation after preflight."
|
|
6651
|
+
);
|
|
5370
6652
|
if (ctx.kind !== "cli" && params.reason !== "upgrade") fail("ROLE_NOT_ALLOWED");
|
|
5371
6653
|
log(
|
|
5372
6654
|
`shutdown requested: ${String(params.reason || "operator").replace(/[^a-z_-]/gi, "").slice(0, 40)}; from=${VERSION}; to=${String(params.version || "unknown").replace(/[^a-z0-9.:-]/gi, "").slice(0, 80)}`
|
|
@@ -5381,6 +6663,12 @@ async function startDaemon(home) {
|
|
|
5381
6663
|
};
|
|
5382
6664
|
rpc.on("close", () => {
|
|
5383
6665
|
peers.delete(rpc);
|
|
6666
|
+
if (!stopping && watcher) {
|
|
6667
|
+
try {
|
|
6668
|
+
standby.watch(watcher.sid, watcher.token, "detach");
|
|
6669
|
+
} catch {
|
|
6670
|
+
}
|
|
6671
|
+
}
|
|
5384
6672
|
if (!stopping) core.disconnect(ctx);
|
|
5385
6673
|
if (!peers.size) idleSince = Date.now();
|
|
5386
6674
|
});
|
|
@@ -5390,10 +6678,13 @@ async function startDaemon(home) {
|
|
|
5390
6678
|
if (stopping) return;
|
|
5391
6679
|
stopping = true;
|
|
5392
6680
|
clearInterval(timer);
|
|
5393
|
-
|
|
6681
|
+
clearInterval(wakeTimer);
|
|
6682
|
+
standby.close();
|
|
6683
|
+
const closed = new Promise((resolve2) => server.close(() => resolve2()));
|
|
5394
6684
|
for (const ctx of [...core.contexts]) core.disconnect(ctx);
|
|
5395
6685
|
core.close();
|
|
5396
6686
|
for (const peer of peers) peer.close();
|
|
6687
|
+
await closed;
|
|
5397
6688
|
store.close();
|
|
5398
6689
|
rmSync4(p.socket, { force: true });
|
|
5399
6690
|
rmSync4(p.info, { force: true });
|
|
@@ -5425,9 +6716,9 @@ async function startDaemon(home) {
|
|
|
5425
6716
|
() => {
|
|
5426
6717
|
try {
|
|
5427
6718
|
core.housekeep();
|
|
5428
|
-
if (existsSync2(p.spawn) && Date.now() -
|
|
6719
|
+
if (existsSync2(p.spawn) && Date.now() - statSync4(p.spawn).mtimeMs > 1e4)
|
|
5429
6720
|
rmSync4(p.spawn, { recursive: true, force: true });
|
|
5430
|
-
if (!peers.size && Date.now() - idleSince >= core.config.idleExitMinutes * 6e4)
|
|
6721
|
+
if (!peers.size && !standby.active && Date.now() - idleSince >= core.config.idleExitMinutes * 6e4)
|
|
5431
6722
|
void stop();
|
|
5432
6723
|
} catch (e) {
|
|
5433
6724
|
log(`housekeeping failed: ${String(e)}`);
|
|
@@ -5445,8 +6736,40 @@ async function startDaemon(home) {
|
|
|
5445
6736
|
}
|
|
5446
6737
|
}
|
|
5447
6738
|
|
|
6739
|
+
// src/daemon/preflight.ts
|
|
6740
|
+
import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
|
|
6741
|
+
import { existsSync as existsSync3, mkdtempSync, rmSync as rmSync5 } from "node:fs";
|
|
6742
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
6743
|
+
import { join as join5 } from "node:path";
|
|
6744
|
+
async function preflight(home) {
|
|
6745
|
+
const dir = mkdtempSync(join5(tmpdir2(), "cmdr-preflight-"));
|
|
6746
|
+
try {
|
|
6747
|
+
const source = paths(home).db, target = join5(dir, "state.db");
|
|
6748
|
+
if (existsSync3(source)) {
|
|
6749
|
+
const db = new DatabaseSync4(source, { readOnly: true });
|
|
6750
|
+
try {
|
|
6751
|
+
db.prepare("VACUUM INTO ?").run(target);
|
|
6752
|
+
} finally {
|
|
6753
|
+
db.close();
|
|
6754
|
+
}
|
|
6755
|
+
}
|
|
6756
|
+
const store = new Store(target);
|
|
6757
|
+
try {
|
|
6758
|
+
store.sessions();
|
|
6759
|
+
store.squads();
|
|
6760
|
+
store.messages();
|
|
6761
|
+
store.standbys();
|
|
6762
|
+
} finally {
|
|
6763
|
+
store.close();
|
|
6764
|
+
}
|
|
6765
|
+
} finally {
|
|
6766
|
+
rmSync5(dir, { recursive: true, force: true });
|
|
6767
|
+
}
|
|
6768
|
+
}
|
|
6769
|
+
|
|
5448
6770
|
// src/daemon/main.ts
|
|
5449
|
-
|
|
6771
|
+
var operation = process.argv.includes("--preflight") ? preflight() : startDaemon();
|
|
6772
|
+
operation.catch((e) => {
|
|
5450
6773
|
process.stderr.write(`cmdr daemon: ${e.message}
|
|
5451
6774
|
`);
|
|
5452
6775
|
process.exitCode = 1;
|