cmdr-mcp 0.1.1 → 0.2.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.
@@ -6,11 +6,419 @@ var __export = (target, all) => {
6
6
  __defProp(target, name, { get: all[name], enumerable: true });
7
7
  };
8
8
 
9
+ // src/daemon/standby.ts
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import { isAbsolute } from "node:path";
12
+
13
+ // src/daemon/adapters/codex.ts
14
+ import { spawn } from "node:child_process";
15
+ import { createInterface } from "node:readline";
16
+ var CodexAdapter = class {
17
+ constructor(options) {
18
+ this.options = options;
19
+ }
20
+ child;
21
+ ready;
22
+ next = 1;
23
+ pending = /* @__PURE__ */ new Map();
24
+ connect() {
25
+ if (this.ready) return this.ready;
26
+ const child = spawn(
27
+ this.options.executable || "codex",
28
+ ["app-server", "proxy", ...this.options.socket ? ["--sock", this.options.socket] : []],
29
+ { stdio: "pipe" }
30
+ );
31
+ this.child = child;
32
+ const lines = createInterface({ input: child.stdout });
33
+ let stderr = "";
34
+ child.stderr.on("data", (chunk) => {
35
+ stderr = (stderr + String(chunk)).slice(-1e3);
36
+ });
37
+ const failed = (error) => {
38
+ if (this.child !== child) return;
39
+ this.child = void 0;
40
+ this.ready = void 0;
41
+ for (const p of this.pending.values()) {
42
+ clearTimeout(p.timer);
43
+ p.reject(
44
+ new Error(
45
+ stderr.trim() || error?.message || "Codex proxy disconnected; wake outcome may be uncertain"
46
+ )
47
+ );
48
+ }
49
+ this.pending.clear();
50
+ lines.close();
51
+ child.kill();
52
+ };
53
+ child.on("error", failed);
54
+ child.on("exit", () => failed());
55
+ child.stdin.on("error", failed);
56
+ lines.on("line", (line) => {
57
+ if (Buffer.byteLength(line) > 4 * 1024 * 1024) {
58
+ this.close();
59
+ return;
60
+ }
61
+ try {
62
+ const m = JSON.parse(line), p = this.pending.get(m.id);
63
+ if (!p) return;
64
+ this.pending.delete(m.id);
65
+ clearTimeout(p.timer);
66
+ if (m.error)
67
+ p.reject(new Error(`Codex ${m.error.code}: ${String(m.error.message).slice(0, 300)}`));
68
+ else p.resolve(m.result);
69
+ } catch {
70
+ this.close();
71
+ }
72
+ });
73
+ this.ready = this.request("initialize", {
74
+ clientInfo: { name: "cmdr", version: "1" },
75
+ capabilities: { experimentalApi: true }
76
+ }).then(() => {
77
+ child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
78
+ }).catch((error) => {
79
+ this.close();
80
+ throw error;
81
+ });
82
+ return this.ready;
83
+ }
84
+ request(method, params) {
85
+ return new Promise((resolve2, reject) => {
86
+ const id = this.next++;
87
+ const timer = setTimeout(() => {
88
+ this.pending.delete(id);
89
+ reject(new Error(`Codex ${method} timed out; reconcile before retrying`));
90
+ this.close();
91
+ }, 1e4);
92
+ this.pending.set(id, { resolve: resolve2, reject, timer });
93
+ this.child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
94
+ });
95
+ }
96
+ async call(method, params) {
97
+ await this.connect();
98
+ return this.request(method, params);
99
+ }
100
+ async state(native) {
101
+ let result = await this.call("thread/read", { threadId: native, includeTurns: false });
102
+ if (result.thread?.status?.type === "notLoaded") {
103
+ result = await this.call("thread/resume", { threadId: native, excludeTurns: true });
104
+ }
105
+ await this.call("thread/queue/list", { threadId: native, limit: 1 });
106
+ const type = result.thread?.status?.type;
107
+ return type === "idle" ? "idle" : type === "active" ? "busy" : "unknown";
108
+ }
109
+ async lookup(native, request) {
110
+ let cursor = null;
111
+ do {
112
+ const page = await this.call("thread/queue/list", { threadId: native, cursor, limit: 100 });
113
+ const match = page.data?.find((q) => q.clientUserMessageId === request.id);
114
+ if (match) return { found: true, submission: String(match.id) };
115
+ cursor = page.nextCursor;
116
+ } while (cursor);
117
+ do {
118
+ const page = await this.call("thread/turns/list", {
119
+ threadId: native,
120
+ cursor,
121
+ limit: 50,
122
+ itemsView: "full"
123
+ });
124
+ if (page.data?.some(
125
+ (t) => t.items?.some((i) => i.type === "userMessage" && i.clientId === request.id)
126
+ ))
127
+ return { found: true };
128
+ const oldest = page.data?.at(-1)?.startedAt;
129
+ if (oldest && oldest * 1e3 < request.created_at - 6e4) break;
130
+ cursor = page.nextCursor;
131
+ } while (cursor);
132
+ return { found: false };
133
+ }
134
+ async enqueue(native, request) {
135
+ const text2 = `[cmdr wake ${request.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.`;
136
+ const result = await this.call("thread/queue/add", {
137
+ threadId: native,
138
+ clientUserMessageId: request.id,
139
+ input: [{ type: "text", text: text2, text_elements: [] }]
140
+ });
141
+ if (!result.queuedSubmission?.id)
142
+ throw new Error("Codex queue response did not confirm acceptance");
143
+ return String(result.queuedSubmission.id);
144
+ }
145
+ async start(native, submission) {
146
+ if (await this.state(native) !== "idle") return;
147
+ await this.call("thread/queue/start", { threadId: native, queuedSubmissionId: submission });
148
+ }
149
+ close() {
150
+ const child = this.child;
151
+ this.child = void 0;
152
+ this.ready = void 0;
153
+ for (const p of this.pending.values()) {
154
+ clearTimeout(p.timer);
155
+ p.reject(new Error("Codex adapter closed"));
156
+ }
157
+ this.pending.clear();
158
+ child?.kill();
159
+ }
160
+ };
161
+
162
+ // src/shared/protocol.ts
163
+ var LIMITS = {
164
+ maxWaitSec: 300,
165
+ maxBody: 32768,
166
+ maxData: 65536,
167
+ maxFrame: 2 * 1024 * 1024
168
+ };
169
+ var CmdrError = class extends Error {
170
+ constructor(code, message = code) {
171
+ super(message);
172
+ this.code = code;
173
+ }
174
+ };
175
+ function fail(code, message) {
176
+ throw new CmdrError(code, message);
177
+ }
178
+ var terminalWork = (m) => !!m?.work && ["completed", "failed", "cancelled"].includes(m.work.state);
179
+
180
+ // src/daemon/standby.ts
181
+ var StandbyManager = class {
182
+ constructor(core, factory = (s) => new CodexAdapter(s)) {
183
+ this.core = core;
184
+ this.factory = factory;
185
+ for (const listener of core.store.standbys())
186
+ if (listener.enabled && listener.wake_mode !== "manual") {
187
+ listener.health = "starting";
188
+ listener.host_state = "unknown";
189
+ core.store.saveStandby(listener);
190
+ }
191
+ }
192
+ adapters = /* @__PURE__ */ new Map();
193
+ running = false;
194
+ stopped = false;
195
+ get active() {
196
+ return this.core.store.standbys().some((s) => s.enabled && s.wake_mode !== "manual");
197
+ }
198
+ configure(p) {
199
+ const store = this.core.store, session = store.session(p.sid) || fail("NOT_JOINED");
200
+ if (!session.native_id || !session.squad_id)
201
+ fail("IDENTITY_REQUIRED", "Join with the real host session ID first");
202
+ let s = store.standby(session.sid);
203
+ if (p.action === "status")
204
+ return s || { sid: p.sid, wake_mode: "manual", health: "manual", enabled: false };
205
+ if (!["start", "stop", "resume"].includes(p.action)) fail("INVALID_ARGUMENT");
206
+ if (p.executable && (typeof p.executable !== "string" || !isAbsolute(p.executable)))
207
+ fail("INVALID_ARGUMENT", "executable must be an absolute path");
208
+ if (p.socket && (typeof p.socket !== "string" || !isAbsolute(p.socket)))
209
+ fail("INVALID_ARGUMENT", "socket must be an absolute path");
210
+ if (p.adapter && !["codex", "manual"].includes(p.adapter)) fail("INVALID_ARGUMENT");
211
+ if (p.resolve && !["retry", "accepted"].includes(p.resolve)) fail("INVALID_ARGUMENT");
212
+ if (!s)
213
+ s = {
214
+ sid: session.sid,
215
+ enabled: false,
216
+ wake_mode: "manual",
217
+ health: "manual",
218
+ host_state: "unknown",
219
+ checked_at: null
220
+ };
221
+ if (p.action === "stop") {
222
+ s.enabled = false;
223
+ s.health = "stopped";
224
+ } else {
225
+ if (p.adapter === "codex" && session.agent !== "codex")
226
+ fail("INVALID_ARGUMENT", "Codex adapter requires a Codex member");
227
+ s.wake_mode = p.adapter || (p.action === "start" ? session.agent === "codex" ? "codex" : "manual" : s.wake_mode);
228
+ s.enabled = true;
229
+ s.health = s.wake_mode === "manual" ? "manual" : "starting";
230
+ s.executable = p.executable || s.executable;
231
+ s.socket = p.socket || s.socket;
232
+ if (p.resolve === "retry") s.request = void 0;
233
+ if (p.resolve === "accepted" && s.request) s.request.state = "accepted";
234
+ if (s.request?.state === "failed") s.request = void 0;
235
+ }
236
+ s.generation = (s.generation || 0) + 1;
237
+ this.adapters.get(s.sid)?.close();
238
+ this.adapters.delete(s.sid);
239
+ this.save(
240
+ s,
241
+ "standby.changed",
242
+ p.resolve ? `operator resolved wake as ${p.resolve}` : p.action
243
+ );
244
+ return s;
245
+ }
246
+ save(s, kind, reason) {
247
+ if (this.stopped || !this.core.store.session(s.sid)) return;
248
+ const cursor = this.core.store.eventCursor();
249
+ this.core.store.transaction(() => {
250
+ this.core.store.saveStandby(s);
251
+ if (kind)
252
+ this.core.record(kind, this.core.store.session(s.sid).squad_id, {
253
+ to_sid: s.sid,
254
+ reason,
255
+ data: {
256
+ wake_id: s.request?.id,
257
+ message_ids: s.request?.message_ids,
258
+ state: s.request?.state,
259
+ health: s.health,
260
+ host_state: s.host_state
261
+ }
262
+ });
263
+ });
264
+ this.core.publishEvents(cursor);
265
+ }
266
+ actionable(sid) {
267
+ const session = this.core.store.session(sid);
268
+ return [
269
+ ...new Map(
270
+ [
271
+ ...this.core.inbox(session).filter((m) => m.attn),
272
+ ...this.core.store.commands(sid).filter(
273
+ (m) => !m.blocked_by || ["completed", "failed", "cancelled"].includes(
274
+ this.core.store.message(m.blocked_by)?.work?.state || ""
275
+ )
276
+ )
277
+ ].map((m) => [m.id, m])
278
+ ).values()
279
+ ];
280
+ }
281
+ async tick() {
282
+ if (this.running || this.stopped) return;
283
+ this.running = true;
284
+ try {
285
+ for (const [sid, adapter] of this.adapters) {
286
+ const current = this.core.store.standby(sid);
287
+ if (!current?.enabled || current.wake_mode === "manual") {
288
+ adapter.close();
289
+ this.adapters.delete(sid);
290
+ }
291
+ }
292
+ const records = this.core.store.standbys().filter((s) => s.enabled && s.wake_mode !== "manual");
293
+ let next = 0;
294
+ await Promise.all(
295
+ Array.from({ length: Math.min(4, records.length) }, async () => {
296
+ while (!this.stopped && next < records.length) await this.check(records[next++]);
297
+ })
298
+ );
299
+ } finally {
300
+ this.running = false;
301
+ }
302
+ }
303
+ async check(s) {
304
+ const store = this.core.store, session = store.session(s.sid);
305
+ if (!session?.native_id || !session.squad_id) return;
306
+ let adapter = this.adapters.get(s.sid);
307
+ if (!adapter) {
308
+ adapter = this.factory(s);
309
+ this.adapters.set(s.sid, adapter);
310
+ }
311
+ 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;
312
+ try {
313
+ const host = await adapter.state(session.native_id);
314
+ if (!valid()) return;
315
+ s.host_state = host;
316
+ s.checked_at = Date.now();
317
+ s.error = host === "unknown" ? "Host session is not loaded or its runtime state is unavailable; resume it in the host." : void 0;
318
+ const work = this.actionable(s.sid);
319
+ const fingerprint = createHash("sha256").update(
320
+ JSON.stringify(
321
+ work.map((m) => [m.id, m.work?.state, m.work?.updated_at, m.work?.cancel_requested_at])
322
+ )
323
+ ).digest("hex");
324
+ if (s.request && s.request.state !== "observed") {
325
+ if (s.request.state === "accepted" && host === "busy") {
326
+ s.health = "healthy";
327
+ this.save(s);
328
+ return;
329
+ }
330
+ const found = await adapter.lookup(session.native_id, s.request);
331
+ if (!valid()) return;
332
+ if (found.submission) {
333
+ s.request.state = "accepted";
334
+ s.request.submission_id = found.submission;
335
+ s.request.message_ids = [
336
+ .../* @__PURE__ */ new Set([...s.request.message_ids, ...work.map((m) => m.id)])
337
+ ];
338
+ s.health = host === "unknown" ? "error" : "healthy";
339
+ this.save(
340
+ s,
341
+ recordChanged(store.standby(s.sid), s) ? "wake.accepted" : void 0,
342
+ s.error
343
+ );
344
+ if (host === "idle") await adapter.start(session.native_id, found.submission);
345
+ return;
346
+ }
347
+ if (found.found || s.request.state === "accepted") {
348
+ const unresolved = s.request.message_ids.some((id) => work.some((m) => m.id === id));
349
+ s.request.state = "accepted";
350
+ s.request.submission_id = void 0;
351
+ if (!unresolved || found.found && host === "idle" && fingerprint !== s.request.fingerprint) {
352
+ s.request.state = "observed";
353
+ s.health = host === "unknown" ? "error" : "healthy";
354
+ this.save(s, "wake.observed");
355
+ } else {
356
+ s.health = host === "idle" && Date.now() - s.request.created_at > 6e4 ? "stalled" : host === "unknown" ? "error" : "healthy";
357
+ this.save(s, recordChanged(store.standby(s.sid), s) ? "standby.health" : void 0);
358
+ return;
359
+ }
360
+ } else {
361
+ s.request.state = "uncertain";
362
+ s.health = "uncertain";
363
+ s.error = "Wake not found in host queue/history. Inspect host and use standby resume --resolve retry|accepted; no automatic replay.";
364
+ this.save(
365
+ s,
366
+ recordChanged(store.standby(s.sid), s) ? "wake.uncertain" : void 0,
367
+ s.error
368
+ );
369
+ return;
370
+ }
371
+ }
372
+ s.health = host === "unknown" ? "error" : "healthy";
373
+ this.save(s, recordChanged(store.standby(s.sid), s) ? "standby.health" : void 0, s.error);
374
+ if (!work.length || host !== "idle") return;
375
+ s.request = {
376
+ id: randomUUID(),
377
+ fingerprint,
378
+ message_ids: work.map((m) => m.id),
379
+ created_at: Date.now(),
380
+ state: "requested"
381
+ };
382
+ this.save(s, "wake.requested");
383
+ const submission = await adapter.enqueue(session.native_id, s.request);
384
+ if (!valid()) return;
385
+ s.request.state = "accepted";
386
+ s.request.submission_id = submission;
387
+ this.save(s, "wake.accepted");
388
+ await adapter.start(session.native_id, submission);
389
+ } catch (e) {
390
+ if (!valid()) return;
391
+ s.checked_at = Date.now();
392
+ s.error = String(e).slice(0, 500);
393
+ if (s.request && s.request.state !== "observed") {
394
+ s.request.state = "uncertain";
395
+ s.health = "uncertain";
396
+ s.request.error = s.error;
397
+ } else s.health = "error";
398
+ const previous = store.standby(s.sid);
399
+ this.save(
400
+ s,
401
+ recordChanged(previous, s) || previous?.error !== s.error ? "wake.failed" : void 0,
402
+ s.error
403
+ );
404
+ }
405
+ }
406
+ close() {
407
+ this.stopped = true;
408
+ for (const a of this.adapters.values()) a.close();
409
+ this.adapters.clear();
410
+ }
411
+ };
412
+ function recordChanged(a, b) {
413
+ return a?.health !== b.health || a?.host_state !== b.host_state || a?.request?.state !== b.request?.state;
414
+ }
415
+
9
416
  // src/daemon/server.ts
10
417
  import { createServer } from "node:net";
11
418
  import { chmodSync as chmodSync2, rmSync as rmSync4, writeFileSync as writeFileSync3, existsSync as existsSync2, statSync as statSync3 } from "node:fs";
12
419
 
13
420
  // src/daemon/core.ts
421
+ import { randomUUID as randomUUID3 } from "node:crypto";
14
422
  import { readdirSync as readdirSync2, rmSync, writeFileSync } from "node:fs";
15
423
  import { join as join2 } from "node:path";
16
424
 
@@ -4128,23 +4536,6 @@ var coerce = {
4128
4536
  };
4129
4537
  var NEVER = INVALID;
4130
4538
 
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
4539
  // src/shared/schemas.ts
4149
4540
  var text = external_exports.string().min(1).refine((v) => Buffer.byteLength(v) <= LIMITS.maxBody, "MESSAGE_TOO_LARGE");
4150
4541
  var data = external_exports.record(external_exports.unknown()).refine((v) => Buffer.byteLength(JSON.stringify(v)) <= LIMITS.maxData, "MESSAGE_TOO_LARGE").optional();
@@ -4157,16 +4548,20 @@ var schemas = {
4157
4548
  squad: external_exports.string().optional(),
4158
4549
  name: external_exports.string().trim().min(1).max(64).optional(),
4159
4550
  note: text.optional(),
4160
- squad_name: external_exports.string().trim().min(1).max(64).optional()
4551
+ squad_name: external_exports.string().trim().min(1).max(64).optional(),
4552
+ takeover: external_exports.boolean().default(false),
4553
+ standby: external_exports.enum(["auto", "manual"]).optional(),
4554
+ rebind: external_exports.string().optional()
4161
4555
  }).strict(),
4162
4556
  list: external_exports.object({
4163
4557
  ...identity,
4558
+ full: external_exports.boolean().default(false),
4164
4559
  scope: external_exports.enum(["squad", "all"]).optional(),
4165
4560
  squad: external_exports.string().optional()
4166
4561
  }).strict(),
4167
4562
  report: external_exports.object({
4168
4563
  ...identity,
4169
- status: external_exports.enum(["ready", "working", "blocked", "done", "failed"]),
4564
+ status: external_exports.enum(["ready", "working", "blocked", "done", "failed", "cancelled"]),
4170
4565
  message: text,
4171
4566
  reply_to: external_exports.string().optional(),
4172
4567
  data
@@ -4176,7 +4571,10 @@ var schemas = {
4176
4571
  ...identity,
4177
4572
  to: external_exports.union([external_exports.string().min(1), external_exports.array(external_exports.string().min(1)).min(1).max(1e3)]),
4178
4573
  message: text,
4179
- type: external_exports.enum(["command", "answer", "info"]).default("command"),
4574
+ type: external_exports.enum(["command", "cancel", "answer", "info"]).default("command"),
4575
+ task_key: external_exports.string().min(1).max(128).optional(),
4576
+ reassign: external_exports.string().optional(),
4577
+ attention: external_exports.boolean().optional(),
4180
4578
  priority: external_exports.enum(["high", "normal", "low"]).optional(),
4181
4579
  reply_to: external_exports.string().optional(),
4182
4580
  data
@@ -4187,7 +4585,10 @@ var schemas = {
4187
4585
  limit: external_exports.number().int().min(1).max(100).default(20),
4188
4586
  peek: external_exports.boolean().default(false),
4189
4587
  history: external_exports.boolean().default(false),
4190
- since: external_exports.string().optional()
4588
+ since: external_exports.string().optional(),
4589
+ id: external_exports.string().optional(),
4590
+ recover: external_exports.boolean().default(false),
4591
+ full: external_exports.boolean().default(false)
4191
4592
  }).strict(),
4192
4593
  leave: external_exports.object({ ...identity, dissolve: external_exports.boolean().default(false), message: text.optional() }).strict()
4193
4594
  };
@@ -4202,13 +4603,13 @@ function parse(tool, args) {
4202
4603
  }
4203
4604
 
4204
4605
  // src/shared/ids.ts
4205
- import { randomBytes, randomUUID } from "node:crypto";
4606
+ import { randomBytes, randomUUID as randomUUID2 } from "node:crypto";
4206
4607
  var alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
4207
4608
  function squadId() {
4208
4609
  return Array.from(randomBytes(6), (b) => alphabet[b % alphabet.length]).join("");
4209
4610
  }
4210
4611
  var messageId = () => `m_${Date.now().toString(36)}${randomBytes(6).toString("hex")}`;
4211
- var provisionalId = (agent) => `${agent}:prov-${randomUUID()}`;
4612
+ var provisionalId = (agent) => `${agent}:prov-${randomUUID2()}`;
4212
4613
  var safeSid = (sid) => Buffer.from(sid).toString("base64url");
4213
4614
 
4214
4615
  // src/shared/env.ts
@@ -4228,17 +4629,29 @@ function recommendedWait(agent, env = process.env) {
4228
4629
  }
4229
4630
 
4230
4631
  // src/daemon/core.ts
4632
+ function bounded(values, limit) {
4633
+ const result = [];
4634
+ let bytes = 0;
4635
+ for (const value of values) {
4636
+ const size = Buffer.byteLength(JSON.stringify(value));
4637
+ if (result.length >= limit || result.length > 0 && bytes + size > LIMITS.maxFrame / 2) break;
4638
+ result.push(value);
4639
+ bytes += size;
4640
+ }
4641
+ return result;
4642
+ }
4231
4643
  var Core = class {
4232
4644
  constructor(store, paths2, config2) {
4233
4645
  this.store = store;
4234
4646
  this.paths = paths2;
4235
4647
  this.config = config2;
4236
4648
  for (const s of store.sessions()) {
4237
- s.presence = "offline";
4649
+ s.presence = s.transport === "cli" ? "cli" : "offline";
4238
4650
  store.saveSession(s);
4239
4651
  }
4240
4652
  this.refreshFlags();
4241
4653
  }
4654
+ configureStandby;
4242
4655
  contexts = /* @__PURE__ */ new Set();
4243
4656
  waiters = /* @__PURE__ */ new Set();
4244
4657
  effects = [];
@@ -4250,22 +4663,32 @@ var Core = class {
4250
4663
  ctx.closed = true;
4251
4664
  this.contexts.delete(ctx);
4252
4665
  for (const w of this.waiters) if (w.ctx === ctx) w.finish();
4253
- if (ctx.sid && ![...this.contexts].some((c) => c.kind === "mcp" && c.sid === ctx.sid)) {
4666
+ if (ctx.sid && ![...this.contexts].some(
4667
+ (c) => c.kind === "mcp" && c.transport !== "cli" && c.sid === ctx.sid
4668
+ )) {
4254
4669
  const s = this.store.session(ctx.sid);
4255
4670
  if (s) {
4256
- s.presence = "offline";
4257
- s.last_seen_at = Date.now();
4258
- this.store.saveSession(s);
4671
+ s.presence = s.transport === "cli" ? "cli" : "offline";
4672
+ this.atomic(() => {
4673
+ this.store.saveSession(s);
4674
+ this.record("session.disconnected", s.squad_id, {
4675
+ to_sid: s.sid,
4676
+ data: { presence: s.presence }
4677
+ });
4678
+ });
4259
4679
  }
4260
4680
  }
4261
4681
  }
4262
4682
  close() {
4683
+ for (const ctx of this.contexts) ctx.closed = true;
4263
4684
  for (const w of this.waiters) w.finish();
4264
4685
  }
4265
4686
  me(ctx) {
4266
4687
  return ctx.sid ? this.store.session(ctx.sid) : void 0;
4267
4688
  }
4268
4689
  required(ctx) {
4690
+ if (ctx.sid && this.store.revoked(ctx.sid))
4691
+ fail("ENDPOINT_REPLACED", "This endpoint was replaced; use the new member session.");
4269
4692
  return this.me(ctx) || fail("NOT_JOINED", "Register a session first.");
4270
4693
  }
4271
4694
  member(ctx, role) {
@@ -4290,10 +4713,13 @@ var Core = class {
4290
4713
  role: s.role,
4291
4714
  squad: s.squad_id,
4292
4715
  name: s.name,
4716
+ member_id: s.member_id || s.sid,
4717
+ wake_mode: this.store.standby(s.sid)?.wake_mode || "manual",
4718
+ listener: this.standbyView(s.sid),
4293
4719
  identity: s.native_id ? "confirmed" : "provisional",
4294
4720
  recommended_wait: this.waitHint(s)
4295
4721
  } : null,
4296
- unread: s ? this.store.queue(s.sid).length : 0
4722
+ unread: s ? this.inbox(s).length : 0
4297
4723
  };
4298
4724
  }
4299
4725
  board(q) {
@@ -4303,18 +4729,101 @@ var Core = class {
4303
4729
  members: this.members(q.id).map((s) => this.view(s))
4304
4730
  };
4305
4731
  }
4306
- view(s) {
4307
- const queue = this.store.queue(s.sid);
4732
+ standbyView(sid) {
4733
+ const standby = this.store.standby(sid);
4734
+ return standby ? {
4735
+ sid: standby.sid,
4736
+ wake_mode: standby.wake_mode,
4737
+ enabled: standby.enabled,
4738
+ health: standby.health,
4739
+ host_state: standby.host_state,
4740
+ checked_at: standby.checked_at,
4741
+ error: standby.error,
4742
+ request: standby.request ? {
4743
+ id: standby.request.id,
4744
+ state: standby.request.state,
4745
+ created_at: standby.request.created_at
4746
+ } : void 0,
4747
+ can_auto_respond: standby.enabled && standby.wake_mode !== "manual" && standby.health === "healthy"
4748
+ } : { wake_mode: "manual", health: "manual", can_auto_respond: false };
4749
+ }
4750
+ inbox(s, history = false) {
4751
+ return [
4752
+ ...this.store.queue(s.sid, history),
4753
+ ...s.role === "commander" && s.squad_id ? this.store.queue(`squad:${s.squad_id}`, history) : []
4754
+ ].filter((m) => history || !m.blocked_by || terminalWork(this.store.message(m.blocked_by))).sort((a, b) => a.priority - b.priority || a.seq - b.seq);
4755
+ }
4756
+ view(s, full = false, commandSquad) {
4757
+ const commands = this.store.commands(s.sid).filter((m) => !commandSquad || m.squad_id === commandSquad).map((m) => ({
4758
+ id: m.id,
4759
+ task_key: m.task_key,
4760
+ state: m.work?.state || (m.status === "queued" ? "queued" : "read"),
4761
+ created_at: m.created_at,
4762
+ updated_at: m.work?.updated_at || m.created_at,
4763
+ unacked_for: m.work?.accepted_at ? null : Math.floor((Date.now() - m.created_at) / 1e3),
4764
+ cancel_requested_at: m.work?.cancel_requested_at,
4765
+ blocked_by: m.blocked_by
4766
+ }));
4308
4767
  return {
4309
- ...s,
4768
+ ...full ? s : {
4769
+ sid: s.sid,
4770
+ agent: s.agent,
4771
+ name: s.name,
4772
+ role: s.role,
4773
+ cwd: s.cwd,
4774
+ native_id: s.native_id,
4775
+ presence: s.presence,
4776
+ activity: s.activity,
4777
+ last_status: s.last_status
4778
+ },
4779
+ member_id: s.member_id || s.sid,
4310
4780
  title: titleFor(s),
4311
4781
  short: s.sid.slice(0, s.sid.indexOf(":") + 9),
4312
4782
  squad: s.squad_id,
4313
4783
  last_seen: s.last_seen_at,
4314
- pending: queue.filter((m) => m.type === "command").length
4784
+ activity_at: s.activity_at || null,
4785
+ last_progress_at: s.last_progress_at || null,
4786
+ hook_seen_at: s.hook_seen_at || null,
4787
+ pending: commands.filter((m) => m.state === "queued").length,
4788
+ unacked: commands.filter((m) => !["accepted"].includes(m.state)).length,
4789
+ in_progress: commands.filter((m) => m.state === "accepted").length,
4790
+ commands,
4791
+ listener: this.standbyView(s.sid)
4315
4792
  };
4316
4793
  }
4794
+ record(kind, channel, detail = {}) {
4795
+ return this.store.appendEvent({ at: Date.now(), kind, channel, ...detail });
4796
+ }
4797
+ messageEvent(kind, m, reason) {
4798
+ this.record(kind, m.squad_id, {
4799
+ from_sid: m.from_sid,
4800
+ to_sid: m.to_sid,
4801
+ message_id: m.id,
4802
+ reply_to: m.reply_to,
4803
+ message: m,
4804
+ reason
4805
+ });
4806
+ }
4807
+ publishEvents(after) {
4808
+ for (const event of this.store.events(after))
4809
+ for (const c of this.contexts) {
4810
+ const t = c.tail;
4811
+ if (!t || event.event_seq <= t.after) continue;
4812
+ t.after = event.event_seq;
4813
+ if (t.squad && t.squad !== event.channel) continue;
4814
+ if (t.for && t.for !== event.to_sid && !(event.to_sid === `squad:${event.channel}` && this.store.squad(event.channel)?.commander_sid === t.for))
4815
+ continue;
4816
+ c.notify(
4817
+ "lifecycle.event",
4818
+ t.full ? event : {
4819
+ ...event,
4820
+ message: event.message ? { ...event.message, body: event.message.body.slice(0, 160), data: null } : void 0
4821
+ }
4822
+ );
4823
+ }
4824
+ }
4317
4825
  atomic(fn) {
4826
+ const cursor = this.store.eventCursor();
4318
4827
  this.effects = [];
4319
4828
  let result;
4320
4829
  try {
@@ -4326,12 +4835,13 @@ var Core = class {
4326
4835
  const effects = this.effects;
4327
4836
  this.effects = [];
4328
4837
  for (const m of effects) {
4329
- this.flag(m.to_sid);
4838
+ const recipient = m.to_sid.startsWith("squad:") ? this.store.squad(m.squad_id)?.commander_sid : m.to_sid;
4839
+ if (recipient) this.flag(recipient);
4330
4840
  for (const c of this.contexts) {
4331
- if (c.sid === m.to_sid)
4841
+ if (c.sid === recipient)
4332
4842
  c.notify("msg.new", {
4333
- sid: m.to_sid,
4334
- count: this.store.queue(m.to_sid).length,
4843
+ sid: recipient,
4844
+ count: this.inbox(this.store.session(recipient)).length,
4335
4845
  top_priority: m.priority
4336
4846
  });
4337
4847
  if (c.tail && (!c.tail.squad || c.tail.squad === m.squad_id))
@@ -4341,8 +4851,15 @@ var Core = class {
4341
4851
  });
4342
4852
  }
4343
4853
  }
4854
+ this.publishEvents(cursor);
4344
4855
  for (const w of [...this.waiters]) {
4345
- if (this.store.queue(w.sid).some((m) => !w.answer || m.type === "answer" && m.reply_to === w.answer))
4856
+ if (!this.store.session(w.sid)) {
4857
+ w.finish();
4858
+ continue;
4859
+ }
4860
+ if (this.inbox(this.store.session(w.sid)).some(
4861
+ (m) => !w.answer || m.type === "answer" && m.reply_to === w.answer
4862
+ ))
4346
4863
  w.finish();
4347
4864
  }
4348
4865
  return result;
@@ -4357,17 +4874,17 @@ var Core = class {
4357
4874
  rmSync(this.paths.flag(sid), { force: true });
4358
4875
  }
4359
4876
  refreshFlags() {
4360
- const queued = new Set(
4361
- this.store.messages().filter((m) => m.status === "queued").map((m) => m.to_sid)
4362
- );
4877
+ const queued = /* @__PURE__ */ new Set();
4878
+ for (const s of this.store.sessions()) {
4879
+ const queue = this.inbox(s);
4880
+ if (!queue.length) continue;
4881
+ queued.add(s.sid);
4882
+ 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)
4883
+ this.flag(s.sid);
4884
+ }
4363
4885
  for (const f of readdirSync2(this.paths.flags))
4364
4886
  if (!queued.has(Buffer.from(f, "base64url").toString()))
4365
4887
  rmSync(join2(this.paths.flags, f), { force: true });
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
- }
4371
4888
  }
4372
4889
  register(ctx, p) {
4373
4890
  if (!["mcp", "hook", "cli"].includes(p.kind))
@@ -4381,6 +4898,7 @@ var Core = class {
4381
4898
  const sid = p.native_id ? `${agent}:${String(p.native_id)}` : p.sid || ctx.sid || provisionalId(agent);
4382
4899
  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
4900
  fail("INVALID_ARGUMENT");
4901
+ if (this.store.revoked(sid)) fail("ENDPOINT_REPLACED");
4384
4902
  let s = this.store.session(sid);
4385
4903
  if (!s)
4386
4904
  s = {
@@ -4396,7 +4914,8 @@ var Core = class {
4396
4914
  role: "none",
4397
4915
  squad_id: null,
4398
4916
  presence: "offline",
4399
- activity: "busy",
4917
+ activity: "unknown",
4918
+ member_id: `member:${randomUUID3()}`,
4400
4919
  last_status: null,
4401
4920
  last_notified_seq: 0,
4402
4921
  last_notified_at: 0,
@@ -4415,17 +4934,24 @@ var Core = class {
4415
4934
  if (p.kind === "mcp") {
4416
4935
  if (ctx.sid && ctx.sid !== sid)
4417
4936
  fail("ROLE_NOT_ALLOWED", "Use session.identify to bind identity");
4418
- s.presence = "online";
4937
+ ctx.transport = p.transport === "cli" ? "cli" : "mcp";
4938
+ s.transport = ctx.transport;
4939
+ s.presence = ctx.transport === "mcp" || [...this.contexts].some((c) => c.sid === sid && c.kind === "mcp" && c.transport !== "cli") ? "online" : "cli";
4419
4940
  ctx.sid = sid;
4420
4941
  ctx.agent = agent;
4421
4942
  ctx.waitHint = Number.isFinite(p.wait_hint) ? Math.min(300, Math.max(0, p.wait_hint)) : recommendedWait(agent, {});
4422
4943
  }
4423
4944
  this.store.saveSession(s);
4945
+ this.record("session.registered", s.squad_id, {
4946
+ to_sid: s.sid,
4947
+ data: { presence: s.presence, transport: s.transport }
4948
+ });
4424
4949
  return s;
4425
4950
  }
4426
4951
  identify(ctx, native, force = false) {
4427
4952
  if (typeof native !== "string" || !native || native.length > 256) fail("INVALID_ARGUMENT");
4428
4953
  const old = this.required(ctx), sid = `${old.agent}:${native}`;
4954
+ if (this.store.revoked(sid)) fail("ENDPOINT_REPLACED");
4429
4955
  if (old.sid === sid) return old;
4430
4956
  if (old.native_id && !force) return old;
4431
4957
  const target = this.store.session(sid);
@@ -4434,6 +4960,7 @@ var Core = class {
4434
4960
  const merged = {
4435
4961
  ...old,
4436
4962
  ...target,
4963
+ member_id: old.role !== "none" ? old.member_id || old.sid : target?.member_id || old.member_id || old.sid,
4437
4964
  sid,
4438
4965
  native_id: native,
4439
4966
  presence: "online",
@@ -4474,14 +5001,14 @@ var Core = class {
4474
5001
  return merged;
4475
5002
  }
4476
5003
  enqueue(from, q, to, type, body, opts = {}) {
4477
- if (this.store.queue(to).length >= this.config.maxQueue)
5004
+ if (!opts.terminalAck && this.store.queue(to).filter((m2) => m2.type === "cancel" === (type === "cancel")).length >= (type === "cancel" ? 100 : this.config.maxQueue))
4478
5005
  fail("QUEUE_FULL", `Queue for ${to} is full`);
4479
5006
  const m = {
4480
5007
  id: messageId(),
4481
5008
  seq: 0,
4482
5009
  squad_id: q,
4483
5010
  type,
4484
- priority: opts.priority ?? (["command", "ask", "answer"].includes(type) ? 0 : type === "report" ? 2 : 1),
5011
+ priority: opts.priority ?? (["command", "cancel", "ask", "answer"].includes(type) ? 0 : type === "report" ? 2 : 1),
4485
5012
  from_sid: from?.sid || (opts.operator ? "operator" : "system"),
4486
5013
  from_role: from?.role || (opts.operator ? "operator" : "system"),
4487
5014
  from_name: from?.name || null,
@@ -4490,11 +5017,13 @@ var Core = class {
4490
5017
  data: opts.data || null,
4491
5018
  reply_to: opts.reply_to || null,
4492
5019
  status: "queued",
4493
- attn: opts.attn ?? ["command", "ask", "answer"].includes(type),
5020
+ attn: opts.attn ?? ["command", "cancel", "ask", "answer"].includes(type),
4494
5021
  created_at: Date.now(),
4495
5022
  delivered_at: null
4496
5023
  };
5024
+ if (type === "command") m.work = { state: "queued", updated_at: m.created_at };
4497
5025
  this.store.insert(m);
5026
+ this.messageEvent("message.queued", m);
4498
5027
  this.effects.push(m);
4499
5028
  return m;
4500
5029
  }
@@ -4502,51 +5031,115 @@ var Core = class {
4502
5031
  for (const s of to)
4503
5032
  this.enqueue(null, q.id, s.sid, "system", body, { priority: high ? 0 : 1, attn: high });
4504
5033
  }
5034
+ roleInbox(q, old) {
5035
+ for (const m of this.store.messages())
5036
+ if (m.squad_id === q.id && m.to_sid === old && !["command", "cancel", "answer"].includes(m.type)) {
5037
+ m.to_sid = `squad:${q.id}`;
5038
+ this.store.saveMessage(m);
5039
+ }
5040
+ }
5041
+ rebind(ctx, member) {
5042
+ const target = this.required(ctx);
5043
+ if (!target.native_id) fail("IDENTITY_REQUIRED");
5044
+ const old = this.store.sessions().find((s) => (s.member_id || s.sid) === member) || fail("MEMBER_NOT_FOUND");
5045
+ if (old.sid === target.sid) return;
5046
+ if (!old.squad_id) fail("NOT_JOINED", "The member must belong to a channel before rebinding");
5047
+ if (target.squad_id) fail("ALREADY_JOINED");
5048
+ const merged = {
5049
+ ...old,
5050
+ sid: target.sid,
5051
+ agent: target.agent,
5052
+ native_id: target.native_id,
5053
+ member_id: old.member_id || old.sid,
5054
+ pid: target.pid,
5055
+ cwd: target.cwd,
5056
+ terminal: target.terminal,
5057
+ transcript_path: target.transcript_path,
5058
+ presence: target.presence,
5059
+ transport: target.transport,
5060
+ activity: "unknown",
5061
+ last_seen_at: Date.now(),
5062
+ ended_at: null
5063
+ };
5064
+ this.store.saveSession(merged);
5065
+ for (const q of this.store.squads())
5066
+ if (q.commander_sid === old.sid) {
5067
+ q.commander_sid = target.sid;
5068
+ this.store.saveSquad(q);
5069
+ }
5070
+ for (const m of this.store.messages()) {
5071
+ if (m.to_sid === old.sid) m.to_sid = target.sid;
5072
+ if (m.from_sid === old.sid) m.from_sid = target.sid;
5073
+ this.store.saveMessage(m);
5074
+ }
5075
+ this.store.migrateMembership(old.sid, target.sid);
5076
+ this.store.revoke(old.sid, target.sid);
5077
+ this.store.deleteSession(old.sid);
5078
+ this.store.deleteStandby(old.sid);
5079
+ for (const w of this.waiters) if (w.sid === old.sid) w.finish();
5080
+ this.clearFlag(old.sid);
5081
+ if (this.inbox(merged).length) this.flag(target.sid);
5082
+ this.record("member.rebound", merged.squad_id, {
5083
+ from_sid: old.sid,
5084
+ to_sid: target.sid,
5085
+ data: { member_id: merged.member_id, wake_mode: "manual" }
5086
+ });
5087
+ }
4505
5088
  joinSquad(ctx, p) {
5089
+ if (p.rebind && (p.role || p.squad || p.squad_name || p.takeover))
5090
+ fail("INVALID_ARGUMENT", "rebind cannot be combined with a role or channel change");
5091
+ if (p.rebind) this.rebind(ctx, p.rebind);
4506
5092
  const s = this.required(ctx);
4507
5093
  let q, role = p.role;
4508
5094
  if (p.squad_name) {
4509
- if (p.role || p.squad)
4510
- fail("INVALID_ARGUMENT", "squad_name is the atomic shortcut; omit role and squad");
5095
+ if (p.squad) fail("INVALID_ARGUMENT", "Use squad or squad_name, not both");
4511
5096
  q = this.store.squads().find((q2) => q2.name_key === p.squad_name.toLowerCase() && q2.status !== "dissolved");
4512
- if (s.squad_id && s.squad_id === q?.id) return this.joinResult(ctx, q);
4513
- if (q?.status === "orphaned")
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;
5097
+ if (s.squad_id === q?.id && !role) return this.joinResult(ctx, q);
5098
+ role ||= "executor";
4520
5099
  } else if (p.squad) q = this.store.squad(p.squad) || fail("SQUAD_NOT_FOUND");
5100
+ else if (p.rebind && s.squad_id) return this.joinResult(ctx, this.store.squad(s.squad_id));
4521
5101
  if (!role) fail("INVALID_ARGUMENT", "Provide role or squad_name");
5102
+ if (q?.status === "dissolved") fail("SQUAD_NOT_FOUND", "Channel is closed");
4522
5103
  if (s.squad_id) {
4523
5104
  if ((q?.id === s.squad_id || !q && !p.squad_name && role === "commander") && s.role === role)
4524
5105
  return this.joinResult(ctx, this.store.squad(s.squad_id));
4525
- if (q?.id === s.squad_id && q.status === "orphaned" && role === "commander") {
4526
- this.store.leave(s.sid);
4527
- } else fail("ALREADY_JOINED");
5106
+ if (q?.id === s.squad_id && role === "commander") this.store.leave(s.sid);
5107
+ else fail("ALREADY_JOINED");
4528
5108
  }
4529
- if (q?.status === "dissolved") fail("SQUAD_NOT_FOUND", "Squad is dissolved");
4530
5109
  if (!q) {
4531
- if (role !== "commander") fail("SQUAD_NOT_FOUND");
4532
- const key = p.name?.toLowerCase() || null;
5110
+ if (role !== "commander" && !p.squad_name) fail("SQUAD_NOT_FOUND");
5111
+ const name = p.squad_name || p.name || null, key = name?.toLowerCase() || null;
4533
5112
  if (key && this.store.squads().some((x) => x.name_key === key && x.status !== "dissolved"))
4534
- fail("SQUAD_NAME_EXISTS", "Use squad_name to join by name or specify a squad ID.");
5113
+ fail("SQUAD_NAME_EXISTS");
4535
5114
  let id;
4536
5115
  do {
4537
5116
  id = squadId();
4538
5117
  } while (this.store.squad(id));
4539
5118
  q = {
4540
5119
  id,
4541
- name: p.name || null,
5120
+ name,
4542
5121
  name_key: key,
4543
- commander_sid: s.sid,
4544
- status: "active",
5122
+ commander_sid: role === "commander" ? s.sid : null,
5123
+ status: role === "commander" ? "active" : "orphaned",
4545
5124
  created_at: Date.now(),
4546
5125
  updated_at: Date.now()
4547
5126
  };
5127
+ this.record("channel.created", q.id);
4548
5128
  } else if (role === "commander") {
4549
- if (q.commander_sid && q.commander_sid !== s.sid) fail("SQUAD_HAS_COMMANDER");
5129
+ if (q.commander_sid && q.commander_sid !== s.sid) {
5130
+ if (!p.takeover)
5131
+ fail(
5132
+ "SQUAD_HAS_COMMANDER",
5133
+ "Use takeover=true for an explicit handover; offline does not mean stopped."
5134
+ );
5135
+ const old = this.store.session(q.commander_sid);
5136
+ this.roleInbox(q, old.sid);
5137
+ this.store.leave(old.sid);
5138
+ old.role = "executor";
5139
+ this.store.saveSession(old);
5140
+ this.store.join(old);
5141
+ this.record("commander.handover", q.id, { from_sid: old.sid, to_sid: s.sid });
5142
+ }
4550
5143
  q.commander_sid = s.sid;
4551
5144
  q.status = "active";
4552
5145
  this.system(
@@ -4554,29 +5147,25 @@ var Core = class {
4554
5147
  this.members(q.id).filter((member) => member.sid !== s.sid),
4555
5148
  "commander_joined"
4556
5149
  );
4557
- const inbox = this.store.queue(`squad:${q.id}`);
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
- }
5150
+ this.record("commander.claimed", q.id, { to_sid: s.sid });
4564
5151
  }
4565
5152
  s.role = role;
4566
5153
  s.squad_id = q.id;
4567
- s.name = p.name || null;
5154
+ s.name = p.name || s.name;
4568
5155
  q.updated_at = Date.now();
4569
5156
  this.store.saveSquad(q);
4570
5157
  this.store.saveSession(s);
4571
5158
  this.store.join(s);
5159
+ this.record("member.joined", q.id, { to_sid: s.sid, data: { role, member_id: s.member_id } });
4572
5160
  if (role === "executor" && q.commander_sid)
4573
5161
  this.enqueue(
4574
5162
  s,
4575
5163
  q.id,
4576
- q.commander_sid,
5164
+ `squad:${q.id}`,
4577
5165
  "system",
4578
5166
  `member_joined${p.note ? `: ${p.note}` : ""}`
4579
5167
  );
5168
+ if (this.inbox(s).length) this.flag(s.sid);
4580
5169
  return this.joinResult(ctx, q);
4581
5170
  }
4582
5171
  joinResult(ctx, q) {
@@ -4587,13 +5176,15 @@ var Core = class {
4587
5176
  join_prompt,
4588
5177
  user_reply: s.role === "commander" ? `Squad ${q.id}${q.name ? ` (${q.name})` : ""} is ready. Paste this into each other session:
4589
5178
  ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report ready and wait for commands.`,
4590
- 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). Then read(wait=${wait2}); standby for at most 40 rounds. 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."}`
5179
+ standby: this.standbyView(s.sid),
5180
+ 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. 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. Never write a private wake 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
5181
  };
4592
5182
  }
4593
5183
  leave(ctx, p) {
4594
5184
  const s = this.member(ctx), q = this.store.squad(s.squad_id);
4595
5185
  if (p.dissolve && s.role !== "commander") fail("ROLE_NOT_ALLOWED");
4596
5186
  if (s.role === "commander") {
5187
+ this.roleInbox(q, s.sid);
4597
5188
  this.system(
4598
5189
  q,
4599
5190
  this.members(q.id).filter((x) => x.sid !== s.sid),
@@ -4608,12 +5199,18 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4608
5199
  m.role = "none";
4609
5200
  m.squad_id = null;
4610
5201
  this.store.saveSession(m);
5202
+ const listener = this.store.standby(m.sid);
5203
+ if (listener) {
5204
+ listener.enabled = false;
5205
+ listener.health = "stopped";
5206
+ this.store.saveStandby(listener);
5207
+ }
4611
5208
  }
4612
5209
  } else if (q.commander_sid)
4613
5210
  this.enqueue(
4614
5211
  s,
4615
5212
  q.id,
4616
- q.commander_sid,
5213
+ `squad:${q.id}`,
4617
5214
  "system",
4618
5215
  `member_left${p.message ? `: ${p.message}` : ""}`
4619
5216
  );
@@ -4623,6 +5220,13 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4623
5220
  this.store.saveSession(s);
4624
5221
  q.updated_at = Date.now();
4625
5222
  this.store.saveSquad(q);
5223
+ this.record(p.dissolve ? "channel.closed" : "member.left", q.id, { from_sid: s.sid });
5224
+ const standby = this.store.standby(s.sid);
5225
+ if (standby) {
5226
+ standby.enabled = false;
5227
+ standby.health = "stopped";
5228
+ this.store.saveStandby(standby);
5229
+ }
4626
5230
  return { left: true, squad: q.id, status: q.status };
4627
5231
  }
4628
5232
  rate(ctx) {
@@ -4640,7 +5244,7 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4640
5244
  for (const s of all) if (s.sid !== sender) found.set(s.sid, s);
4641
5245
  continue;
4642
5246
  }
4643
- const exact = all.filter((s) => s.sid === token);
5247
+ const exact = all.filter((s) => s.sid === token || s.member_id === token);
4644
5248
  const matches = exact.length ? exact : all.filter((s) => s.name === token || s.sid.startsWith(token));
4645
5249
  if (matches.length !== 1)
4646
5250
  fail(
@@ -4665,65 +5269,207 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4665
5269
  "Answers require reply_to for an ask from the recipient in this squad."
4666
5270
  );
4667
5271
  }
5272
+ if (p.type === "cancel" && (!p.reply_to || p.reassign))
5273
+ fail("INVALID_ARGUMENT", "cancel requires reply_to=<command id>");
5274
+ const previous = p.reassign ? this.store.message(p.reassign) : void 0;
5275
+ if (p.reassign && (p.type !== "command" || !previous || previous.type !== "command" || previous.squad_id !== qid || terminalWork(previous) || recipients.length !== 1))
5276
+ fail("INVALID_ARGUMENT", "reassign must reference one unfinished command in this channel");
5277
+ if (previous?.work?.replacement_id) fail("ALREADY_REASSIGNED");
5278
+ if (previous?.task_key && p.task_key && p.task_key !== previous.task_key)
5279
+ fail("INVALID_ARGUMENT", "Reassignment must preserve the original task_key");
5280
+ const taskKey = previous?.task_key || p.task_key;
5281
+ if (p.task_key && (p.type !== "command" || recipients.length !== 1))
5282
+ fail(
5283
+ "INVALID_ARGUMENT",
5284
+ "task_key identifies one command owner; do not broadcast the same ticket"
5285
+ );
5286
+ if (taskKey && this.store.commands().some((m) => m.squad_id === qid && m.task_key === taskKey && m.id !== previous?.id))
5287
+ fail("TASK_OWNED", "This task_key already has unfinished work. Use reassign=<command id>.");
5288
+ const warnings = recipients.flatMap((s) => {
5289
+ const active = this.store.commands(s.sid);
5290
+ return active.length ? [
5291
+ {
5292
+ code: "UNFINISHED_WORK",
5293
+ sid: s.sid,
5294
+ command_ids: active.map((m) => m.id),
5295
+ message: "Verify ownership before dispatch. Offline does not mean stopped."
5296
+ }
5297
+ ] : [];
5298
+ });
4668
5299
  this.rate(ctx);
4669
- const messages = recipients.map(
4670
- (s) => this.enqueue(from, qid, s.sid, p.type, p.message, {
5300
+ const cancel = (command, body) => {
5301
+ command.work ||= {
5302
+ state: command.status === "queued" ? "queued" : "read",
5303
+ updated_at: command.created_at
5304
+ };
5305
+ command.work.cancel_requested_at = Date.now();
5306
+ if (command.work.state === "queued") {
5307
+ command.work.state = "cancelled";
5308
+ command.status = "delivered";
5309
+ command.delivered_at = Date.now();
5310
+ }
5311
+ command.work.updated_at = Date.now();
5312
+ this.store.saveMessage(command);
5313
+ this.messageEvent(
5314
+ command.work.state === "cancelled" ? "work.cancelled" : "work.cancel_requested",
5315
+ command
5316
+ );
5317
+ return this.enqueue(from, qid, command.to_sid, "cancel", body, {
5318
+ priority: -1,
5319
+ reply_to: command.id,
5320
+ attn: true,
5321
+ operator: ctx.kind === "cli"
5322
+ });
5323
+ };
5324
+ if (previous)
5325
+ cancel(
5326
+ previous,
5327
+ `Cancel command ${previous.id} at the next safe checkpoint and report cancelled with reply_to. Reassignment waits for your terminal report.`
5328
+ );
5329
+ const messages = recipients.map((s) => {
5330
+ if (p.type === "cancel") {
5331
+ const command = this.store.message(p.reply_to);
5332
+ if (!command || command.type !== "command" || command.to_sid !== s.sid || command.squad_id !== qid || terminalWork(command))
5333
+ fail("INVALID_ARGUMENT", "Cancel target must own an unfinished command");
5334
+ return cancel(command, p.message);
5335
+ }
5336
+ const m = this.enqueue(from, qid, s.sid, p.type, p.message, {
4671
5337
  priority: p.priority ? { high: 0, normal: 1, low: 2 }[p.priority] : void 0,
4672
5338
  reply_to: p.reply_to,
4673
5339
  data: p.data,
5340
+ attn: p.attention,
4674
5341
  operator: ctx.kind === "cli"
4675
- })
4676
- );
5342
+ });
5343
+ if (p.type === "command") {
5344
+ m.task_key = taskKey;
5345
+ if (previous && !terminalWork(previous)) m.blocked_by = previous.id;
5346
+ this.store.saveMessage(m);
5347
+ if (previous) {
5348
+ previous.work.replacement_id = m.id;
5349
+ this.store.saveMessage(previous);
5350
+ this.messageEvent(
5351
+ "work.reassigned",
5352
+ m,
5353
+ `Replaces ${previous.id}; ${m.blocked_by ? "waiting for original owner to stop" : "original was unread"}`
5354
+ );
5355
+ }
5356
+ }
5357
+ return m;
5358
+ });
4677
5359
  return {
4678
5360
  ids: messages.map((m) => m.id),
5361
+ queued_to: recipients.map((s) => s.sid),
4679
5362
  delivered_to: recipients.map((s) => s.sid),
5363
+ delivery_hint: "queued_to / delivered_to mean enqueued, not read or accepted",
5364
+ warnings: p.type === "command" ? warnings : [],
5365
+ blocked_by: messages.find((m) => m.blocked_by)?.blocked_by,
4680
5366
  offline: recipients.filter((s) => s.presence === "offline").map((s) => s.sid),
4681
5367
  idle: recipients.filter((s) => s.activity === "idle").map((s) => s.sid)
4682
5368
  };
4683
5369
  }
4684
5370
  reportOrAsk(ctx, p, ask) {
4685
- const s = this.member(ctx, "executor"), q = this.store.squad(s.squad_id);
5371
+ const s = !ask && p.reply_to ? this.required(ctx) : this.member(ctx, "executor");
5372
+ let command = !ask && p.reply_to ? this.store.message(p.reply_to) : void 0;
5373
+ let terminalAck = false;
5374
+ const q = this.store.squad(command?.squad_id || s.squad_id) || fail("NOT_JOINED");
5375
+ if (!ask && p.reply_to) {
5376
+ command = this.store.message(p.reply_to);
5377
+ if (!command || command.type !== "command" || command.to_sid !== s.sid || command.squad_id !== q.id)
5378
+ fail("INVALID_ARGUMENT", "reply_to must identify a command owned by this member");
5379
+ if (command.blocked_by && !terminalWork(this.store.message(command.blocked_by)))
5380
+ fail("REASSIGNMENT_PENDING", "Original owner has not stopped");
5381
+ const state = {
5382
+ working: "accepted",
5383
+ blocked: "accepted",
5384
+ done: "completed",
5385
+ failed: "failed",
5386
+ cancelled: "cancelled"
5387
+ }[p.status];
5388
+ if (!state) fail("INVALID_ARGUMENT", "ready is not a command acknowledgement");
5389
+ if (terminalWork(command) && state !== command.work.state) fail("WORK_TERMINAL");
5390
+ if (!terminalWork(command)) {
5391
+ const now = Date.now();
5392
+ command.work ||= { state: "read", updated_at: now };
5393
+ if (command.work.cancel_requested_at && state === "accepted")
5394
+ fail("CANCEL_REQUESTED", "Stop at a safe checkpoint and report cancelled");
5395
+ const changed = command.work.state !== state;
5396
+ command.work.state = state;
5397
+ command.work.updated_at = now;
5398
+ if (state === "accepted") command.work.accepted_at ||= now;
5399
+ command.status = "delivered";
5400
+ command.delivered_at ||= now;
5401
+ this.store.saveMessage(command);
5402
+ terminalAck = terminalWork(command);
5403
+ this.messageEvent(changed ? `work.${state}` : "work.progress", command);
5404
+ if (terminalWork(command) && command.work.replacement_id) {
5405
+ const replacement = this.store.message(command.work.replacement_id);
5406
+ this.effects.push(replacement);
5407
+ this.messageEvent("work.released", replacement);
5408
+ }
5409
+ }
5410
+ }
4686
5411
  this.rate(ctx);
4687
- const to = q.commander_sid || `squad:${q.id}`;
5412
+ const to = `squad:${q.id}`;
4688
5413
  const m = this.enqueue(s, q.id, to, ask ? "ask" : "report", ask ? p.question : p.message, {
4689
5414
  reply_to: p.reply_to,
4690
5415
  data: ask ? p.data : { ...p.data, status: p.status },
4691
5416
  priority: ask ? 0 : ["blocked", "failed"].includes(p.status) ? 1 : 2,
4692
- attn: ask || ["done", "failed", "blocked"].includes(p.status)
5417
+ attn: ask || ["done", "failed", "blocked", "cancelled"].includes(p.status),
5418
+ // Each issued command reserves admission for its first terminal report.
5419
+ // Keep work and report atomic even under backpressure; repeats use the ordinary cap.
5420
+ terminalAck
4693
5421
  });
4694
5422
  if (!ask) {
4695
5423
  s.last_status = { status: p.status, message: p.message.slice(0, 300) };
5424
+ s.last_progress_at = Date.now();
5425
+ s.activity_at = Date.now();
5426
+ s.activity = this.store.commands(s.sid).some((m2) => m2.work?.state === "accepted") ? "busy" : "idle";
4696
5427
  this.store.saveSession(s);
4697
5428
  }
4698
5429
  return {
4699
5430
  id: m.id,
4700
5431
  delivered_to: to,
5432
+ queued_to: to,
5433
+ work: command?.work,
5434
+ ...!ask && !p.reply_to && this.store.commands(s.sid).length ? { warning: "Uncorrelated report does not accept or finish a command; provide reply_to." } : {},
4701
5435
  commander_presence: q.commander_sid ? this.store.session(q.commander_sid)?.presence : "orphaned",
4702
5436
  answered: false
4703
5437
  };
4704
5438
  }
4705
5439
  readNow(ctx, p, answer) {
4706
5440
  const s = this.required(ctx);
4707
- let queue = this.store.queue(s.sid, p.history);
5441
+ 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);
5442
+ if (p.id) {
5443
+ const m = this.store.message(p.id);
5444
+ if (!m || m.to_sid !== s.sid && !(s.role === "commander" && m.to_sid === `squad:${s.squad_id}`))
5445
+ fail("MESSAGE_NOT_FOUND");
5446
+ if (m.status === "queued" && m.blocked_by && !terminalWork(this.store.message(m.blocked_by)))
5447
+ fail("REASSIGNMENT_PENDING", "Original owner has not stopped");
5448
+ queue = [m];
5449
+ }
4708
5450
  if (p.since) {
4709
5451
  const since = this.store.message(p.since);
4710
- if (!since || since.to_sid !== s.sid) fail("INVALID_ARGUMENT", "Unknown since message");
5452
+ if (!since || since.to_sid !== s.sid && !(s.role === "commander" && since.to_sid === `squad:${s.squad_id}`))
5453
+ fail("INVALID_ARGUMENT", "Unknown since message");
4711
5454
  queue = queue.filter((m) => m.seq > since.seq);
4712
5455
  }
4713
5456
  if (answer) queue = queue.filter((m) => m.type === "answer" && m.reply_to === answer);
4714
- const messages = queue.slice(0, p.limit || 20);
4715
- if (!p.peek && !p.history && !ctx.closed)
5457
+ const messages = bounded(queue, p.limit || 20);
5458
+ if (!p.peek && !p.history && !p.recover && !p.id && !ctx.closed)
4716
5459
  for (const m of messages) {
4717
5460
  m.status = "delivered";
4718
5461
  m.delivered_at = Date.now();
5462
+ if (m.type === "command" && (!m.work || m.work.state === "queued"))
5463
+ m.work = { ...m.work, state: "read", updated_at: Date.now() };
4719
5464
  this.store.saveMessage(m);
5465
+ this.messageEvent("message.read", m);
4720
5466
  }
4721
- const remaining = this.store.queue(s.sid).length;
5467
+ const remaining = this.inbox(s).length;
4722
5468
  if (!remaining) this.clearFlag(s.sid);
4723
5469
  return {
4724
5470
  messages: ctx.closed ? [] : messages,
4725
5471
  remaining,
4726
- squad_summary: s.squad_id ? this.board(this.store.squad(s.squad_id)) : null
5472
+ ...p.full ? { squad_summary: s.squad_id ? this.board(this.store.squad(s.squad_id)) : null } : {}
4727
5473
  };
4728
5474
  }
4729
5475
  wait(ctx, seconds, answer, signal) {
@@ -4744,7 +5490,9 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4744
5490
  w.timer = setTimeout(w.finish, seconds * 1e3);
4745
5491
  this.waiters.add(w);
4746
5492
  signal?.addEventListener("abort", w.finish, { once: true });
4747
- if (this.store.queue(w.sid).some((m) => !answer || m.type === "answer" && m.reply_to === answer))
5493
+ if (this.inbox(this.store.session(w.sid)).some(
5494
+ (m) => !answer || m.type === "answer" && m.reply_to === answer
5495
+ ))
4748
5496
  w.finish();
4749
5497
  });
4750
5498
  }
@@ -4753,18 +5501,19 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4753
5501
  if (squad && !this.store.squad(squad)) fail("SQUAD_NOT_FOUND");
4754
5502
  return {
4755
5503
  sessions: this.store.sessions().filter(
4756
- (s) => squad ? s.squad_id === squad : s.presence === "online" || s.role !== "none"
5504
+ (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
5505
  ).map((s) => ({
4758
- ...this.view(s),
4759
- ...s.sid === me?.sid ? { unread: this.store.queue(s.sid).length } : {}
5506
+ ...this.view(s, p.full, squad || void 0),
5507
+ ...s.sid === me?.sid ? { unread: this.inbox(s).length } : {}
4760
5508
  })),
4761
- squads: this.store.squads().filter((q) => squad ? q.id === squad : q.status !== "dissolved").map((q) => this.board(q))
5509
+ squads: this.store.squads().filter((q) => squad ? q.id === squad : q.status !== "dissolved").map((q) => p.full ? this.board(q) : q)
4762
5510
  };
4763
5511
  }
4764
5512
  hook(ctx, p) {
4765
5513
  if (!p.session_id || !["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "Stop"].includes(p.event))
4766
5514
  return {};
4767
5515
  const agent = /^[a-z][a-z0-9_-]{0,63}$/.test(p.agent || "") ? p.agent : "generic", sid = `${agent}:${p.session_id}`;
5516
+ if (this.store.revoked(sid)) return {};
4768
5517
  let s = this.store.session(sid);
4769
5518
  if (p.event === "SessionEnd" && !s) return {};
4770
5519
  if (p.event === "SessionStart") {
@@ -4787,6 +5536,16 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4787
5536
  if (!s) return {};
4788
5537
  const now = Date.now();
4789
5538
  s.last_seen_at = now;
5539
+ s.hook_seen_at = now;
5540
+ s.activity_at = now;
5541
+ this.record("session.activity", s.squad_id, {
5542
+ to_sid: s.sid,
5543
+ reason: p.event,
5544
+ data: {
5545
+ activity: p.event === "Stop" ? "idle" : p.event === "SessionEnd" ? s.activity : "busy",
5546
+ presence: p.event === "SessionEnd" ? "offline" : s.presence
5547
+ }
5548
+ });
4790
5549
  if (p.event === "SessionEnd") {
4791
5550
  s.presence = "offline";
4792
5551
  s.ended_at = now;
@@ -4794,11 +5553,11 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4794
5553
  return {};
4795
5554
  }
4796
5555
  s.activity = p.event === "Stop" ? "idle" : "busy";
4797
- const queue = this.store.queue(sid), max = Math.max(0, ...queue.map((m) => m.seq));
5556
+ const queue = this.inbox(s), max = Math.max(0, ...queue.map((m) => m.seq));
4798
5557
  const groups = /* @__PURE__ */ new Map();
4799
5558
  for (const m of queue) {
4800
5559
  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}`;
5560
+ const label = `${m.type} (${m.priority < 0 ? "urgent" : ["high", "normal", "low"][m.priority]}) from ${sender}`;
4802
5561
  groups.set(label, (groups.get(label) || 0) + 1);
4803
5562
  }
4804
5563
  const details = [...groups].slice(0, 3).map(([label, n]) => `${n} ${label}`).join(", ").slice(0, 175);
@@ -4816,7 +5575,7 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4816
5575
  0,
4817
5576
  300
4818
5577
  );
4819
- } else if (queue.length && (max > s.last_notified_seq || queue.some((m) => m.priority === 0) && now - s.last_notified_at >= this.config.remindIntervalSec * 1e3))
5578
+ } 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
5579
  result.inject = summary.slice(0, 300);
4821
5580
  if (result.inject) {
4822
5581
  s.last_notified_seq = max;
@@ -4830,8 +5589,10 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4830
5589
  this.atomic(() => {
4831
5590
  const cutoff = Date.now() - this.config.ttlDays * 864e5;
4832
5591
  this.store.expireMessages(all ? Number.MAX_SAFE_INTEGER : cutoff);
5592
+ this.store.expireEvents(all ? Number.MAX_SAFE_INTEGER : cutoff);
5593
+ if (all) this.store.purge();
4833
5594
  for (const s of this.store.sessions())
4834
- if (all || s.presence === "offline" && s.last_seen_at < cutoff) {
5595
+ if (all || !s.squad_id && s.presence !== "online" && s.last_seen_at < cutoff && !this.store.commands(s.sid).length) {
4835
5596
  if (s.squad_id && s.role === "commander") {
4836
5597
  const q = this.store.squad(s.squad_id);
4837
5598
  q.commander_sid = null;
@@ -4852,7 +5613,7 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4852
5613
  this.store.deleteSession(s.sid);
4853
5614
  }
4854
5615
  for (const q of this.store.squads())
4855
- if (all || q.status !== "active" && q.updated_at < cutoff && !this.members(q.id).length)
5616
+ 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
5617
  this.store.deleteSquad(q.id);
4857
5618
  });
4858
5619
  if (all)
@@ -4891,24 +5652,62 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4891
5652
  identity: "provisional"
4892
5653
  })),
4893
5654
  squads: this.store.squads().length,
4894
- connections: [...this.contexts].filter((c) => c.kind === "mcp").length
5655
+ connections: [...this.contexts].filter((c) => c.kind === "mcp").length,
5656
+ clients: [...this.contexts].map((c) => ({
5657
+ sid: c.sid,
5658
+ kind: c.kind,
5659
+ version: c.version,
5660
+ client: c.client
5661
+ })),
5662
+ listeners: this.store.standbys().map((s) => this.standbyView(s.sid))
4895
5663
  };
4896
5664
  if (method === "admin.housekeep" || method === "admin.purge")
4897
5665
  return this.housekeep(method === "admin.purge" && params.all === true);
4898
- if (method === "admin.peek") return { messages: this.store.queue(params.sid) };
5666
+ if (method === "admin.peek")
5667
+ return { messages: this.inbox(this.store.session(params.sid) || fail("NOT_JOINED")) };
4899
5668
  if (method === "admin.read") {
4900
5669
  const s = this.store.session(params.sid) || fail("NOT_JOINED");
4901
5670
  return this.atomic(
4902
5671
  () => this.readNow({ ...ctx, sid: s.sid }, parse("read", params.options || {}))
4903
5672
  );
4904
5673
  }
4905
- if (method === "admin.tail") {
4906
- ctx.tail = { squad: params.squad, full: params.full };
4907
- return { subscribed: true };
5674
+ if (method === "admin.tail" || method === "admin.events") {
5675
+ const after = params.after === void 0 ? Math.max(0, this.store.eventCursor() - 20) : params.after;
5676
+ if (!Number.isSafeInteger(after) || after < 0)
5677
+ fail("INVALID_ARGUMENT", "after must be a nonnegative event_seq");
5678
+ const high = this.store.eventCursor();
5679
+ if (after > high)
5680
+ fail(
5681
+ "CURSOR_AHEAD",
5682
+ "Cursor is ahead of this database; verify CMDR_HOME or restart from --after 0"
5683
+ );
5684
+ const recipient = params.for ? this.store.session(params.for) : void 0;
5685
+ const candidates = this.store.eventPage(after, {
5686
+ squad: params.squad,
5687
+ to: params.for,
5688
+ roleInbox: recipient?.role === "commander" ? `squad:${recipient.squad_id}` : void 0
5689
+ });
5690
+ const events = bounded(
5691
+ params.full ? candidates : candidates.map((e) => ({
5692
+ ...e,
5693
+ message: e.message ? { ...e.message, body: e.message.body.slice(0, 160), data: null } : void 0
5694
+ })),
5695
+ 100
5696
+ );
5697
+ const next = events.length < candidates.length ? events[events.length - 1].event_seq : high;
5698
+ if (method === "admin.tail")
5699
+ ctx.tail = { squad: params.squad, for: params.for, full: params.full, after: high };
5700
+ return {
5701
+ events,
5702
+ next,
5703
+ high,
5704
+ gap: after < this.store.eventFloor(),
5705
+ retained_after: this.store.eventFloor()
5706
+ };
4908
5707
  }
4909
5708
  if (method === "admin.recent")
4910
5709
  return {
4911
- messages: this.store.messages().filter((m) => !params.squad || m.squad_id === params.squad).slice(-Math.min(100, Math.max(1, params.limit || 20))).map((m) => params.full ? m : { ...m, body: m.body.slice(0, 160), data: null })
5710
+ messages: this.store.messages().filter((m) => !params.squad || m.squad_id === params.squad).slice(-20)
4912
5711
  };
4913
5712
  fail("INVALID_ARGUMENT", `Unknown method ${method}`);
4914
5713
  }
@@ -4930,13 +5729,12 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4930
5729
  if (method === "msg.history") p.history = true;
4931
5730
  if (ctx.sid) {
4932
5731
  const s = this.required(ctx);
4933
- s.activity = "busy";
4934
5732
  s.last_seen_at = Date.now();
4935
5733
  this.store.saveSession(s);
4936
5734
  }
4937
5735
  if (tool === "read") {
4938
5736
  this.required(ctx);
4939
- if (!p.history && !p.since && !this.store.queue(ctx.sid).length)
5737
+ if (!p.history && !p.since && !p.recover && !p.id && !this.inbox(this.required(ctx)).length)
4940
5738
  await this.wait(ctx, p.wait, void 0, signal);
4941
5739
  if (signal?.aborted) fail("REQUEST_CANCELLED");
4942
5740
  if (ctx.closed) fail("DAEMON_UNAVAILABLE");
@@ -4967,6 +5765,17 @@ ${join_prompt}` : `Joined squad ${q.id}${s.name ? ` as ${s.name}` : ""}; report
4967
5765
  return this.reportOrAsk(ctx, p, false);
4968
5766
  }
4969
5767
  });
5768
+ if (tool === "join" && p.standby && this.configureStandby) {
5769
+ return this.envelope(ctx, {
5770
+ ...result,
5771
+ standby: this.required(ctx).native_id ? this.configureStandby(ctx.sid, p.standby) : {
5772
+ wake_mode: "manual",
5773
+ health: "manual",
5774
+ can_auto_respond: false,
5775
+ reason: "A confirmed native session ID is required for managed standby; membership is retained."
5776
+ }
5777
+ });
5778
+ }
4970
5779
  return this.envelope(ctx, result);
4971
5780
  }
4972
5781
  };
@@ -4986,7 +5795,58 @@ var Store = class {
4986
5795
  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
5796
  CREATE INDEX IF NOT EXISTS queue ON messages(to_sid,status,priority,seq);
4988
5797
  CREATE INDEX IF NOT EXISTS reply ON messages(reply_to);
4989
- CREATE INDEX IF NOT EXISTS created ON messages(created_at);`);
5798
+ CREATE INDEX IF NOT EXISTS created ON messages(created_at);
5799
+ CREATE TABLE IF NOT EXISTS events(event_seq INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, payload TEXT NOT NULL);
5800
+ CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY, value INTEGER NOT NULL);
5801
+ CREATE TABLE IF NOT EXISTS standby(sid TEXT PRIMARY KEY, payload TEXT NOT NULL);
5802
+ CREATE TABLE IF NOT EXISTS revoked(sid TEXT PRIMARY KEY, replacement TEXT NOT NULL);`);
5803
+ this.migrateWork();
5804
+ }
5805
+ migrateWork() {
5806
+ const legacy = this.unpack(
5807
+ this.db.prepare(
5808
+ "SELECT payload FROM messages WHERE json_extract(payload,'$.type')='command' AND json_extract(payload,'$.work') IS NULL"
5809
+ ).all()
5810
+ );
5811
+ if (!legacy.length) return;
5812
+ this.transaction(() => {
5813
+ for (const m of legacy) {
5814
+ m.work = {
5815
+ state: m.status === "queued" ? "queued" : "read",
5816
+ updated_at: m.delivered_at || m.created_at
5817
+ };
5818
+ const reports = this.unpack(
5819
+ this.db.prepare("SELECT payload FROM messages WHERE reply_to=? ORDER BY seq").all(m.id)
5820
+ );
5821
+ for (const report of reports) {
5822
+ if (report.type !== "report" || report.from_sid !== m.to_sid || report.squad_id !== m.squad_id || terminalWork(m))
5823
+ continue;
5824
+ const state = {
5825
+ working: "accepted",
5826
+ blocked: "accepted",
5827
+ done: "completed",
5828
+ failed: "failed",
5829
+ cancelled: "cancelled"
5830
+ }[String(report.data?.status)];
5831
+ if (!state) continue;
5832
+ m.work.state = state;
5833
+ m.work.updated_at = report.created_at;
5834
+ if (state === "accepted") m.work.accepted_at ||= report.created_at;
5835
+ m.status = "delivered";
5836
+ m.delivered_at ||= report.created_at;
5837
+ }
5838
+ this.saveMessage(m);
5839
+ this.appendEvent({
5840
+ at: Date.now(),
5841
+ kind: "work.migrated",
5842
+ channel: m.squad_id,
5843
+ message_id: m.id,
5844
+ to_sid: m.to_sid,
5845
+ from_sid: m.from_sid,
5846
+ message: m
5847
+ });
5848
+ }
5849
+ });
4990
5850
  }
4991
5851
  transaction(fn) {
4992
5852
  this.db.exec("BEGIN IMMEDIATE");
@@ -5072,7 +5932,89 @@ var Store = class {
5072
5932
  this.db.prepare("DELETE FROM messages WHERE id=?").run(id);
5073
5933
  }
5074
5934
  expireMessages(before) {
5075
- this.db.prepare("DELETE FROM messages WHERE created_at<?").run(before);
5935
+ const active = this.commands();
5936
+ const unfinished = new Set(active.map((m) => m.id));
5937
+ const dependencies = new Set(active.map((m) => m.blocked_by));
5938
+ for (const m of this.messages())
5939
+ 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)))
5940
+ this.deleteMessage(m.id);
5941
+ }
5942
+ commands(sid) {
5943
+ return this.messages().filter(
5944
+ (m) => m.type === "command" && (!sid || m.to_sid === sid) && !terminalWork(m)
5945
+ );
5946
+ }
5947
+ appendEvent(event) {
5948
+ const r = this.db.prepare("INSERT INTO events(at,payload) VALUES (?,?)").run(event.at, JSON.stringify(event));
5949
+ const value = { ...event, event_seq: Number(r.lastInsertRowid) };
5950
+ this.db.prepare("UPDATE events SET payload=? WHERE event_seq=?").run(JSON.stringify(value), value.event_seq);
5951
+ return value;
5952
+ }
5953
+ eventCursor() {
5954
+ return Number(
5955
+ this.db.prepare("SELECT seq FROM sqlite_sequence WHERE name='events'").get()?.seq || 0
5956
+ );
5957
+ }
5958
+ events(after = 0) {
5959
+ return this.unpack(
5960
+ this.db.prepare("SELECT payload FROM events WHERE event_seq>? ORDER BY event_seq").all(after)
5961
+ );
5962
+ }
5963
+ eventPage(after, filter) {
5964
+ return this.unpack(
5965
+ this.db.prepare(
5966
+ `SELECT payload FROM events WHERE event_seq>?
5967
+ AND (? IS NULL OR json_extract(payload,'$.channel')=?)
5968
+ AND (? IS NULL OR json_extract(payload,'$.to_sid')=? OR json_extract(payload,'$.to_sid')=?)
5969
+ ORDER BY event_seq LIMIT 101`
5970
+ ).all(
5971
+ after,
5972
+ filter.squad || null,
5973
+ filter.squad || null,
5974
+ filter.to || null,
5975
+ filter.to || null,
5976
+ filter.roleInbox || null
5977
+ )
5978
+ );
5979
+ }
5980
+ eventFloor() {
5981
+ return Number(
5982
+ this.db.prepare("SELECT value FROM metadata WHERE key='event_floor'").get()?.value || 0
5983
+ );
5984
+ }
5985
+ expireEvents(before) {
5986
+ const last = Number(
5987
+ this.db.prepare("SELECT MAX(event_seq) AS seq FROM events WHERE at<?").get(before)?.seq || 0
5988
+ );
5989
+ if (last) {
5990
+ this.db.prepare(
5991
+ "INSERT INTO metadata VALUES ('event_floor',?) ON CONFLICT(key) DO UPDATE SET value=MAX(value,excluded.value)"
5992
+ ).run(last);
5993
+ this.db.prepare("DELETE FROM events WHERE event_seq<=?").run(last);
5994
+ }
5995
+ }
5996
+ standbys() {
5997
+ return this.unpack(this.db.prepare("SELECT payload FROM standby").all());
5998
+ }
5999
+ standby(sid) {
6000
+ return this.standbys().find((s) => s.sid === sid);
6001
+ }
6002
+ saveStandby(s) {
6003
+ this.db.prepare(
6004
+ "INSERT INTO standby VALUES (?,?) ON CONFLICT(sid) DO UPDATE SET payload=excluded.payload"
6005
+ ).run(s.sid, JSON.stringify(s));
6006
+ }
6007
+ deleteStandby(sid) {
6008
+ this.db.prepare("DELETE FROM standby WHERE sid=?").run(sid);
6009
+ }
6010
+ revoke(sid, replacement) {
6011
+ this.db.prepare("INSERT OR REPLACE INTO revoked VALUES (?,?)").run(sid, replacement);
6012
+ }
6013
+ revoked(sid) {
6014
+ return !!this.db.prepare("SELECT sid FROM revoked WHERE sid=?").get(sid)?.sid;
6015
+ }
6016
+ purge() {
6017
+ this.db.exec("DELETE FROM messages; DELETE FROM standby; DELETE FROM revoked;");
5076
6018
  }
5077
6019
  close() {
5078
6020
  this.db.close();
@@ -5146,14 +6088,14 @@ function logger(path) {
5146
6088
  import { homedir as homedir2, tmpdir } from "node:os";
5147
6089
  import { join as join3, resolve } from "node:path";
5148
6090
  import { mkdirSync, chmodSync } from "node:fs";
5149
- import { createHash } from "node:crypto";
6091
+ import { createHash as createHash2 } from "node:crypto";
5150
6092
  function paths(home = process.env.CMDR_HOME || join3(homedir2(), ".cmdr")) {
5151
6093
  home = resolve(home);
5152
6094
  let socket = join3(home, "cmdr.sock");
5153
6095
  if (Buffer.byteLength(socket) > 100)
5154
6096
  socket = join3(
5155
6097
  tmpdir(),
5156
- `cmdr-${createHash("sha256").update(`${process.getuid?.()}:${home}`).digest("hex").slice(0, 20)}.sock`
6098
+ `cmdr-${createHash2("sha256").update(`${process.getuid?.()}:${home}`).digest("hex").slice(0, 20)}.sock`
5157
6099
  );
5158
6100
  return {
5159
6101
  home,
@@ -5331,8 +6273,16 @@ var Rpc = class extends EventEmitter {
5331
6273
  };
5332
6274
 
5333
6275
  // src/shared/version.ts
5334
- var VERSION = true ? "0.1.1" : "0.1.0";
6276
+ var MIN_CLIENT_VERSION = "0.2.0";
6277
+ var VERSION = true ? "0.2.0" : MIN_CLIENT_VERSION;
5335
6278
  var PROTOCOL = 1;
6279
+ function newer(a, b) {
6280
+ const x = a.split(".").map(Number), y = b.split(".").map(Number);
6281
+ for (let i = 0; i < 3; i++) {
6282
+ if (x[i] !== y[i]) return x[i] > y[i];
6283
+ }
6284
+ return false;
6285
+ }
5336
6286
 
5337
6287
  // src/daemon/server.ts
5338
6288
  async function startDaemon(home) {
@@ -5341,6 +6291,15 @@ async function startDaemon(home) {
5341
6291
  prepare(p);
5342
6292
  if (!acquireLock(p.lock)) return null;
5343
6293
  const log = logger(p.log), store = new Store(p.db), core = new Core(store, p, config(p.config));
6294
+ const standby = new StandbyManager(core);
6295
+ core.configureStandby = (sid, mode) => standby.configure({
6296
+ sid,
6297
+ action: "start",
6298
+ ...mode === "manual" ? { adapter: "manual" } : {}
6299
+ });
6300
+ const wakeTimer = setInterval(() => {
6301
+ void standby.tick().catch((e) => log(`standby failed: ${String(e)}`));
6302
+ }, 2e3);
5344
6303
  const peers = /* @__PURE__ */ new Set();
5345
6304
  let idleSince = Date.now(), stopping = false;
5346
6305
  rmSync4(p.socket, { force: true });
@@ -5363,10 +6322,29 @@ async function startDaemon(home) {
5363
6322
  "PROTOCOL_MISMATCH",
5364
6323
  `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
6324
  );
6325
+ const clientVersion = typeof params.version === "string" ? params.version.match(/^(\d+\.\d+\.\d+)(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/)?.[1] : void 0;
6326
+ if (!clientVersion || newer(MIN_CLIENT_VERSION, clientVersion))
6327
+ fail(
6328
+ "PROTOCOL_MISMATCH",
6329
+ `Daemon ${VERSION} requires client ${MIN_CLIENT_VERSION} or newer for compatible tool semantics. Update/reinstall the plugin cache and restart the host MCP connection.`
6330
+ );
5366
6331
  greeted = true;
6332
+ ctx.version = String(params.version || "unknown").slice(0, 80);
6333
+ ctx.client = String(params.client || "unknown").slice(0, 80);
5367
6334
  return { version: VERSION, protocol: PROTOCOL };
5368
6335
  }
6336
+ if (method === "admin.standby") {
6337
+ if (ctx.kind !== "cli") fail("ROLE_NOT_ALLOWED");
6338
+ const result2 = standby.configure(params);
6339
+ void standby.tick();
6340
+ return result2;
6341
+ }
5369
6342
  if (method === "admin.shutdown") {
6343
+ if (params.reason === "upgrade")
6344
+ fail(
6345
+ "UPGRADE_REQUIRES_RESTART",
6346
+ "Automatic replacement is disabled. Run cmdr daemon restart from the new installation after preflight."
6347
+ );
5370
6348
  if (ctx.kind !== "cli" && params.reason !== "upgrade") fail("ROLE_NOT_ALLOWED");
5371
6349
  log(
5372
6350
  `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)}`
@@ -5390,10 +6368,13 @@ async function startDaemon(home) {
5390
6368
  if (stopping) return;
5391
6369
  stopping = true;
5392
6370
  clearInterval(timer);
5393
- server.close();
6371
+ clearInterval(wakeTimer);
6372
+ standby.close();
6373
+ const closed = new Promise((resolve2) => server.close(() => resolve2()));
5394
6374
  for (const ctx of [...core.contexts]) core.disconnect(ctx);
5395
6375
  core.close();
5396
6376
  for (const peer of peers) peer.close();
6377
+ await closed;
5397
6378
  store.close();
5398
6379
  rmSync4(p.socket, { force: true });
5399
6380
  rmSync4(p.info, { force: true });
@@ -5427,7 +6408,7 @@ async function startDaemon(home) {
5427
6408
  core.housekeep();
5428
6409
  if (existsSync2(p.spawn) && Date.now() - statSync3(p.spawn).mtimeMs > 1e4)
5429
6410
  rmSync4(p.spawn, { recursive: true, force: true });
5430
- if (!peers.size && Date.now() - idleSince >= core.config.idleExitMinutes * 6e4)
6411
+ if (!peers.size && !standby.active && Date.now() - idleSince >= core.config.idleExitMinutes * 6e4)
5431
6412
  void stop();
5432
6413
  } catch (e) {
5433
6414
  log(`housekeeping failed: ${String(e)}`);
@@ -5445,8 +6426,40 @@ async function startDaemon(home) {
5445
6426
  }
5446
6427
  }
5447
6428
 
6429
+ // src/daemon/preflight.ts
6430
+ import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
6431
+ import { existsSync as existsSync3, mkdtempSync, rmSync as rmSync5 } from "node:fs";
6432
+ import { tmpdir as tmpdir2 } from "node:os";
6433
+ import { join as join4 } from "node:path";
6434
+ async function preflight(home) {
6435
+ const dir = mkdtempSync(join4(tmpdir2(), "cmdr-preflight-"));
6436
+ try {
6437
+ const source = paths(home).db, target = join4(dir, "state.db");
6438
+ if (existsSync3(source)) {
6439
+ const db = new DatabaseSync3(source, { readOnly: true });
6440
+ try {
6441
+ db.prepare("VACUUM INTO ?").run(target);
6442
+ } finally {
6443
+ db.close();
6444
+ }
6445
+ }
6446
+ const store = new Store(target);
6447
+ try {
6448
+ store.sessions();
6449
+ store.squads();
6450
+ store.messages();
6451
+ store.standbys();
6452
+ } finally {
6453
+ store.close();
6454
+ }
6455
+ } finally {
6456
+ rmSync5(dir, { recursive: true, force: true });
6457
+ }
6458
+ }
6459
+
5448
6460
  // src/daemon/main.ts
5449
- startDaemon().catch((e) => {
6461
+ var operation = process.argv.includes("--preflight") ? preflight() : startDaemon();
6462
+ operation.catch((e) => {
5450
6463
  process.stderr.write(`cmdr daemon: ${e.message}
5451
6464
  `);
5452
6465
  process.exitCode = 1;