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/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
+ }