@volter/supercode-orchestrator 0.4.4
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 +532 -0
- package/activation.mjs +270 -0
- package/adapters/api-server.mjs +615 -0
- package/adapters/chat-sdk.mjs +475 -0
- package/adapters/chunks.mjs +14 -0
- package/adapters/loopback.mjs +37 -0
- package/adapters/rh2.mjs +349 -0
- package/apply/doors.d.ts +2 -0
- package/apply/doors.mjs +292 -0
- package/apply/hermes_door.py +129 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.mjs +441 -0
- package/apply/state.mjs +142 -0
- package/bin/hermes-worker.mjs +11 -0
- package/bin/orchestrator.mjs +730 -0
- package/board.mjs +81 -0
- package/commands.mjs +37 -0
- package/cron.mjs +967 -0
- package/events.mjs +107 -0
- package/gates.mjs +281 -0
- package/hermes-cli.mjs +440 -0
- package/hermes-home.mjs +264 -0
- package/hermes-locks.mjs +188 -0
- package/hosted.mjs +81 -0
- package/index.d.ts +508 -0
- package/index.mjs +38 -0
- package/ir.mjs +205 -0
- package/kanban-worker.mjs +155 -0
- package/lease.mjs +51 -0
- package/loop.mjs +864 -0
- package/mcp/bridge.mjs +133 -0
- package/mcp/hermes-kanban-tools.json +477 -0
- package/mcp/hermes-tools.json +288 -0
- package/mcp/mount.mjs +161 -0
- package/mcp/server.mjs +160 -0
- package/mcp/tools.mjs +57 -0
- package/mcp/verbs.mjs +348 -0
- package/ontology.d.ts +557 -0
- package/operator.mjs +426 -0
- package/orchestration.mjs +166 -0
- package/package.json +51 -0
- package/reducer.mjs +792 -0
- package/webhook.mjs +263 -0
- package/worker-route.mjs +122 -0
package/activation.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Profile activation: the env and cwd a worker process gets (docs/ORCHESTRATOR-IR.md §2.2).
|
|
2
|
+
// The profile folder is a complete home; each harness's config home is a
|
|
3
|
+
// subfolder named by its id, pointed at through the harness's own env var.
|
|
4
|
+
|
|
5
|
+
import { join, relative, resolve } from 'node:path';
|
|
6
|
+
import { mkdirSync, readFileSync, readdirSync, lstatSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { CLAUDE_BRIDGE, linkTo, skillSources } from './hermes-home.mjs';
|
|
8
|
+
|
|
9
|
+
/** Which env var each harness reads its config home from (supercode registry evidence). */
|
|
10
|
+
export const HOME_ENV = Object.freeze({
|
|
11
|
+
'claude-code': 'CLAUDE_CONFIG_DIR',
|
|
12
|
+
codex: 'CODEX_HOME',
|
|
13
|
+
opencode: 'OPENCODE_CONFIG_DIR',
|
|
14
|
+
pi: 'PI_CODING_AGENT_DIR',
|
|
15
|
+
hermes: 'HERMES_HOME',
|
|
16
|
+
openclaw: 'OPENCLAW_STATE_DIR',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const ORCH_ENV = Object.freeze({
|
|
20
|
+
root: 'SUPERCODE_ORCHESTRATOR_ROOT',
|
|
21
|
+
profile: 'SUPERCODE_ORCHESTRATOR_PROFILE',
|
|
22
|
+
surface: 'SUPERCODE_ORCHESTRATOR_SURFACE',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function resolveRef(value, vault) {
|
|
26
|
+
if (typeof value === 'string') return value;
|
|
27
|
+
// `${NAME}`, as Hermes expands it: the home's `.env` (the vault), then the process environment
|
|
28
|
+
const name = value.dotenv ?? value.env;
|
|
29
|
+
if (vault?.has(name)) return vault.get(name);
|
|
30
|
+
if (process.env[name] !== undefined) return process.env[name];
|
|
31
|
+
throw new Error(`secret ${name} is in neither the home's .env nor the environment`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The launch env for one worker session. Never inherits the parent's whole
|
|
36
|
+
* environment: only the harness home, the profile's declared env (refs
|
|
37
|
+
* resolved), and the orchestrator's own three variables.
|
|
38
|
+
*/
|
|
39
|
+
export function activationEnv({ root, profile, surfaceKey, vault }) {
|
|
40
|
+
const env = {};
|
|
41
|
+
const worker = profile.worker;
|
|
42
|
+
const homeVar = HOME_ENV[worker.harness];
|
|
43
|
+
if (homeVar) env[homeVar] = join(profile.dir, worker.harness);
|
|
44
|
+
for (const [k, v] of Object.entries(worker.env ?? {})) env[k] = resolveRef(v, vault);
|
|
45
|
+
env[ORCH_ENV.root] = root;
|
|
46
|
+
env[ORCH_ENV.profile] = profile.name;
|
|
47
|
+
if (surfaceKey) env[ORCH_ENV.surface] = surfaceKey;
|
|
48
|
+
return env;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function activationCwd(profile, workdir) {
|
|
52
|
+
return resolve(profile.dir, workdir ?? profile.worker?.cwd ?? '.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where each harness reads a persona from any cwd, and in what form: its config home's own instruction
|
|
57
|
+
* file. A worker often runs in a project checkout outside the home, where the project walk never reaches
|
|
58
|
+
* the profile's `AGENTS.md` (Codex's walk also stops at the innermost git root, ORC-1 F1), so the
|
|
59
|
+
* persona is linked into the config home: Codex reads `$CODEX_HOME/AGENTS.md`; Claude Code reads
|
|
60
|
+
* `$CLAUDE_CONFIG_DIR/CLAUDE.md`, whose `@AGENTS.md` import resolves beside it, so an `AGENTS.md` link
|
|
61
|
+
* sits there too (docs/plans/hermes-compat.md row 2).
|
|
62
|
+
*/
|
|
63
|
+
export const HOME_PERSONA = Object.freeze({
|
|
64
|
+
codex: { 'AGENTS.md': 'link' },
|
|
65
|
+
'claude-code': { 'CLAUDE.md': 'bridge', 'AGENTS.md': 'link' },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Put the profile's persona where the worker's harness reads it from any cwd: links to the profile's
|
|
70
|
+
* own `AGENTS.md` (the source), never a copy to keep in step. Idempotent.
|
|
71
|
+
*/
|
|
72
|
+
export function materializePersona(profile) {
|
|
73
|
+
const worker = profile.worker;
|
|
74
|
+
const files = worker && HOME_PERSONA[worker.harness];
|
|
75
|
+
if (!files || !profile.persona) return null;
|
|
76
|
+
const home = join(profile.dir, worker.harness);
|
|
77
|
+
mkdirSync(home, { recursive: true });
|
|
78
|
+
for (const [file, form] of Object.entries(files)) {
|
|
79
|
+
const target = join(home, file);
|
|
80
|
+
if (form === 'link') linkTo(target, '../AGENTS.md');
|
|
81
|
+
else if (!existsSync(target) || readFileSync(target, 'utf8') !== CLAUDE_BRIDGE) writeFileSync(target, CLAUDE_BRIDGE);
|
|
82
|
+
}
|
|
83
|
+
return home;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Where each harness reads skills from any cwd: its config home's `skills/` (Codex's user root there, the
|
|
88
|
+
* one Supercode's loader and Codex both read; Claude Code's user root). Each skill a profile has — its
|
|
89
|
+
* workers' form and the skills Hermes manages — is linked in flat by name (docs/plans/hermes-compat.md
|
|
90
|
+
* row 3); a link whose skill is gone is removed; nothing else in the folder is touched.
|
|
91
|
+
*/
|
|
92
|
+
export const HOME_SKILLS = Object.freeze({ codex: 'skills', 'claude-code': 'skills' });
|
|
93
|
+
|
|
94
|
+
export function materializeSkills(profile) {
|
|
95
|
+
const worker = profile.worker;
|
|
96
|
+
const sub = worker && HOME_SKILLS[worker.harness];
|
|
97
|
+
if (!sub) return null;
|
|
98
|
+
const sources = skillSources(profile.dir);
|
|
99
|
+
const into = join(profile.dir, worker.harness, sub);
|
|
100
|
+
mkdirSync(into, { recursive: true });
|
|
101
|
+
for (const [name, from] of sources) linkTo(join(into, name), relative(into, from));
|
|
102
|
+
for (const entry of readdirSync(into)) {
|
|
103
|
+
const path = join(into, entry);
|
|
104
|
+
if (lstatSync(path).isSymbolicLink() && !sources.has(entry) && !existsSync(path)) rmSync(path, { force: true });
|
|
105
|
+
}
|
|
106
|
+
return into;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* How each worker CLI takes a model at launch, measured on each CLI's own
|
|
111
|
+
* `--help` (claude 2.1.280, codex-cli 0.156.1, gemini). A model set for a
|
|
112
|
+
* harness not listed here is refused where it is set (`/model`, the job
|
|
113
|
+
* doors) and again at launch: running on the harness's default instead would
|
|
114
|
+
* spend on a model nobody chose.
|
|
115
|
+
*/
|
|
116
|
+
export const MODEL_ARGUMENTS = Object.freeze({
|
|
117
|
+
'claude-code': (model) => ['--model', model],
|
|
118
|
+
codex: (model) => ['-c', `model=${JSON.stringify(model)}`],
|
|
119
|
+
gemini: (model) => ['-m', model],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The launch arguments that put text in a worker's system prompt, as Hermes's agent puts the home's memory in
|
|
124
|
+
* its own at session start (docs/plans/hermes-compat.md row 13): Claude Code appends to its system prompt;
|
|
125
|
+
* Codex takes developer instructions (a `developer_instructions` in the Codex home's `config.toml` is
|
|
126
|
+
* overridden for the session).
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* The launch flag that names a worker's session, where the harness has one. A worker carries its orchestrator's
|
|
130
|
+
* name (its home's), so the orchestrator stays addressable by that name as it replaces its workers.
|
|
131
|
+
*/
|
|
132
|
+
export const NAME_ARGUMENTS = Object.freeze({
|
|
133
|
+
'claude-code': (name) => ['--name', name],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const INSTRUCTION_ARGUMENTS = Object.freeze({
|
|
137
|
+
'claude-code': (text) => ['--append-system-prompt', text],
|
|
138
|
+
codex: (text) => ['-c', `developer_instructions=${JSON.stringify(text)}`],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** Why `model` cannot reach a `harness` worker, or null when it can (or none is set). */
|
|
142
|
+
export function modelError(harness, model) {
|
|
143
|
+
if (!model || !harness) return null;
|
|
144
|
+
return MODEL_ARGUMENTS[harness] ? null : `the orchestrator has no launch flag to give ${harness} a model; model ${model} cannot be honoured`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The launch arguments that carry a worker's model. */
|
|
148
|
+
export function modelArguments(worker) {
|
|
149
|
+
const broken = modelError(worker.harness, worker.model);
|
|
150
|
+
if (broken) throw new Error(broken);
|
|
151
|
+
return worker.model ? MODEL_ARGUMENTS[worker.harness](worker.model) : [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Where each harness takes an approval policy at start (the harness SDK's `approval_policy`, Codex's
|
|
156
|
+
* `approvalPolicy` on `thread/start` and `thread/resume`): `untrusted` asks before every command, and the
|
|
157
|
+
* orchestrator answers each ask with Hermes's approval rule (loop.mjs `onWorkerEvent`). Codex takes no such
|
|
158
|
+
* policy from its config (`approval_policy = "untrusted"` in `config.toml` stops its app server: measured).
|
|
159
|
+
*/
|
|
160
|
+
export const APPROVAL_POLICY = Object.freeze({ codex: 'untrusted' });
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Where the owner turned a profile's approvals off, its worker runs as a container's does: nothing asks, and the
|
|
164
|
+
* harness's own sandbox is off. Codex's default (workspace-write, no network) otherwise asks to reach GitHub and, with
|
|
165
|
+
* nobody attending, is denied (measured: the organization cycle could not read its own Discussion). Codex takes the
|
|
166
|
+
* policy on `thread/start` and its sandbox as a launch override.
|
|
167
|
+
*/
|
|
168
|
+
export const OPEN_POLICY = Object.freeze({ codex: 'never' });
|
|
169
|
+
export const OPEN_ARGUMENTS = Object.freeze({ codex: ['-c', 'sandbox_mode="danger-full-access"'] });
|
|
170
|
+
|
|
171
|
+
const approvalsOff = (profile) => String(profile?.residue?.config?.approvals?.mode ?? 'manual').trim().toLowerCase() === 'off';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The approval policy a worker starts with: its harness's asking policy where the profile keeps Hermes's
|
|
175
|
+
* approvals (`approvals.mode`, `manual` when unset, as Hermes reads it), its open policy where the owner turned them off.
|
|
176
|
+
*/
|
|
177
|
+
export function approvalPolicy(profile, harness) {
|
|
178
|
+
return approvalsOff(profile) ? OPEN_POLICY[harness] : APPROVAL_POLICY[harness];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The launch arguments that turn a harness's own sandbox off, for a profile whose approvals are off. */
|
|
182
|
+
export function sandboxArguments(profile, harness) {
|
|
183
|
+
return approvalsOff(profile) ? [...(OPEN_ARGUMENTS[harness] ?? [])] : [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Hermes `resolve_turn_limit`: the profile's `agent.max_turns` as a positive
|
|
188
|
+
* cap, or null for unlimited (Hermes's default; `none`, `inf`, 0, -1 and
|
|
189
|
+
* anything unparseable included).
|
|
190
|
+
*/
|
|
191
|
+
export function turnLimit(profile) {
|
|
192
|
+
const config = profile?.residue?.config ?? {};
|
|
193
|
+
// Hermes reads agent.max_turns, then a root-level max_turns
|
|
194
|
+
const raw = config.agent?.max_turns ?? config.max_turns;
|
|
195
|
+
if (raw === null || raw === undefined || typeof raw === 'boolean') return null;
|
|
196
|
+
const n = Math.trunc(Number(String(raw).trim()));
|
|
197
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* How each worker CLI takes a turn cap at launch. Claude Code takes `--max-turns`, which its --help does not
|
|
202
|
+
* list (the flag is in the binary and is the SDK's `maxTurns`); codex-cli 0.156.1 takes none.
|
|
203
|
+
*/
|
|
204
|
+
export const TURN_ARGUMENTS = Object.freeze({
|
|
205
|
+
// Claude Code's own turn cap in print mode (`--max-turns`, the SDK's `maxTurns`)
|
|
206
|
+
'claude-code': (turns) => ['--max-turns', String(turns)],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* For a worker that takes no turn cap at launch, the event it reports once per model round trip, which the
|
|
211
|
+
* orchestrator counts against `agent.max_turns` (loop.mjs `holdTurnLimit`). Measured on codex-cli through the
|
|
212
|
+
* app server: a turn of three commands and a reply reports `thread/tokenUsage/updated` four times, once per
|
|
213
|
+
* model call, and reports no `tool` events at all.
|
|
214
|
+
*/
|
|
215
|
+
export const ROUND_TRIP_EVENTS = Object.freeze({
|
|
216
|
+
codex: (ev) => ev.type === 'native' && ev.kind === 'thread/tokenUsage/updated',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* One worker's count of a turn's round trips against `agent.max_turns`, or null where the cap is rendered at launch,
|
|
221
|
+
* there is none, or the worker reports no round trip. `count(ev)` is true once, on the call past the cap, when the
|
|
222
|
+
* caller interrupts the turn. A turn the cap stopped is not a success: Codex reports it as `interrupted`, which the
|
|
223
|
+
* SDK reads as completed, so `settle(w)` turns that turn's final into a failure that names the cap.
|
|
224
|
+
*/
|
|
225
|
+
export function turnCounter(profile, harness) {
|
|
226
|
+
const roundTrip = ROUND_TRIP_EVENTS[harness];
|
|
227
|
+
const cap = turnLimit(profile);
|
|
228
|
+
if (!roundTrip || TURN_ARGUMENTS[harness] || cap === null) return null;
|
|
229
|
+
let n = 0;
|
|
230
|
+
return {
|
|
231
|
+
cap,
|
|
232
|
+
start() { n = 0; },
|
|
233
|
+
count(ev) {
|
|
234
|
+
if (!roundTrip(ev)) return false;
|
|
235
|
+
n += 1;
|
|
236
|
+
return n === cap + 1;
|
|
237
|
+
},
|
|
238
|
+
settle(w) {
|
|
239
|
+
const capped = n > cap;
|
|
240
|
+
n = 0;
|
|
241
|
+
return capped ? { ...w, outcome: 'error', message: `${cap + 1} model calls passed agent.max_turns ${cap}; the turn was interrupted` } : w;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A folder's `agent.max_turns` is Hermes's config carried over, not a limit
|
|
248
|
+
* the setup declared: `hermes setup` writes one by default (150, or 90), so
|
|
249
|
+
* refusing on it would stop every imported home. It is rendered where a
|
|
250
|
+
* worker takes it and otherwise reported, once per start, as not enforced.
|
|
251
|
+
* A turn limit the package declares is the applier's to refuse (plan §7).
|
|
252
|
+
*/
|
|
253
|
+
/** Said once per (profile, harness, cap) for the life of the process. */
|
|
254
|
+
const UNENFORCED = new Set();
|
|
255
|
+
|
|
256
|
+
export function limitArguments(profile, worker, log = () => {}) {
|
|
257
|
+
const turns = turnLimit(profile);
|
|
258
|
+
if (turns === null) return [];
|
|
259
|
+
const render = TURN_ARGUMENTS[worker.harness];
|
|
260
|
+
if (!render && ROUND_TRIP_EVENTS[worker.harness]) return [];
|
|
261
|
+
if (!render) {
|
|
262
|
+
const said = `${profile.name}|${worker.harness}|${turns}`;
|
|
263
|
+
if (!UNENFORCED.has(said)) {
|
|
264
|
+
UNENFORCED.add(said);
|
|
265
|
+
log('warn', `profile ${profile.name}: agent.max_turns ${turns} is not enforced: ${worker.harness} takes no turn limit at launch`);
|
|
266
|
+
}
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
return render(turns);
|
|
270
|
+
}
|