cyberfleet 0.0.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 unional
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
File without changes
package/dist/cli.mjs CHANGED
@@ -1,499 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from "node:child_process";
3
- import { Command } from "commander";
4
- import { randomBytes } from "node:crypto";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
6
- import { dirname, join, resolve } from "node:path";
7
- //#region src/console/worktree.ts
8
- /** The only worktree backend at MVP — plain `git worktree`. */
9
- const gitWorktreeAdapter = {
10
- add(exec, opts) {
11
- if (exec("git", [
12
- "-C",
13
- opts.primaryRoot,
14
- "worktree",
15
- "add",
16
- "-b",
17
- opts.branch,
18
- opts.path
19
- ]) === null) throw new Error(`git worktree add failed for ${opts.path}`);
20
- return {
21
- root: resolve(opts.path),
22
- branch: opts.branch
23
- };
24
- },
25
- remove(exec, path, opts) {
26
- if (exec("git", [
27
- "-C",
28
- opts.primaryRoot,
29
- "worktree",
30
- "remove",
31
- path,
32
- "--force"
33
- ]) === null) throw new Error(`git worktree remove failed for ${path}`);
34
- }
35
- };
36
- /**
37
- * Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
38
- * checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
39
- */
40
- function resolvePrimaryRoot(exec) {
41
- const commonDir = exec("git", [
42
- "rev-parse",
43
- "--path-format=absolute",
44
- "--git-common-dir"
45
- ]);
46
- if (!commonDir) throw new Error("cannot resolve the primary checkout — not inside a git repository");
47
- return dirname(commonDir);
48
- }
49
- /**
50
- * The flagship rule: refuse when a ship's resolved worktree root would be the primary checkout
51
- * itself. Mirrors firstmate's "refuse unless the path is a real worktree root distinct from the
52
- * project primary checkout" — a mission is never run in the primary checkout.
53
- */
54
- function assertDistinctFromPrimary(worktreeRoot, primaryRoot) {
55
- if (resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error("refusing to run a mission in the primary checkout — spawn a worktree-ship distinct from the flagship");
56
- }
57
- //#endregion
58
- //#region src/paths.ts
59
- /** The tracked marker file inside `.cyberfleet/` that makes a project root a ship (see detectMode). */
60
- const MARKER_FILE = "config.json";
61
- /** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
62
- function projectRoot(cwd = process.cwd()) {
63
- let dir = resolve(cwd);
64
- for (;;) {
65
- if (existsSync(join(dir, ".git"))) return dir;
66
- const parent = dirname(dir);
67
- if (parent === dir) return resolve(cwd);
68
- dir = parent;
69
- }
70
- }
71
- function makeDefaultExec(cwd) {
72
- return (cmd, args) => {
73
- try {
74
- return execFileSync(cmd, args, {
75
- encoding: "utf8",
76
- stdio: [
77
- "ignore",
78
- "pipe",
79
- "ignore"
80
- ],
81
- cwd
82
- }).trim();
83
- } catch {
84
- return null;
85
- }
86
- };
87
- }
88
- /**
89
- * Resolve the shared `.cyberfleet` transport root. Precedence: explicit --root/--space, then
90
- * CYBERFLEET_ROOT, then the PRIMARY checkout's `.cyberfleet` (ADR-0022 decision 10 — the primary
91
- * and every worktree are both ships, but runtime state — agents/inbox/panes — stays pinned to the
92
- * primary so it is shared fleet-wide instead of forking per worktree), falling back to the
93
- * caller's own project root when not inside a git repository.
94
- */
95
- function resolveRoot(opts = {}) {
96
- const env = opts.env ?? process.env;
97
- const explicit = opts.root ?? opts.space ?? env.CYBERFLEET_ROOT;
98
- if (explicit) return resolve(explicit);
99
- const exec = opts.exec ?? makeDefaultExec(opts.cwd);
100
- let primary;
101
- try {
102
- primary = resolvePrimaryRoot(exec);
103
- } catch {
104
- primary = void 0;
105
- }
106
- return join(primary ?? projectRoot(opts.cwd), ".cyberfleet");
107
- }
108
- /**
109
- * Detect ship vs. command-center by the tracked `.cyberfleet/config.json` marker's presence alone
110
- * at this project root — no check against `.agents/specs` or any other SDD state. The marker is
111
- * tracked (`.gitignore` excludes the rest of `.cyberfleet/`), so it travels to every worktree: the
112
- * primary checkout AND every worktree it spawns are both ships once the marker exists. Checking
113
- * the marker file rather than just the directory avoids a false positive from a stray empty
114
- * `.cyberfleet/` dir (e.g. one left behind after a partial teardown).
115
- */
116
- function detectMode(opts = {}) {
117
- const cwdRoot = projectRoot(opts.cwd);
118
- return {
119
- mode: existsSync(join(cwdRoot, ".cyberfleet", MARKER_FILE)) ? "ship" : "command-center",
120
- cwdRoot,
121
- fleetRoot: resolveRoot(opts)
122
- };
123
- }
124
- /**
125
- * Create the tracked `.cyberfleet/config.json` marker at `root` (a fleet root, i.e. a
126
- * `.cyberfleet/` dir path) if it does not already exist, making that project root a ship. Callers
127
- * pass the root under which `.cyberfleet/` should be created/ensured; this mkdir's it as needed.
128
- * Idempotent — never overwrites an existing marker.
129
- */
130
- function ensureFleetMarker(root) {
131
- mkdirSync(root, { recursive: true });
132
- const marker = join(root, MARKER_FILE);
133
- if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
134
- }
135
- const paths = {
136
- agentsDir: (root) => join(root, "agents"),
137
- agentFile: (root, id) => join(root, "agents", `${id}.json`),
138
- panesDir: (root) => join(root, "panes"),
139
- paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
140
- selfFile: (root) => join(root, "self"),
141
- inboxDir: (root, id) => join(root, "inbox", id),
142
- inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
143
- dataDir: (root, id) => join(root, "data", id),
144
- briefFile: (root, id) => join(root, "data", id, "brief.md"),
145
- worktreeDir: (root, id) => join(root, "worktrees", id)
146
- };
147
- /** tmux pane ids look like "%3"; make them filesystem-safe. */
148
- function sanitizePane(pane) {
149
- return pane.replace(/[^A-Za-z0-9_-]/g, "_");
150
- }
151
- //#endregion
152
- //#region src/identity.ts
153
- const realExec = (cmd, args) => {
154
- try {
155
- return execFileSync(cmd, args, {
156
- encoding: "utf8",
157
- stdio: [
158
- "ignore",
159
- "pipe",
160
- "ignore"
161
- ]
162
- }).trim();
163
- } catch {
164
- return null;
165
- }
166
- };
167
- const nowIso = (ctx) => new Date(ctx.now?.() ?? Date.now()).toISOString();
168
- function randomId() {
169
- return randomBytes(8).toString("hex");
170
- }
171
- function writeJson$1(file, data) {
172
- mkdirSync(dirname(file), { recursive: true });
173
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
174
- }
175
- /** Detect the harness: explicit wins, then env probes, then the tmux pane command. */
176
- function detectHarness(explicit, ctx) {
177
- if (explicit) {
178
- if (explicit === "claude" || explicit === "cursor" || explicit === "codex") return explicit;
179
- throw new Error(`unknown --harness "${explicit}" (expected claude | cursor | codex)`);
180
- }
181
- const env = ctx.env ?? process.env;
182
- if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
183
- if (Object.keys(env).some((k) => k.startsWith("CURSOR"))) return "cursor";
184
- if (Object.keys(env).some((k) => k.startsWith("CODEX"))) return "codex";
185
- const pane = env.TMUX_PANE;
186
- if (pane) {
187
- const cmd = (ctx.exec ?? realExec)("tmux", [
188
- "display-message",
189
- "-p",
190
- "-t",
191
- pane,
192
- "#{pane_current_command}"
193
- ]);
194
- if (cmd?.includes("cursor")) return "cursor";
195
- if (cmd?.includes("codex")) return "codex";
196
- if (cmd?.includes("claude")) return "claude";
197
- }
198
- }
199
- /**
200
- * Recover the calling agent's own id. In tmux the pane file is authoritative — a pane with no
201
- * pane file is simply unregistered and must NOT adopt the non-tmux self identity (per the frozen
202
- * scenario, the env/self-file fallback applies only when there is no $TMUX_PANE).
203
- */
204
- function resolveSelfId(ctx) {
205
- const env = ctx.env ?? process.env;
206
- const pane = env.TMUX_PANE;
207
- if (pane) {
208
- const file = paths.paneFile(ctx.root, pane);
209
- return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
210
- }
211
- if (env.CYBERFLEET_AGENT_ID) return env.CYBERFLEET_AGENT_ID;
212
- const self = paths.selfFile(ctx.root);
213
- if (existsSync(self)) return readFileSync(self, "utf8").trim();
214
- }
215
- function loadAgent(root, id) {
216
- const file = paths.agentFile(root, id);
217
- if (!existsSync(file)) return void 0;
218
- return JSON.parse(readFileSync(file, "utf8"));
219
- }
220
- function saveAgent(root, rec) {
221
- writeJson$1(paths.agentFile(root, rec.id), rec);
222
- }
223
- function listAgents(root) {
224
- const dir = paths.agentsDir(root);
225
- if (!existsSync(dir)) return [];
226
- return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(`${dir}/${f}`, "utf8"))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
227
- }
228
- /** Register (or idempotently refresh) this session's identity. */
229
- function register(ctx, input) {
230
- ensureFleetMarker(ctx.root);
231
- const env = ctx.env ?? process.env;
232
- const harness = detectHarness(input.harness, ctx);
233
- if (!harness) throw new Error("could not detect harness — pass --harness claude|cursor|codex");
234
- const existingId = resolveSelfId(ctx);
235
- const existing = existingId ? loadAgent(ctx.root, existingId) : void 0;
236
- const id = existing?.id ?? existingId ?? randomId();
237
- const ts = nowIso(ctx);
238
- const pane = env.TMUX_PANE;
239
- const exec = ctx.exec ?? realExec;
240
- const rec = {
241
- id,
242
- handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
243
- harness,
244
- cwd: process.cwd(),
245
- worktree: existing?.worktree ?? gitWorktree(exec),
246
- tmux: pane ? {
247
- pane,
248
- window: exec("tmux", [
249
- "display-message",
250
- "-p",
251
- "-t",
252
- pane,
253
- "#{window_id}"
254
- ]) ?? void 0,
255
- session: env.TMUX?.split(",")[0]
256
- } : null,
257
- status: "active",
258
- createdAt: existing?.createdAt ?? ts,
259
- lastSeen: ts,
260
- ...existing?.brief ? { brief: existing.brief } : {},
261
- ...existing?.spawnedBy ? { spawnedBy: existing.spawnedBy } : {}
262
- };
263
- saveAgent(ctx.root, rec);
264
- if (pane) writeFileText(paths.paneFile(ctx.root, pane), id);
265
- else writeFileText(paths.selfFile(ctx.root), id);
266
- return rec;
267
- }
268
- function gitWorktree(exec) {
269
- const root = exec("git", ["rev-parse", "--show-toplevel"]);
270
- if (!root) return null;
271
- return {
272
- root,
273
- branch: exec("git", [
274
- "rev-parse",
275
- "--abbrev-ref",
276
- "HEAD"
277
- ]) ?? void 0
278
- };
279
- }
280
- function writeFileText(file, text) {
281
- mkdirSync(dirname(file), { recursive: true });
282
- writeFileSync(file, text);
283
- }
284
- /** Resolve a recipient argument (id or handle) to an agent id. */
285
- function resolveRecipient(root, to) {
286
- if (loadAgent(root, to)) return to;
287
- const match = listAgents(root).find((a) => a.handle === to);
288
- if (!match) throw new Error(`no agent addressable as "${to}"`);
289
- return match.id;
290
- }
291
- /**
292
- * Resolve a ship reference by id, handle, or its worktree branch (the ship↔CR join key, ADR-0022
293
- * decision 8 + the `add-fleet-comms` CR's convention: an `AgentRecord.worktree.branch` equals the
294
- * SDD `<cr-ref>` it maps to). Used by verbs that address "the ship working on CR X" as well as
295
- * "the ship named X".
296
- */
297
- function resolveShip(root, ref) {
298
- const byId = loadAgent(root, ref);
299
- if (byId) return byId;
300
- const agents = listAgents(root);
301
- const byHandle = agents.find((a) => a.handle === ref);
302
- if (byHandle) return byHandle;
303
- const byBranch = agents.find((a) => a.worktree?.branch === ref);
304
- if (byBranch) return byBranch;
305
- throw new Error(`no ship addressable as "${ref}" (tried id, handle, and worktree branch/CR)`);
306
- }
307
- /**
308
- * Pause a ship's mission — a cyberfleet-level marker on its `AgentRecord.status` only. This is
309
- * NOT a bridge to SDD's `pause-mission` checkpoint (which rewrites the plan brief's todos/NEXT
310
- * anchor) — that gap is flagged, not silently papered over: a caller who wants the actual mission
311
- * checkpoint must run `sdd:pause-mission` in-session.
312
- */
313
- function pauseAgent(root, id) {
314
- const rec = loadAgent(root, id);
315
- if (!rec) throw new Error(`no agent "${id}"`);
316
- rec.status = "paused";
317
- saveAgent(root, rec);
318
- return rec;
319
- }
320
- function bumpLastSeen(ctx, id) {
321
- const rec = loadAgent(ctx.root, id);
322
- if (!rec) return;
323
- rec.lastSeen = nowIso(ctx);
324
- saveAgent(ctx.root, rec);
325
- }
326
- /** Refresh this session's own last-seen if it is registered — best-effort, never throws. */
327
- function touch(ctx) {
328
- const id = resolveSelfId(ctx);
329
- if (id) bumpLastSeen(ctx, id);
330
- }
331
- const STALE_MS = 900 * 1e3;
332
- /** Mark agents whose pane is gone or whose last-seen is stale as exited. */
333
- function prune(ctx) {
334
- const exec = ctx.exec ?? realExec;
335
- const now = ctx.now?.() ?? Date.now();
336
- const changed = [];
337
- for (const rec of listAgents(ctx.root)) {
338
- if (rec.status === "exited") continue;
339
- const paneGone = rec.tmux?.pane ? exec("tmux", [
340
- "has-session",
341
- "-t",
342
- rec.tmux.pane
343
- ]) === null && !(exec("tmux", [
344
- "list-panes",
345
- "-a",
346
- "-F",
347
- "#{pane_id}"
348
- ]) ?? "").split("\n").includes(rec.tmux.pane) : false;
349
- const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
350
- if (paneGone || stale) {
351
- rec.status = "exited";
352
- saveAgent(ctx.root, rec);
353
- changed.push(rec);
354
- }
355
- }
356
- return changed;
357
- }
358
- //#endregion
359
- //#region src/install.ts
360
- const hookCommand = (event) => `cyberfleet inbox --hook --event ${event}`;
361
- const VENDORS = {
362
- claude: {
363
- file: ".claude/settings.json",
364
- shape: "claude",
365
- events: {
366
- SessionStart: "SessionStart",
367
- PostToolUse: "PostToolUse"
368
- }
369
- },
370
- cursor: {
371
- file: ".cursor/hooks.json",
372
- shape: "cursor",
373
- events: { SessionStart: "sessionStart" }
374
- },
375
- codex: {
376
- file: ".codex/hooks.json",
377
- shape: "cursor",
378
- events: {
379
- SessionStart: "SessionStart",
380
- PostToolUse: "PostToolUse"
381
- }
382
- }
383
- };
384
- function readJson(file) {
385
- if (!existsSync(file)) return {};
386
- try {
387
- return JSON.parse(readFileSync(file, "utf8"));
388
- } catch {
389
- return {};
390
- }
391
- }
392
- function writeJson(file, data) {
393
- mkdirSync(dirname(file), { recursive: true });
394
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
395
- }
396
- /** Register the surfacing hook into one harness's config, idempotently. */
397
- function install(harness, projectDir = process.cwd()) {
398
- const spec = VENDORS[harness];
399
- if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
400
- const file = join(projectDir, spec.file);
401
- const settings = readJson(file);
402
- const results = [];
403
- for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
404
- const command = hookCommand(canonical);
405
- const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
406
- results.push({
407
- harness,
408
- event: canonical,
409
- vendorEvent,
410
- file,
411
- status
412
- });
413
- }
414
- writeJson(file, settings);
415
- return results;
416
- }
417
- function upsertClaude(settings, event, command) {
418
- const hooks = settings.hooks ??= {};
419
- const groups = hooks[event] ??= [];
420
- if (groups.some((g) => g.hooks?.some((h) => h.command === command))) return "already present";
421
- const group = { hooks: [{
422
- type: "command",
423
- command
424
- }] };
425
- if (event === "PostToolUse") group.matcher = "Write|Edit";
426
- groups.push(group);
427
- return "registered";
428
- }
429
- function upsertCursor(settings, event, command) {
430
- if (settings.version == null) settings.version = 1;
431
- const hooks = settings.hooks ??= {};
432
- const list = hooks[event] ??= [];
433
- if (list.some((h) => h.command === command)) return "already present";
434
- list.push({ command });
435
- return "registered";
436
- }
437
- //#endregion
438
- //#region src/message.ts
439
- /** Write one message file into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
440
- function send(ctx, input) {
441
- const toId = resolveRecipient(ctx.root, input.to);
442
- const from = loadAgent(ctx.root, input.fromId);
443
- const ts = ctx.now?.() ?? Date.now();
444
- const id = `${ts}-${randomBytes(3).toString("hex")}`;
445
- const msg = {
446
- id,
447
- from: input.fromId,
448
- fromHandle: from?.handle ?? input.fromId,
449
- to: toId,
450
- ...input.subject ? { subject: input.subject } : {},
451
- body: input.body,
452
- ...input.thread ? { thread: input.thread } : {},
453
- ...input.replyTo ? { replyTo: input.replyTo } : {},
454
- ts,
455
- sentAt: new Date(ts).toISOString()
456
- };
457
- const dir = paths.inboxDir(ctx.root, toId);
458
- mkdirSync(dir, { recursive: true });
459
- writeFileSync(join(dir, `${id}.json`), `${JSON.stringify(msg, null, 2)}\n`);
460
- return msg;
461
- }
462
- /** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
463
- function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
464
- if (body != null) return body;
465
- if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
466
- throw new Error("provide --body <text> or --body-file <path|->");
467
- }
468
- function readDir(dir) {
469
- if (!existsSync(dir)) return [];
470
- return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")));
471
- }
472
- /** List the caller's mail, chronological (lexical filename sort == time order). */
473
- function inbox(ctx, q) {
474
- const unread = readDir(paths.inboxDir(ctx.root, q.meId)).map((m) => ({
475
- ...m,
476
- read: false
477
- }));
478
- const acked = q.unread ? [] : readDir(paths.inboxReadDir(ctx.root, q.meId)).map((m) => ({
479
- ...m,
480
- read: true
481
- }));
482
- let items = [...unread, ...acked];
483
- if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
484
- return items.sort((a, b) => a.id.localeCompare(b.id));
485
- }
486
- /** Print + ack a message by moving it out of the unread set. Errors if not unread. */
487
- function read(ctx, meId, msgId) {
488
- const src = join(paths.inboxDir(ctx.root, meId), `${msgId}.json`);
489
- if (!existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
490
- const msg = JSON.parse(readFileSync(src, "utf8"));
491
- const readDirPath = paths.inboxReadDir(ctx.root, meId);
492
- mkdirSync(readDirPath, { recursive: true });
493
- renameSync(src, join(readDirPath, `${msgId}.json`));
494
- return msg;
495
- }
496
- //#endregion
2
+ import { Command, Option } from "commander";
3
+ import { FileStore, emit, listAgents, projectRoot, realExec, resolveAgent, resolvePrimaryRoot, resolveRoot, selectSessionAdapter, toonList, toonObject, touch } from "cyberlegion";
4
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
497
6
  //#region src/sdd/hal.ts
498
7
  /**
499
8
  * Infer whether a CR's persisted gate lines show a self-assertion the run-level leash did not
@@ -832,300 +341,149 @@ function buildMissions(agentsRoot, agents) {
832
341
  return agents.map((a) => buildMissionRow(agentsRoot, a));
833
342
  }
834
343
  //#endregion
835
- //#region src/output.ts
836
- function printFields(fields) {
837
- const entries = Object.entries(fields).filter(([, v]) => v != null);
838
- if (entries.length === 0) return;
839
- const width = Math.max(...entries.map(([k]) => k.length));
840
- for (const [key, val] of entries) console.log(`${key.padEnd(width)} ${val}`);
841
- }
842
- function printTable(items, cols) {
843
- if (items.length === 0) {
844
- console.log("(none)");
845
- return;
846
- }
847
- const widths = cols.map((c) => Math.max(c.label.length, ...items.map((i) => c.get(i).length)));
848
- console.log(cols.map((c, i) => c.label.toUpperCase().padEnd(widths[i])).join(" "));
849
- console.log(widths.map((w) => "-".repeat(w)).join(" "));
850
- for (const item of items) console.log(cols.map((c, i) => c.get(item).padEnd(widths[i])).join(" "));
851
- }
852
- //#endregion
853
- //#region src/runtime/inject-inbox.ts
854
- const EVENTS = ["SessionStart", "PostToolUse"];
344
+ //#region src/mode.ts
345
+ /** The tracked marker file that makes a cyberlegion root initialized (mirrors cyberlegion's own
346
+ * `ensureMarker` — kept in sync by hand since it is not itself exported). */
347
+ const MARKER_FILE = "config.json";
855
348
  /**
856
- * Resolve the calling agent, gather its pending brief + unread mail, and return the
857
- * SessionStart-style injection payload — or null when there is nothing to inject (an
858
- * unregistered caller or an empty inbox never fails the harness hook).
349
+ * Detect ship vs. command-center by the tracked `.agents/cyberlegion/config.json` marker's
350
+ * presence alone at this project root — no check against `.agents/specs` or any other SDD state.
351
+ * A spawned unit worktree is stamped with its own marker at spawn time (see cyberlegion's
352
+ * `session.ts`), so this simply asks: is this cwd's project root one of those stamped worktrees?
859
353
  */
860
- function injectInbox(ctx, event) {
861
- if (!EVENTS.includes(event)) throw new Error(`unsupported --event "${event}" (expected ${EVENTS.join(" | ")})`);
862
- const meId = resolveSelfId(ctx);
863
- if (!meId) return null;
864
- const parts = [];
865
- const rec = loadAgent(ctx.root, meId);
866
- if (rec?.status === "spawning") {
867
- const briefFile = paths.briefFile(ctx.root, meId);
868
- if (existsSync(briefFile)) parts.push(`## Your brief\n\n${readFileSync(briefFile, "utf8").trim()}`);
869
- rec.status = "active";
870
- saveAgent(ctx.root, rec);
871
- }
872
- const unread = inbox({ root: ctx.root }, {
873
- meId,
874
- unread: true
875
- });
876
- if (unread.length > 0) {
877
- const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
878
- parts.push(`## Unread fleet mail (${unread.length})\n\n${lines.join("\n")}`);
879
- }
880
- if (parts.length === 0) return null;
881
- return { hookSpecificOutput: {
882
- hookEventName: event,
883
- additionalContext: parts.join("\n\n")
884
- } };
354
+ function detectMode(opts = {}) {
355
+ const cwdRoot = projectRoot(opts.cwd);
356
+ return {
357
+ mode: existsSync(join(cwdRoot, ".agents", "cyberlegion", MARKER_FILE)) ? "ship" : "command-center",
358
+ cwdRoot,
359
+ fleetRoot: resolveRoot(opts)
360
+ };
885
361
  }
886
362
  //#endregion
887
363
  //#region src/cli.ts
888
364
  function ctxOf(opts) {
889
365
  return {
890
- root: resolveRoot({
366
+ store: new FileStore(resolveRoot({
891
367
  root: opts.root,
892
368
  space: opts.space
893
- }),
369
+ })),
894
370
  env: process.env
895
371
  };
896
372
  }
897
- function requireSelf(ctx) {
898
- const id = resolveSelfId(ctx);
899
- if (!id) throw new Error("no fleet identity in this session — run `cyberfleet register` first");
900
- bumpLastSeen(ctx, id);
901
- return id;
373
+ function formatOf(opts) {
374
+ return opts.format === "json" ? "json" : "toon";
375
+ }
376
+ /**
377
+ * Pause a unit's mission — a cyberfleet-level marker on its `AgentRecord.status` only. Not
378
+ * exported by cyberlegion (fleet-specific concept), so it stays here — a thin write through the
379
+ * shared `Store` seam. This is NOT a bridge to SDD's `pause-mission` checkpoint (which rewrites the
380
+ * plan brief's todos/## NEXT anchor) — that gap is flagged, not silently papered over: a caller who
381
+ * wants the actual mission checkpoint must run `sdd:pause-mission` in-session.
382
+ */
383
+ function pauseAgent(ctx, id) {
384
+ const rec = ctx.store.getAgent(id);
385
+ if (!rec) throw new Error(`no agent "${id}"`);
386
+ rec.status = "paused";
387
+ ctx.store.putAgent(rec);
388
+ return rec;
902
389
  }
903
- const rootOpts = (cmd) => cmd.option("--root <path>", "transport root (.cyberfleet dir)").option("--space <path>", "alias for --root");
390
+ const rootOpts = (cmd) => cmd.option("--root <path>", "cyberlegion hub root (overrides the global hub / $CYBERLEGION_ROOT)").option("--space <path>", "alias for --root").addOption(new Option("--format <format>", "output format").choices(["toon", "json"]).default("toon"));
904
391
  const program = new Command();
905
- program.name("cyberfleet").description("Harness-agnostic, MCP-free inter-agent sessions and messaging").version("0.0.0");
906
- rootOpts(program.command("register")).description("register or refresh this session identity").option("--handle <name>", "human handle for this agent").option("--harness <h>", "claude | cursor | codex (else auto-detected)").action((opts) => {
907
- const rec = register(ctxOf(opts), {
908
- handle: opts.handle,
909
- harness: opts.harness
910
- });
911
- printFields({
912
- id: rec.id,
913
- handle: rec.handle,
914
- harness: rec.harness,
915
- status: rec.status
916
- });
917
- });
918
- rootOpts(program.command("who")).description("list the addressable fleet").option("--all", "include exited agents").action((opts) => {
919
- const ctx = ctxOf(opts);
920
- touch(ctx);
921
- printTable(listAgents(ctx.root).filter((a) => opts.all || a.status !== "exited"), [
922
- {
923
- label: "handle",
924
- get: (a) => a.handle
925
- },
926
- {
927
- label: "harness",
928
- get: (a) => a.harness
929
- },
930
- {
931
- label: "cwd",
932
- get: (a) => a.cwd
933
- },
934
- {
935
- label: "status",
936
- get: (a) => a.status
937
- },
938
- {
939
- label: "pane",
940
- get: (a) => a.tmux?.pane ?? "-"
941
- },
942
- {
943
- label: "last-seen",
944
- get: (a) => a.lastSeen
945
- },
946
- {
947
- label: "id",
948
- get: (a) => a.id
949
- }
950
- ]);
951
- });
952
- rootOpts(program.command("mode")).description("report ship (a .cyberfleet/ dir here) vs command-center, and the shared fleet root").action((opts) => {
392
+ program.name("cyberfleet").description("Fleet layer over cyberlegion — ships, missions, and the Council view").version("0.0.0");
393
+ rootOpts(program.command("mode")).description("report ship (a spawned unit worktree) vs command-center, and the shared fleet root").action((opts) => {
953
394
  const info = detectMode({
954
395
  root: opts.root,
955
396
  space: opts.space
956
397
  });
957
- printFields({
958
- mode: info.mode,
959
- cwdRoot: info.cwdRoot,
960
- fleetRoot: info.fleetRoot
961
- });
962
- });
963
- rootOpts(program.command("send")).description("send a message to a peer (by handle or id)").requiredOption("--to <peer>", "recipient handle or id").option("--subject <s>", "subject").option("--body <text>", "message body").option("--body-file <path>", "read body from a file, or - for stdin").option("--thread <id>", "thread id").option("--reply-to <msg>", "message id this replies to").action((opts) => {
964
- const ctx = ctxOf(opts);
965
- const fromId = requireSelf(ctx);
966
- const body = resolveBody(opts.body, opts.bodyFile);
967
- const msg = send({ root: ctx.root }, {
968
- fromId,
969
- to: opts.to,
970
- subject: opts.subject,
971
- body,
972
- thread: opts.thread,
973
- replyTo: opts.replyTo
974
- });
975
- printFields({
976
- sent: msg.id,
977
- to: opts.to,
978
- subject: msg.subject
979
- });
980
- });
981
- rootOpts(program.command("inbox")).description("list your mail, or emit the SessionStart hook payload").option("--unread", "only un-acked mail").option("--from <id>", "filter by sender").option("--hook", "emit the harness hook injection payload instead").option("--event <event>", "SessionStart | PostToolUse (with --hook)", "SessionStart").action((opts) => {
982
- const ctx = ctxOf(opts);
983
- if (opts.hook) {
984
- touch(ctx);
985
- const payload = injectInbox(ctx, opts.event);
986
- if (payload) console.log(JSON.stringify(payload));
987
- return;
988
- }
989
- const meId = requireSelf(ctx);
990
- const items = inbox({ root: ctx.root }, {
991
- meId,
992
- unread: opts.unread,
993
- from: opts.from
994
- });
995
- if (items.length === 0) {
996
- console.log(opts.unread ? "(no unread mail)" : "(inbox empty)");
997
- return;
998
- }
999
- for (const m of items) console.log(`${m.read ? " " : "*"} ${m.id} ${m.fromHandle}${m.subject ? ` — ${m.subject}` : ""}: ${m.body}`);
1000
- });
1001
- rootOpts(program.command("read")).description("print a message and acknowledge it").argument("<msg-id>", "message id").action((msgId, opts) => {
1002
- const ctx = ctxOf(opts);
1003
- const meId = requireSelf(ctx);
1004
- const msg = read({ root: ctx.root }, meId, msgId);
1005
- printFields({
1006
- from: msg.fromHandle,
1007
- subject: msg.subject,
1008
- id: msg.id
398
+ emit(formatOf(opts), {
399
+ toon: toonObject({
400
+ mode: info.mode,
401
+ cwdRoot: info.cwdRoot,
402
+ fleetRoot: info.fleetRoot
403
+ }),
404
+ json: info
1009
405
  });
1010
- console.log(`\n${msg.body}`);
1011
406
  });
1012
- rootOpts(program.command("spawn")).description("launch a new peer session in its own git worktree (tmux or herdr)").requiredOption("--harness <h>", "claude | cursor | codex").option("--task <text>", "brief text, or - for stdin").option("--brief-file <path>", "read the brief from a file").option("--handle <name>", "handle for the new peer").option("--branch <name>", "branch for the new ship worktree (default cyberfleet/ship-<id>)").option("--worktree-path <path>", "where to check out the new ship worktree").action(async (opts) => {
1013
- const { spawn } = await import("./spawn-C036T1Ob.mjs");
407
+ rootOpts(program.command("missions")).description("who needs the Council's hands — ships × mission × gate × leash, derived from SDD state").option("--agents-root <path>", "override the root under which .agents/ is resolved (default: the primary checkout)").action((opts) => {
1014
408
  const ctx = ctxOf(opts);
1015
409
  touch(ctx);
1016
- const res = spawn(ctx, {
1017
- harness: opts.harness,
1018
- task: opts.task,
1019
- briefFile: opts.briefFile,
1020
- handle: opts.handle,
1021
- branch: opts.branch,
1022
- worktreePath: opts.worktreePath
1023
- });
1024
- printFields({
1025
- spawned: res.agent.id,
1026
- handle: res.agent.handle,
1027
- harness: res.agent.harness,
1028
- worktree: res.agent.worktree?.root,
1029
- pane: res.pane,
1030
- launch: res.launch
410
+ const rows = buildMissions(opts.agentsRoot ?? resolveAgentsRoot(realExec), listAgents(ctx.store).filter((a) => a.status !== "exited"));
411
+ emit(formatOf(opts), {
412
+ toon: toonList("missions", rows, [
413
+ {
414
+ key: "handle",
415
+ get: (r) => r.handle
416
+ },
417
+ {
418
+ key: "branch",
419
+ get: (r) => r.branch ?? "-"
420
+ },
421
+ {
422
+ key: "status",
423
+ get: (r) => r.status
424
+ },
425
+ {
426
+ key: "mission",
427
+ get: (r) => r.mission ? `${r.mission.status} ${r.mission.completed}/${r.mission.total}` : "-"
428
+ },
429
+ {
430
+ key: "spec",
431
+ get: (r) => r.spec?.status ?? "-"
432
+ },
433
+ {
434
+ key: "gate:spec",
435
+ get: (r) => r.gate.spec ? `${r.gate.spec.verdict}(${r.gate.spec.by})` : "-"
436
+ },
437
+ {
438
+ key: "gate:impl",
439
+ get: (r) => r.gate.impl ? `${r.gate.impl.verdict}(${r.gate.impl.by})` : "-"
440
+ },
441
+ {
442
+ key: "leash",
443
+ get: (r) => r.leash ?? "-"
444
+ },
445
+ {
446
+ key: "council",
447
+ get: (r) => r.needsCouncil ? "yes" : "-"
448
+ },
449
+ {
450
+ key: "hal",
451
+ get: (r) => r.hal ? "!" : ""
452
+ }
453
+ ], `${rows.length} ships`),
454
+ json: rows
1031
455
  });
1032
456
  });
1033
- rootOpts(program.command("prune")).description("mark dead agents exited and sweep").action((opts) => {
1034
- const ctx = ctxOf(opts);
1035
- touch(ctx);
1036
- const changed = prune(ctx);
1037
- console.log(changed.length ? `pruned ${changed.length} agent(s)` : "nothing to prune");
1038
- });
1039
- program.command("install").description("wire the fleet surfacing hook into a harness config").requiredOption("--agent <harness>", "claude | cursor | codex").option("--dir <path>", "project dir to write config into", process.cwd()).action((opts) => {
1040
- const results = install(opts.agent, opts.dir);
1041
- for (const r of results) console.log(`${r.status}: ${r.harness} ${r.vendorEvent} → ${r.file}`);
1042
- });
1043
- rootOpts(program.command("missions")).description("who needs the Council's hands — ships × mission × gate × leash, derived from SDD state").option("--json", "emit the full structured array instead of the table").option("--agents-root <path>", "override the root under which .agents/ is resolved (default: the primary checkout)").action((opts) => {
1044
- const ctx = ctxOf(opts);
1045
- touch(ctx);
1046
- const rows = buildMissions(opts.agentsRoot ?? resolveAgentsRoot(realExec), listAgents(ctx.root).filter((a) => a.status !== "exited"));
1047
- if (opts.json) {
1048
- console.log(JSON.stringify(rows, null, 2));
1049
- return;
1050
- }
1051
- printTable(rows, [
1052
- {
1053
- label: "handle",
1054
- get: (r) => r.handle
1055
- },
1056
- {
1057
- label: "branch/cr",
1058
- get: (r) => r.branch ?? "-"
1059
- },
1060
- {
1061
- label: "status",
1062
- get: (r) => r.status
1063
- },
1064
- {
1065
- label: "mission",
1066
- get: (r) => r.mission ? `${r.mission.status} ${r.mission.completed}/${r.mission.total}` : "-"
1067
- },
1068
- {
1069
- label: "spec",
1070
- get: (r) => r.spec?.status ?? "-"
1071
- },
1072
- {
1073
- label: "gate:spec",
1074
- get: (r) => r.gate.spec ? `${r.gate.spec.verdict}(${r.gate.spec.by})` : "-"
1075
- },
1076
- {
1077
- label: "gate:impl",
1078
- get: (r) => r.gate.impl ? `${r.gate.impl.verdict}(${r.gate.impl.by})` : "-"
1079
- },
1080
- {
1081
- label: "leash",
1082
- get: (r) => r.leash ?? "-"
1083
- },
1084
- {
1085
- label: "council",
1086
- get: (r) => r.needsCouncil ? "yes" : "-"
1087
- },
1088
- {
1089
- label: "HAL",
1090
- get: (r) => r.hal ? "!" : ""
1091
- }
1092
- ]);
1093
- });
1094
457
  rootOpts(program.command("jump")).description("select/focus a ship's session (tmux pane), or print its worktree path to cd into").argument("<peer>", "handle, id, or worktree branch/CR ref").action((peer, opts) => {
1095
458
  const ctx = ctxOf(opts);
1096
459
  touch(ctx);
1097
- const agent = resolveShip(ctx.root, peer);
1098
- if (agent.tmux?.pane) try {
1099
- execFileSync("tmux", [
1100
- "select-pane",
1101
- "-t",
1102
- agent.tmux.pane
1103
- ], { stdio: "ignore" });
1104
- printFields({
1105
- jumped: agent.handle,
1106
- pane: agent.tmux.pane
460
+ const agent = resolveAgent(ctx.store, peer);
461
+ const pane = agent.tmux?.pane ?? ctx.store.findPaneByAgentId(agent.id);
462
+ if (pane) try {
463
+ selectSessionAdapter(ctx.env ?? process.env).focus(realExec, { id: pane });
464
+ emit(formatOf(opts), {
465
+ toon: toonObject({
466
+ jumped: agent.handle,
467
+ pane
468
+ }),
469
+ json: {
470
+ jumped: agent.handle,
471
+ pane
472
+ }
1107
473
  });
1108
474
  return;
1109
475
  } catch {}
1110
476
  console.log(agent.worktree?.root ?? agent.cwd);
1111
477
  });
1112
- rootOpts(program.command("ack")).description("acknowledge a message (thin alias of `read` — both print + move it to acked)").argument("<msg-id>", "message id").action((msgId, opts) => {
1113
- const ctx = ctxOf(opts);
1114
- const meId = requireSelf(ctx);
1115
- const msg = read({ root: ctx.root }, meId, msgId);
1116
- printFields({
1117
- acked: msg.id,
1118
- from: msg.fromHandle,
1119
- subject: msg.subject
1120
- });
1121
- });
1122
478
  rootOpts(program.command("pause")).description("pause a ship's mission — a cyberfleet-level status marker only (see note)").argument("<peer>", "handle, id, or worktree branch/CR ref").action((peer, opts) => {
1123
479
  const ctx = ctxOf(opts);
1124
- const agent = resolveShip(ctx.root, peer);
1125
- const rec = pauseAgent(ctx.root, agent.id);
1126
- printFields({
1127
- paused: rec.handle,
1128
- status: rec.status
480
+ const rec = pauseAgent(ctx, resolveAgent(ctx.store, peer).id);
481
+ emit(formatOf(opts), {
482
+ toon: toonObject({
483
+ paused: rec.handle,
484
+ status: rec.status
485
+ }),
486
+ json: rec
1129
487
  });
1130
488
  process.stderr.write("note: this only flips the cyberfleet ship record to status:paused — it is NOT a bridge to SDD's pause-mission checkpoint (which rewrites the plan brief's todos/## NEXT anchor). Run `sdd:pause-mission` in-session for the actual mission checkpoint (flagged gap).\n");
1131
489
  });
@@ -1152,4 +510,4 @@ program.parseAsync(process.argv).catch((err) => {
1152
510
  process.exit(1);
1153
511
  });
1154
512
  //#endregion
1155
- export { ensureFleetMarker as a, gitWorktreeAdapter as c, saveAgent as i, resolvePrimaryRoot as l, realExec as n, paths as o, resolveSelfId as r, assertDistinctFromPrimary as s, randomId as t };
513
+ export {};
package/package.json CHANGED
@@ -1,56 +1,56 @@
1
1
  {
2
- "name": "cyberfleet",
3
- "version": "0.0.0",
4
- "description": "Harness-agnostic, MCP-free inter-agent sessions and messaging over the filesystem",
5
- "keywords": [
6
- "agent",
7
- "claude-code",
8
- "cursor",
9
- "codex",
10
- "inter-agent",
11
- "tmux"
12
- ],
13
- "repository": {
14
- "type": "git",
15
- "url": "git+https://github.com/cyberuni/cyberplace.git",
16
- "directory": "packages/cyberfleet"
17
- },
18
- "license": "MIT",
19
- "author": "unional <homawong@gmail.com>",
20
- "type": "module",
21
- "bin": {
22
- "cyberfleet": "bin/cyberfleet.mjs"
23
- },
24
- "exports": {
25
- "./package.json": "./package.json"
26
- },
27
- "files": [
28
- "bin",
29
- "dist"
30
- ],
31
- "scripts": {
32
- "build": "tsdown",
33
- "dev": "tsx src/cli.ts",
34
- "prepack": "pnpm build",
35
- "test": "pnpm build && vitest run src",
36
- "test:watch": "vitest",
37
- "typecheck": "tsc --noEmit"
38
- },
39
- "dependencies": {
40
- "commander": "^14.0.3"
41
- },
42
- "devDependencies": {
43
- "@types/node": "^24.10.1",
44
- "tsdown": "^0.22.0",
45
- "tsx": "^4.22.3",
46
- "typescript": "^6.0.0",
47
- "vitest": "^4.1.7"
48
- },
49
- "engines": {
50
- "node": ">=22"
51
- },
52
- "publishConfig": {
53
- "access": "public",
54
- "provenance": true
55
- }
56
- }
2
+ "name": "cyberfleet",
3
+ "version": "0.0.2",
4
+ "description": "Harness-agnostic, MCP-free inter-agent sessions and messaging over the filesystem",
5
+ "keywords": [
6
+ "agent",
7
+ "claude-code",
8
+ "cursor",
9
+ "codex",
10
+ "inter-agent",
11
+ "tmux"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/cyberuni/cyberplace.git",
16
+ "directory": "packages/cyberfleet"
17
+ },
18
+ "license": "MIT",
19
+ "author": "unional <homawong@gmail.com>",
20
+ "type": "module",
21
+ "bin": {
22
+ "cyberfleet": "bin/cyberfleet.mjs"
23
+ },
24
+ "exports": {
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "dist"
30
+ ],
31
+ "dependencies": {
32
+ "commander": "^14.0.3",
33
+ "cyberlegion": "0.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^24.10.1",
37
+ "tsdown": "^0.22.0",
38
+ "tsx": "^4.22.3",
39
+ "typescript": "^6.0.0",
40
+ "vitest": "^4.1.7"
41
+ },
42
+ "engines": {
43
+ "node": ">=22"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "provenance": true
48
+ },
49
+ "scripts": {
50
+ "build": "tsdown",
51
+ "dev": "tsx src/cli.ts",
52
+ "test": "pnpm build && vitest run src",
53
+ "test:watch": "vitest",
54
+ "typecheck": "tsc --noEmit"
55
+ }
56
+ }
package/readme.md ADDED
@@ -0,0 +1,59 @@
1
+ # cyberfleet
2
+
3
+ [![npm version](https://img.shields.io/npm/v/cyberfleet.svg)](https://www.npmjs.com/package/cyberfleet)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ The fleet layer on top of [cyberlegion](../cyberlegion). It carries only fleet-specific logic — ships, missions, and the Council view — derived from SDD state. The mechanism verbs (register, send, inbox, spawn, prune, …) are **not** re-exposed here; run those directly through the `cyberlegion` CLI it depends upon.
7
+
8
+ ## Usage
9
+
10
+ No install required — run with `npx`:
11
+
12
+ ```sh
13
+ npx cyberfleet <command>
14
+ ```
15
+
16
+ Or pin to an exact version:
17
+
18
+ ```sh
19
+ npx cyberfleet@0.1.0 <command>
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ ```sh
25
+ # Report ship (a spawned unit worktree) vs command-center, and the shared fleet root
26
+ npx cyberfleet mode
27
+
28
+ # Who needs the Council's hands — ships × mission × gate × leash, derived from SDD state
29
+ npx cyberfleet missions
30
+ npx cyberfleet missions --agents-root <path>
31
+
32
+ # Select/focus a ship's session (tmux pane), or print its worktree path to cd into
33
+ npx cyberfleet jump <peer>
34
+
35
+ # Pause a ship's mission — a cyberfleet-level status marker only (NOT the SDD pause-mission checkpoint)
36
+ npx cyberfleet pause <peer>
37
+
38
+ # SDD gate operations
39
+ npx cyberfleet gate approve <cr> <gateName> # Council ratification — STUBBED, not safely relayable via CLI
40
+ ```
41
+
42
+ `<peer>` accepts a handle, id, or worktree branch / CR ref.
43
+
44
+ ## Global options
45
+
46
+ | Option | Description |
47
+ |---------------------|-------------------------------------------------------------------|
48
+ | `--root <path>` | cyberlegion hub root (overrides the global hub / `$CYBERLEGION_ROOT`) |
49
+ | `--space <path>` | Alias for `--root` |
50
+ | `--format <format>` | Output format: `toon` (default) or `json` |
51
+
52
+ ## Notes
53
+
54
+ - **`pause` is not a mission checkpoint.** It only flips a ship's `AgentRecord.status`. To checkpoint an actual mission (rewrite the plan brief's todos / `## NEXT` anchor), run `sdd:pause-mission` in-session.
55
+ - **`gate approve` is stubbed.** Human-attributed Council ratification cannot be safely relayed through this CLI.
56
+
57
+ ## License
58
+
59
+ MIT
@@ -1,193 +0,0 @@
1
- import { a as ensureFleetMarker, c as gitWorktreeAdapter, i as saveAgent, l as resolvePrimaryRoot, n as realExec, o as paths, r as resolveSelfId, s as assertDistinctFromPrimary, t as randomId } from "./cli.mjs";
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- //#region src/console/session.herdr.ts
5
- /**
6
- * herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
7
- * multiplexer that also reports real busy-state (working / idle / blocked / done); this adapter
8
- * only drives its pane lifecycle, not the state feed. Talks to herdr's own CLI (`herdr pane ...`)
9
- * rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
10
- * convention exactly like the tmux adapter — no new client/transport needed.
11
- *
12
- * herdr is optional/experimental and is not installed in this environment; this follows herdr's
13
- * documented CLI reference (split/run/read/close) but is unverified against a live binary.
14
- */
15
- const herdrSessionAdapter = {
16
- name: "herdr",
17
- open(exec, opts) {
18
- const id = exec("herdr", [
19
- "pane",
20
- "split",
21
- "--current",
22
- "--direction",
23
- "right",
24
- "--cwd",
25
- opts.cwd
26
- ]);
27
- if (!id) throw new Error("herdr pane split failed");
28
- const target = { id };
29
- exec("herdr", [
30
- "pane",
31
- "run",
32
- id,
33
- opts.launch
34
- ]);
35
- return target;
36
- },
37
- send(exec, target, text) {
38
- exec("herdr", [
39
- "pane",
40
- "run",
41
- target.id,
42
- text
43
- ]);
44
- },
45
- read(exec, target, opts) {
46
- const args = [
47
- "pane",
48
- "read",
49
- target.id,
50
- "--source",
51
- "visible"
52
- ];
53
- if (opts?.lines != null) args.push("--lines", String(opts.lines));
54
- return exec("herdr", args) ?? "";
55
- },
56
- teardown(exec, target) {
57
- exec("herdr", [
58
- "pane",
59
- "close",
60
- target.id
61
- ]);
62
- }
63
- };
64
- //#endregion
65
- //#region src/console/session.tmux.ts
66
- /** tmux backend — detected via `$TMUX`. */
67
- const tmuxSessionAdapter = {
68
- name: "tmux",
69
- open(exec, opts) {
70
- const pane = exec("tmux", [
71
- "split-window",
72
- "-h",
73
- "-c",
74
- opts.cwd,
75
- "-P",
76
- "-F",
77
- "#{pane_id}"
78
- ]);
79
- if (!pane) throw new Error("tmux split-window failed");
80
- const target = { id: pane };
81
- tmuxSessionAdapter.send(exec, target, opts.launch);
82
- return target;
83
- },
84
- send(exec, target, text) {
85
- exec("tmux", [
86
- "send-keys",
87
- "-t",
88
- target.id,
89
- text,
90
- "Enter"
91
- ]);
92
- },
93
- read(exec, target, opts) {
94
- const args = [
95
- "capture-pane",
96
- "-p",
97
- "-t",
98
- target.id
99
- ];
100
- if (opts?.lines != null) args.push("-S", `-${opts.lines}`);
101
- return exec("tmux", args) ?? "";
102
- },
103
- teardown(exec, target) {
104
- exec("tmux", [
105
- "kill-pane",
106
- "-t",
107
- target.id
108
- ]);
109
- }
110
- };
111
- //#endregion
112
- //#region src/console/index.ts
113
- /** Backend selection: `$TMUX` → tmux, `$HERDR_ENV` → herdr, else a clear error. */
114
- function selectSessionAdapter(env) {
115
- if (env.TMUX) return tmuxSessionAdapter;
116
- if (env.HERDR_ENV) return herdrSessionAdapter;
117
- throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
118
- }
119
- //#endregion
120
- //#region src/spawn.ts
121
- /** How each harness's own CLI is launched in the new pane. */
122
- const LAUNCH_MAP = {
123
- claude: "claude",
124
- cursor: "cursor-agent",
125
- codex: "codex"
126
- };
127
- /**
128
- * Launch a new peer session as a genuine sibling ship (ADR-0022 decision 8): create a real git
129
- * worktree distinct from the primary checkout (the flagship rule), open a session backend (tmux
130
- * or herdr — decision 9) with its cwd set to that worktree, pre-register the peer, and drop its
131
- * brief as a file the peer's own SessionStart hook reads — never typed into its prompt.
132
- */
133
- function spawn(ctx, input) {
134
- ensureFleetMarker(ctx.root);
135
- const env = ctx.env ?? process.env;
136
- const exec = ctx.exec ?? realExec;
137
- const sessionAdapter = selectSessionAdapter(env);
138
- const harness = input.harness;
139
- if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
140
- const brief = resolveBrief(input);
141
- if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
142
- const id = randomId();
143
- const primaryRoot = resolvePrimaryRoot(exec);
144
- const branch = input.branch ?? `cyberfleet/ship-${id}`;
145
- const worktreePath = input.worktreePath ?? paths.worktreeDir(ctx.root, id);
146
- const worktree = gitWorktreeAdapter.add(exec, {
147
- primaryRoot,
148
- path: worktreePath,
149
- branch
150
- });
151
- assertDistinctFromPrimary(worktree.root, primaryRoot);
152
- ensureFleetMarker(join(worktree.root, ".cyberfleet"));
153
- const launch = LAUNCH_MAP[harness];
154
- const target = sessionAdapter.open(exec, {
155
- cwd: worktree.root,
156
- launch
157
- });
158
- const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
159
- const rec = {
160
- id,
161
- handle: input.handle ?? id.slice(0, 6),
162
- harness,
163
- cwd: worktree.root,
164
- worktree,
165
- tmux: sessionAdapter.name === "tmux" ? { pane: target.id } : null,
166
- status: "spawning",
167
- createdAt: ts,
168
- lastSeen: ts,
169
- brief: paths.briefFile(ctx.root, id),
170
- ...resolveSelfId(ctx) ? { spawnedBy: resolveSelfId(ctx) } : {}
171
- };
172
- saveAgent(ctx.root, rec);
173
- writeText(paths.paneFile(ctx.root, target.id), id);
174
- writeText(paths.briefFile(ctx.root, id), brief);
175
- return {
176
- agent: rec,
177
- pane: target.id,
178
- launch
179
- };
180
- }
181
- /** Resolve a spawn brief from --brief-file, --task -, or --task <text>; null if no source given. */
182
- function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
183
- if (input.briefFile) return readFileSync(input.briefFile, "utf8");
184
- if (input.task === "-") return readStdin();
185
- if (input.task != null && input.task !== "") return input.task;
186
- return null;
187
- }
188
- function writeText(file, text) {
189
- mkdirSync(dirname(file), { recursive: true });
190
- writeFileSync(file, text);
191
- }
192
- //#endregion
193
- export { spawn };