@mmerterden/multi-agent-pipeline 20.29.0 → 20.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +5 -5
- package/README.tr.md +5 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -7
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +10 -1
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/manifest.json +149 -48
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
- package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
- package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
- package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/contract/CHANGELOG.md +62 -0
- package/pipeline/contract/README.md +52 -6
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/manifest.json +367 -22
- package/pipeline/contract/types/index.d.ts +530 -3
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
- package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
- package/pipeline/multi-agent-refs/help/en.md +3 -0
- package/pipeline/multi-agent-refs/help/tr.md +3 -0
- package/pipeline/office/NOTICE.md +42 -0
- package/pipeline/office/assets/BIN.png +0 -0
- package/pipeline/office/assets/BOOKSHELF.png +0 -0
- package/pipeline/office/assets/CACTUS.png +0 -0
- package/pipeline/office/assets/CLOCK.png +0 -0
- package/pipeline/office/assets/COFFEE.png +0 -0
- package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
- package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/DESK_FRONT.png +0 -0
- package/pipeline/office/assets/DESK_SIDE.png +0 -0
- package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
- package/pipeline/office/assets/HANGING_PLANT.png +0 -0
- package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
- package/pipeline/office/assets/LARGE_PLANT.png +0 -0
- package/pipeline/office/assets/PC_BACK.png +0 -0
- package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
- package/pipeline/office/assets/PC_SIDE.png +0 -0
- package/pipeline/office/assets/PLANT.png +0 -0
- package/pipeline/office/assets/PLANT_2.png +0 -0
- package/pipeline/office/assets/POT.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
- package/pipeline/office/assets/SOFA_BACK.png +0 -0
- package/pipeline/office/assets/SOFA_FRONT.png +0 -0
- package/pipeline/office/assets/SOFA_SIDE.png +0 -0
- package/pipeline/office/assets/TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/WHITEBOARD.png +0 -0
- package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/char_0.png +0 -0
- package/pipeline/office/assets/char_1.png +0 -0
- package/pipeline/office/assets/char_2.png +0 -0
- package/pipeline/office/assets/char_3.png +0 -0
- package/pipeline/office/assets/char_4.png +0 -0
- package/pipeline/office/assets/char_5.png +0 -0
- package/pipeline/office/assets/floor_0.png +0 -0
- package/pipeline/office/assets/floor_1.png +0 -0
- package/pipeline/office/assets/floor_2.png +0 -0
- package/pipeline/office/assets/floor_3.png +0 -0
- package/pipeline/office/assets/floor_4.png +0 -0
- package/pipeline/office/assets/floor_5.png +0 -0
- package/pipeline/office/assets/floor_6.png +0 -0
- package/pipeline/office/assets/floor_7.png +0 -0
- package/pipeline/office/assets/floor_8.png +0 -0
- package/pipeline/office/assets/wall_0.png +0 -0
- package/pipeline/office/index.html +5319 -0
- package/pipeline/schemas/activity-chat.schema.json +78 -0
- package/pipeline/schemas/activity-roles.json +48 -0
- package/pipeline/schemas/activity-roles.schema.json +49 -0
- package/pipeline/schemas/activity.schema.json +429 -0
- package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
- package/pipeline/schemas/autopilot-on.schema.json +111 -0
- package/pipeline/schemas/contract-error.schema.json +33 -3
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/prefs.schema.json +19 -0
- package/pipeline/schemas/prs.schema.json +73 -0
- package/pipeline/schemas/review-verdict-request.schema.json +20 -0
- package/pipeline/schemas/review-verdict.schema.json +62 -0
- package/pipeline/schemas/routine-delete.schema.json +59 -0
- package/pipeline/schemas/routines.schema.json +38 -0
- package/pipeline/schemas/run-evidence.schema.json +29 -0
- package/pipeline/schemas/run-results.schema.json +218 -0
- package/pipeline/schemas/service-read.schema.json +58 -0
- package/pipeline/schemas/stack-change.schema.json +82 -0
- package/pipeline/schemas/stack-request.schema.json +33 -0
- package/pipeline/schemas/stack.schema.json +59 -0
- package/pipeline/schemas/steer-request.schema.json +25 -0
- package/pipeline/schemas/steer.schema.json +59 -0
- package/pipeline/schemas/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +60 -0
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +73 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1027 -0
- package/pipeline/scripts/autopilot-control.mjs +214 -9
- package/pipeline/scripts/contract-server.mjs +1178 -48
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -0
- package/pipeline/scripts/office-build.mjs +89 -0
- package/pipeline/scripts/office-control.mjs +383 -0
- package/pipeline/scripts/office-ledger.mjs +409 -0
- package/pipeline/scripts/routine-registry.mjs +42 -6
- package/pipeline/scripts/run-results.mjs +373 -0
- package/pipeline/scripts/service-read.mjs +244 -0
- package/pipeline/scripts/stack-toggle.mjs +285 -0
- package/pipeline/scripts/steer-run.mjs +97 -0
- package/pipeline/scripts/toolkit-tools.mjs +599 -0
- package/pipeline/scripts/uninstall.mjs +3 -0
- package/pipeline/scripts/work-items.mjs +304 -0
- package/pipeline/skills/.skill-manifest.json +19 -7
- package/pipeline/skills/.skills-index.json +34 -1
- package/pipeline/skills/shared/README.md +6 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
- package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/skills-index.md +4 -1
|
@@ -50,7 +50,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
|
|
|
50
50
|
import { homedir } from "node:os";
|
|
51
51
|
import { dirname, join } from "node:path";
|
|
52
52
|
import { fileURLToPath } from "node:url";
|
|
53
|
-
import {
|
|
53
|
+
import { modelRateLookup, priceUsage as priceRecord } from "./_cost.mjs";
|
|
54
54
|
import { readJsonl } from "./_jsonl.mjs";
|
|
55
55
|
import { runMain } from "../lib/fatal.mjs";
|
|
56
56
|
import { messages, outputLanguage } from "../lib/user-facing.mjs";
|
|
@@ -167,58 +167,7 @@ const asNumber = (v, fallback) => {
|
|
|
167
167
|
|
|
168
168
|
const TABLE = JSON.parse(readFileSync(join(HERE, "cost-table.json"), "utf-8"));
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
* Wire ID -> rate. Transcripts carry the wire value ("claude-opus-5-5"); the
|
|
172
|
-
* price table is keyed by rung, so each rung's `modelId` and `copilotModelId`
|
|
173
|
-
* map to its rate, `legacy` adds previous-generation IDs at their own rates,
|
|
174
|
-
* and `models` overrides the rung rate for a dispatched ID whose model is not
|
|
175
|
-
* the rung's (Copilot CLI's older models bill at their own rate). Matching on
|
|
176
|
-
* the ID rather than the rung name keeps a generation move a data edit, which
|
|
177
|
-
* is the contract cost-table.json states for itself.
|
|
178
|
-
*/
|
|
179
|
-
const RATE_BY_MODEL_ID = new Map([
|
|
180
|
-
...Object.entries(TABLE.legacy || {}),
|
|
181
|
-
...Object.values(TABLE.prices || {}).flatMap((p) =>
|
|
182
|
-
[p.modelId, p.copilotModelId].filter(Boolean).map((id) => [id, p]),
|
|
183
|
-
),
|
|
184
|
-
...Object.entries(TABLE.models || {}),
|
|
185
|
-
]);
|
|
186
|
-
const IDS_LONGEST_FIRST = [...RATE_BY_MODEL_ID.keys()].sort((a, b) => b.length - a.length);
|
|
187
|
-
|
|
188
|
-
function rateFor(modelId) {
|
|
189
|
-
if (!modelId) return null;
|
|
190
|
-
const exact = RATE_BY_MODEL_ID.get(modelId);
|
|
191
|
-
if (exact) return exact;
|
|
192
|
-
// A dated snapshot id (a known id, a hyphen, a release date) prices as that
|
|
193
|
-
// id; the longest known id wins, so a current id is never read as a shorter
|
|
194
|
-
// legacy one. Written as a shape rather than as a literal, because
|
|
195
|
-
// smoke-model-fallback.sh reads every model id in this tree and requires it
|
|
196
|
-
// to exist in cost-table.json. An id nobody knows prices as nothing and is
|
|
197
|
-
// COUNTED as unpriced rather than folded in at zero.
|
|
198
|
-
const id = IDS_LONGEST_FIRST.find(
|
|
199
|
-
(known) => modelId.startsWith(`${known}-`) || modelId.startsWith(`${known}[`),
|
|
200
|
-
);
|
|
201
|
-
return id ? RATE_BY_MODEL_ID.get(id) : null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Cache WRITES are not a discount, they are a surcharge: Anthropic prices a
|
|
206
|
-
* 1h cache write above fresh input. cost-table.json carries cacheReadPerMtok
|
|
207
|
-
* only, so the write rate is derived here at the published 1.25x of input and
|
|
208
|
-
* labelled as derived - an unpriced component silently dropped would make
|
|
209
|
-
* every long-context session look cheap, which is the direction that hurts.
|
|
210
|
-
*/
|
|
211
|
-
const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
212
|
-
|
|
213
|
-
function priceRecord(rate, u) {
|
|
214
|
-
const fresh = Number(u.input_tokens || 0);
|
|
215
|
-
const out = Number(u.output_tokens || 0);
|
|
216
|
-
const cacheRead = Number(u.cache_read_input_tokens || 0);
|
|
217
|
-
const cacheWrite = Number(u.cache_creation_input_tokens || 0);
|
|
218
|
-
const base = costUsd(rate, fresh, out, cacheRead);
|
|
219
|
-
if (base === null) return null;
|
|
220
|
-
return base + (cacheWrite / 1_000_000) * (rate.inPerMtok || 0) * CACHE_WRITE_MULTIPLIER;
|
|
221
|
-
}
|
|
170
|
+
const rateFor = modelRateLookup(TABLE);
|
|
222
171
|
|
|
223
172
|
// --- the series --------------------------------------------------------------
|
|
224
173
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-10-14 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `copilotModelId`, where present, is the name Copilot CLI dispatches for the rung: one of the dotted IDs `copilot help config` lists (Copilot rejects any other as not available), the newest of the rung's family there. A rung without one (fable) is not offered on Copilot CLI, where its personas take the opus rung. smoke-model-fallback.sh checks the IDs against `copilot help config` when the CLI is installed. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site; `free-chain` (the `free` rung) only from bulk-read and delegate, where the multi-agent-toolkit's verified free-model chain answers and the caller's default takes over when it escalates; any other provider from no call site. model-dispatch.sh enforces this. Unknown models render USD as ' - ' and emit a footnote - never block PR-body generation. cacheReadPerMtok is the discounted rate for prompt-cache hits, published per model and well below inPerMtok; the renderer prices a phase's tokens_cached at this rate when the tracker records it, so resume/cache reuse is visible in the ledger.",
|
|
3
3
|
"schemaVersion": "1.1.0",
|
|
4
|
+
"verifiedAt": "2026-10-14",
|
|
4
5
|
"_legacyReadme": "`legacy` prices previous-generation wire IDs a transcript may still name, keyed by the exact ID (a dated snapshot of one prices as it). Dispatch never reads it; cost-analyze consults it after the current `modelId` values and the `models` map. Official Anthropic prices per million tokens.",
|
|
5
6
|
"legacy": {
|
|
6
7
|
"claude-fable-5": { "inPerMtok": 10.0, "outPerMtok": 50.0, "cacheReadPerMtok": 1.0 },
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* office-build.mjs - assemble the office panel page from pipeline/office-src.
|
|
4
|
+
*
|
|
5
|
+
* The server's CSP allows one nonce'd inline script, one nonce'd inline style
|
|
6
|
+
* and data: images, so the page has to be a single file. The sources stay
|
|
7
|
+
* readable as separate files (shell.html, style.css, world.js, app.js,
|
|
8
|
+
* strings.json) and this script inlines them into
|
|
9
|
+
* pipeline/office/index.html: the list of sprite names in pipeline/office/assets
|
|
10
|
+
* (served by contract-server.mjs at /office/assets/), the strings as a JSON
|
|
11
|
+
* literal, the two code files in order inside one IIFE.
|
|
12
|
+
*
|
|
13
|
+
* node pipeline/scripts/office-build.mjs write the page
|
|
14
|
+
* node pipeline/scripts/office-build.mjs --check exit 1 when the page on
|
|
15
|
+
* disk is not what the
|
|
16
|
+
* sources build to
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { basename, join } from "node:path";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
const ROOT = join(import.meta.dirname, "..");
|
|
24
|
+
const SRC = join(ROOT, "office-src");
|
|
25
|
+
const OUT = join(ROOT, "office", "index.html");
|
|
26
|
+
const ASSETS = join(ROOT, "office", "assets");
|
|
27
|
+
const PNG_SIGNATURE = "89504e470d0a1a0a";
|
|
28
|
+
|
|
29
|
+
// The sprite names the page may load from /office/assets/: every PNG beside
|
|
30
|
+
// the page, checked to be one.
|
|
31
|
+
function atlas() {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const name of readdirSync(ASSETS).sort()) {
|
|
34
|
+
if (!name.endsWith(".png")) continue;
|
|
35
|
+
const buf = readFileSync(join(ASSETS, name));
|
|
36
|
+
if (buf.subarray(0, 8).toString("hex") !== PNG_SIGNATURE) {
|
|
37
|
+
throw new Error(`${name} is not a PNG file`);
|
|
38
|
+
}
|
|
39
|
+
out.push(basename(name, ".png"));
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function build() {
|
|
45
|
+
const read = (f) => readFileSync(join(SRC, f), "utf8");
|
|
46
|
+
const strings = JSON.parse(read("strings.json"));
|
|
47
|
+
const sprites = atlas();
|
|
48
|
+
const script = [
|
|
49
|
+
'"use strict";',
|
|
50
|
+
"(() => {",
|
|
51
|
+
`const ATLAS = ${JSON.stringify(sprites)};`,
|
|
52
|
+
`const STRINGS = ${JSON.stringify(strings)};`,
|
|
53
|
+
'const spriteUrl = (name) => "/office/assets/" + name + ".png";',
|
|
54
|
+
read("world.js").trim(),
|
|
55
|
+
read("app.js").trim(),
|
|
56
|
+
"})();",
|
|
57
|
+
].join("\n");
|
|
58
|
+
const style = read("style.css").trim();
|
|
59
|
+
// Text that would close the inline element early, or open an HTML comment
|
|
60
|
+
// inside it, turns source into markup.
|
|
61
|
+
if (/<\/script|<!--/i.test(script)) throw new Error("the script contains </script or <!--");
|
|
62
|
+
if (/<\/style/i.test(style)) throw new Error("style.css contains </style");
|
|
63
|
+
const shell = read("shell.html");
|
|
64
|
+
for (const marker of ["/*STYLE*/", "/*SCRIPT*/"]) {
|
|
65
|
+
if (shell.split(marker).length !== 2) throw new Error(`shell.html needs exactly one ${marker}`);
|
|
66
|
+
}
|
|
67
|
+
return shell.replace("/*STYLE*/", () => style).replace("/*SCRIPT*/", () => script);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
71
|
+
if (isMain) {
|
|
72
|
+
const html = build();
|
|
73
|
+
if (process.argv.includes("--check")) {
|
|
74
|
+
let current = "";
|
|
75
|
+
try {
|
|
76
|
+
current = readFileSync(OUT, "utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
// A missing page is stale by definition.
|
|
79
|
+
}
|
|
80
|
+
if (current !== html) {
|
|
81
|
+
console.error("office/index.html is stale: run node pipeline/scripts/office-build.mjs");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
console.log("office/index.html matches its sources");
|
|
85
|
+
} else {
|
|
86
|
+
writeFileSync(OUT, html);
|
|
87
|
+
console.log(`wrote ${OUT} (${Buffer.byteLength(html)} bytes)`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file office-control.mjs - start, stop and report the office panel: the
|
|
4
|
+
* one script behind /multi-agent:office-on, office-off and office-status, so
|
|
5
|
+
* the three commands cannot disagree about what is running.
|
|
6
|
+
*
|
|
7
|
+
* on [--no-open] start contract-server.mjs in the background with the
|
|
8
|
+
* installed panel page (../office/index.html, passed as
|
|
9
|
+
* MA_OFFICE_PAGE) and an 8 hour lifetime, or reuse the one this script
|
|
10
|
+
* started if it is still alive, then open the browser at
|
|
11
|
+
* /office with a one-time code in the URL fragment,
|
|
12
|
+
* through a 0600 launcher file so the code is never on a
|
|
13
|
+
* command line. The
|
|
14
|
+
* page trades the code for the token on its first load;
|
|
15
|
+
* the code is valid for 10 seconds and one use. The token
|
|
16
|
+
* is never printed and never put in a URL.
|
|
17
|
+
* off stop the server this script started and forget it. The
|
|
18
|
+
* SIGTERM goes only to a pid whose token file still names
|
|
19
|
+
* it, whose command is contract-server.mjs and whose start
|
|
20
|
+
* time matches the token file's, so a reused pid is never
|
|
21
|
+
* signalled. Nothing is left running: no relay, no session,
|
|
22
|
+
* no watcher exists to stop.
|
|
23
|
+
* status running or not, the URL without the token, the idle
|
|
24
|
+
* limit, remote access, the paired devices the phone
|
|
25
|
+
* registry lists, and the last panel actions from
|
|
26
|
+
* office-actions.jsonl. Remote access is "not available in
|
|
27
|
+
* this installation" unless a remote layer is present: a
|
|
28
|
+
* prefs.global.office.remote block, or a separately
|
|
29
|
+
* installed module at ~/.claude/office-remote/. This
|
|
30
|
+
* script only reports it; it carries no remote code.
|
|
31
|
+
*
|
|
32
|
+
* State: one 0600 file, ~/.claude/logs/multi-agent/office/office.json, with
|
|
33
|
+
* the pid, port and token file of the server this script started. Nothing runs
|
|
34
|
+
* while the panel is closed: the server exits by itself after its idle time.
|
|
35
|
+
*
|
|
36
|
+
* Usage:
|
|
37
|
+
* office-control.mjs on [--no-open] [--json]
|
|
38
|
+
* office-control.mjs off [--json]
|
|
39
|
+
* office-control.mjs status [--json]
|
|
40
|
+
*
|
|
41
|
+
* Exit: 0 done, 1 could not start, 2 usage.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
45
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
46
|
+
import {
|
|
47
|
+
chmodSync,
|
|
48
|
+
existsSync,
|
|
49
|
+
mkdirSync,
|
|
50
|
+
openSync,
|
|
51
|
+
readFileSync,
|
|
52
|
+
readdirSync,
|
|
53
|
+
rmSync,
|
|
54
|
+
statSync,
|
|
55
|
+
writeFileSync,
|
|
56
|
+
} from "node:fs";
|
|
57
|
+
import { homedir } from "node:os";
|
|
58
|
+
import { dirname, join } from "node:path";
|
|
59
|
+
import { fileURLToPath } from "node:url";
|
|
60
|
+
|
|
61
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const IDLE_MINUTES = 15;
|
|
63
|
+
const MAX_HOURS = 8;
|
|
64
|
+
/** How far a process's start may sit before its token file's startedAt. */
|
|
65
|
+
const START_SKEW_MS = 120 * 1000;
|
|
66
|
+
|
|
67
|
+
const home = (env) => env.HOME || homedir();
|
|
68
|
+
const stateDir = (env) => join(home(env), ".claude", "logs", "multi-agent", "office");
|
|
69
|
+
const stateFile = (env) => join(stateDir(env), "office.json");
|
|
70
|
+
const actionsLog = (env) =>
|
|
71
|
+
join(home(env), ".claude", "logs", "multi-agent", "office-actions.jsonl");
|
|
72
|
+
|
|
73
|
+
const readJson = (p) => {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The panel page: next to this scripts directory, in a checkout and in the install. */
|
|
82
|
+
export const pagePath = () => join(HERE, "..", "office", "index.html");
|
|
83
|
+
|
|
84
|
+
/** Whether a remote layer is present; none ships with the pipeline. */
|
|
85
|
+
export function remoteLayer(env = process.env) {
|
|
86
|
+
const prefs = readJson(join(home(env), ".claude", "multi-agent-preferences.json"));
|
|
87
|
+
const block = prefs?.global?.office?.remote;
|
|
88
|
+
if (block && typeof block === "object" && !Array.isArray(block)) return true;
|
|
89
|
+
return existsSync(join(home(env), ".claude", "office-remote"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const remoteText = (env) =>
|
|
93
|
+
remoteLayer(env) ? "provided by a separate remote layer" : "not available in this installation";
|
|
94
|
+
|
|
95
|
+
/** The contract server: beside this script, else the install. */
|
|
96
|
+
export function serverScript(env = process.env) {
|
|
97
|
+
return (
|
|
98
|
+
[
|
|
99
|
+
env.MA_CONTRACT_SERVER,
|
|
100
|
+
join(HERE, "contract-server.mjs"),
|
|
101
|
+
join(home(env), ".claude", "scripts", "contract-server.mjs"),
|
|
102
|
+
]
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.find((p) => existsSync(p)) ?? null
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const alive = (pid) => {
|
|
109
|
+
try {
|
|
110
|
+
process.kill(pid, 0);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return e.code === "EPERM";
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function psField(pid, field) {
|
|
118
|
+
const r = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], {
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
timeout: 5000,
|
|
121
|
+
});
|
|
122
|
+
return r.status === 0 ? r.stdout.trim() : "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether `pid` is still the server a state record names: its token file
|
|
127
|
+
* names the same pid, the process runs contract-server.mjs, and it started
|
|
128
|
+
* no later than the token file and at most START_SKEW_MS before it.
|
|
129
|
+
*/
|
|
130
|
+
export function isOurServer(s, tokenDoc) {
|
|
131
|
+
if (!s || !Number.isInteger(s.pid) || !alive(s.pid)) return false;
|
|
132
|
+
if (!tokenDoc || tokenDoc.pid !== s.pid || typeof tokenDoc.startedAt !== "string") return false;
|
|
133
|
+
if (!psField(s.pid, "command").includes("contract-server.mjs")) return false;
|
|
134
|
+
const began = Date.parse(psField(s.pid, "lstart"));
|
|
135
|
+
const wrote = Date.parse(tokenDoc.startedAt);
|
|
136
|
+
if (!Number.isFinite(began) || !Number.isFinite(wrote)) return false;
|
|
137
|
+
return began <= wrote + 1000 && wrote - began <= START_SKEW_MS;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The server this script started, if it is still running. */
|
|
141
|
+
export function current(env = process.env) {
|
|
142
|
+
const s = readJson(stateFile(env));
|
|
143
|
+
const doc = s ? readJson(s.tokenFile ?? "") : null;
|
|
144
|
+
if (!isOurServer(s, doc) || !doc.token) return null;
|
|
145
|
+
return { ...s, token: doc.token };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A one-time panel code from the running server (bearer, no Origin). */
|
|
149
|
+
async function panelCode(s) {
|
|
150
|
+
const r = await fetch(`${s.url}/office/code`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { Authorization: `Bearer ${s.token}` },
|
|
153
|
+
});
|
|
154
|
+
if (!r.ok) throw new Error(`the server refused a panel code (${r.status})`);
|
|
155
|
+
const { code } = await r.json();
|
|
156
|
+
if (typeof code !== "string") throw new Error("the server returned no panel code");
|
|
157
|
+
return code;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function writeState(env, record) {
|
|
161
|
+
mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
|
|
162
|
+
chmodSync(stateDir(env), 0o700);
|
|
163
|
+
writeFileSync(stateFile(env), JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function waitForReady(file, ms) {
|
|
167
|
+
const until = Date.now() + ms;
|
|
168
|
+
while (Date.now() < until) {
|
|
169
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
170
|
+
const nl = text.indexOf("\n");
|
|
171
|
+
if (nl !== -1) return JSON.parse(text.slice(0, nl));
|
|
172
|
+
await new Promise((r) => globalThis.setTimeout(r, 100));
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** How long a launcher file lives: the panel code's own lifetime, plus slack. */
|
|
178
|
+
const LAUNCHER_TTL_MS = 15 * 1000;
|
|
179
|
+
const launcherTtl = (env) => {
|
|
180
|
+
const n = Number(env.MA_OFFICE_LAUNCHER_TTL_MS);
|
|
181
|
+
return Number.isInteger(n) && n > 0 && n < LAUNCHER_TTL_MS ? n : LAUNCHER_TTL_MS;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A 0600 page in the 0700 state directory that forwards to the panel with the
|
|
186
|
+
* code in the fragment. The code lives only in this file and the browser: an
|
|
187
|
+
* opener's argv is readable by every local user (`ps`), a file in a 0700
|
|
188
|
+
* directory is not. Its CSP allows nothing but its own inline script, by hash.
|
|
189
|
+
*/
|
|
190
|
+
export function writeLauncher(env, target) {
|
|
191
|
+
const dir = stateDir(env);
|
|
192
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
193
|
+
chmodSync(dir, 0o700);
|
|
194
|
+
const script = `location.replace(${JSON.stringify(target)})`;
|
|
195
|
+
const hash = createHash("sha256").update(script).digest("base64");
|
|
196
|
+
const html =
|
|
197
|
+
`<!doctype html><meta charset="utf-8">` +
|
|
198
|
+
`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-${hash}'; base-uri 'none'; form-action 'none'">` +
|
|
199
|
+
`<meta name="referrer" content="no-referrer"><title>Opening the panel</title>` +
|
|
200
|
+
`<script>${script}</script>`;
|
|
201
|
+
const file = join(dir, `launch-${randomBytes(12).toString("hex")}.html`);
|
|
202
|
+
writeFileSync(file, html, { mode: 0o600, flag: "wx" });
|
|
203
|
+
chmodSync(file, 0o600);
|
|
204
|
+
return file;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Launcher files left by an earlier start, past their lifetime, are removed. */
|
|
208
|
+
function sweepLaunchers(env) {
|
|
209
|
+
let names;
|
|
210
|
+
try {
|
|
211
|
+
names = readdirSync(stateDir(env));
|
|
212
|
+
} catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const n of names) {
|
|
216
|
+
if (!/^launch-[0-9a-f]{24}\.html$/.test(n)) continue;
|
|
217
|
+
const p = join(stateDir(env), n);
|
|
218
|
+
try {
|
|
219
|
+
if (Date.now() - statSync(p).mtimeMs > launcherTtl(env)) rmSync(p, { force: true });
|
|
220
|
+
} catch {
|
|
221
|
+
// Already gone.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Removes the launcher after its lifetime, from a detached process whose argv names only the path. */
|
|
227
|
+
function scheduleRemoval(file, env) {
|
|
228
|
+
const child = spawn(
|
|
229
|
+
process.execPath,
|
|
230
|
+
[
|
|
231
|
+
"-e",
|
|
232
|
+
"setTimeout(() => { try { require('fs').rmSync(process.argv[1], { force: true }); } catch {} }, Number(process.argv[2]))",
|
|
233
|
+
file,
|
|
234
|
+
String(launcherTtl(env)),
|
|
235
|
+
],
|
|
236
|
+
{ detached: true, stdio: "ignore" },
|
|
237
|
+
);
|
|
238
|
+
child.unref();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function openBrowser(target, env) {
|
|
242
|
+
if (env.MA_OFFICE_NO_BROWSER === "1") return false;
|
|
243
|
+
sweepLaunchers(env);
|
|
244
|
+
const file = writeLauncher(env, target);
|
|
245
|
+
scheduleRemoval(file, env);
|
|
246
|
+
const bin = env.MA_OFFICE_OPENER || (process.platform === "darwin" ? "open" : "xdg-open");
|
|
247
|
+
const r = spawnSync(bin, [`file://${file}`], { stdio: "ignore", timeout: 15000 });
|
|
248
|
+
return r.status === 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Start (or reuse) the panel server and open the browser. */
|
|
252
|
+
export async function officeOn({ env = process.env, open = true } = {}) {
|
|
253
|
+
let s = current(env);
|
|
254
|
+
let reused = true;
|
|
255
|
+
if (!s) {
|
|
256
|
+
reused = false;
|
|
257
|
+
const server = serverScript(env);
|
|
258
|
+
if (!server) throw new Error("contract-server.mjs was not found; install the pipeline first");
|
|
259
|
+
mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
|
|
260
|
+
const ready = join(stateDir(env), "ready.json");
|
|
261
|
+
rmSync(ready, { force: true });
|
|
262
|
+
const out = openSync(ready, "w", 0o600);
|
|
263
|
+
const child = spawn(
|
|
264
|
+
process.execPath,
|
|
265
|
+
[
|
|
266
|
+
server,
|
|
267
|
+
"--port",
|
|
268
|
+
"0",
|
|
269
|
+
"--idle-minutes",
|
|
270
|
+
String(IDLE_MINUTES),
|
|
271
|
+
"--max-hours",
|
|
272
|
+
String(MAX_HOURS),
|
|
273
|
+
],
|
|
274
|
+
{
|
|
275
|
+
env: { ...env, MA_OFFICE_PAGE: env.MA_OFFICE_PAGE || pagePath() },
|
|
276
|
+
detached: true,
|
|
277
|
+
stdio: ["ignore", out, "ignore"],
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
child.unref();
|
|
281
|
+
const info = await waitForReady(ready, 15000);
|
|
282
|
+
rmSync(ready, { force: true });
|
|
283
|
+
if (!info) throw new Error("the server did not report ready");
|
|
284
|
+
writeState(env, {
|
|
285
|
+
pid: info.pid,
|
|
286
|
+
port: info.port,
|
|
287
|
+
url: info.url,
|
|
288
|
+
tokenFile: info.tokenFile,
|
|
289
|
+
idleMinutes: info.idleMinutes,
|
|
290
|
+
maxHours: info.maxHours ?? null,
|
|
291
|
+
startedAt: new Date().toISOString(),
|
|
292
|
+
});
|
|
293
|
+
s = current(env);
|
|
294
|
+
if (!s) throw new Error("the server stopped right after it started");
|
|
295
|
+
}
|
|
296
|
+
const page = `${s.url}/office`;
|
|
297
|
+
const opened = open ? openBrowser(`${page}#code=${await panelCode(s)}`, env) : false;
|
|
298
|
+
return { running: true, reused, url: page, pid: s.pid, idleMinutes: s.idleMinutes, opened };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Stop the server this script started. */
|
|
302
|
+
export function officeOff({ env = process.env } = {}) {
|
|
303
|
+
const s = readJson(stateFile(env));
|
|
304
|
+
let stopped = false;
|
|
305
|
+
if (isOurServer(s, s ? readJson(s.tokenFile ?? "") : null)) {
|
|
306
|
+
try {
|
|
307
|
+
process.kill(s.pid, "SIGTERM");
|
|
308
|
+
stopped = true;
|
|
309
|
+
} catch {
|
|
310
|
+
stopped = false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
rmSync(stateFile(env), { force: true });
|
|
314
|
+
return { running: false, stopped, remote: remoteText(env) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function devices(env) {
|
|
318
|
+
const reg = readJson(join(home(env), ".claude", "logs", "multi-agent", "phone", "devices.json"));
|
|
319
|
+
return Array.isArray(reg?.devices)
|
|
320
|
+
? reg.devices.filter((d) => d && !d.revokedAt).map((d) => ({ name: String(d.name ?? d.id) }))
|
|
321
|
+
: [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** What is running, and what the panel did recently. */
|
|
325
|
+
export function officeStatus({ env = process.env, last = 10 } = {}) {
|
|
326
|
+
const s = current(env);
|
|
327
|
+
let actions;
|
|
328
|
+
try {
|
|
329
|
+
actions = readFileSync(actionsLog(env), "utf8")
|
|
330
|
+
.trim()
|
|
331
|
+
.split("\n")
|
|
332
|
+
.filter(Boolean)
|
|
333
|
+
.slice(-last)
|
|
334
|
+
.map((l) => {
|
|
335
|
+
const a = JSON.parse(l);
|
|
336
|
+
return { at: a.at, via: a.via, route: a.route, step: a.step, status: a.status };
|
|
337
|
+
});
|
|
338
|
+
} catch {
|
|
339
|
+
actions = [];
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
running: Boolean(s),
|
|
343
|
+
url: s ? `${s.url}/office` : null,
|
|
344
|
+
pid: s?.pid ?? null,
|
|
345
|
+
idleMinutes: s?.idleMinutes ?? null,
|
|
346
|
+
remote: remoteText(env),
|
|
347
|
+
pairedDevices: devices(env),
|
|
348
|
+
recentActions: actions,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function main(argv) {
|
|
353
|
+
const [cmd, ...rest] = argv;
|
|
354
|
+
const json = rest.includes("--json");
|
|
355
|
+
const flags = rest.filter((a) => a !== "--json");
|
|
356
|
+
let out;
|
|
357
|
+
try {
|
|
358
|
+
if (cmd === "on" && flags.every((f) => f === "--no-open")) {
|
|
359
|
+
out = await officeOn({ open: !flags.includes("--no-open") });
|
|
360
|
+
} else if (cmd === "off" && !flags.length) out = officeOff();
|
|
361
|
+
else if (cmd === "status" && !flags.length) out = officeStatus();
|
|
362
|
+
else {
|
|
363
|
+
process.stderr.write("usage: office-control.mjs on [--no-open] | off | status [--json]\n");
|
|
364
|
+
return 2;
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
process.stderr.write(`office-control: ${err.message}\n`);
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
if (json) process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
371
|
+
else {
|
|
372
|
+
for (const [k, v] of Object.entries(out)) {
|
|
373
|
+
process.stdout.write(`${k}: ${typeof v === "object" ? JSON.stringify(v) : v}\n`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
380
|
+
main(process.argv.slice(2)).then((code) => {
|
|
381
|
+
process.exitCode = code;
|
|
382
|
+
});
|
|
383
|
+
}
|