cyberfleet 0.0.0 → 0.0.1
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 +21 -0
- package/bin/cyberfleet.mjs +0 -0
- package/dist/cli.mjs +114 -756
- package/package.json +55 -55
- package/readme.md +59 -0
- package/dist/spawn-C036T1Ob.mjs +0 -193
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.
|
package/bin/cyberfleet.mjs
CHANGED
|
File without changes
|
package/dist/cli.mjs
CHANGED
|
@@ -1,499 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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/
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
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
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
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
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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>", "
|
|
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("
|
|
906
|
-
rootOpts(program.command("
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
})
|
|
963
|
-
|
|
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("
|
|
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
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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 =
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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 {
|
|
513
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
2
|
+
"name": "cyberfleet",
|
|
3
|
+
"version": "0.0.1",
|
|
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.0.1"
|
|
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
|
+
[](https://www.npmjs.com/package/cyberfleet)
|
|
4
|
+
[](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
|
package/dist/spawn-C036T1Ob.mjs
DELETED
|
@@ -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 };
|