@volter/supercode-orchestrator 0.4.160 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/adapters/chat-sdk.mjs +4 -1
- package/adapters/rh2.mjs +18 -20
- package/agent-home.mjs +35 -0
- package/agent-profiles.mjs +53 -0
- package/agents.mjs +443 -0
- package/bin/agent-door.mjs +53 -0
- package/bin/runtime-door.mjs +23 -0
- package/bin/runtime.mjs +59 -4
- package/board/agent-declarations.mjs +29 -0
- package/board/ci.mjs +6 -1
- package/board/cli.mjs +158 -31
- package/board/dispatch.mjs +299 -121
- package/board/engine.mjs +30 -10
- package/board/event-fleet.mjs +59 -0
- package/board/event-relay.mjs +45 -0
- package/board/event-stream.mjs +104 -0
- package/board/incidents.mjs +57 -0
- package/board/published.mjs +86 -0
- package/board/runtime-door.mjs +12 -0
- package/board/store.mjs +39 -6
- package/board/tools.mjs +1 -0
- package/board/wake.mjs +17 -56
- package/board/workflow.mjs +28 -7
- package/board/workspace.mjs +18 -15
- package/commands.mjs +2 -2
- package/conversations.mjs +108 -0
- package/cron.mjs +30 -4
- package/gates.mjs +3 -2
- package/hermes-cli.mjs +19 -2
- package/index.d.ts +43 -2
- package/index.mjs +4 -0
- package/installed-apps.mjs +52 -0
- package/ir.mjs +3 -3
- package/kanban-worker.mjs +16 -6
- package/loop.mjs +211 -21
- package/mcp/bridge.mjs +13 -2
- package/mcp/tools.mjs +17 -0
- package/mcp/verbs.mjs +29 -7
- package/ontology.d.ts +246 -2
- package/operator.mjs +6 -1
- package/package.json +2 -2
- package/reducer.mjs +133 -28
- package/webhook.mjs +8 -4
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Installed integration apps of an orchestrator home (mirrored ontology M4, ADR 0017). An app reaches a profile only by
|
|
2
|
+
// an installation the other side consented to: its admin installs the app there, and the one-time code that consent
|
|
3
|
+
// returns is exchanged here, with the app's client secret, for the installation's token. The record lives in the home's
|
|
4
|
+
// `apps/` folder (owner-only); no `platforms:` block attaches an integration. Uninstalling on the other side revokes the
|
|
5
|
+
// token, and the channel stops; `remove` forgets the record here.
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const APPS_DIR = 'apps';
|
|
10
|
+
/** The apps this orchestrator can host, by name: what each installs and which adapter it attaches. */
|
|
11
|
+
export const INSTALLABLE_APPS = Object.freeze({
|
|
12
|
+
'room-chat': { side: 'rh2', adapter: 'rh2', describe: 'Room chat → orchestrator: a person\'s line in an RH2 Room reaches the agent seated there' },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const recordPath = (root, name) => join(root, APPS_DIR, `${name}.json`);
|
|
16
|
+
|
|
17
|
+
export function installedApps(root) {
|
|
18
|
+
const dir = join(root, APPS_DIR);
|
|
19
|
+
if (!existsSync(dir)) return [];
|
|
20
|
+
return readdirSync(dir).filter((file) => file.endsWith('.json')).map((file) => {
|
|
21
|
+
const record = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
22
|
+
return { ...record, name: file.slice(0, -5) };
|
|
23
|
+
}).filter((record) => INSTALLABLE_APPS[record.name]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Exchange an RH2 install code for the installation's token and keep the record. */
|
|
27
|
+
export async function installApp({ root, name, origin, clientId, clientSecret, code, profile = 'default', fetchImpl = globalThis.fetch }) {
|
|
28
|
+
const app = INSTALLABLE_APPS[name];
|
|
29
|
+
if (!app) throw new Error(`no installable app named ${name} (${Object.keys(INSTALLABLE_APPS).join(', ')})`);
|
|
30
|
+
if (!/^https:\/\/|^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(String(origin))) throw new Error('the RH2 origin is an https URL');
|
|
31
|
+
const base = new URL(origin).origin;
|
|
32
|
+
const response = await fetchImpl(`${base}/api/v3/automation/apps/oauth/access`, { method: 'POST', redirect: 'manual', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ clientId, clientSecret, code }) });
|
|
33
|
+
const payload = await response.json().catch(() => ({}));
|
|
34
|
+
if (!response.ok) throw Object.assign(new Error(payload?.error?.message ?? `the install code exchange answered ${response.status}`), { code: payload?.error?.code });
|
|
35
|
+
const { accessToken, expiresAt, installationId, organizationId, principalId } = payload.data;
|
|
36
|
+
const record = { app: name, side: app.side, origin: base, organizationId, principalId, installationId, expiresAt, profile, token: accessToken, installedAt: new Date().toISOString() };
|
|
37
|
+
mkdirSync(join(root, APPS_DIR), { recursive: true, mode: 0o700 });
|
|
38
|
+
const pending = `${recordPath(root, name)}.${process.pid}.tmp`;
|
|
39
|
+
writeFileSync(pending, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
40
|
+
renameSync(pending, recordPath(root, name));
|
|
41
|
+
return { ...record, token: undefined };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function removeApp(root, name) {
|
|
45
|
+
const path = recordPath(root, name);
|
|
46
|
+
if (!existsSync(path)) return false;
|
|
47
|
+
rmSync(path);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What `apps list` shows: never the token. */
|
|
52
|
+
export const appView = (record) => ({ name: record.name, side: record.side, origin: record.origin, organization: record.organizationId, principal: record.principalId, installation: record.installationId, profile: record.profile, expires_at: record.expiresAt });
|
package/ir.mjs
CHANGED
|
@@ -135,7 +135,7 @@ export function homeChannel(profile, platform = null) {
|
|
|
135
135
|
return null;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
export const END_REASONS = new Set(['idle', 'daily', 'reset', 'new', 'handoff', 'error']);
|
|
138
|
+
export const END_REASONS = new Set(['idle', 'daily', 'reset', 'new', 'handoff', 'transfer', 'left', 'error']);
|
|
139
139
|
|
|
140
140
|
export const FIRE_STATUSES = new Set(['claimed', 'running', 'succeeded', 'failed', 'timeout', 'unknown']);
|
|
141
141
|
|
|
@@ -156,7 +156,7 @@ export function resolveRoute(routes, key, guildId) {
|
|
|
156
156
|
if (m.thread_id && m.thread_id !== key.thread_id) continue;
|
|
157
157
|
if (!best || routeSpecificity(r) > routeSpecificity(best)) best = r;
|
|
158
158
|
}
|
|
159
|
-
return best ? best.
|
|
159
|
+
return best ? best.agent : null;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
// ---------------------------------------------------------------- profiles / state
|
|
@@ -183,7 +183,7 @@ export function emptyRuntime() {
|
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
export function emptyState(root) {
|
|
186
|
-
return { root, profiles: { default: emptyProfile('default', root) }, runtime: emptyRuntime() };
|
|
186
|
+
return { root, profiles: { default: emptyProfile('default', root) }, agents: {}, conversations: {}, transfers: [], handoffs: [], usage: {}, runtime: emptyRuntime() };
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/** Stable, secret-free JSON of the persisted half of the state (runtime omitted). */
|
package/kanban-worker.mjs
CHANGED
|
@@ -14,13 +14,14 @@ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
|
14
14
|
import { load } from './orchestration.mjs';
|
|
15
15
|
import { INSTRUCTION_ARGUMENTS, activationCwd, activationEnv, sandboxArguments, approvalPolicy, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
|
|
16
16
|
import { workerRoute } from './worker-route.mjs';
|
|
17
|
-
import { KANBAN_ENV, untrustedServerVerdict, hermesInstall } from './hermes-cli.mjs';
|
|
17
|
+
import { KANBAN_ENV, untrustedServerVerdict, hermesInstall, memorySnapshot } from './hermes-cli.mjs';
|
|
18
18
|
import { pinnedSkills, taskProtocol } from './board/worker-prompt.mjs';
|
|
19
19
|
import { mountOrchestratorMcp } from './mcp/mount.mjs';
|
|
20
20
|
import { normalizeWorkerEvent } from './loop.mjs';
|
|
21
21
|
import { DEFAULT_BOARD, json, openBoard, tx } from './board/store.mjs';
|
|
22
22
|
import { setRunSession } from './board/engine.mjs';
|
|
23
23
|
import { permissionResponse } from './reducer.mjs';
|
|
24
|
+
import { boardGuardArguments } from '../teams/board-process-guard.mjs';
|
|
24
25
|
|
|
25
26
|
/** Hermes's worker argv, as `_default_spawn` builds it. */
|
|
26
27
|
export function parseHermesArgv(argv) {
|
|
@@ -50,11 +51,12 @@ export async function runKanbanWorker(argv, env = process.env) {
|
|
|
50
51
|
const args = parseHermesArgv(argv);
|
|
51
52
|
if (!env.HERMES_HOME || !args.query) { say('hermes-worker: HERMES_HOME and -q are required (the dispatcher sets both)'); return 2; }
|
|
52
53
|
const home = resolve(env.HERMES_HOME);
|
|
53
|
-
const root = rootOf(home);
|
|
54
|
+
const root = env.SUPERCODE_AGENT_HOME ? resolve(env.SUPERCODE_AGENT_HOME) : rootOf(home);
|
|
54
55
|
const { state, vault } = await load(root);
|
|
55
56
|
const profile = Object.values(state.profiles).find((p) => resolve(p.dir) === home) ?? state.profiles[args.profile ?? 'default'];
|
|
56
57
|
if (!profile?.worker?.harness) { say(`hermes-worker: no worker for profile ${args.profile ?? home}`); return 2; }
|
|
57
|
-
|
|
58
|
+
// The agent's home is already the effective profile. Hermes must not switch back to the shared template.
|
|
59
|
+
if (profile.worker.harness === 'hermes') return runHermes(env.SUPERCODE_AGENT_ID?withoutProfile(argv):argv, env);
|
|
58
60
|
const harness = profile.worker.harness;
|
|
59
61
|
const task = Object.fromEntries(KANBAN_ENV.filter((k) => env[k]).map((k) => [k, env[k]]));
|
|
60
62
|
const taskId = task.HERMES_KANBAN_TASK ?? 'none';
|
|
@@ -67,10 +69,12 @@ export async function runKanbanWorker(argv, env = process.env) {
|
|
|
67
69
|
materializeSkills(profile);
|
|
68
70
|
const route = workerRoute(profile, worker, vault);
|
|
69
71
|
const surfaceKey = `kanban|task|${taskId}`;
|
|
70
|
-
const
|
|
72
|
+
const identityEnv=Object.fromEntries(Object.entries(env).filter(([key])=>key.startsWith('SUPERCODE_AGENT_')));
|
|
73
|
+
const runEnv = { ...activationEnv({ root, profile, surfaceKey, vault }), ...route.env, ...task, ...identityEnv,HERMES_HOME: home };
|
|
74
|
+
const memory=env.SUPERCODE_AGENT_ID?await memorySnapshot(home):'';
|
|
71
75
|
// the persona, the task protocol and the pinned skills, supercode's own (board/worker-prompt.mjs)
|
|
72
76
|
// the board workflow's own words for this run (its status's prompt and doors), after the task protocol
|
|
73
|
-
const instructions = [userHomePersona(profile), taskProtocol(taskId), env.SUPERCODE_BOARD_PROMPT ?? '', pinnedSkills(profile.dir, args.skills)].filter(Boolean).join('\n\n');
|
|
77
|
+
const instructions = [userHomePersona(profile), taskProtocol(taskId), env.SUPERCODE_BOARD_PROMPT ?? '', memory,pinnedSkills(profile.dir, args.skills)].filter(Boolean).join('\n\n');
|
|
74
78
|
|
|
75
79
|
const client = new SupercodeHarnessClient(env.SUPERCODE_BIN ? { command: env.SUPERCODE_BIN } : {});
|
|
76
80
|
try {
|
|
@@ -87,7 +91,7 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
|
|
|
87
91
|
const reasoning = args.reasoning && harness === 'codex' ? ['-c', `model_reasoning_effort=${JSON.stringify(args.reasoning)}`] : [];
|
|
88
92
|
const launch = {
|
|
89
93
|
program: base.program,
|
|
90
|
-
arguments: [...(base.arguments ?? []), ...modelArguments(worker), ...route.args, ...(instructions && INSTRUCTION_ARGUMENTS[harness] ? INSTRUCTION_ARGUMENTS[harness](instructions) : []), ...userHomeArguments(profile), ...reasoning, ...sandboxArguments(profile, harness), ...limitArguments(profile, worker, (level, line) => say(line))],
|
|
94
|
+
arguments: boardGuardArguments(harness, [...(base.arguments ?? []), ...modelArguments(worker), ...route.args, ...(instructions && INSTRUCTION_ARGUMENTS[harness] ? INSTRUCTION_ARGUMENTS[harness](instructions) : []), ...userHomeArguments(profile), ...reasoning, ...sandboxArguments(profile, harness), ...limitArguments(profile, worker, (level, line) => say(line))], { enabled: ['claude-code','codex'].includes(harness), cwd }),
|
|
91
95
|
env: { ...(base.env ?? {}), ...runEnv },
|
|
92
96
|
};
|
|
93
97
|
const mount = mountOrchestratorMcp({ root, profile, harness, surfaceKey, launch, vault, task });
|
|
@@ -162,3 +166,9 @@ function runHermes(argv, env) {
|
|
|
162
166
|
child.on('error', () => done(1));
|
|
163
167
|
});
|
|
164
168
|
}
|
|
169
|
+
|
|
170
|
+
function withoutProfile(argv) {
|
|
171
|
+
const selected=[];
|
|
172
|
+
for(let i=0;i<argv.length;i+=1){if(argv[i]==='-p'||argv[i]==='--profile'){i+=1;continue;}selected.push(argv[i]);}
|
|
173
|
+
return selected;
|
|
174
|
+
}
|
package/loop.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { read, save, retainWorld, releaseWorld } from './orchestration.mjs';
|
|
|
12
12
|
import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, approvalPolicy, sandboxArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
|
|
13
13
|
import { workerRoute } from './worker-route.mjs';
|
|
14
14
|
import { hermesAccess, hermesApproval, untrustedServerVerdict, hermesToolEnv, memorySnapshot, mirrorSession, mirrorState, recordIncident, sealMirror } from './hermes-cli.mjs';
|
|
15
|
-
import { hermesSessionKey } from './ir.mjs';
|
|
15
|
+
import { hermesSessionKey, homeChannel } from './ir.mjs';
|
|
16
16
|
import { surfaceKeyString } from './ir.mjs';
|
|
17
17
|
import { BREAKER_FAILURES, BREAKER_MINUTES, MAX_ATTEMPTS, historyRetention, jobGate } from './cron.mjs';
|
|
18
18
|
import { startWebhookListener } from './webhook.mjs';
|
|
@@ -23,6 +23,9 @@ import { performRemoveProfileDir } from './operator.mjs';
|
|
|
23
23
|
import { harnessHomes } from './mcp/verbs.mjs';
|
|
24
24
|
import { checkMonitor, runJobScript } from './gates.mjs';
|
|
25
25
|
import { startBoard } from './board.mjs';
|
|
26
|
+
import { SenderIdentityRecord } from '@volter/teams/sender-identity';
|
|
27
|
+
import { agentDoor,agentScope,teamsUnavailable,ensureProfileAgent,localProfileAgent,localAgentRecord,localAgentSessions,synchronizeLocalAgents,ensureForkAgent,agentRunForKey,agentIntent,agentPreamble,agentEnvironment,recordAgentRun,recordNativeAgentSession,pendingAgentRuns,synchronizeAgentRun,agentRunForSession } from './agents.mjs';
|
|
28
|
+
import { materializeAgentHome } from './agent-home.mjs';
|
|
26
29
|
import { localMachine } from './board/dispatch.mjs';
|
|
27
30
|
import { execFile } from 'node:child_process';
|
|
28
31
|
import { promisify } from 'node:util';
|
|
@@ -55,6 +58,7 @@ export class Orchestrator {
|
|
|
55
58
|
this.state = state;
|
|
56
59
|
this.executeJob = executeJob;
|
|
57
60
|
this.hostedTasks = new Set();
|
|
61
|
+
this.identityRecord = new SenderIdentityRecord();
|
|
58
62
|
state.runtime ??= {};
|
|
59
63
|
state.runtime.hostedJobs = !!executeJob;
|
|
60
64
|
state.runtime.machine = localMachine(); // a job naming another machine fires there (cron.mjs startFire)
|
|
@@ -183,11 +187,24 @@ export class Orchestrator {
|
|
|
183
187
|
tick(at = this.clock()) {
|
|
184
188
|
this.queue = this.queue.then(() => this.adoptOutsideJobs()).catch((error) => this.log('warn', `cron/jobs.json not read back: ${error.message}`));
|
|
185
189
|
return this.dispatch(events.tick(at, cronInactivitySeconds(), cronMaxParallel())).then(
|
|
186
|
-
(result) => { this.tickerHeartbeat(true); return result; },
|
|
190
|
+
(result) => { this.tickerHeartbeat(true); this.synchronizeProfileSessions();return result; },
|
|
187
191
|
(error) => { this.tickerHeartbeat(false); throw error; },
|
|
188
192
|
);
|
|
189
193
|
}
|
|
190
194
|
|
|
195
|
+
synchronizeProfileSessions(){
|
|
196
|
+
if(!existsSync(join(this.state.root,'agent-state.sqlite')))return;
|
|
197
|
+
try{
|
|
198
|
+
const context=process.env.SUPERCODE_CONTEXT,scope=agentScope(context);
|
|
199
|
+
if(scope.offline)return;
|
|
200
|
+
for(const pending of synchronizeLocalAgents({root:this.state.root,profiles:this.state.profiles,context,declarations:this.state.agents??{}}))this.log('warn',`agent ${pending.agent} declaration pending: ${pending.error}`);
|
|
201
|
+
for(const row of pendingAgentRuns(this.state.root,scope.org)){
|
|
202
|
+
try{synchronizeAgentRun(this.state.root,scope.org,row,{context,intended:row.profile_json?JSON.parse(row.profile_json):undefined});}
|
|
203
|
+
catch(error){this.log('warn',`agent session ${row.session_ref}: ${error.message}`);}
|
|
204
|
+
}
|
|
205
|
+
}catch(error){this.log('warn',`agent session synchronization: ${error.message}`);}
|
|
206
|
+
}
|
|
207
|
+
|
|
191
208
|
/**
|
|
192
209
|
* Hermes's ticker liveness (`cron/jobs.py` `record_ticker_heartbeat`), in each profile's cron store: the
|
|
193
210
|
* epoch of every tick in `ticker_heartbeat`, and of every tick that did not fail in `ticker_last_success`.
|
|
@@ -255,6 +272,12 @@ export class Orchestrator {
|
|
|
255
272
|
}
|
|
256
273
|
|
|
257
274
|
async apply(event) {
|
|
275
|
+
if(event.type==='inbound'&&!event.is_me) {
|
|
276
|
+
// An adapter's display name or supplied principal never proves identity.
|
|
277
|
+
const observation=`${event.key.platform}:${event.sender}`;
|
|
278
|
+
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
|
+
catch(error){event={...event,identity:{observation,principal:null,classification:'unresolved',label:'unresolved'}};this.log('warn',`identity observation unavailable: ${error.message}`);}
|
|
280
|
+
}
|
|
258
281
|
const effects = step(this.state, event);
|
|
259
282
|
for (const effect of effects) {
|
|
260
283
|
this.effectLog.push(effect);
|
|
@@ -292,7 +315,7 @@ export class Orchestrator {
|
|
|
292
315
|
let got;
|
|
293
316
|
try { got = hermesAccess(this.state.profiles[effect.profile].dir, 'issue', effect); } catch (error) { this.log('warn', `pairing: ${error.message}`); return; }
|
|
294
317
|
if (got?.silent) return;
|
|
295
|
-
|
|
318
|
+
this.enqueue({ type: 'pairing_issued', profile: effect.profile, key: effect.key, code: got?.code ?? null, at: this.clock() });
|
|
296
319
|
return;
|
|
297
320
|
}
|
|
298
321
|
case 'access_write': {
|
|
@@ -300,7 +323,10 @@ export class Orchestrator {
|
|
|
300
323
|
try {
|
|
301
324
|
const got = hermesAccess(dir, effect.op, effect);
|
|
302
325
|
if (effect.op === 'approve') {
|
|
303
|
-
if (got)
|
|
326
|
+
if (got) {
|
|
327
|
+
await this.identityRecord.approvedPairing({...got,code:effect.code,principal_id:effect.principal??null,location:dir}).catch(error=>this.log('warn',`pairing identity not recorded: ${error.message}`));
|
|
328
|
+
this.enqueue({ type: 'operator', verb: 'access_granted', profile: effect.profile, args: { platform: got.platform, user_id: got.user_id }, at: this.clock() });
|
|
329
|
+
}
|
|
304
330
|
else this.log('warn', `pairing code ${effect.code}: not pending in Hermes's pairing store (wrong, expired, or locked out)`);
|
|
305
331
|
}
|
|
306
332
|
} catch (error) {
|
|
@@ -308,6 +334,8 @@ export class Orchestrator {
|
|
|
308
334
|
}
|
|
309
335
|
return;
|
|
310
336
|
}
|
|
337
|
+
case 'usage_refresh': return this.refreshUsage(effect);
|
|
338
|
+
case 'budget_refused': return this.tellBudgetRefusal(effect);
|
|
311
339
|
case 'save': await this.save(); return;
|
|
312
340
|
case 'log': this.log(effect.level, effect.message); return;
|
|
313
341
|
default: this.log('warn', `unknown effect ${effect.type}`);
|
|
@@ -371,7 +399,7 @@ export class Orchestrator {
|
|
|
371
399
|
}
|
|
372
400
|
// Hermes runs a no_agent script in the job's workdir, and an agent job's
|
|
373
401
|
// pre-run script from the scripts folder
|
|
374
|
-
const script = gate.script ? await runJobScript(profile, gate.script, gate.no_agent ? workdir : null, { signal, secrets }) : null;
|
|
402
|
+
const script = gate.script ? await runJobScript(profile, gate.script, gate.no_agent ? workdir : null, { signal, secrets, automation: `cron:${profile.name}/${job.id}` }) : null;
|
|
375
403
|
return { monitor, script };
|
|
376
404
|
})()
|
|
377
405
|
.catch((error) => ({ script: { ok: false, output: `Script execution failed: ${error.message}` } }))
|
|
@@ -383,23 +411,70 @@ export class Orchestrator {
|
|
|
383
411
|
/** Wait for admitted host tasks; useful for deterministic tests and graceful shutdown. */
|
|
384
412
|
async drainHosted() { await Promise.allSettled([...this.hostedTasks]); }
|
|
385
413
|
|
|
386
|
-
async startRuntime({ profile: profileName, keyString, worker, resume_runtime_id = null }) {
|
|
414
|
+
async startRuntime({ profile: profileName, keyString, worker, resume_runtime_id = null,agent_as=null,new_fork=false,run_key=null,starter_session=null,agent:agentName=null }) {
|
|
387
415
|
const profile = this.state.profiles[profileName];
|
|
388
416
|
const lid = leaseId(profileName, keyString);
|
|
389
417
|
const cwd = activationCwd(profile, worker.cwd);
|
|
390
418
|
materializePersona(profile);
|
|
391
419
|
materializeSkills(profile);
|
|
392
|
-
let launch;
|
|
420
|
+
let launch,agent,scope,context,agentPrompt,main,agentHome;
|
|
393
421
|
try {
|
|
422
|
+
context=process.env.SUPERCODE_CONTEXT;scope=agentScope(context);
|
|
423
|
+
const local=error=>{if(!teamsUnavailable(error))throw error;this.log('warn',`agent identity for ${profileName} stays local: ${error.message}`);};
|
|
424
|
+
let previous=null,reader=null;
|
|
425
|
+
if(agent_as) {
|
|
426
|
+
// A launch as a named agent is an act Teams authorizes; it never proceeds on local intent alone.
|
|
427
|
+
agentDoor('management',[agent_as],{context});
|
|
428
|
+
const identity=agentDoor('get',[agent_as],{context}).data,intent=agentIntent(this.state.root,scope.org,agent_as);
|
|
429
|
+
if(!intent)throw new Error('launch as an agent requires its retained profile intent');
|
|
430
|
+
agent={identity,intended:intent.profile,assignments:intent.assignments,org:scope.org};
|
|
431
|
+
} else if(!scope.offline) {
|
|
432
|
+
try{agent=ensureProfileAgent({root:this.state.root,org:scope.org,server:scope.server,profiles:this.state.profiles,profile:profileName,context,agent:agentName,declarations:this.state.agents});}catch(error){local(error);}
|
|
433
|
+
}
|
|
434
|
+
// Situation 16: no reachable Teams. The locally minted identity runs now and is declared later under its id.
|
|
435
|
+
agent??=localProfileAgent({root:this.state.root,org:scope.org,server:scope.server,profiles:this.state.profiles,profile:profileName,agent:agentName,declarations:this.state.agents});
|
|
436
|
+
agent.intended.resolved.worker={...agent.intended.resolved.worker,...worker,cwd};
|
|
437
|
+
main=new_fork||!agent.identity.main_session;
|
|
438
|
+
if(agent.synced!==false){
|
|
439
|
+
try{
|
|
440
|
+
previous=agentDoor('sessions',[agent.identity.id,{limit:200}],{context}).items??[];
|
|
441
|
+
if(resume_runtime_id&&!new_fork) {
|
|
442
|
+
// a session no sync rule publishes is unknown to Teams: its owner is the local run record, read below
|
|
443
|
+
let resumed=null;
|
|
444
|
+
try{resumed=agentDoor('session',[`${worker.harness}:${resume_runtime_id}`],{context}).data;}catch(error){if(error.code!=='not_found')throw error;}
|
|
445
|
+
if(resumed){
|
|
446
|
+
if(resumed.agent?.id!==agent.identity.id)throw new Error('the resumed session is not owned by this profile agent');
|
|
447
|
+
main=resumed.main;
|
|
448
|
+
} else {
|
|
449
|
+
const owner=agentRunForSession(this.state.root,[`${worker.harness}:${resume_runtime_id}`]);
|
|
450
|
+
if(owner&&owner.agent_id!==agent.identity.id)throw new Error('the resumed session is not owned by this profile agent');
|
|
451
|
+
if(owner)main=owner.main===1;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
reader=agentDoor('readerContext',[agent.identity.id,null],{context,timeout:60000});
|
|
455
|
+
}catch(error){local(error);}
|
|
456
|
+
}
|
|
457
|
+
if(resume_runtime_id&&!new_fork&&!previous){
|
|
458
|
+
const owner=agentRunForSession(this.state.root,[`${worker.harness}:${resume_runtime_id}`]);
|
|
459
|
+
if(owner&&owner.agent_id!==agent.identity.id)throw new Error('the resumed session is not owned by this profile agent');
|
|
460
|
+
if(owner)main=owner.main===1;
|
|
461
|
+
}
|
|
462
|
+
previous??=localAgentSessions(this.state.root,scope.org,agent.identity.id);
|
|
463
|
+
agentPrompt=agentPreamble(agent,{main,previous});
|
|
464
|
+
// a conversation passes between this home's agents by name (conversation_transfer); the model learns the names here
|
|
465
|
+
const peers=[...new Set([...Object.keys(this.state.profiles??{}),...Object.keys(this.state.agents??{})])].filter((name)=>name!==(agent.identity.name??profile.name)&&name!==profile.name);
|
|
466
|
+
if(peers.length)agentPrompt+=`\nThe other agents of this home: ${peers.join(', ')}. conversation_transfer hands a conversation to one of them by that name; they are not Claude Code sessions.`;
|
|
467
|
+
agentHome=materializeAgentHome(this.state.root,scope.org,agent.identity.id,profile);
|
|
394
468
|
// the home's own `model:` is the worker's model and route (docs/plans/hermes-compat.md row 6)
|
|
395
469
|
const route = workerRoute(profile, worker, this.vault);
|
|
396
470
|
// a worker of any harness but Hermes gets Hermes's CLI against its profile's home (row 7)
|
|
397
|
-
const tools = worker.harness === 'hermes' ? {} : hermesToolEnv(
|
|
398
|
-
const env = { ...tools, ...activationEnv({ root: this.state.root, profile, surfaceKey: keyString, vault: this.vault }), ...route.env };
|
|
399
|
-
|
|
400
|
-
|
|
471
|
+
const tools = worker.harness === 'hermes' ? {} : hermesToolEnv(agentHome);
|
|
472
|
+
const env = { ...tools, ...activationEnv({ root: this.state.root, profile, surfaceKey: keyString, vault: this.vault }), ...route.env,...agentEnvironment(agent,{root:this.state.root,context:reader?.context,main}) };
|
|
473
|
+
env.HERMES_HOME=agentHome;
|
|
474
|
+
// All sessions of this agent carry its state; a shared profile contributes configuration only.
|
|
475
|
+
const memory = INSTRUCTION_ARGUMENTS[worker.harness] ? await memorySnapshot(agentHome).catch((error) => { this.log('warn', `memory ${keyString}: ${error.message}`); return ''; }) : '';
|
|
401
476
|
// a worker in the user's own home carries its persona there too (its config home's CLAUDE.md is the user's)
|
|
402
|
-
const instructionText = [userHomePersona(profile), memory].filter(Boolean).join('\n\n');
|
|
477
|
+
const instructionText = [agentPrompt,userHomePersona(profile), memory].filter(Boolean).join('\n\n');
|
|
403
478
|
const instructions = instructionText && INSTRUCTION_ARGUMENTS[worker.harness] ? INSTRUCTION_ARGUMENTS[worker.harness](instructionText) : [];
|
|
404
479
|
const naming = NAME_ARGUMENTS[worker.harness]?.(basename(this.state.root)) ?? [];
|
|
405
480
|
launch = await this.resolveLaunch(profile, { ...worker, model: route.model }, env, [...route.args, ...instructions, ...naming, ...userHomeArguments(profile)]);
|
|
@@ -414,7 +489,7 @@ export class Orchestrator {
|
|
|
414
489
|
socketPath: this.bridge?.path ?? socketPathFor(this.state.root), vault: this.vault,
|
|
415
490
|
});
|
|
416
491
|
this.log('info', `mcp mount (${worker.harness}, ${mount.door}): ${mount.ran}`);
|
|
417
|
-
if (resume_runtime_id) resume_runtime_id = await this.continueFromHermes(profile, keyString, worker, resume_runtime_id, cwd);
|
|
492
|
+
if (resume_runtime_id) resume_runtime_id = await this.continueFromHermes(profile, keyString, worker, resume_runtime_id, cwd,agentHome);
|
|
418
493
|
// read before the runtime exists: a runtime emits from the moment it is made
|
|
419
494
|
const steer = Boolean((await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.steer);
|
|
420
495
|
let runtime;
|
|
@@ -426,9 +501,11 @@ export class Orchestrator {
|
|
|
426
501
|
runtime = await this.client.resumeManagedRuntime({ ...params, runtime_id: resume_runtime_id });
|
|
427
502
|
this.log('info', `resume ${keyString}: reopened ${resume_runtime_id}`);
|
|
428
503
|
} catch (error) {
|
|
504
|
+
if(new_fork)throw error;
|
|
429
505
|
this.log('warn', `resume ${keyString}: ${resume_runtime_id} did not reopen (${error.message}); starting fresh`);
|
|
430
506
|
}
|
|
431
507
|
}
|
|
508
|
+
if(new_fork&&!runtime)throw new Error('this harness cannot resume the materialized fork; no fresh replacement was started');
|
|
432
509
|
runtime ??= await this.client.startManagedRuntime(params);
|
|
433
510
|
} catch (error) {
|
|
434
511
|
this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: null, ev: { kind: 'error', message: `start failed: ${error.message}` }, at: this.clock() }));
|
|
@@ -439,12 +516,51 @@ export class Orchestrator {
|
|
|
439
516
|
const previous = this.runtimes.get(lid);
|
|
440
517
|
if (previous && previous !== runtime) this.retired.set(previous.handle.runtime_id, previous);
|
|
441
518
|
this.runtimes.set(lid, runtime);
|
|
519
|
+
if(!INSTRUCTION_ARGUMENTS[worker.harness])(this.runtimeAgentPreambles??=new Map()).set(lid,agentPrompt);
|
|
520
|
+
const session=`${worker.harness}:${runtime.handle.runtime_id}`;
|
|
521
|
+
recordAgentRun(this.state.root,scope.org,agent.identity.id,{session,machine:null,run:run_key??lid,profile:agent.intended.resolved,main,starterSession:starter_session});
|
|
522
|
+
if(agent.synced!==false)try{synchronizeAgentRun(this.state.root,scope.org,{agent_id:agent.identity.id,session_ref:session,machine:null,main},{context,intended:agent.intended.resolved});}
|
|
523
|
+
catch(error){this.log('warn',`agent session ${session} retained for synchronization: ${error.message}`);}
|
|
442
524
|
this.enqueue(events.worker({
|
|
443
525
|
profile: profileName, keyString, runtime_id: runtime.handle.runtime_id,
|
|
444
526
|
ev: { kind: 'started', runtime_id: runtime.handle.runtime_id, session_id: runtime.handle.runtime_id, connection: runtime.connection, harness: worker.harness, steer },
|
|
445
527
|
at: this.clock(),
|
|
446
528
|
}));
|
|
447
529
|
this.watch(profileName, keyString, runtime);
|
|
530
|
+
return runtime.handle;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Fork through native handoff/materialization. The source remains retained and the child has its own id. */
|
|
534
|
+
async forkRuntime({profile:profileName,keyString,parent,locator:given,name,key,machine=null}) {
|
|
535
|
+
if(machine)throw new Error('fork this owned workspace on its execution host; move it through the workspace door afterward');
|
|
536
|
+
const locator=await this.wholeLocator(given);
|
|
537
|
+
const profile=this.state.profiles[profileName];if(!profile)throw new Error('fork requires a named profile');
|
|
538
|
+
const binding=profile.bindings[keyString];
|
|
539
|
+
if(!binding||binding.ended_at)throw new Error('register the fork target conversation before starting its runtime');
|
|
540
|
+
if(this.runtimes.has(leaseId(profileName,keyString)))throw new Error('fork requires an unoccupied target conversation');
|
|
541
|
+
if(!(await this.descriptorFor(locator.harness))?.runtime?.capabilities?.resume_session)throw new Error('this harness cannot resume a materialized fork');
|
|
542
|
+
const context=process.env.SUPERCODE_CONTEXT,scope=agentDoor('scope',[],{context}),session=`${locator.harness}:${locator.session_id}`;
|
|
543
|
+
const child=ensureForkAgent({root:this.state.root,org:scope.org,server:scope.server,profiles:this.state.profiles,parent,session,name,key,context});
|
|
544
|
+
const runKey=`fork:${key}`,prior=agentRunForKey(this.state.root,scope.org,child.identity.id,runKey);
|
|
545
|
+
let target;
|
|
546
|
+
if(prior) {
|
|
547
|
+
const native=prior.session_ref.slice(prior.session_ref.indexOf(':')+1);
|
|
548
|
+
const page=await this.client.discover({harnesses:[locator.harness],query:native,limit:200});
|
|
549
|
+
target=page.sessions.find(item=>item.locator.harness===locator.harness&&item.locator.session_id===native)?.locator;
|
|
550
|
+
if(!target)throw new Error('retained fork session is not available; no replacement was created');
|
|
551
|
+
}else {
|
|
552
|
+
const loaded=await this.client.loadWindow(locator,{message_limit:1});
|
|
553
|
+
if(loaded.session.session_id!==locator.session_id)throw new Error('fork locator and native session identity differ');
|
|
554
|
+
const cwd=activationCwd(profile,child.intended.resolved.worker?.cwd),store=WORKER_STORE[locator.harness];
|
|
555
|
+
if(!store)throw new Error('this harness has no admitted native materialization door');
|
|
556
|
+
const {artifact}=await this.client.handoffSession({locator,target_harness:locator.harness,cwd});
|
|
557
|
+
const home=materializeAgentHome(this.state.root,scope.org,child.identity.id,profile);
|
|
558
|
+
target=(await this.client.materializeSession({artifact,cwd,homes:store(locator.harness==='hermes'?home:join(profile.dir,locator.harness))})).locator;
|
|
559
|
+
recordAgentRun(this.state.root,scope.org,child.identity.id,{session:`${target.harness}:${target.session_id}`,machine:null,run:runKey,profile:child.intended.resolved,main:true,starterSession:session});
|
|
560
|
+
}
|
|
561
|
+
const runtime=await this.startRuntime({profile:profileName,keyString,worker:{...profile.worker,...child.intended.resolved.worker,harness:target.harness},resume_runtime_id:target.session_id,agent_as:child.identity.id,new_fork:true,run_key:runKey,starter_session:session});
|
|
562
|
+
if(!runtime)throw new Error('the child session did not start; its declared identity and native fork remain retained');
|
|
563
|
+
return {agent:child.identity,locator:target,runtime};
|
|
448
564
|
}
|
|
449
565
|
|
|
450
566
|
/**
|
|
@@ -461,6 +577,26 @@ export class Orchestrator {
|
|
|
461
577
|
this.enqueue(events.operator({ verb: 'binding_interrupted', profile: profileName, args: { keyString, runtime_id }, at: this.clock() }));
|
|
462
578
|
};
|
|
463
579
|
if (!runtime_id || !harness) return interrupted('no runtime recorded on the binding');
|
|
580
|
+
const held = this.state.profiles[profileName]?.bindings?.[keyString];
|
|
581
|
+
const owner=agentRunForSession(this.state.root,[`${harness}:${held?.worker?.session_id??runtime_id}`,`${harness}:${runtime_id}`]);
|
|
582
|
+
let preamble=null;
|
|
583
|
+
if(owner) {
|
|
584
|
+
try {
|
|
585
|
+
const context=process.env.SUPERCODE_CONTEXT;
|
|
586
|
+
const intent=agentIntent(this.state.root,owner.org,owner.agent_id);
|
|
587
|
+
if(!intent)throw new Error('retained session has no agent profile intent');
|
|
588
|
+
try{
|
|
589
|
+
const live=agentDoor('session',[owner.session_ref],{context}).data;
|
|
590
|
+
if(live.agent?.id!==owner.agent_id||live.agent.state!=='active')throw new Error('retained session agent is no longer active or has different ownership');
|
|
591
|
+
const previous=agentDoor('sessions',[owner.agent_id,{limit:200}],{context}).items??[];
|
|
592
|
+
preamble=agentPreamble({identity:live.agent,intended:intent.profile,assignments:intent.assignments},{main:live.main,previous});
|
|
593
|
+
}catch(error){
|
|
594
|
+
// Teams cannot be asked: the session continues as the agent this home recorded, never as another.
|
|
595
|
+
if(!teamsUnavailable(error))throw error;
|
|
596
|
+
preamble=agentPreamble(localAgentRecord(this.state.root,owner.org,owner.agent_id),{main:owner.main===1,previous:localAgentSessions(this.state.root,owner.org,owner.agent_id)});
|
|
597
|
+
}
|
|
598
|
+
}catch(error){return interrupted(`agent identity unavailable: ${error.message}`);}
|
|
599
|
+
}
|
|
464
600
|
let capable = false;
|
|
465
601
|
try {
|
|
466
602
|
capable = Boolean((await this.descriptorFor(harness))?.runtime?.capabilities?.attach_existing_process);
|
|
@@ -475,8 +611,8 @@ export class Orchestrator {
|
|
|
475
611
|
return interrupted(`attach_existing failed: ${error.message}`);
|
|
476
612
|
}
|
|
477
613
|
this.runtimes.set(leaseId(profileName, keyString), runtime);
|
|
614
|
+
if(preamble)(this.runtimeAgentPreambles??=new Map()).set(leaseId(profileName,keyString),preamble);
|
|
478
615
|
this.watch(profileName, keyString, runtime);
|
|
479
|
-
const held = this.state.profiles[profileName]?.bindings?.[keyString];
|
|
480
616
|
this.enqueue(events.worker({
|
|
481
617
|
profile: profileName, keyString, runtime_id: runtime.handle.runtime_id,
|
|
482
618
|
ev: { kind: 'started', runtime_id: runtime.handle.runtime_id, session_id: held?.worker?.session_id || runtime.handle.runtime_id, connection: runtime.connection, harness, reattach_of: runtime_id },
|
|
@@ -540,6 +676,14 @@ export class Orchestrator {
|
|
|
540
676
|
|
|
541
677
|
onWorkerEvent(profileName, keyString, runtime, ev) {
|
|
542
678
|
const at = this.clock();
|
|
679
|
+
// Child tool frames cannot transfer the parent runtime's ownership to their own native id.
|
|
680
|
+
const nativeId=!ev.payload?.parent_tool_use_id&&(ev.payload?.type==='system'&&ev.payload?.subtype==='init'?ev.payload.session_id:ev.sessionId!==runtime.handle.runtime_id?ev.sessionId:null);
|
|
681
|
+
if(typeof nativeId==='string'&&nativeId) {
|
|
682
|
+
try {
|
|
683
|
+
recordNativeAgentSession(this.state.root,`${runtime.handle.harness}:${runtime.handle.runtime_id}`,`${runtime.handle.harness}:${nativeId}`);
|
|
684
|
+
this.enqueue(events.worker({profile:profileName,keyString,runtime_id:runtime.handle.runtime_id,ev:{kind:'session',session_id:nativeId},at}));
|
|
685
|
+
}catch(error){this.log('warn',`native session ownership: ${error.message}`);}
|
|
686
|
+
}
|
|
543
687
|
// every event the runtime reports is the worker being alive, including the
|
|
544
688
|
// tool calls, reasoning and turn starts the reducer does not otherwise read
|
|
545
689
|
let w = normalizeWorkerEvent(ev) ?? { kind: 'activity' };
|
|
@@ -584,7 +728,7 @@ export class Orchestrator {
|
|
|
584
728
|
// `cron_incomplete_no_output` for a run that said nothing)
|
|
585
729
|
const said = (loaded.session.messages ?? []).some((m) => m.role === 'assistant' && (typeof m.content === 'string' ? m.content.trim() : (m.content ?? []).length));
|
|
586
730
|
const end = b.recurrence && final ? (said ? 'cron_complete' : 'cron_incomplete_no_output') : null;
|
|
587
|
-
const got = await mirrorSession(profile
|
|
731
|
+
const got = await mirrorSession(this.sessionStateHome(profile,w), {
|
|
588
732
|
id: w.session_id, sessionKey: hermesSessionKey(profileName, b.key), binding: b, session: loaded.session,
|
|
589
733
|
locator: locator.storage?.path ?? null, parent: this.continued?.get(keyString) ?? null, end,
|
|
590
734
|
});
|
|
@@ -599,7 +743,7 @@ export class Orchestrator {
|
|
|
599
743
|
const cached = this.locators?.get(keyString);
|
|
600
744
|
if (cached?.session_id === w.session_id) return cached;
|
|
601
745
|
const { sessions } = await this.client.discover({
|
|
602
|
-
harnesses: [w.harness], homes: WORKER_STORE[w.harness](join(profile.dir, w.harness)),
|
|
746
|
+
harnesses: [w.harness], homes: WORKER_STORE[w.harness](w.harness==='hermes'?this.sessionStateHome(profile,w):join(profile.dir, w.harness)),
|
|
603
747
|
include_child_sessions: true, root_session_id: w.session_id, limit: 8,
|
|
604
748
|
});
|
|
605
749
|
const found = sessions.find((d) => d.locator.session_id === w.session_id)?.locator ?? null;
|
|
@@ -613,16 +757,21 @@ export class Orchestrator {
|
|
|
613
757
|
* held when this home was its), its transcript is handed to the worker's harness and materialized in
|
|
614
758
|
* the worker's home, the old mirror sealed, and that new session resumed; otherwise `sessionId`.
|
|
615
759
|
*/
|
|
616
|
-
|
|
760
|
+
sessionStateHome(profile,worker) {
|
|
761
|
+
const owner=agentRunForSession(this.state.root,[`${worker.harness}:${worker.session_id}`,`${worker.harness}:${worker.runtime_id}`]);
|
|
762
|
+
return owner?materializeAgentHome(this.state.root,owner.org,owner.agent_id,profile):profile.dir;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async continueFromHermes(profile, keyString, worker, sessionId, cwd,stateHome=this.sessionStateHome(profile,{...worker,session_id:sessionId})) {
|
|
617
766
|
const store = WORKER_STORE[worker.harness];
|
|
618
767
|
if (!store) return sessionId;
|
|
619
|
-
const state = await mirrorState(
|
|
768
|
+
const state = await mirrorState(stateHome, sessionId);
|
|
620
769
|
if (!state?.ahead) return sessionId;
|
|
621
770
|
try {
|
|
622
|
-
const hermes = { harness: 'hermes', session_id: sessionId, storage: { kind: 'file', path: join(
|
|
771
|
+
const hermes = { harness: 'hermes', session_id: sessionId, storage: { kind: 'file', path: join(stateHome, 'state.db') } };
|
|
623
772
|
const { artifact } = await this.client.handoffSession({ locator: hermes, target_harness: worker.harness, cwd });
|
|
624
773
|
const { locator } = await this.client.materializeSession({ artifact, cwd, homes: store(join(profile.dir, worker.harness)) });
|
|
625
|
-
await sealMirror(
|
|
774
|
+
await sealMirror(stateHome, sessionId, locator.session_id);
|
|
626
775
|
(this.locators ??= new Map()).set(keyString, locator);
|
|
627
776
|
(this.continued ??= new Map()).set(keyString, sessionId);
|
|
628
777
|
this.log('info', `resume ${keyString}: Hermes carried ${sessionId} on (${state.messages} messages); it goes on as ${worker.harness} ${locator.session_id}`);
|
|
@@ -644,15 +793,56 @@ export class Orchestrator {
|
|
|
644
793
|
try { await runtime.steer(text); } catch (error) { requeue(error.message); }
|
|
645
794
|
}
|
|
646
795
|
|
|
647
|
-
|
|
796
|
+
/** A session's whole locator: the harness's doors load a session by its storage address, which discovery names. */
|
|
797
|
+
async wholeLocator(locator) {
|
|
798
|
+
if (locator.storage) return locator;
|
|
799
|
+
const found = await this.client.request('harness.v1.sessions.discover', { query: locator.session_id, harnesses: [locator.harness], limit: 200 });
|
|
800
|
+
const whole = found.sessions?.find((item) => item.locator?.harness === locator.harness && item.locator?.session_id === locator.session_id)?.locator;
|
|
801
|
+
if (!whole) throw new Error(`${locator.harness}:${locator.session_id} is not in its harness catalogue`);
|
|
802
|
+
return whole;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** G3: a session's usage, recomputed whole from its harness's own records, returns as an event. */
|
|
806
|
+
async refreshUsage({ session, agent }) {
|
|
807
|
+
try {
|
|
808
|
+
const locator = await this.wholeLocator({ harness: session.harness, session_id: session.session_id });
|
|
809
|
+
const reply = await this.client.request('harness.v1.sessions.usage', { locator });
|
|
810
|
+
this.enqueue({ type: 'usage', session: `${session.harness}:${session.session_id}`, agent, usage: reply.usage ?? [], at: this.clock() });
|
|
811
|
+
} catch (error) { this.log('warn', `usage of ${session.harness}:${session.session_id} unread: ${error.message}`); }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** G3: a refused start is told where the profile's home is and logged; nothing is paused or ended. */
|
|
815
|
+
tellBudgetRefusal({ profile, agent, reason }) {
|
|
816
|
+
this.log('warn', `budget: ${reason}`);
|
|
817
|
+
const owner = this.state.profiles[profile];
|
|
818
|
+
const home = owner ? homeChannel(owner) : null;
|
|
819
|
+
if (home) this.enqueue(events.operator({ verb: 'send_message', profile, args: { key: home, text: `Budget: ${reason}. Its next start waits for the window to pass or the budget to change.` }, at: this.clock() }));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** G2: what a transfer's receiver is given of the conversation, from the giving session's own transcript. */
|
|
823
|
+
async transferHistory(history) {
|
|
824
|
+
const tail = history.mode === 'filtered' ? Math.max(1, Math.min(200, Number(history.filter?.last ?? 20))) : 200;
|
|
825
|
+
try {
|
|
826
|
+
const loaded = await this.client.loadWindow(await this.wholeLocator({ harness: history.harness, session_id: history.session_id }), { message_tail: tail });
|
|
827
|
+
const lines = (loaded.session?.messages ?? []).filter((m) => ['user', 'assistant'].includes(m.role) && m.content)
|
|
828
|
+
.map((m) => `${m.role === 'user' ? 'them' : history.from}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`);
|
|
829
|
+
return lines.length ? `The conversation so far (${history.mode === 'full' ? 'whole' : `last ${tail}`}, from ${history.from}'s session):\n${lines.join('\n')}\n\n` : '';
|
|
830
|
+
} catch (error) { this.log('warn', `transfer history unread: ${error.message}`); return ''; }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async sendInput({ profile, keyString, text, context_from = null, skills = [], job_id = null, history = null }) {
|
|
648
834
|
const runtime = this.runtimes.get(leaseId(profile, keyString));
|
|
649
835
|
if (!runtime) { this.log('warn', `send_input with no runtime for ${keyString}`); return; }
|
|
650
836
|
const owner = this.state.profiles[profile];
|
|
651
837
|
const dir = owner?.dir;
|
|
652
838
|
let input = context_from && dir ? contextFromPrefix(dir, job_id, context_from) + text : text;
|
|
839
|
+
if (history) input = (await this.transferHistory(history)) + input;
|
|
653
840
|
if (skills?.length && owner) input = await this.skillsPrefix(owner, skills, owner.jobs?.[job_id]?.workdir ?? null, input) ?? input;
|
|
841
|
+
const lid=leaseId(profile,keyString),preamble=this.runtimeAgentPreambles?.get(lid);
|
|
842
|
+
if(preamble)input=`${preamble}\n\n${input}`;
|
|
654
843
|
try {
|
|
655
844
|
await runtime.sendInput(input);
|
|
845
|
+
this.runtimeAgentPreambles?.delete(lid);
|
|
656
846
|
} catch (error) {
|
|
657
847
|
// A turn the harness is still running (one this loop did not start, or whose end it has not heard yet) is not a
|
|
658
848
|
// dead session: the line waits for that turn's end, as a refused steer does. Ending the binding on it killed a
|
package/mcp/bridge.mjs
CHANGED
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
import { SOCKET_ENV } from './server.mjs';
|
|
38
38
|
import { createServer } from 'node:net';
|
|
39
39
|
import { rmSync } from 'node:fs';
|
|
40
|
-
import { runVerb } from './verbs.mjs';
|
|
40
|
+
import { runVerb, keyFromString } from './verbs.mjs';
|
|
41
|
+
import { surfaceKeyString } from '../ir.mjs';
|
|
41
42
|
import { applyOperator } from '../operator.mjs';
|
|
42
43
|
|
|
43
44
|
export { socketPathFor } from './server.mjs';
|
|
@@ -122,7 +123,17 @@ async function dispatch(orch, call, log) {
|
|
|
122
123
|
async function dispatchOperator(orch, { op, args, profile }, log) {
|
|
123
124
|
const name = typeof profile === 'string' && profile ? profile : 'default';
|
|
124
125
|
let answer;
|
|
125
|
-
|
|
126
|
+
if (op === 'agents.fork') {
|
|
127
|
+
// Operation `fork`: a declared child agent, its profile a copy of the parent's, its session a fork of the parent's.
|
|
128
|
+
try {
|
|
129
|
+
const a = args ?? {};
|
|
130
|
+
for (const field of ['surface', 'parent', 'harness', 'session_id', 'name', 'key']) if (typeof a[field] !== 'string' || !a[field]) throw new Error(`agents.fork needs ${field}`);
|
|
131
|
+
const key = keyFromString(a.surface);
|
|
132
|
+
if (!key) throw new Error(`surface ${JSON.stringify(a.surface)} is not a surface key: write it as platform|kind|chat_id|thread_id|participant_id`);
|
|
133
|
+
const forked = await orch.forkRuntime({ profile: name, keyString: surfaceKeyString(key), parent: a.parent, locator: { harness: a.harness, session_id: a.session_id }, name: a.name, key: a.key, machine: a.machine ?? null });
|
|
134
|
+
answer = { ok: true, result: { ran: `forked ${a.harness}:${a.session_id} as agent ${forked.agent.path} (${forked.agent.id})`, agent: forked.agent, locator: forked.locator } };
|
|
135
|
+
} catch (error) { answer = { ok: false, error: error.message }; }
|
|
136
|
+
} else try {
|
|
126
137
|
answer = await applyOperator(orch.state, op, args ?? {}, name, orch.clock(), (event) => orch.dispatch(event));
|
|
127
138
|
} catch (error) {
|
|
128
139
|
answer = { ok: false, error: error.message };
|
package/mcp/tools.mjs
CHANGED
|
@@ -42,6 +42,23 @@ export const TOOLS = Object.freeze([
|
|
|
42
42
|
required: ['to'],
|
|
43
43
|
},
|
|
44
44
|
},
|
|
45
|
+
{
|
|
46
|
+
name: 'conversation_transfer',
|
|
47
|
+
// G2: OpenAI Agents' handoff, AutoGen Swarm's HandoffMessage, the voice front's hand_off.
|
|
48
|
+
description:
|
|
49
|
+
'Hand this conversation to another agent of this home: your binding ends, the agent takes the conversation '
|
|
50
|
+
+ 'and is given its history. Delegating work is a card, not a transfer.',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
to: { type: 'string', description: 'The receiving agent of this home, by name: a profile (`default`, or a profile folder\'s name) or an agent declared in it; never a Claude Code session. A refused name is answered with the home\'s agents.' },
|
|
55
|
+
reason: { type: 'string', description: 'Why, in one line; the receiver is told.' },
|
|
56
|
+
history: { type: 'string', enum: ['full', 'filtered', 'none'], description: 'What of the conversation the receiver is given (default full).' },
|
|
57
|
+
last: { type: 'integer', minimum: 1, maximum: 200, description: 'For filtered: how many recent messages.' },
|
|
58
|
+
},
|
|
59
|
+
required: ['to', 'reason'],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
45
62
|
]);
|
|
46
63
|
|
|
47
64
|
/**
|