@volter/supercode-orchestrator 0.5.211 → 0.5.213
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/README.md +33 -7
- package/bin/orchestrator.mjs +5 -0
- package/board/attachment-write.mjs +68 -0
- package/board/ci.mjs +105 -78
- package/board/cli.mjs +38 -2
- package/board/dispatch.mjs +171 -51
- package/board/engine.mjs +20 -11
- package/board/github-rest.mjs +84 -2
- package/board/incidents.mjs +1 -0
- package/board/machine-fleet.mjs +3 -1
- package/board/session-work.mjs +7 -0
- package/board/store.mjs +2 -2
- package/board/workflow.mjs +39 -13
- package/board/workspace.mjs +21 -10
- package/board-input.d.ts +2 -0
- package/board-input.mjs +33 -1
- package/connection.mjs +5 -1
- package/hermes-cli.mjs +7 -19
- package/index.d.ts +1 -1
- package/loop.mjs +11 -1
- package/mcp/mount.mjs +22 -14
- package/orchestration.mjs +29 -2
- package/package.json +3 -3
package/board/github-rest.mjs
CHANGED
|
@@ -26,10 +26,77 @@ function connection() {
|
|
|
26
26
|
resource TEXT, used INTEGER, remaining INTEGER, reset_at INTEGER, retry_after INTEGER);
|
|
27
27
|
CREATE INDEX IF NOT EXISTS github_attempts_window ON github_attempts(credential,started_at,purpose);
|
|
28
28
|
CREATE INDEX IF NOT EXISTS github_attempts_home_window ON github_attempts(started_at,purpose);
|
|
29
|
-
CREATE TABLE IF NOT EXISTS github_cooldown (credential TEXT PRIMARY KEY, until_at INTEGER NOT NULL, reason TEXT NOT NULL)
|
|
29
|
+
CREATE TABLE IF NOT EXISTS github_cooldown (credential TEXT PRIMARY KEY, until_at INTEGER NOT NULL, reason TEXT NOT NULL);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS github_discovery (
|
|
31
|
+
key TEXT PRIMARY KEY, observed_at INTEGER NOT NULL, until_at INTEGER NOT NULL,
|
|
32
|
+
value TEXT, error TEXT, claim TEXT, claim_until INTEGER NOT NULL DEFAULT 0);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS github_refusals (
|
|
34
|
+
id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, credential TEXT NOT NULL, machine TEXT NOT NULL,
|
|
35
|
+
pid INTEGER NOT NULL, caller TEXT NOT NULL, purpose TEXT NOT NULL, method TEXT NOT NULL,
|
|
36
|
+
endpoint TEXT NOT NULL, code TEXT NOT NULL, reason TEXT NOT NULL);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS github_refusals_window ON github_refusals(started_at,purpose);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS github_observation_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);`);
|
|
39
|
+
db.prepare("INSERT OR IGNORE INTO github_observation_meta(key,value) VALUES('refusals_since',?)").run(Date.now());
|
|
30
40
|
connections.set(path, db);
|
|
31
41
|
return db;
|
|
32
42
|
}
|
|
43
|
+
|
|
44
|
+
const discoveryReads = new Map();
|
|
45
|
+
const discoveryPending = () => Object.assign(new Error('GitHub discovery is already being read by another process; no duplicate read was started'), { code: 'github_discovery_pending', deferred: true });
|
|
46
|
+
/** Advisory observations survive command/owner restarts. Fresh transition reads never consume a cached value,
|
|
47
|
+
* but publish their successful observation for discovery. A short transaction claims each advisory read; no
|
|
48
|
+
* transaction spans I/O and a different process defers rather than waiting or duplicating its wire attempts. */
|
|
49
|
+
export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlOf = () => ttl, cacheErrors = true } = {}) {
|
|
50
|
+
const key = createHash('sha256').update(identity).digest('hex');
|
|
51
|
+
const localKey = `${pathOf()}\0${key}`;
|
|
52
|
+
if (!fresh && discoveryReads.has(localKey)) return discoveryReads.get(localKey);
|
|
53
|
+
const work = async () => {
|
|
54
|
+
const db = connection(), began = Date.now(), claim = randomUUID();
|
|
55
|
+
if (!fresh) {
|
|
56
|
+
let cached = null;
|
|
57
|
+
db.exec('BEGIN IMMEDIATE');
|
|
58
|
+
try {
|
|
59
|
+
const held = db.prepare('SELECT * FROM github_discovery WHERE key=?').get(key);
|
|
60
|
+
if (held && held.until_at > began && (held.value != null || held.error != null)) {
|
|
61
|
+
cached = held;
|
|
62
|
+
} else {
|
|
63
|
+
if (held?.claim_until > began) throw discoveryPending();
|
|
64
|
+
db.prepare(`INSERT INTO github_discovery(key,observed_at,until_at,claim,claim_until) VALUES(?,0,0,?,?)
|
|
65
|
+
ON CONFLICT(key) DO UPDATE SET claim=excluded.claim,claim_until=excluded.claim_until`).run(key, claim, began + 360_000);
|
|
66
|
+
}
|
|
67
|
+
db.exec('COMMIT');
|
|
68
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
69
|
+
if (cached) {
|
|
70
|
+
if (cached.error) { const failure = JSON.parse(cached.error); throw Object.assign(new Error(failure.message), failure); }
|
|
71
|
+
return JSON.parse(cached.value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const retain = (value, error, duration = ttl) => {
|
|
75
|
+
db.prepare(`INSERT INTO github_discovery(key,observed_at,until_at,value,error) VALUES(?,?,?,?,?)
|
|
76
|
+
ON CONFLICT(key) DO UPDATE SET observed_at=excluded.observed_at,until_at=excluded.until_at,
|
|
77
|
+
value=excluded.value,error=excluded.error,claim=NULL,claim_until=0
|
|
78
|
+
WHERE github_discovery.observed_at<=excluded.observed_at`).run(key, began, Date.now() + duration, value, error);
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
const value = await read();
|
|
82
|
+
retain(JSON.stringify(value), null, ttlOf(value));
|
|
83
|
+
return value;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!fresh && cacheErrors) retain(null, JSON.stringify({ message: String(error.message).slice(0, 1000), code: error.code, deferred: error.deferred, retry_at: error.retry_at }));
|
|
86
|
+
throw error;
|
|
87
|
+
} finally {
|
|
88
|
+
db.prepare('UPDATE github_discovery SET claim=NULL,claim_until=0 WHERE key=? AND claim=?').run(key, claim);
|
|
89
|
+
// Retain only live observations/claims. Expired advisory data has no evidentiary role.
|
|
90
|
+
db.prepare('DELETE FROM github_discovery WHERE until_at<=? AND claim_until<=?').run(Date.now(), Date.now());
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const pending = work();
|
|
94
|
+
if (!fresh) {
|
|
95
|
+
discoveryReads.set(localKey, pending);
|
|
96
|
+
try { return await pending; } finally { if (discoveryReads.get(localKey) === pending) discoveryReads.delete(localKey); }
|
|
97
|
+
}
|
|
98
|
+
return pending;
|
|
99
|
+
}
|
|
33
100
|
const refusal = (message) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET' });
|
|
34
101
|
const integer = (value) => value != null && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
|
|
35
102
|
function endpointOf(url) {
|
|
@@ -63,7 +130,15 @@ async function issue(url, options, { purpose }) {
|
|
|
63
130
|
db.prepare('INSERT INTO github_attempts(id,credential,started_at,machine,pid,caller,purpose,method,endpoint) VALUES(?,?,?,?,?,?,?,?,?)')
|
|
64
131
|
.run(id, credential, at, hostname(), process.pid, caller, purpose, method, endpoint);
|
|
65
132
|
db.exec('COMMIT');
|
|
66
|
-
} catch (error) {
|
|
133
|
+
} catch (error) {
|
|
134
|
+
db.exec('ROLLBACK');
|
|
135
|
+
// A refused action made no wire attempt. Keep its receipt separately so a full-hour qualification can read
|
|
136
|
+
// actual budget refusals as well as usage, without inflating the allowance or inferring success from silence.
|
|
137
|
+
if (error.code === 'GITHUB_BUDGET') db.prepare(`INSERT INTO github_refusals
|
|
138
|
+
(id,started_at,credential,machine,pid,caller,purpose,method,endpoint,code,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)`)
|
|
139
|
+
.run(id, at, credential, hostname(), process.pid, caller, purpose, method, endpoint, error.code, error.message);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
67
142
|
let response, outcome = 'unknown';
|
|
68
143
|
try { response = await fetch(url, { ...options, redirect: 'manual' }); outcome = response.ok ? 'ok' : `HTTP_${response.status}`; return response; }
|
|
69
144
|
catch (error) { outcome = error.code ?? error.name ?? 'transport_error'; throw error; }
|
|
@@ -109,7 +184,14 @@ export function githubUsage({ since = Date.now() - HOUR } = {}) {
|
|
|
109
184
|
if (!existsSync(path)) return { path, recorded: false, callers: [], endpoints: [] };
|
|
110
185
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
111
186
|
try {
|
|
187
|
+
const hasRefusals = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='github_refusals'").get());
|
|
188
|
+
const hasMeta = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='github_observation_meta'").get());
|
|
189
|
+
const refusalsSince = hasMeta ? db.prepare("SELECT value FROM github_observation_meta WHERE key='refusals_since'").get()?.value ?? null : null;
|
|
112
190
|
return { path, recorded: true, since, limits: { hourly: GITHUB_HOURLY_ATTEMPTS, discovery: GITHUB_DISCOVERY_ATTEMPTS },
|
|
191
|
+
refusals_recorded: hasRefusals, refusals_since: refusalsSince, refusals_complete: refusalsSince != null && refusalsSince <= since,
|
|
192
|
+
refusals: hasRefusals ? db.prepare(`SELECT credential,machine,pid,caller,purpose,method,endpoint,code,COUNT(*) refusals,
|
|
193
|
+
MIN(started_at) first_at,MAX(started_at) last_at FROM github_refusals WHERE started_at>=?
|
|
194
|
+
GROUP BY credential,machine,pid,caller,purpose,method,endpoint,code`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })) : [],
|
|
113
195
|
callers: db.prepare(`SELECT credential,machine,pid,caller,purpose,COUNT(*) attempts,SUM(status IS NULL) unanswered
|
|
114
196
|
FROM github_attempts WHERE started_at>=? GROUP BY credential,machine,pid,caller,purpose`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })),
|
|
115
197
|
endpoints: db.prepare(`SELECT credential,machine,pid,caller,purpose,method,endpoint,status,COUNT(*) attempts
|
package/board/incidents.mjs
CHANGED
|
@@ -126,6 +126,7 @@ export function auditEndedRuns(db, board, entry) {
|
|
|
126
126
|
if (decided) continue; // its manager or creator ended it by an explicit act
|
|
127
127
|
const audited = db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='audited'").get(task.id,task.run_id);
|
|
128
128
|
if (audited) continue; // Every explicitly schedules the next successful audit
|
|
129
|
+
if (session?.retain_after_run && task.outcome === 'completed') continue; // an explicit finish hands work to review
|
|
129
130
|
if (['blocked','paused','reconfigured','review_passed','review','review_requested','audited'].includes(task.outcome)) continue;
|
|
130
131
|
surfaceIncident(db, board, entry, task.id, 'run_ended', { error: task.error, outcome: task.outcome }, task.run_id);
|
|
131
132
|
}
|
package/board/machine-fleet.mjs
CHANGED
|
@@ -112,7 +112,9 @@ export async function readMachineFleet(machines, local) {
|
|
|
112
112
|
// what a session that needs input says it waits for, what started its open turn (Codex: `goal` for a thread
|
|
113
113
|
// goal's continuation) and since when, where its harness publishes them
|
|
114
114
|
waiting_for: activity.evidence?.waiting_for ?? null, turn_source: activity.evidence?.source ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null,
|
|
115
|
-
turn_trigger: activity.evidence?.turn_trigger ?? null
|
|
115
|
+
turn_trigger: activity.evidence?.turn_trigger ?? null,
|
|
116
|
+
// the question its pane shows and its options, where its machine read one off the screen (VO-3395)
|
|
117
|
+
question: activity.evidence?.question ?? null });
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
const ok = online.has(local);
|
package/board/session-work.mjs
CHANGED
|
@@ -50,3 +50,10 @@ export function idleInterval(session, live, observedAt) {
|
|
|
50
50
|
const since = Math.min(observedAt, Math.max(read, Number(session.watch_since ?? 0)));
|
|
51
51
|
return { since, quiet: Math.max(0, observedAt - since) };
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/** A question a run's session shows on its pane (VO-3395), as one line: its words and each option, numbered. */
|
|
55
|
+
export function questionText(question) {
|
|
56
|
+
if (!question) return '';
|
|
57
|
+
const options = (question.options ?? []).map((option) => `[${option.number}] ${option.label}`).join(' ');
|
|
58
|
+
return `${question.question ? `${question.question} ` : ''}${options}`.trim();
|
|
59
|
+
}
|
package/board/store.mjs
CHANGED
|
@@ -516,7 +516,7 @@ export function heldConfigWatched(slug, source, watched) {
|
|
|
516
516
|
|
|
517
517
|
export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
|
|
518
518
|
// a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
|
|
519
|
-
if (current().readOnly) return
|
|
519
|
+
if (current().readOnly) return openBoardSnapshot(root, slug);
|
|
520
520
|
if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
|
|
521
521
|
let entry = heldBoards.get(slug);
|
|
522
522
|
if (!entry) {
|
|
@@ -553,7 +553,7 @@ function heldHandle(entry, slug) {
|
|
|
553
553
|
* reader can take; a store not in WAL or not laid out; one it cannot begin reading, `unreadable`) is left to the owner
|
|
554
554
|
* (listing.mjs byOwner).
|
|
555
555
|
*/
|
|
556
|
-
function
|
|
556
|
+
export function openBoardSnapshot(root, slug) {
|
|
557
557
|
const use = backingOf(root, slug);
|
|
558
558
|
if (!use) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
559
559
|
const path = backingPaths(root, slug)[use];
|
package/board/workflow.mjs
CHANGED
|
@@ -382,6 +382,14 @@ function stateRequestOf(db, id) {
|
|
|
382
382
|
|
|
383
383
|
const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
|
|
384
384
|
const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
|
|
385
|
+
/** Card bodies use Markdown headings and standalone labels such as `Acceptance:` and `Tasks:`. Every label ends
|
|
386
|
+
* the preceding section, so a task or another section's checkbox never acquires Acceptance permissions. */
|
|
387
|
+
export function cardSectionHeading(line) {
|
|
388
|
+
const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
|
|
389
|
+
if (heading) return heading[1].replace(/:$/, '').toLowerCase();
|
|
390
|
+
const label = /^\s*([A-Za-z][A-Za-z0-9 _-]*):\s*$/.exec(line);
|
|
391
|
+
return label ? label[1].trim().toLowerCase() : null;
|
|
392
|
+
}
|
|
385
393
|
/** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
|
|
386
394
|
* and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
|
|
387
395
|
* needs, added after it); no other line added, removed or changed. */
|
|
@@ -393,8 +401,8 @@ function onlyTicks(was, body) {
|
|
|
393
401
|
// an outcome is a line of the card's Acceptance section (its heading at any level), not a task or any other checkbox
|
|
394
402
|
let section = null;
|
|
395
403
|
return before.every((line, i) => {
|
|
396
|
-
const heading =
|
|
397
|
-
if (heading) section = heading
|
|
404
|
+
const heading = cardSectionHeading(line);
|
|
405
|
+
if (heading !== null) section = heading;
|
|
398
406
|
if (line === after[i]) return true;
|
|
399
407
|
if (section !== 'acceptance') return false;
|
|
400
408
|
const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
|
|
@@ -487,8 +495,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
487
495
|
const lines = String(task.body ?? '').split('\n');
|
|
488
496
|
let acceptance = false;
|
|
489
497
|
return lines.every((line) => {
|
|
490
|
-
const heading =
|
|
491
|
-
if (heading) acceptance = heading
|
|
498
|
+
const heading = cardSectionHeading(line);
|
|
499
|
+
if (heading !== null) acceptance = heading === 'acceptance';
|
|
492
500
|
if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
|
|
493
501
|
return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
|
|
494
502
|
});
|
|
@@ -1055,7 +1063,7 @@ const ACTIONS = {
|
|
|
1055
1063
|
else runSession(ctx, (s) => ({ ...s, [counter]: 0 }));
|
|
1056
1064
|
}
|
|
1057
1065
|
},
|
|
1058
|
-
end_run(ctx, { outcome, summary = null, error = null } = {}) {
|
|
1066
|
+
end_run(ctx, { outcome, summary = null, error = null, close_session = true } = {}) {
|
|
1059
1067
|
const row = one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id);
|
|
1060
1068
|
const t = now();
|
|
1061
1069
|
if (row?.current_run_id) {
|
|
@@ -1063,10 +1071,15 @@ const ACTIONS = {
|
|
|
1063
1071
|
retireSupersededRuns(ctx.db, ctx.id, runId, json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode);
|
|
1064
1072
|
run(ctx.db, `UPDATE task_runs SET status = ?, outcome = ?, summary = COALESCE(?, summary), error = COALESCE(?, error), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
|
|
1065
1073
|
WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
|
|
1066
|
-
requestSessionClose(ctx.db, ctx.id, { runId });
|
|
1067
1074
|
ctx.runId = runId;
|
|
1068
1075
|
ctx.lastOutcome = outcome;
|
|
1069
|
-
ctx
|
|
1076
|
+
runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
|
|
1077
|
+
// A declared completion may end the attempt while retaining its role session. In particular, its own caller
|
|
1078
|
+
// must be able to finish and return before the board starts the independent review.
|
|
1079
|
+
if (close_session) {
|
|
1080
|
+
requestSessionClose(ctx.db, ctx.id, { runId });
|
|
1081
|
+
ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
|
|
1082
|
+
}
|
|
1070
1083
|
} else if (summary || ctx.scope.event?.metadata) {
|
|
1071
1084
|
// an outcome reached with no run open (a person completing a card nobody claimed)
|
|
1072
1085
|
const task = one(ctx.db, 'SELECT assignee FROM tasks WHERE id = ?', ctx.id);
|
|
@@ -1153,8 +1166,12 @@ const ACTIONS = {
|
|
|
1153
1166
|
if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
|
|
1154
1167
|
},
|
|
1155
1168
|
decompose(ctx, said) {
|
|
1156
|
-
//
|
|
1157
|
-
|
|
1169
|
+
// Native arc tasks are worked by the arc's session, never dispatched as prerequisite cards of their own.
|
|
1170
|
+
// Retain the older workflow's dependency action only for separately declared cards.
|
|
1171
|
+
for (const child of said?.children ?? []) {
|
|
1172
|
+
if (one(ctx.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id=?', child)?.subtask_of === ctx.id) continue;
|
|
1173
|
+
run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, ctx.id);
|
|
1174
|
+
}
|
|
1158
1175
|
},
|
|
1159
1176
|
// card ids a handoff names that no card has (Hermes flags them the same way)
|
|
1160
1177
|
check_references(ctx, said) {
|
|
@@ -1239,16 +1256,25 @@ const ACTIONS = {
|
|
|
1239
1256
|
/** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
|
|
1240
1257
|
export function retireSupersededRuns(db, id, runId, session) {
|
|
1241
1258
|
if (!session?.session_id && !session?.pane) return;
|
|
1242
|
-
for (const old of all(db, `SELECT id,task_id,metadata FROM task_runs WHERE id IN (
|
|
1243
|
-
SELECT id FROM task_runs WHERE id<? AND
|
|
1259
|
+
for (const old of all(db, `SELECT id,task_id,ended_at,metadata FROM task_runs WHERE id IN (
|
|
1260
|
+
SELECT id FROM task_runs WHERE id<? AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1244
1261
|
AND json_extract(metadata, '$.supercode.session_id')=?
|
|
1245
|
-
UNION SELECT id FROM task_runs WHERE id<? AND
|
|
1262
|
+
UNION SELECT id FROM task_runs WHERE id<? AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1246
1263
|
AND json_extract(metadata, '$.supercode.pane')=?)`,
|
|
1247
1264
|
runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
|
|
1248
1265
|
const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
|
|
1249
1266
|
if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
|
|
1250
1267
|
if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
|
|
1251
|
-
|
|
1268
|
+
const transferred = (s.pane && s.pane === session.pane) || (!s.pane && !session.pane && s.session_id === session.session_id);
|
|
1269
|
+
if (old.ended_at) {
|
|
1270
|
+
if (transferred && s.close_transferred_to !== runId) {
|
|
1271
|
+
whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
|
|
1272
|
+
run(db, 'UPDATE task_runs SET metadata=? WHERE id=?', JSON.stringify(whole), old.id);
|
|
1273
|
+
record(db, old.task_id, 'session_transferred', { successor: runId, session_id: s.session_id, pane: s.pane ?? null }, old.id);
|
|
1274
|
+
}
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (transferred) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
|
|
1252
1278
|
run(db, "UPDATE task_runs SET status='superseded', outcome='superseded', ended_at=?, claim_lock=NULL, claim_expires=NULL, worker_pid=NULL, metadata=? WHERE id=?", now(), JSON.stringify(whole), old.id);
|
|
1253
1279
|
record(db, old.task_id, 'run_superseded', { successor: runId, session_id: s.session_id, pane: s.pane }, old.id);
|
|
1254
1280
|
}
|
package/board/workspace.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Filesystem/RPC work happens before a board transaction; the ownership check is pure.
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
5
5
|
import { inspectCheckout } from '@volter/teams/checkout';
|
|
@@ -130,23 +130,34 @@ export async function checkWorkspace(board, task) {
|
|
|
130
130
|
if (task.workspace_kind === 'scratch') return null;
|
|
131
131
|
task = { ...task, handle: task.handle ?? cardHandleOf(board, task.id) };
|
|
132
132
|
const facts = await resolveWorkspace(task), peers = [];
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
const workspaceRoot = task.workspace_kind === 'worktree' ? facts.path : facts.root;
|
|
134
|
+
// New claims retain their exact checkout receipt before launch. Legacy claims without a receipt are resolved
|
|
135
|
+
// once for this check, including subdirectories and remote aliases that their retained path alone cannot identify.
|
|
136
|
+
{
|
|
137
|
+
for (const row of board.db.prepare(`SELECT t.id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
|
|
135
138
|
if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
|
|
136
139
|
let other;
|
|
137
|
-
try { other = await resolveWorkspace({ ...row,
|
|
138
|
-
if (
|
|
140
|
+
try { other = await resolveWorkspace({ ...row, handle: cardHandleOf(board, row.id) }); } catch { continue; } // Its retained path/reservation is still checked below.
|
|
141
|
+
if ((row.workspace_kind === 'worktree' ? other.path : other.root) === workspaceRoot) peers.push(row.id);
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
|
-
const checked = { ...facts,
|
|
144
|
+
const checked = { ...facts, workspaceRoot, peers };
|
|
142
145
|
assertWorkspaceOwner(board.db, task, checked);
|
|
143
146
|
return checked;
|
|
144
147
|
}
|
|
148
|
+
const workspacePath = (path, local) => {
|
|
149
|
+
if (!path) return null;
|
|
150
|
+
if (local) { try { return realpathSync(path); } catch { /* a pre-launch reservation may not exist yet */ } }
|
|
151
|
+
return path.replace(/\/+$/, '');
|
|
152
|
+
};
|
|
145
153
|
export function assertWorkspaceOwner(db, task, checked) {
|
|
146
|
-
if (!checked?.
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
if (!checked?.workspaceRoot) return;
|
|
155
|
+
const machine = normalMachine(task.machine ?? localMachine()), local = machine === localMachine();
|
|
156
|
+
const path = workspacePath(checked.workspaceRoot, local);
|
|
157
|
+
const conflict = db.prepare(`SELECT t.id,t.workspace_path,c.machine,c.workspace_root FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch'`).all()
|
|
158
|
+
.find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === machine &&
|
|
159
|
+
(workspacePath(row.workspace_root, local) === path || workspacePath(row.workspace_path, local) === path || checked.peers?.includes(row.id)));
|
|
160
|
+
if (conflict) throw new Error(`workspace ${checked.path} is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
export const isLocalWorkspace = task => !task.machine || normalMachine(task.machine) === localMachine();
|
package/board-input.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** Read a remote card write's selected text file (or stdin) on the caller's machine. */
|
|
2
2
|
export function prepareBoardText(argv: string[]): { argv: string[]; stdin: string | null };
|
|
3
|
+
/** Read a caller-local attachment in bounded requests, ending with its digest. */
|
|
4
|
+
export function prepareBoardInputs(argv: string[]): Generator<{ argv: string[]; stdin: string | null }>;
|
package/board-input.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// A remote card write reads its text on the caller's machine, before the board door is called.
|
|
2
2
|
// The board's own argument parser decides which file is used; its last value wins.
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { basename } from 'node:path';
|
|
4
6
|
import { parse } from './board/args.mjs';
|
|
5
7
|
|
|
6
8
|
/** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
|
|
@@ -14,3 +16,33 @@ export function prepareBoardText(argv) {
|
|
|
14
16
|
// reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
|
|
15
17
|
return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
|
|
16
18
|
}
|
|
19
|
+
|
|
20
|
+
/** Each yielded request fits one board frame. Only the last answer is the attachment receipt. */
|
|
21
|
+
export function* prepareBoardInputs(argv) {
|
|
22
|
+
const args = parse(argv);
|
|
23
|
+
if (args._[0] !== 'attach' || args.help) { yield prepareBoardText(argv); return; }
|
|
24
|
+
if (args._.length !== 3 || args.upload !== undefined) throw new Error('attach needs a card id and a local file');
|
|
25
|
+
const source = openSync(args._[2], 'r');
|
|
26
|
+
try {
|
|
27
|
+
const before = fstatSync(source, { bigint: true });
|
|
28
|
+
if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('attach needs a regular file of supported size');
|
|
29
|
+
const upload = randomUUID(), size = Number(before.size), hash = createHash('sha256');
|
|
30
|
+
const command = ['attach', args._[1], '-', '--upload', upload];
|
|
31
|
+
if (args.json) command.push('--json');
|
|
32
|
+
for (const key of ['name', 'content-type', 'author']) if (args[key] !== undefined) command.push(`--${key}`, args[key]);
|
|
33
|
+
const input = body => ({ argv: command, stdin: JSON.stringify(body) });
|
|
34
|
+
yield input({ action: 'begin', size, name: args.name ?? basename(args._[2]) });
|
|
35
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
36
|
+
for (let offset = 0; offset < size;) {
|
|
37
|
+
const count = readSync(source, buffer, 0, Math.min(buffer.length, size - offset), offset);
|
|
38
|
+
if (count <= 0) throw new Error('attachment ended during upload; no attachment was committed');
|
|
39
|
+
const bytes = buffer.subarray(0, count); hash.update(bytes);
|
|
40
|
+
yield input({ action: 'append', offset, bytes: bytes.toString('base64') });
|
|
41
|
+
offset += count;
|
|
42
|
+
}
|
|
43
|
+
const after = fstatSync(source, { bigint: true });
|
|
44
|
+
if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs)
|
|
45
|
+
throw new Error('attachment changed during upload; no attachment was committed');
|
|
46
|
+
yield input({ action: 'commit', sha256: hash.digest('hex') });
|
|
47
|
+
} finally { closeSync(source); }
|
|
48
|
+
}
|
package/connection.mjs
CHANGED
|
@@ -38,6 +38,10 @@ export async function connectionCall(record, side, method, path, body, token) {
|
|
|
38
38
|
if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status});
|
|
39
39
|
return payload;
|
|
40
40
|
}
|
|
41
|
+
/** A registration that trusts a Workplace as its issuer trusts this connection's Workplace and organization, never the
|
|
42
|
+
* one its committed manifest names (agents.teams-app.json names production's): otherwise every connection's agents app
|
|
43
|
+
* trusted production's tokens, and its own Workplace's notices and views were decided as the issuer app instead. */
|
|
44
|
+
export function trustedIssuer(manifest,record){return manifest.issuer===undefined?{}:{issuer:record.issuer,issuer_organization:record.organization};}
|
|
41
45
|
export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
|
|
42
46
|
export function generateModuleConfig(root, record, grant) {
|
|
43
47
|
if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
|
|
@@ -172,7 +176,7 @@ export async function connectWorkplace(flags) {
|
|
|
172
176
|
grant.rh2=exchanged.data;privateJson(path,record);
|
|
173
177
|
}
|
|
174
178
|
if(selected.teamsManifest&&!grant.teams){
|
|
175
|
-
if(!grant.registration){grant.stagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.registration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{...selected.teamsManifest,name:`${selected.teamsManifest.name} (${id.slice(0,8)})`,secret:grant.stagedSecret},owner)).data;privateJson(path,record);}
|
|
179
|
+
if(!grant.registration){grant.stagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.registration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{...selected.teamsManifest,...trustedIssuer(selected.teamsManifest,record),name:`${selected.teamsManifest.name} (${id.slice(0,8)})`,secret:grant.stagedSecret},owner)).data;privateJson(path,record);}
|
|
176
180
|
grant.registration.secret=grant.stagedSecret;
|
|
177
181
|
if(!grant.consent){grant.stagedCode??=`sci_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.consent=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps/${grant.registration.id}/installations`,{redirect_url:selected.teamsManifest.redirect_urls[0],state:id,actions:selected.teamsManifest.actions,code:grant.stagedCode},owner)).data;privateJson(path,record);}
|
|
178
182
|
grant.consent.code=grant.stagedCode;
|
package/hermes-cli.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Hermes's own tools beside a worker (docs/plans/hermes-compat.md row 7). A home is a Hermes home, and the
|
|
2
2
|
// skills it carries call Hermes's CLI (`hermes kanban …`, `hermes cron notepad …`, `hermes cron doctor`)
|
|
3
3
|
// against it, so a worker of any harness runs with `HERMES_HOME` set to its profile's folder and the
|
|
4
|
-
// pinned `hermes` on its `PATH
|
|
5
|
-
// in `cron/executions.db`) the orchestrator records through Hermes's own function.
|
|
4
|
+
// pinned `hermes` on its `PATH`. Native cron incidents use the owning interchange codec, in the same executions store.
|
|
6
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
7
6
|
import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
|
|
8
7
|
import { delimiter, dirname, join } from 'node:path';
|
|
9
8
|
import { parseDotenv } from './orchestration.mjs';
|
|
10
9
|
import { locateHermes } from './apply/doors.mjs';
|
|
10
|
+
import { recordCronIncident } from '@volter/supercode-interchange/cron-incidents';
|
|
11
|
+
import { redactText } from '@volter/teams/redact';
|
|
11
12
|
|
|
12
13
|
/** Hermes's interpreter over `code`, `payload` on stdin, without blocking the daemon: `{ status, stdout, stderr }`. */
|
|
13
14
|
function python(install, code, payload, env, timeout) {
|
|
@@ -48,23 +49,10 @@ export function hermesToolEnv(profileDir) {
|
|
|
48
49
|
return env;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*/
|
|
56
|
-
export function recordIncident(profileDir, { job_id, job_name = null, error }) {
|
|
57
|
-
const install = hermesInstall();
|
|
58
|
-
if (!install?.python) return { id: null, why: 'no Hermes install on PATH' };
|
|
59
|
-
const code = 'import json,sys\nfrom cron.incidents import upsert_incident\na=json.loads(sys.stdin.read())\nprint(json.dumps(upsert_incident(a["job_id"], a["error"], job_name=a.get("job_name"))))';
|
|
60
|
-
const run = spawnSync(install.python, ['-c', code], {
|
|
61
|
-
input: JSON.stringify({ job_id, job_name, error: String(error ?? 'failed') }),
|
|
62
|
-
encoding: 'utf8', timeout: 30_000, cwd: install.source,
|
|
63
|
-
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source },
|
|
64
|
-
});
|
|
65
|
-
if (run.status !== 0) return { id: null, why: (run.stderr || run.error?.message || '').trim().slice(-300) };
|
|
66
|
-
const [id] = JSON.parse(run.stdout.trim().split('\n').pop());
|
|
67
|
-
return { id };
|
|
52
|
+
/** Record native cron failure grouping without requiring a separate Hermes Python installation. */
|
|
53
|
+
export function recordIncident(profileDir, { job_id, error }) {
|
|
54
|
+
try { return recordCronIncident(profileDir, { job_id, error: String(error ?? 'failed'), redacted_error: redactText(String(error ?? 'failed')) }); }
|
|
55
|
+
catch (error) { return { id: null, why: error.message }; }
|
|
68
56
|
}
|
|
69
57
|
|
|
70
58
|
// Access through Hermes's own pairing store and writers (docs/plans/hermes-compat.md row 8): a code is
|
package/index.d.ts
CHANGED
|
@@ -443,7 +443,7 @@ export interface McpServerLaunch { name: string; command: string; arguments: str
|
|
|
443
443
|
export function mountOrchestratorMcp(o: {
|
|
444
444
|
root: Path; profile: Profile; harness: string; surfaceKey?: string | null;
|
|
445
445
|
launch?: { program: string; arguments?: string[]; env?: Record<string, string> };
|
|
446
|
-
socketPath?: Path;
|
|
446
|
+
socketPath?: Path; program?: string;
|
|
447
447
|
/** The Teams id of the agent the session is; the rh2-agent record installed for that id (`--agent`) is mounted beside
|
|
448
448
|
* the orchestrator server as `rh2-agent`. */
|
|
449
449
|
agentId?: string | null;
|
package/loop.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
8
8
|
import { basename, join, dirname } from 'node:path';
|
|
9
|
+
import { parseSessionWire, stringifySessionWire } from '@volter/supercode-teams/session-read-client';
|
|
9
10
|
import { step, leaseId } from './reducer.mjs';
|
|
10
11
|
import * as events from './events.mjs';
|
|
11
12
|
import { read, save, retainWorld, releaseWorld, refreshVault } from './orchestration.mjs';
|
|
@@ -278,9 +279,18 @@ export class Orchestrator {
|
|
|
278
279
|
try {event={...event,identity:await this.identityRecord.observe({observation,location:JSON.stringify(event.key),at:Date.parse(event.at),name:event.sender_name,evidence:{adapter:event.adapter,message_id:event.message_id}})};}
|
|
279
280
|
catch(error){event={...event,identity:{observation,principal:null,classification:'unresolved',label:'unresolved'}};this.log('warn',`identity observation unavailable: ${error.message}`);}
|
|
280
281
|
}
|
|
282
|
+
const before = parseSessionWire(stringifySessionWire(this.state));
|
|
281
283
|
const effects = step(this.state, event);
|
|
282
|
-
|
|
284
|
+
const firstSave = effects.findIndex(effect => effect.type === 'save');
|
|
285
|
+
if (firstSave >= 0) {
|
|
286
|
+
// Persist the reducer's claims before any external action. A refused save must leave no in-memory gate or
|
|
287
|
+
// hosted-fire marker behind: otherwise the next tick thinks an execution that never started is still alive.
|
|
288
|
+
try { await this.save(); }
|
|
289
|
+
catch (error) { this.state = before; throw error; }
|
|
290
|
+
}
|
|
291
|
+
for (const [index, effect] of effects.entries()) {
|
|
283
292
|
this.effectLog.push(effect);
|
|
293
|
+
if (index === firstSave) continue;
|
|
284
294
|
await this.perform(effect);
|
|
285
295
|
}
|
|
286
296
|
return effects;
|
package/mcp/mount.mjs
CHANGED
|
@@ -24,13 +24,19 @@ import { SOCKET_ENV, socketPathFor } from './server.mjs';
|
|
|
24
24
|
import { mcpServers } from '../hermes-home.mjs';
|
|
25
25
|
import { KANBAN_ENV } from '../hermes-cli.mjs';
|
|
26
26
|
import { installedAgentRecord } from '../installed-apps.mjs';
|
|
27
|
+
import { supercodeHome } from '@volter/teams/home';
|
|
27
28
|
|
|
28
29
|
export { MCP_DOORS, doorFor } from '@volter/supercode-harness-sdk/mcp-doors';
|
|
29
30
|
|
|
30
|
-
/** The stdio server this
|
|
31
|
+
/** The stdio server `supercode orchestrator mcp-server` runs (the mount names that door, never this path). */
|
|
31
32
|
export const SERVER_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'server.mjs');
|
|
32
|
-
/** The
|
|
33
|
-
|
|
33
|
+
/** The machine's stable `supercode` (the source pin's shim in the supercode home, else the one on PATH), which runs
|
|
34
|
+
* every program a session mounts (`orchestrator mcp-server`, `orchestrator apps mcp`): a config that names it names no
|
|
35
|
+
* release, so the pin that retires a tree leaves it working. */
|
|
36
|
+
function stableSupercode() {
|
|
37
|
+
const shim = join(supercodeHome(), 'bin', process.platform === 'win32' ? 'supercode.cmd' : 'supercode');
|
|
38
|
+
return existsSync(shim) ? shim : 'supercode';
|
|
39
|
+
}
|
|
34
40
|
/** The MCP server an agent with an installed rh2-agent gets: `rh2 mcp` as that agent (its own name, so the user's own
|
|
35
41
|
* `rh2` server, whatever it holds, is neither replaced nor relied on). */
|
|
36
42
|
export const RH2_AGENT_SERVER = 'rh2-agent';
|
|
@@ -40,7 +46,7 @@ export const RH2_AGENT_SERVER = 'rh2-agent';
|
|
|
40
46
|
* Teams id): a record is found by that id alone, so an agent sharing another's name is never mounted with its record. */
|
|
41
47
|
function rh2AgentServer(root, agentId, program) {
|
|
42
48
|
if (!installedAgentRecord(root, agentId)) return null;
|
|
43
|
-
return { command:
|
|
49
|
+
return { command: stableSupercode(), args: ['orchestrator', 'apps', 'mcp', 'rh2-agent', '--root', root, '--agent', agentId, '--node', program] };
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'session' : 'session');
|
|
@@ -55,13 +61,12 @@ const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0
|
|
|
55
61
|
* @param {string|null} o.surfaceKey the surface this session is bound to
|
|
56
62
|
* @param {object|undefined} o.launch the launch the loop resolved, if any
|
|
57
63
|
* @param {string} [o.socketPath] defaults to `<root>/orchestrator.sock`
|
|
58
|
-
* @param {string} [o.
|
|
59
|
-
* @param {string} [o.program] the interpreter, defaults to this node
|
|
64
|
+
* @param {string} [o.program] the node the server runs with, defaults to this node
|
|
60
65
|
* @param {string|null} [o.agentId] the Teams id of the agent whose own session this is, whose installed rh2-agent is
|
|
61
66
|
* mounted beside; null for a worker acting for an agent
|
|
62
67
|
* @returns {{door: string, ran: string, launch: object|undefined, mcp_servers: object[]|null, path: string|null}}
|
|
63
68
|
*/
|
|
64
|
-
export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath,
|
|
69
|
+
export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, program = process.execPath, vault = new Map(), task = null, agentId = null }) {
|
|
65
70
|
const door = doorFor(harness);
|
|
66
71
|
// the profile's own servers (`.mcp.json`, the workers' form; docs/plans/hermes-compat.md row 4), mounted
|
|
67
72
|
// beside this one through the same door, `${VAR}` resolved from the profile's vault
|
|
@@ -81,6 +86,9 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
81
86
|
// board's tools for it (docs/plans/hermes-compat.md row 14)
|
|
82
87
|
const sessionEnv = { ...env, ...(surfaceKey ? { [ORCH_ENV.surface]: surfaceKey } : {}), ...(task ?? {}) };
|
|
83
88
|
|
|
89
|
+
// the server through the stable supercode, never this release's server.mjs: a pin that retires this tree leaves the
|
|
90
|
+
// session's next server start (a resume, a reconnect) working
|
|
91
|
+
const server = { command: stableSupercode(), args: ['orchestrator', 'mcp-server', '--node', program] };
|
|
84
92
|
switch (door) {
|
|
85
93
|
case 'flag': {
|
|
86
94
|
if (!launch) {
|
|
@@ -88,14 +96,14 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
88
96
|
}
|
|
89
97
|
const file = join(home, `mcp-orchestrator-${slug(surfaceKey)}.json`);
|
|
90
98
|
mkdirSync(home, { recursive: true });
|
|
91
|
-
writeMcpConfigFile(file, { ...own, [SERVER_NAME]: {
|
|
99
|
+
writeMcpConfigFile(file, { ...own, [SERVER_NAME]: { ...server, env: sessionEnv } });
|
|
92
100
|
return { door, ran: `wrote ${file} and passed --mcp-config`, launch: withMcpConfigFlag(launch, file), mcp_servers: null, path: file };
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
case 'toml': {
|
|
96
104
|
const file = join(home, 'config.toml');
|
|
97
105
|
mkdirSync(home, { recursive: true });
|
|
98
|
-
const block = tomlBlock(
|
|
106
|
+
const block = tomlBlock(server, env);
|
|
99
107
|
let text = upsertTomlBlock(existsSync(file) ? readFileSync(file, 'utf8') : '', block, SERVER_NAME);
|
|
100
108
|
const written = ownNames(home);
|
|
101
109
|
for (const name of written) if (!(name in own)) text = removeTomlBlock(text, name);
|
|
@@ -115,7 +123,7 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
115
123
|
for (const name of ownNames(home)) if (!(name in own)) delete config.mcp[name];
|
|
116
124
|
for (const [name, server] of Object.entries(own)) config.mcp[name] = opencodeEntry(server);
|
|
117
125
|
recordOwnNames(home, Object.keys(own));
|
|
118
|
-
config.mcp[SERVER_NAME] = opencodeEntry({
|
|
126
|
+
config.mcp[SERVER_NAME] = opencodeEntry({ ...server, env });
|
|
119
127
|
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
120
128
|
return { door, ran: `wrote mcp.${SERVER_NAME} into ${file}`, launch, mcp_servers: null, path: file };
|
|
121
129
|
}
|
|
@@ -131,7 +139,7 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
131
139
|
launch,
|
|
132
140
|
mcp_servers: [
|
|
133
141
|
...Object.entries(own).filter(([, server]) => server.command).map(([name, server]) => acpEntry(name, server)),
|
|
134
|
-
acpEntry(SERVER_NAME, {
|
|
142
|
+
acpEntry(SERVER_NAME, { ...server, env: sessionEnv }),
|
|
135
143
|
],
|
|
136
144
|
path: null,
|
|
137
145
|
};
|
|
@@ -139,11 +147,11 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
|
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
/** The orchestrator's own `[mcp_servers.orchestrator]` block. */
|
|
142
|
-
function tomlBlock(
|
|
150
|
+
function tomlBlock({ command, args }, env) {
|
|
143
151
|
const lines = [
|
|
144
152
|
`[mcp_servers.${SERVER_NAME}]`,
|
|
145
|
-
`command = ${tomlString(
|
|
146
|
-
`args = [${tomlString(
|
|
153
|
+
`command = ${tomlString(command)}`,
|
|
154
|
+
`args = [${args.map(tomlString).join(', ')}]`,
|
|
147
155
|
];
|
|
148
156
|
for (const [k, v] of Object.entries(env)) lines.push(`env.${k} = ${tomlString(v)}`);
|
|
149
157
|
// The per-session half rides codex's own env allowlist: the server is a child
|