@mmerterden/multi-agent-pipeline 20.30.0 → 20.33.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 +80 -0
- package/README.md +45 -0
- package/README.tr.md +59 -0
- package/docs/facts.json +3 -3
- package/install/_unattended-profile.mjs +61 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +9 -2
- package/install/index.mjs +10 -1
- package/install/templates/claude-hooks.json +12 -1
- package/manifest.json +123 -82
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/office-on/SKILL.md +8 -7
- package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/sync/SKILL.md +12 -0
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
- package/pipeline/contract/CHANGELOG.md +46 -0
- package/pipeline/contract/README.md +22 -8
- 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/fixtures/launch-plan.json +1 -1
- package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
- package/pipeline/contract/fixtures/runs-empty.json +1 -1
- package/pipeline/contract/fixtures/runs-failed.json +6 -1
- package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
- package/pipeline/contract/fixtures/runs-running.json +6 -1
- package/pipeline/contract/manifest.json +153 -19
- package/pipeline/contract/types/index.d.ts +304 -6
- package/pipeline/lib/bg-launch.mjs +106 -0
- package/pipeline/lib/code-digest.mjs +49 -0
- package/pipeline/lib/credential-resolve.mjs +42 -2
- package/pipeline/multi-agent-refs/features/office-threat-model.md +24 -6
- package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
- package/pipeline/multi-agent-refs/help/en.md +2 -2
- package/pipeline/multi-agent-refs/help/tr.md +2 -2
- package/pipeline/multi-agent-refs/phases/modes.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
- package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
- package/pipeline/office/NOTICE.md +4 -1
- package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
- package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
- package/pipeline/office/assets/COUNTER_SINK.png +0 -0
- package/pipeline/office/assets/DEVICE_STAND.png +0 -0
- package/pipeline/office/assets/FILING_CABINET.png +0 -0
- package/pipeline/office/assets/FRIDGE.png +0 -0
- package/pipeline/office/assets/MAILBOX.png +0 -0
- package/pipeline/office/assets/MOP_BUCKET.png +0 -0
- package/pipeline/office/assets/PRINTER.png +0 -0
- package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
- package/pipeline/office/assets/SERVER_RACK.png +0 -0
- package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
- package/pipeline/office/assets/TOILET_STALL.png +0 -0
- package/pipeline/office/assets/VENDING.png +0 -0
- package/pipeline/office/assets/WASHBASIN.png +0 -0
- package/pipeline/office/assets/WATER_COOLER.png +0 -0
- package/pipeline/office/index.html +3996 -884
- package/pipeline/office/toolkit.json +3 -0
- package/pipeline/schemas/activity.schema.json +188 -4
- package/pipeline/schemas/agent-state.schema.json +493 -115
- package/pipeline/schemas/contract-error.schema.json +12 -1
- package/pipeline/schemas/job-change.schema.json +72 -0
- package/pipeline/schemas/job-request.schema.json +38 -0
- package/pipeline/schemas/launch.json +4 -4
- package/pipeline/schemas/office-credentials.schema.json +71 -0
- package/pipeline/schemas/office-jobs.json +253 -0
- package/pipeline/schemas/office-jobs.schema.json +62 -0
- package/pipeline/schemas/office-memory.schema.json +105 -0
- package/pipeline/schemas/office-settings-request.schema.json +30 -0
- package/pipeline/schemas/office-settings.schema.json +67 -0
- package/pipeline/schemas/phases.json +10 -2
- package/pipeline/schemas/phases.schema.json +86 -19
- package/pipeline/schemas/prs.schema.json +5 -0
- package/pipeline/schemas/prune-logs-request.schema.json +26 -0
- package/pipeline/schemas/prune-logs.schema.json +52 -0
- package/pipeline/schemas/repo-change.schema.json +54 -0
- package/pipeline/schemas/repo-request.schema.json +29 -0
- package/pipeline/schemas/repos.schema.json +49 -1
- package/pipeline/schemas/runs-index.schema.json +257 -46
- package/pipeline/schemas/system-status.schema.json +111 -0
- package/pipeline/schemas/tools.schema.json +5 -0
- package/pipeline/schemas/unattended-profile.json +7 -2
- package/pipeline/scripts/_active-run.mjs +2 -1
- package/pipeline/scripts/_spawn-async.mjs +9 -5
- package/pipeline/scripts/activity-snapshot.mjs +219 -23
- package/pipeline/scripts/autopilot-runner.mjs +7 -5
- package/pipeline/scripts/contract-server.mjs +551 -25
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
- package/pipeline/scripts/job-launch.mjs +477 -0
- package/pipeline/scripts/launch-request.mjs +94 -9
- package/pipeline/scripts/office-build.mjs +148 -11
- package/pipeline/scripts/office-control.mjs +34 -7
- package/pipeline/scripts/office-credentials.mjs +106 -0
- package/pipeline/scripts/office-memory.mjs +200 -0
- package/pipeline/scripts/office-prune.mjs +125 -0
- package/pipeline/scripts/office-settings.mjs +145 -0
- package/pipeline/scripts/phase-tracker.sh +12 -5
- package/pipeline/scripts/prune-logs.sh +53 -2
- package/pipeline/scripts/runs-index.mjs +62 -2
- package/pipeline/scripts/system-status.mjs +196 -0
- package/pipeline/scripts/task-mirror.mjs +150 -0
- package/pipeline/scripts/task-mirror.sh +20 -0
- package/pipeline/scripts/toolkit-tools.mjs +33 -7
- package/pipeline/scripts/transcript-index.mjs +4 -1
- package/pipeline/scripts/work-items.mjs +191 -22
- package/pipeline/skills/.skill-manifest.json +6 -6
- package/pipeline/skills/.skills-index.json +1 -1
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +3 -4
- package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
- package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
- package/pipeline/skills/skills-index.md +1 -1
|
@@ -40,10 +40,12 @@ const CONTRACT = JSON.parse(
|
|
|
40
40
|
readFileSync(new URL("../schemas/phases.json", import.meta.url), "utf8"),
|
|
41
41
|
);
|
|
42
42
|
const PHASES = CONTRACT.phases;
|
|
43
|
+
// A mode may name its steps itself: the analysis command runs the six slots as
|
|
44
|
+
// Intake, Evidence, Synthesis, Review, Publish and Report.
|
|
43
45
|
const modePhases = (name) =>
|
|
44
46
|
CONTRACT.modes[name].phases.map((id) => {
|
|
45
47
|
const p = PHASES.find((x) => x.id === id);
|
|
46
|
-
return `${p.id}:${p.name}`;
|
|
48
|
+
return `${p.id}:${CONTRACT.modes[name].names?.[id] ?? p.name}`;
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
const PHASE_NAMES = PHASES.map((p) => `${p.id}:${p.name}`);
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* job-launch.mjs - turn an office job into one unattended background session.
|
|
4
|
+
*
|
|
5
|
+
* A job is an entry of schemas/office-jobs.json: a command, the role that does
|
|
6
|
+
* it, whether it is a team job (a pipeline run, which meets in a room) or a
|
|
7
|
+
* solo one (one agent at a desk), and typed parameters. Nothing here takes
|
|
8
|
+
* free text except a team job's work item, which goes through the launch
|
|
9
|
+
* request rules (launch-request.mjs inputErrors) like every other launch.
|
|
10
|
+
*
|
|
11
|
+
* planJob checks a job against the catalog and builds what the server starts:
|
|
12
|
+
* the same `claude --bg` shape launch.json declares for background runs, the
|
|
13
|
+
* unattended permission profile, no permission prompts, and the environment
|
|
14
|
+
* that marks the session unattended. A team job writes a launch request the
|
|
15
|
+
* run's Phase 0 reads; a solo job names its command and arguments directly.
|
|
16
|
+
* startJob starts it through lib/bg-launch.mjs with the autopilot runner's
|
|
17
|
+
* sandbox check, and records the session under the office log so the floor
|
|
18
|
+
* can seat it where it belongs. A team job is refused while another run is
|
|
19
|
+
* live in the same repository, as the autopilot queue does: two runs in one
|
|
20
|
+
* checkout fight over the git index.
|
|
21
|
+
*
|
|
22
|
+
* node job-launch.mjs list the catalog with availability (JSON)
|
|
23
|
+
*
|
|
24
|
+
* @module pipeline/scripts/job-launch
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
realpathSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
statSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from "node:fs";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { isAbsolute, join, normalize, sep } from "node:path";
|
|
40
|
+
|
|
41
|
+
import { startBackgroundAsync } from "../lib/bg-launch.mjs";
|
|
42
|
+
import { listSessions, sessionLiveness } from "../lib/claude-sessions.mjs";
|
|
43
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
44
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
45
|
+
import { unattendedLogsRoot } from "../lib/pr-request-location.mjs";
|
|
46
|
+
import { unattendedSettingsPath } from "../lib/unattended-settings-location.mjs";
|
|
47
|
+
import { commandsDir } from "./commands.mjs";
|
|
48
|
+
import {
|
|
49
|
+
CONTRACT_VERSION as LAUNCH_VERSION,
|
|
50
|
+
configuredJiraHost,
|
|
51
|
+
inputErrors,
|
|
52
|
+
launchSpec,
|
|
53
|
+
planLaunch,
|
|
54
|
+
workingTreeState,
|
|
55
|
+
writeRequestFile,
|
|
56
|
+
} from "./launch-request.mjs";
|
|
57
|
+
import { listRuns, logsRoot } from "./_run-paths.mjs";
|
|
58
|
+
|
|
59
|
+
export const CONTRACT_VERSION = "1.0.0";
|
|
60
|
+
|
|
61
|
+
const JOBS = JSON.parse(
|
|
62
|
+
readFileSync(new URL("../schemas/office-jobs.json", import.meta.url), "utf8"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const JIRA_KEY = /^[A-Z][A-Z0-9]+-[0-9]+$/;
|
|
66
|
+
const GH_OWNER = "[A-Za-z0-9][A-Za-z0-9-]{0,38}";
|
|
67
|
+
const GH_REPO = "[A-Za-z0-9._-]{1,100}";
|
|
68
|
+
const PR_URL = new RegExp(`^https://github\\.com/${GH_OWNER}/${GH_REPO}/pull/[1-9][0-9]{0,9}$`);
|
|
69
|
+
const ISSUE_URL = new RegExp(
|
|
70
|
+
`^https://github\\.com/${GH_OWNER}/${GH_REPO}/issues/[1-9][0-9]{0,9}$`,
|
|
71
|
+
);
|
|
72
|
+
const SHORT_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}#[1-9][0-9]{0,9}$/;
|
|
73
|
+
const NUMBER_REF = /^#[1-9][0-9]{0,9}$/;
|
|
74
|
+
const RUN_ID = /^[A-Za-z0-9#][A-Za-z0-9#._-]{0,127}$/;
|
|
75
|
+
const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]{1,200}(?<![./])$/;
|
|
76
|
+
const REPO_PATH = /^(?![/-])(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]{1,300}$/;
|
|
77
|
+
|
|
78
|
+
export class JobError extends Error {
|
|
79
|
+
constructor(code, message, extra = {}) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.code = code;
|
|
82
|
+
this.extra = extra;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every job in the catalog, in catalog order. */
|
|
87
|
+
export const jobs = () => JOBS.jobs;
|
|
88
|
+
|
|
89
|
+
export const jobById = (id) => JOBS.jobs.find((j) => j.id === id) ?? null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The catalog as GET /v1/jobs serves it: each job with whether its command is
|
|
93
|
+
* installed here, and why not when it is not.
|
|
94
|
+
*/
|
|
95
|
+
export function jobsDocument() {
|
|
96
|
+
const dir = commandsDir();
|
|
97
|
+
return {
|
|
98
|
+
contractVersion: CONTRACT_VERSION,
|
|
99
|
+
jobs: JOBS.jobs.map((j) => {
|
|
100
|
+
const installed =
|
|
101
|
+
j.command === "multi-agent" || Boolean(dir && existsSync(join(dir, j.command, "SKILL.md")));
|
|
102
|
+
return { ...j, available: installed, reason: installed ? null : "command-not-installed" };
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A path inside the repository, relative, that exists there. */
|
|
108
|
+
function insideRepo(repoPath, rel) {
|
|
109
|
+
if (!REPO_PATH.test(rel)) return false;
|
|
110
|
+
const full = normalize(join(repoPath, rel));
|
|
111
|
+
if (full !== repoPath && !full.startsWith(repoPath + sep)) return false;
|
|
112
|
+
try {
|
|
113
|
+
const real = realpathSync(full);
|
|
114
|
+
const root = realpathSync(repoPath);
|
|
115
|
+
return real === root || real.startsWith(root + sep);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every reason `args` do not fit `job`. A run id must be a run the index
|
|
123
|
+
* knows; a repository path must stay inside the repository.
|
|
124
|
+
*/
|
|
125
|
+
export function paramErrors(
|
|
126
|
+
job,
|
|
127
|
+
args,
|
|
128
|
+
{ repoPath, jiraHost = configuredJiraHost(), knownRun } = {},
|
|
129
|
+
) {
|
|
130
|
+
const errors = [];
|
|
131
|
+
const given = args && typeof args === "object" && !Array.isArray(args) ? args : null;
|
|
132
|
+
if (!given) return ["args must be an object"];
|
|
133
|
+
for (const k of Object.keys(given)) {
|
|
134
|
+
if (!job.params.some((p) => p.name === k)) errors.push(`unknown parameter ${k}`);
|
|
135
|
+
}
|
|
136
|
+
for (const p of job.params) {
|
|
137
|
+
const v = given[p.name];
|
|
138
|
+
if (v === undefined || v === "") {
|
|
139
|
+
if (p.required) errors.push(`${p.name} is required`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (typeof v !== "string" || v.length > 4000) {
|
|
143
|
+
errors.push(`${p.name} must be a string`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const bad = (why) => errors.push(`${p.name}: ${why}`);
|
|
147
|
+
switch (p.kind) {
|
|
148
|
+
case "work-item":
|
|
149
|
+
for (const e of inputErrors(v, { channel: "desktop", jiraHost })) bad(e);
|
|
150
|
+
break;
|
|
151
|
+
case "pull-request":
|
|
152
|
+
if (!PR_URL.test(v) && !SHORT_REF.test(v) && !NUMBER_REF.test(v))
|
|
153
|
+
bad("a GitHub pull request URL, repo#N or #N");
|
|
154
|
+
break;
|
|
155
|
+
case "github-issue":
|
|
156
|
+
if (!ISSUE_URL.test(v) && !SHORT_REF.test(v) && !NUMBER_REF.test(v))
|
|
157
|
+
bad("a GitHub issue URL, repo#N or #N");
|
|
158
|
+
break;
|
|
159
|
+
case "jira-key":
|
|
160
|
+
if (!JIRA_KEY.test(v)) bad("a Jira key such as PROJ-123");
|
|
161
|
+
break;
|
|
162
|
+
case "branch":
|
|
163
|
+
if (!BRANCH.test(v)) bad("a branch name");
|
|
164
|
+
break;
|
|
165
|
+
case "run-id":
|
|
166
|
+
if (!RUN_ID.test(v)) bad("a run id");
|
|
167
|
+
else if (knownRun && !knownRun(v)) bad("no run has that id");
|
|
168
|
+
break;
|
|
169
|
+
case "enum":
|
|
170
|
+
if (!p.values.includes(v)) bad(`one of ${p.values.join(", ")}`);
|
|
171
|
+
break;
|
|
172
|
+
case "repo-path":
|
|
173
|
+
if (!repoPath || !insideRepo(repoPath, v)) bad("a path inside the repository");
|
|
174
|
+
break;
|
|
175
|
+
default:
|
|
176
|
+
bad("unknown kind");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return errors;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// A team job started here holds its room until its run writes agent-state.json,
|
|
183
|
+
// which takes a moment; without this a second start in that moment would pass.
|
|
184
|
+
const HOLD_MS = 5 * 60 * 1000;
|
|
185
|
+
const LIST_GRACE_MS = 60 * 1000;
|
|
186
|
+
const held = new Map();
|
|
187
|
+
|
|
188
|
+
/** Hold the room; returns the key to release it with, or null. */
|
|
189
|
+
function holdRoom(repoPath, taskId, sessionId) {
|
|
190
|
+
try {
|
|
191
|
+
const key = realpathSync(repoPath);
|
|
192
|
+
held.set(key, { taskId, sessionId, at: Date.now() });
|
|
193
|
+
return key;
|
|
194
|
+
} catch {
|
|
195
|
+
// A folder that cannot be resolved has no room to hold.
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The run live in `repoPath` right now, or null. A list that cannot be read concludes nothing. */
|
|
201
|
+
export function liveRunIn(repoPath, { sessions = listSessions() } = {}) {
|
|
202
|
+
let root;
|
|
203
|
+
try {
|
|
204
|
+
root = realpathSync(repoPath);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const hold = held.get(root);
|
|
209
|
+
// The hold ends after HOLD_MS, or once its session has had time to show up
|
|
210
|
+
// in the session list and is not there or not live.
|
|
211
|
+
const age = hold ? Date.now() - hold.at : Infinity;
|
|
212
|
+
const gone =
|
|
213
|
+
Boolean(hold) && age > LIST_GRACE_MS && sessionLiveness(hold.sessionId, sessions) === false;
|
|
214
|
+
if (hold && age < HOLD_MS && !gone) return hold.taskId;
|
|
215
|
+
if (hold) held.delete(root);
|
|
216
|
+
for (const rec of startedJobs().values()) {
|
|
217
|
+
if (!rec.team || !rec.repoPath) continue;
|
|
218
|
+
let at;
|
|
219
|
+
try {
|
|
220
|
+
at = realpathSync(rec.repoPath);
|
|
221
|
+
} catch {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (at === root && sessionLiveness(rec.sessionId, sessions) === true) return `job ${rec.jobId}`;
|
|
225
|
+
}
|
|
226
|
+
for (const base of new Set([logsRoot(), unattendedLogsRoot()])) {
|
|
227
|
+
for (const run of listRuns(base)) {
|
|
228
|
+
let state;
|
|
229
|
+
try {
|
|
230
|
+
state = JSON.parse(readFileSync(join(run.dir, "agent-state.json"), "utf8"));
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!["in_progress", "awaiting_input"].includes(state?.status)) continue;
|
|
235
|
+
let at;
|
|
236
|
+
try {
|
|
237
|
+
at = realpathSync(state.projectRoot ?? "");
|
|
238
|
+
} catch {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (at !== root) continue;
|
|
242
|
+
if (sessionLiveness(state.sessionId, sessions) === false) continue;
|
|
243
|
+
return run.taskId;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const fill = (template, values) =>
|
|
250
|
+
template.replace(/\{([a-zA-Z]+)\}/g, (whole, k) => (k in values ? values[k] : whole));
|
|
251
|
+
|
|
252
|
+
/** The prompt a solo job's session receives: the command and its arguments, in catalog order. */
|
|
253
|
+
export function soloPrompt(job, args) {
|
|
254
|
+
const parts = job.params.map((p) => args[p.name]).filter((v) => typeof v === "string" && v);
|
|
255
|
+
return [`/multi-agent:${job.command}`, ...parts].join(" ");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* What the server starts for a job. Pure apart from the files it reads (the
|
|
260
|
+
* launch spec, the questions) and, for a team job, the request file it writes
|
|
261
|
+
* when `write` is true.
|
|
262
|
+
*
|
|
263
|
+
* @returns {{argv: string[], cwd: string, env: Record<string,string>, prompt: string,
|
|
264
|
+
* requestFile: string|null}}
|
|
265
|
+
*/
|
|
266
|
+
export function planJob({ job, args, answers = {}, repoPath, sessionId, write = false }) {
|
|
267
|
+
const settingsFile = unattendedSettingsPath();
|
|
268
|
+
const runLogsRoot = unattendedLogsRoot();
|
|
269
|
+
if (job.launch) {
|
|
270
|
+
const mode = job.launch === "autopilot" ? "autopilot" : "background";
|
|
271
|
+
const request = {
|
|
272
|
+
contractVersion: LAUNCH_VERSION,
|
|
273
|
+
input: args.input,
|
|
274
|
+
mode,
|
|
275
|
+
...(mode === "background" ? { kind: job.launch } : {}),
|
|
276
|
+
answers,
|
|
277
|
+
};
|
|
278
|
+
const requestFile = write
|
|
279
|
+
? writeRequestFile(request, sessionId)
|
|
280
|
+
: join(runLogsRoot, "pending", `${sessionId}.json`);
|
|
281
|
+
const r = planLaunch({
|
|
282
|
+
request,
|
|
283
|
+
requestFile,
|
|
284
|
+
repoPath,
|
|
285
|
+
sessionId,
|
|
286
|
+
settingsFile,
|
|
287
|
+
runLogsRoot,
|
|
288
|
+
workingTree: workingTreeState(repoPath),
|
|
289
|
+
});
|
|
290
|
+
if (r.code !== 0) {
|
|
291
|
+
throw new JobError(r.reason ?? "invalid-request", "the launch request was refused", {
|
|
292
|
+
errors: r.out.errors,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
argv: r.out.argv,
|
|
297
|
+
cwd: r.out.cwd,
|
|
298
|
+
env: r.out.env,
|
|
299
|
+
prompt: r.out.argv[r.out.argv.length - 1],
|
|
300
|
+
requestFile: write ? requestFile : null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const shape = launchSpec().hosts.claude.background.development;
|
|
304
|
+
const values = { sessionId, settingsFile, runLogsRoot, repoPath, requestFile: "" };
|
|
305
|
+
const prompt = soloPrompt(job, args);
|
|
306
|
+
const argv = shape.argv.slice(0, -1).map((a) => fill(a, values));
|
|
307
|
+
const env = Object.fromEntries(
|
|
308
|
+
Object.entries(shape.env)
|
|
309
|
+
// A solo job reads the operator's runs, so it keeps the operator's logs root.
|
|
310
|
+
.filter(([k]) => k !== "MA_LAUNCH_REQUEST" && k !== "LOGS_ROOT")
|
|
311
|
+
.map(([k, v]) => [k, fill(v, values)]),
|
|
312
|
+
);
|
|
313
|
+
return { argv: [...argv, prompt], cwd: repoPath, env, prompt, requestFile: null };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Where the office records the sessions it started, one 0600 file each. */
|
|
317
|
+
export const jobsDir = (env = process.env) =>
|
|
318
|
+
join(env.HOME || homedir(), ".claude", "logs", "multi-agent", "office", "jobs");
|
|
319
|
+
|
|
320
|
+
// A record seats its session for a day at most; older ones are dropped when
|
|
321
|
+
// the next job starts, so the directory stays small for a server that runs on.
|
|
322
|
+
const KEEP_MS = 24 * 60 * 60 * 1000;
|
|
323
|
+
|
|
324
|
+
function pruneJobs(env) {
|
|
325
|
+
const dir = jobsDir(env);
|
|
326
|
+
let names;
|
|
327
|
+
try {
|
|
328
|
+
names = readdirSync(dir);
|
|
329
|
+
} catch {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
for (const n of names) {
|
|
334
|
+
if (!/^[0-9a-f-]{36}\.json$/.test(n)) continue;
|
|
335
|
+
try {
|
|
336
|
+
if (now - statSync(join(dir, n)).mtimeMs > KEEP_MS) rmSync(join(dir, n), { force: true });
|
|
337
|
+
} catch {
|
|
338
|
+
// Gone already.
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The jobs the office started, by session id, for the floor to seat. */
|
|
344
|
+
export function startedJobs(env = process.env) {
|
|
345
|
+
const out = new Map();
|
|
346
|
+
const dir = jobsDir(env);
|
|
347
|
+
if (!existsSync(dir)) return out;
|
|
348
|
+
for (const n of readdirSync(dir)) {
|
|
349
|
+
if (!/^[0-9a-f-]{36}\.json$/.test(n)) continue;
|
|
350
|
+
try {
|
|
351
|
+
const rec = JSON.parse(readFileSync(join(dir, n), "utf8"));
|
|
352
|
+
if (
|
|
353
|
+
rec &&
|
|
354
|
+
typeof rec.sessionId === "string" &&
|
|
355
|
+
Date.now() - Date.parse(rec.startedAt) < KEEP_MS
|
|
356
|
+
)
|
|
357
|
+
out.set(rec.sessionId, rec);
|
|
358
|
+
} catch {
|
|
359
|
+
// A record that does not parse seats nobody.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Check a job request and describe it, starting nothing: the preview the
|
|
367
|
+
* confirmation shows.
|
|
368
|
+
*/
|
|
369
|
+
export function previewJob({ jobId, args = {}, answers, repoPath, knownRun, sessions }) {
|
|
370
|
+
const job = jobById(jobId);
|
|
371
|
+
if (!job) throw new JobError("unknown-job", "no such job");
|
|
372
|
+
const doc = jobsDocument().jobs.find((j) => j.id === jobId);
|
|
373
|
+
if (!doc.available) throw new JobError("command-not-installed", "that command is not installed");
|
|
374
|
+
if (!isAbsolute(repoPath ?? ""))
|
|
375
|
+
throw new JobError("invalid-repo", "the repository path must be absolute");
|
|
376
|
+
const errors = paramErrors(job, args, { repoPath, knownRun });
|
|
377
|
+
if (
|
|
378
|
+
answers !== undefined &&
|
|
379
|
+
(!job.launch || typeof answers !== "object" || Array.isArray(answers))
|
|
380
|
+
)
|
|
381
|
+
errors.push("answers are only for a pipeline run, as an object");
|
|
382
|
+
if (errors.length)
|
|
383
|
+
throw new JobError("invalid-args", "the job does not fit its parameters", { errors });
|
|
384
|
+
if (job.team) {
|
|
385
|
+
const busy = liveRunIn(repoPath, sessions ? { sessions } : undefined);
|
|
386
|
+
if (busy)
|
|
387
|
+
throw new JobError("room-busy", "a run is already live in this repository", { taskId: busy });
|
|
388
|
+
}
|
|
389
|
+
const plan = planJob({ job, args, answers, repoPath, sessionId: randomUUID() });
|
|
390
|
+
return { job, prompt: plan.prompt };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Start a job that previewJob accepted. `precondition` is the unattended
|
|
395
|
+
* sandbox check; `start` is lib/bg-launch.mjs startBackgroundAsync (a test
|
|
396
|
+
* passes a stand-in). Resolves to the record written.
|
|
397
|
+
*/
|
|
398
|
+
export async function startJob({
|
|
399
|
+
jobId,
|
|
400
|
+
args = {},
|
|
401
|
+
answers,
|
|
402
|
+
repoPath,
|
|
403
|
+
repoName,
|
|
404
|
+
knownRun,
|
|
405
|
+
sessions,
|
|
406
|
+
precondition,
|
|
407
|
+
start = startBackgroundAsync,
|
|
408
|
+
env = process.env,
|
|
409
|
+
}) {
|
|
410
|
+
const { job } = previewJob({ jobId, args, answers, repoPath, knownRun, sessions });
|
|
411
|
+
const sessionId = randomUUID();
|
|
412
|
+
const plan = planJob({ job, args, answers, repoPath, sessionId, write: true });
|
|
413
|
+
const { MA_RUN_BASE_SHA: _stale, ...base } = env;
|
|
414
|
+
const deny = [base.MCP_TOOLKIT_INDEX_DENY, plan.env.MCP_TOOLKIT_INDEX_DENY]
|
|
415
|
+
.flatMap((v) => String(v || "").split(","))
|
|
416
|
+
.map((s) => s.trim())
|
|
417
|
+
.filter(Boolean);
|
|
418
|
+
const childEnv = {
|
|
419
|
+
...base,
|
|
420
|
+
...plan.env,
|
|
421
|
+
...(deny.length ? { MCP_TOOLKIT_INDEX_DENY: [...new Set(deny)].join(",") } : {}),
|
|
422
|
+
};
|
|
423
|
+
const key = job.team ? holdRoom(repoPath, `job ${job.id}`, sessionId) : null;
|
|
424
|
+
const release = () => {
|
|
425
|
+
if (key) held.delete(key);
|
|
426
|
+
if (plan.requestFile) rmSync(plan.requestFile, { force: true });
|
|
427
|
+
};
|
|
428
|
+
let r;
|
|
429
|
+
try {
|
|
430
|
+
r = await start({ argv: plan.argv, cwd: plan.cwd, env: childEnv, precondition });
|
|
431
|
+
} catch (err) {
|
|
432
|
+
release();
|
|
433
|
+
throw err;
|
|
434
|
+
}
|
|
435
|
+
if (r.blocked || r.status !== 0) {
|
|
436
|
+
// A launcher that timed out may have left its session running: the room
|
|
437
|
+
// stays held and the request file stays for it.
|
|
438
|
+
if (!r.timedOut) release();
|
|
439
|
+
// What claude printed can name paths; the page gets a fixed message and
|
|
440
|
+
// the reason stays in the server's own output.
|
|
441
|
+
const why = String(r.blocked || r.stderr || r.error?.message || "").trim();
|
|
442
|
+
if (why) process.stderr.write(`job-launch: ${job.id}: ${why.slice(0, 300)}\n`);
|
|
443
|
+
if (r.blocked)
|
|
444
|
+
throw new JobError("sandbox-unavailable", "the unattended sandbox is not available");
|
|
445
|
+
throw new JobError("launch-failed", "the session did not start");
|
|
446
|
+
}
|
|
447
|
+
const record = {
|
|
448
|
+
contractVersion: CONTRACT_VERSION,
|
|
449
|
+
sessionId,
|
|
450
|
+
jobId: job.id,
|
|
451
|
+
command: job.command,
|
|
452
|
+
role: job.role,
|
|
453
|
+
team: job.team,
|
|
454
|
+
repo: repoName,
|
|
455
|
+
repoPath,
|
|
456
|
+
prompt: plan.prompt,
|
|
457
|
+
startedAt: new Date().toISOString(),
|
|
458
|
+
};
|
|
459
|
+
mkdirSync(jobsDir(env), { recursive: true, mode: 0o700 });
|
|
460
|
+
pruneJobs(env);
|
|
461
|
+
writeFileSync(join(jobsDir(env), `${sessionId}.json`), JSON.stringify(record, null, 2) + "\n", {
|
|
462
|
+
mode: 0o600,
|
|
463
|
+
flag: "wx",
|
|
464
|
+
});
|
|
465
|
+
return record;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (invokedDirectly(import.meta.url)) {
|
|
469
|
+
runMain("job-launch", () => {
|
|
470
|
+
const [cmd] = process.argv.slice(2);
|
|
471
|
+
if (cmd !== "list") {
|
|
472
|
+
process.stderr.write("usage: job-launch.mjs list\n");
|
|
473
|
+
process.exit(2);
|
|
474
|
+
}
|
|
475
|
+
process.stdout.write(JSON.stringify(jobsDocument(), null, 2) + "\n");
|
|
476
|
+
});
|
|
477
|
+
}
|
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
writeFileSync,
|
|
100
100
|
} from "node:fs";
|
|
101
101
|
import { spawnSync } from "node:child_process";
|
|
102
|
+
import { createHash } from "node:crypto";
|
|
102
103
|
import { homedir } from "node:os";
|
|
103
104
|
import { basename, isAbsolute, join } from "node:path";
|
|
104
105
|
import { catalog, commandsDir } from "./commands.mjs";
|
|
@@ -108,6 +109,7 @@ import { autopilotRoot, unattendedLogsRoot } from "../lib/pr-request-location.mj
|
|
|
108
109
|
import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
|
|
109
110
|
import { unattendedSettingsPath } from "../lib/unattended-settings-location.mjs";
|
|
110
111
|
import { isUnattended } from "../lib/unattended.mjs";
|
|
112
|
+
import { listRuns, logsRoot } from "./_run-paths.mjs";
|
|
111
113
|
|
|
112
114
|
export const CONTRACT_VERSION = "1.1.0";
|
|
113
115
|
export const MODES = ["interactive", "autopilot", "background"];
|
|
@@ -548,6 +550,84 @@ export function launchRepos(env = process.env) {
|
|
|
548
550
|
return out;
|
|
549
551
|
}
|
|
550
552
|
|
|
553
|
+
/** A stable, path-free handle for a repository path, as a client names one. */
|
|
554
|
+
export const repoId = (p) => createHash("sha256").update(String(p)).digest("hex").slice(0, 16);
|
|
555
|
+
|
|
556
|
+
const isCheckout = (real) => existsSync(join(real, ".git"));
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Checkouts earlier runs worked in that a launch does not accept yet: each
|
|
560
|
+
* run's projectRoot (and every projects[].projectRoot) under both log roots,
|
|
561
|
+
* by real path, kept when it is a git checkout and neither the home directory
|
|
562
|
+
* nor the root. A client offers them for registration; nothing is accepted
|
|
563
|
+
* until registerRepo stores one.
|
|
564
|
+
*
|
|
565
|
+
* @returns {{id: string, path: string, name: string}[]}
|
|
566
|
+
*/
|
|
567
|
+
export function suggestedRepos(env = process.env) {
|
|
568
|
+
const allowed = allowedLaunchRepos(env);
|
|
569
|
+
const home = realDir(homedir());
|
|
570
|
+
const out = new Map();
|
|
571
|
+
for (const root of new Set([logsRoot(), unattendedLogsRoot()])) {
|
|
572
|
+
for (const run of listRuns(root)) {
|
|
573
|
+
const state = readJsonOr(join(run.dir, "agent-state.json"), null);
|
|
574
|
+
if (!state || typeof state !== "object") continue;
|
|
575
|
+
const roots = [
|
|
576
|
+
state.projectRoot,
|
|
577
|
+
...(Array.isArray(state.projects) ? state.projects.map((x) => x?.projectRoot) : []),
|
|
578
|
+
];
|
|
579
|
+
for (const raw of roots) {
|
|
580
|
+
if (typeof raw !== "string" || !isAbsolute(raw)) continue;
|
|
581
|
+
const real = realDir(raw);
|
|
582
|
+
if (!real || real === "/" || real === home || allowed.has(real) || out.has(real)) continue;
|
|
583
|
+
if (!isCheckout(real)) continue;
|
|
584
|
+
out.set(real, { id: repoId(real), path: real, name: basename(real) });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Registered entries that are no longer a directory, as a client lists them
|
|
593
|
+
* for removal: launchRepos leaves them out, so nothing else would show them.
|
|
594
|
+
*
|
|
595
|
+
* @returns {{id: string, path: string, name: string}[]}
|
|
596
|
+
*/
|
|
597
|
+
export function missingRepos(env = process.env) {
|
|
598
|
+
const registered = readJsonOr(launchReposPath(env), {});
|
|
599
|
+
return (Array.isArray(registered?.repos) ? registered.repos : [])
|
|
600
|
+
.filter((p) => typeof p === "string" && p && !realDir(expandHome(p)))
|
|
601
|
+
.map((p) => ({ id: repoId(p), path: p, name: basename(p) }));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Remove one registered entry, named by repoId of its stored path. Configured
|
|
606
|
+
* autopilot repos are not touched: they belong to the autopilot config.
|
|
607
|
+
* Returns the removed path, or null when no entry has that id.
|
|
608
|
+
*/
|
|
609
|
+
export function unregisterRepo(id, env = process.env) {
|
|
610
|
+
let removed = null;
|
|
611
|
+
updateJsonFileSync(
|
|
612
|
+
launchReposPath(env),
|
|
613
|
+
(current) => {
|
|
614
|
+
const repos = Array.isArray(current?.repos) ? current.repos : [];
|
|
615
|
+
const keep = repos.filter((p) => {
|
|
616
|
+
// The list shows entries by their real path, so match that form too.
|
|
617
|
+
const real = typeof p === "string" ? realDir(expandHome(p)) : null;
|
|
618
|
+
if (removed === null && (repoId(p) === id || (real && repoId(real) === id))) {
|
|
619
|
+
removed = p;
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
return true;
|
|
623
|
+
});
|
|
624
|
+
return { ...current, repos: keep };
|
|
625
|
+
},
|
|
626
|
+
{ fallback: {}, mode: 0o600, timeoutMs: lockTimeoutFrom(env) },
|
|
627
|
+
);
|
|
628
|
+
return removed;
|
|
629
|
+
}
|
|
630
|
+
|
|
551
631
|
/** Is `repoPath` a directory a launch may start in? Compared by real path. */
|
|
552
632
|
export function launchRepoAllowed(repoPath, env = process.env) {
|
|
553
633
|
const real = typeof repoPath === "string" && isAbsolute(repoPath) ? realDir(repoPath) : null;
|
|
@@ -560,7 +640,7 @@ export function launchRepoAllowed(repoPath, env = process.env) {
|
|
|
560
640
|
* The read-modify-write holds the file's lock and replaces it atomically, so
|
|
561
641
|
* two registrations at once both land. Returns the stored path.
|
|
562
642
|
*/
|
|
563
|
-
export function registerRepo(repoPath, env = process.env) {
|
|
643
|
+
export function registerRepo(repoPath, env = process.env, { requireCheckout = false } = {}) {
|
|
564
644
|
if (typeof repoPath !== "string" || !isAbsolute(repoPath))
|
|
565
645
|
throw new Error("the path must be absolute");
|
|
566
646
|
const real = realDir(repoPath);
|
|
@@ -568,6 +648,7 @@ export function registerRepo(repoPath, env = process.env) {
|
|
|
568
648
|
if (real === "/" || real === realDir(homedir())) {
|
|
569
649
|
throw new Error("the home directory or the root is not a repository");
|
|
570
650
|
}
|
|
651
|
+
if (requireCheckout && !isCheckout(real)) throw new Error("the path is not a git checkout");
|
|
571
652
|
const file = launchReposPath(env);
|
|
572
653
|
mkdirSync(autopilotRoot(env), { recursive: true, mode: 0o700 });
|
|
573
654
|
updateJsonFileSync(
|
|
@@ -798,12 +879,15 @@ function planMain(rest) {
|
|
|
798
879
|
}
|
|
799
880
|
|
|
800
881
|
/** The `repos` document: launchRepos() with contractVersion; paths dropped under redact. */
|
|
801
|
-
export function reposDocument({ redact = false, env = process.env } = {}) {
|
|
882
|
+
export function reposDocument({ redact = false, env = process.env, extras = false } = {}) {
|
|
802
883
|
const repos = launchRepos(env);
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
884
|
+
const strip = (list) => (redact ? list.map(({ path: _p, ...r }) => r) : list);
|
|
885
|
+
const doc = { contractVersion: CONTRACT_VERSION, repos: strip(repos) };
|
|
886
|
+
if (extras) {
|
|
887
|
+
doc.suggested = strip(suggestedRepos(env));
|
|
888
|
+
doc.missing = strip(missingRepos(env));
|
|
889
|
+
}
|
|
890
|
+
return doc;
|
|
807
891
|
}
|
|
808
892
|
|
|
809
893
|
function resumeMain(rest) {
|
|
@@ -857,11 +941,12 @@ function main(argv) {
|
|
|
857
941
|
const [cmd, ...rest] = argv;
|
|
858
942
|
if (cmd === "repos") {
|
|
859
943
|
const redact = rest.includes("--redact");
|
|
860
|
-
|
|
861
|
-
|
|
944
|
+
const extras = rest.includes("--all");
|
|
945
|
+
if (rest.some((a) => a !== "--redact" && a !== "--all")) {
|
|
946
|
+
process.stderr.write("launch-request: repos takes only --redact and --all\n");
|
|
862
947
|
return 2;
|
|
863
948
|
}
|
|
864
|
-
process.stdout.write(JSON.stringify(reposDocument({ redact }), null, 2) + "\n");
|
|
949
|
+
process.stdout.write(JSON.stringify(reposDocument({ redact, extras }), null, 2) + "\n");
|
|
865
950
|
return 0;
|
|
866
951
|
}
|
|
867
952
|
if (cmd === "questions" || cmd === "spec") {
|