omp-conductor 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +40 -0
- package/skills/conductor-onboarding/SKILL.md +626 -0
- package/src/briefs/orchestrator.md +213 -0
- package/src/briefs/worker.md +146 -0
- package/src/cli.ts +179 -0
- package/src/config.ts +446 -0
- package/src/daemon.ts +689 -0
- package/src/escalate.ts +265 -0
- package/src/lifecycle.ts +367 -0
- package/src/omp.ts +273 -0
- package/src/orchestrator-tick.ts +432 -0
- package/src/orchestrator.ts +267 -0
- package/src/plugin.ts +605 -0
- package/src/routing.ts +160 -0
- package/src/setup.ts +644 -0
- package/src/store.ts +263 -0
- package/src/tracker/github.ts +160 -0
- package/src/types.ts +250 -0
- package/src/worker.ts +292 -0
- package/src/worktree.ts +303 -0
package/src/omp.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single seam between omp-conductor and the omp harness.
|
|
3
|
+
*
|
|
4
|
+
* The harness is a `peerDependency`, not a `devDependency`: this package has to
|
|
5
|
+
* type-check and publish without it on disk. So every untyped access lives in
|
|
6
|
+
* this file, behind one cast at the dynamic-import boundary, and the rest of the
|
|
7
|
+
* package only ever sees {@link AgentSessionLike}. If the harness renames
|
|
8
|
+
* `createAgentSession`, `subscribe` or `abort`, exactly one file breaks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Harness package name. Held in a variable and cast to `string` at the call
|
|
13
|
+
* site on purpose: a non-literal specifier stops `tsc` from trying to resolve
|
|
14
|
+
* the module, which is the whole reason this shim exists.
|
|
15
|
+
*/
|
|
16
|
+
const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The only session surface the dispatcher is allowed to know about: send one
|
|
20
|
+
* prompt, watch the event stream, and kill it. Caps are enforced by the caller
|
|
21
|
+
* against this interface, never by the model inside the session.
|
|
22
|
+
*/
|
|
23
|
+
export interface AgentSessionLike {
|
|
24
|
+
prompt(text: string, opts?: Record<string, unknown>): Promise<unknown>;
|
|
25
|
+
/**
|
|
26
|
+
* Subscribe to one harness event type (`"message_end"`, `"agent_end"`, …), or
|
|
27
|
+
* `"*"` for every event. The payload is the harness's own event union, which
|
|
28
|
+
* this package cannot name without the peer dependency, so it arrives as
|
|
29
|
+
* `unknown` and each caller narrows the two or three fields it reads.
|
|
30
|
+
*/
|
|
31
|
+
on(event: string, cb: (e: unknown) => void): void;
|
|
32
|
+
abort(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Absolute transcript path the harness opened, so a human — and the
|
|
35
|
+
* arm/monitor tooling — can read what the worker actually did. `undefined`
|
|
36
|
+
* when the session has no file backing; never a path we merely asked for.
|
|
37
|
+
*/
|
|
38
|
+
sessionFile?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Set when the harness could not honour the requested model and quietly used
|
|
41
|
+
* another one. Carried out through this seam so the daemon can log the
|
|
42
|
+
* downgrade, instead of it vanishing and a run just reading dumber.
|
|
43
|
+
*/
|
|
44
|
+
modelFallbackMessage?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Module members this shim calls. Checked at the import boundary so a harness
|
|
49
|
+
* bump that drops one fails by name, instead of as `undefined is not a
|
|
50
|
+
* constructor` in the middle of session setup.
|
|
51
|
+
*/
|
|
52
|
+
const REQUIRED_EXPORTS = ["createAgentSession", "SessionManager", "AgentRegistry"] as const;
|
|
53
|
+
|
|
54
|
+
/** The three module members this shim calls, all verified before the cast. */
|
|
55
|
+
interface OmpModule {
|
|
56
|
+
createAgentSession(opts: unknown): Promise<unknown>;
|
|
57
|
+
/** Only the file-backed factories: `inMemory()` would defeat the transcript. */
|
|
58
|
+
SessionManager: {
|
|
59
|
+
create(cwd: string, sessionDir?: string): unknown;
|
|
60
|
+
/**
|
|
61
|
+
* Resume path. Continues the newest transcript for `cwd`, and the harness
|
|
62
|
+
* itself starts a fresh one when there is nothing to continue. Declared
|
|
63
|
+
* optional — and absent from {@link REQUIRED_EXPORTS} — so a harness build
|
|
64
|
+
* without it degrades to a fresh session instead of failing to start.
|
|
65
|
+
*/
|
|
66
|
+
continueRecent?(cwd: string, sessionDir?: string): Promise<unknown>;
|
|
67
|
+
};
|
|
68
|
+
/** Constructed once per session — see the note at the call site. */
|
|
69
|
+
AgentRegistry: new () => unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The members of the harness `AgentSession` this package touches. The real
|
|
74
|
+
* class has hundreds; declaring four keeps the blast radius of an SDK bump
|
|
75
|
+
* proportional to what we actually depend on.
|
|
76
|
+
*/
|
|
77
|
+
interface RawSession {
|
|
78
|
+
prompt(text: string, opts?: unknown): Promise<unknown>;
|
|
79
|
+
subscribe(listener: (event: unknown) => void): unknown;
|
|
80
|
+
abort(opts?: unknown): void;
|
|
81
|
+
dispose?(opts?: unknown): Promise<unknown>;
|
|
82
|
+
readonly sessionFile?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Teardown handles, keyed by the adapter we handed out. Kept off
|
|
87
|
+
* {@link AgentSessionLike} so the interface stays exactly the shared contract
|
|
88
|
+
* every other slice codes against, while callers still have a way to release
|
|
89
|
+
* the harness's background work (MCP clients, watchers) instead of leaking it
|
|
90
|
+
* for the lifetime of the daemon.
|
|
91
|
+
*/
|
|
92
|
+
const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start one omp coding session rooted at `cwd`, with a private agent registry
|
|
96
|
+
* and a file-backed transcript of its own.
|
|
97
|
+
*
|
|
98
|
+
* `sessionDir` chooses the directory the harness writes that transcript into;
|
|
99
|
+
* omitted, the harness picks its default location for `cwd`. Either way the
|
|
100
|
+
* resolved path comes back on `session.sessionFile` — this function never
|
|
101
|
+
* invents one.
|
|
102
|
+
*
|
|
103
|
+
* `resume` continues the most recent transcript for `cwd` instead of opening a
|
|
104
|
+
* blank one. That is what a long-lived session (the orchestrator) wants: a
|
|
105
|
+
* daemon restart should not erase its memory of what it has already escalated.
|
|
106
|
+
* A worker wants the opposite, so it stays off by default.
|
|
107
|
+
*
|
|
108
|
+
* @throws if the peer dependency is absent, naming it — a missing harness is a
|
|
109
|
+
* deployment mistake, and a stack trace about a failed dynamic import sends the
|
|
110
|
+
* reader looking in the wrong place.
|
|
111
|
+
*/
|
|
112
|
+
export async function createSession(opts: {
|
|
113
|
+
cwd: string;
|
|
114
|
+
sessionDir?: string;
|
|
115
|
+
model?: string;
|
|
116
|
+
resume?: boolean;
|
|
117
|
+
}): Promise<AgentSessionLike> {
|
|
118
|
+
let loaded: unknown;
|
|
119
|
+
try {
|
|
120
|
+
// Dynamic import is load-bearing, not laziness: the harness is an optional
|
|
121
|
+
// peer dependency that is absent when this package is type-checked or
|
|
122
|
+
// published, so a static import would fail the build it must survive.
|
|
123
|
+
loaded = await import(OMP_PACKAGE as string);
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`omp-conductor could not load its peer dependency ${OMP_PACKAGE}. Install it alongside omp-conductor (it is deliberately not bundled, so the dispatcher runs the same harness build as the operator).`,
|
|
127
|
+
{ cause },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
// Narrowed onto a `const` so the checks survive into the closure below.
|
|
131
|
+
const namespace: unknown = loaded;
|
|
132
|
+
if (namespace === null || typeof namespace !== "object") {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`${OMP_PACKAGE} loaded but is not a module namespace; omp-conductor needs a harness build that still exposes the SDK entrypoint.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const missing = REQUIRED_EXPORTS.filter(
|
|
138
|
+
(name) => typeof Reflect.get(namespace, name) !== "function",
|
|
139
|
+
);
|
|
140
|
+
if (missing.length > 0) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`${OMP_PACKAGE} loaded but exports no ${missing.join(", ")}; omp-conductor needs a harness build exposing createAgentSession (the session), SessionManager (a file-backed transcript) and AgentRegistry (so concurrent workers do not collide on the "Main" identity).`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
// The one unchecked cast in this package: the shape is verified immediately
|
|
146
|
+
// above, but only the harness itself can name its own types.
|
|
147
|
+
const mod = namespace as unknown as OmpModule;
|
|
148
|
+
|
|
149
|
+
// File-backed on purpose. `SessionManager.inMemory()` leaves
|
|
150
|
+
// `session.sessionFile` undefined, and once the worktree is gone the
|
|
151
|
+
// transcript is the only record of what the worker actually did.
|
|
152
|
+
const sessionManager = await openSessionManager(mod, opts);
|
|
153
|
+
|
|
154
|
+
const created = await mod.createAgentSession({
|
|
155
|
+
cwd: opts.cwd,
|
|
156
|
+
// A raw pattern rather than a resolved Model: the harness resolves it
|
|
157
|
+
// after extensions load, so we never have to import its model registry.
|
|
158
|
+
...(opts.model === undefined ? {} : { modelPattern: opts.model }),
|
|
159
|
+
sessionManager,
|
|
160
|
+
// A private registry per session, never the process-global default: that
|
|
161
|
+
// one admits only one "Main" identity per generation, so a second session
|
|
162
|
+
// sharing it fails to start. The daemon runs `maxConcurrentWorkers`
|
|
163
|
+
// (2 by default) workers at once, which makes this the normal path.
|
|
164
|
+
agentRegistry: new mod.AgentRegistry(),
|
|
165
|
+
});
|
|
166
|
+
const raw = asRawSession(created);
|
|
167
|
+
// Surfaced rather than swallowed: this is how a quiet downgrade to a weaker
|
|
168
|
+
// model reaches the daemon's log instead of only the operator's surprise.
|
|
169
|
+
const fallback =
|
|
170
|
+
created !== null && typeof created === "object"
|
|
171
|
+
? Reflect.get(created, "modelFallbackMessage")
|
|
172
|
+
: undefined;
|
|
173
|
+
const modelFallbackMessage =
|
|
174
|
+
typeof fallback === "string" && fallback !== "" ? fallback : undefined;
|
|
175
|
+
|
|
176
|
+
// One real subscription fanned out per event type, so N `on()` calls cost one
|
|
177
|
+
// listener on the harness stream and unknown event types cost nothing.
|
|
178
|
+
const handlers = new Map<string, ((e: unknown) => void)[]>();
|
|
179
|
+
raw.subscribe((event) => {
|
|
180
|
+
const type = (event as { type?: unknown } | null | undefined)?.type;
|
|
181
|
+
if (typeof type !== "string") return;
|
|
182
|
+
for (const cb of handlers.get(type) ?? []) cb(event);
|
|
183
|
+
for (const cb of handlers.get("*") ?? []) cb(event);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const session: AgentSessionLike = {
|
|
187
|
+
prompt: (text, promptOpts) => raw.prompt(text, promptOpts),
|
|
188
|
+
on(event, cb) {
|
|
189
|
+
const list = handlers.get(event);
|
|
190
|
+
if (list) list.push(cb);
|
|
191
|
+
else handlers.set(event, [cb]);
|
|
192
|
+
},
|
|
193
|
+
abort() {
|
|
194
|
+
raw.abort();
|
|
195
|
+
},
|
|
196
|
+
// The path the session actually opened, never one we asked for: the
|
|
197
|
+
// arm/monitor tooling reads this file as proof of activity, so a path
|
|
198
|
+
// nothing ever writes to is worse than no path at all.
|
|
199
|
+
get sessionFile() {
|
|
200
|
+
return raw.sessionFile;
|
|
201
|
+
},
|
|
202
|
+
...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
disposers.set(session, async () => {
|
|
206
|
+
await raw.dispose?.();
|
|
207
|
+
});
|
|
208
|
+
return session;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Release a session's harness-side resources. Safe to call on any
|
|
213
|
+
* {@link AgentSessionLike} — a hand-rolled fake, or a harness build with no
|
|
214
|
+
* `dispose()`, is a no-op rather than a crash during teardown.
|
|
215
|
+
*/
|
|
216
|
+
export async function disposeSession(session: AgentSessionLike): Promise<void> {
|
|
217
|
+
await disposers.get(session)?.();
|
|
218
|
+
disposers.delete(session);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Pick the session manager for one `createSession` call.
|
|
223
|
+
*
|
|
224
|
+
* On the resume path the harness's own `continueRecent` decides whether there
|
|
225
|
+
* is anything to continue, so this never has to list or stat transcripts
|
|
226
|
+
* itself. `create` is the fallback for two cases: a harness build predating
|
|
227
|
+
* `continueRecent`, and a resume that failed outright.
|
|
228
|
+
*/
|
|
229
|
+
async function openSessionManager(
|
|
230
|
+
mod: OmpModule,
|
|
231
|
+
opts: { cwd: string; sessionDir?: string; resume?: boolean },
|
|
232
|
+
): Promise<unknown> {
|
|
233
|
+
const fresh = (): unknown =>
|
|
234
|
+
opts.sessionDir === undefined
|
|
235
|
+
? mod.SessionManager.create(opts.cwd)
|
|
236
|
+
: mod.SessionManager.create(opts.cwd, opts.sessionDir);
|
|
237
|
+
|
|
238
|
+
if (opts.resume !== true || typeof mod.SessionManager.continueRecent !== "function") return fresh();
|
|
239
|
+
try {
|
|
240
|
+
const resumed = await mod.SessionManager.continueRecent(opts.cwd, opts.sessionDir);
|
|
241
|
+
if (resumed !== null && typeof resumed === "object") return resumed;
|
|
242
|
+
} catch {
|
|
243
|
+
// ponytail: an unreadable newest transcript costs this session its memory
|
|
244
|
+
// rather than blocking startup — a conductor that will not boot because an
|
|
245
|
+
// old .jsonl is corrupt is worse than one that starts forgetful. Upgrade
|
|
246
|
+
// path: walk `SessionManager.list(cwd)` for the newest readable transcript.
|
|
247
|
+
}
|
|
248
|
+
return fresh();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Unwrap whatever `createAgentSession` handed back. It returns
|
|
253
|
+
* `{ session, … }` today; a bare session is accepted too so a minor SDK change
|
|
254
|
+
* in either direction fails loudly here instead of as `undefined is not a
|
|
255
|
+
* function` three events later.
|
|
256
|
+
*/
|
|
257
|
+
function asRawSession(created: unknown): RawSession {
|
|
258
|
+
const wrapper = created as { session?: unknown } | null | undefined;
|
|
259
|
+
const candidate = (
|
|
260
|
+
wrapper && typeof wrapper === "object" && "session" in wrapper ? wrapper.session : created
|
|
261
|
+
) as RawSession | null | undefined;
|
|
262
|
+
if (
|
|
263
|
+
!candidate ||
|
|
264
|
+
typeof candidate.prompt !== "function" ||
|
|
265
|
+
typeof candidate.subscribe !== "function" ||
|
|
266
|
+
typeof candidate.abort !== "function"
|
|
267
|
+
) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`${OMP_PACKAGE} returned an unrecognised session from createAgentSession(); omp-conductor needs { prompt, subscribe, abort }.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return candidate;
|
|
273
|
+
}
|