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.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Self-tick for the fleet orchestrator session.
3
+ *
4
+ * The orchestrator is a 24/7 omp session with a standing brief (ORCHESTRATOR.md)
5
+ * and no user typing into it. A session that is never prompted never runs its
6
+ * loop, so this extension is the heartbeat: every `intervalSeconds` it injects
7
+ * one message that starts a turn.
8
+ *
9
+ * Four properties are worth protecting, and each one is a branch in
10
+ * `tickDecision()`:
11
+ *
12
+ * - **Pause is honoured.** `isPaused()` is imported from ./daemon.ts — the exact
13
+ * function `/conductor pause` writes for and the dispatch loop reads. A second
14
+ * spelling of "is it paused" here is how a paused fleet keeps working.
15
+ * - **A disarmed fleet is not woken.** The arm marker is a file the operator
16
+ * controls; missing means "not armed", and a tick then does nothing.
17
+ * - **The human channel must still be there.** Autonomous dispatch is only
18
+ * defensible while a tier-2 escalation can reach a person, so every tick
19
+ * re-reads the Telegram bridge's access file and requires it enabled with
20
+ * exactly one paired owner. This is fail-closed: an unreadable, unparseable or
21
+ * ambiguous access file stops ticking. A stale arm marker must not outlive the
22
+ * channel that makes running unattended safe.
23
+ * - **Ticks coalesce.** A tick that lands while an earlier one is still queued
24
+ * would stack prompts on a session that is already behind. `hasPendingMessages()`
25
+ * makes the tick idempotent under slow turns.
26
+ *
27
+ * Beyond those four gates, every tick carries the project's `reporting.scope`
28
+ * as one explicit constraint line, re-read from the conductor config on each
29
+ * tick so a `/conductor setup` change binds the next heartbeat rather than
30
+ * waiting for a session restart.
31
+ *
32
+ * The extension is inert unless `<cwd>/.conductor-tick.json` exists, so shipping
33
+ * it inside `omp-conductor` costs an ordinary session nothing.
34
+ */
35
+
36
+ import { existsSync, readFileSync } from "node:fs";
37
+ import { isAbsolute, join, resolve } from "node:path";
38
+ import { findProject, loadConfig } from "./config.ts";
39
+ import { isPaused } from "./daemon.ts";
40
+ import { DEFAULT_REPORT_SCOPE, type ReportScope } from "./types.ts";
41
+
42
+ /** The activation file. Absent means "this is not an orchestrator session". */
43
+ export const TICK_CONFIG_FILE = ".conductor-tick.json";
44
+
45
+ /** Namespaced so a renderer or a session-log reader can pick ticks out. */
46
+ export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
47
+
48
+ /**
49
+ * A tick costs a whole turn of a frontier model, and the orchestrator's loop is
50
+ * about minutes of latency, not seconds. Anything under a minute is a
51
+ * misconfiguration worth refusing rather than obeying.
52
+ */
53
+ export const MIN_INTERVAL_SECONDS = 60;
54
+
55
+ /**
56
+ * The slice of the omp extension API this entry touches, mirroring
57
+ * `ExtensionAPI` / `ExtensionContext` from `@oh-my-pi/pi-coding-agent`.
58
+ *
59
+ * Declared here rather than imported for the reason ./plugin.ts declares its
60
+ * own: the harness is a peer dependency and the package has to type-check
61
+ * without it installed. Structural typing means the real objects satisfy these
62
+ * on the way in.
63
+ */
64
+ interface TickLogger {
65
+ info(message: string, context?: Record<string, unknown>): void;
66
+ error(message: string, context?: Record<string, unknown>): void;
67
+ }
68
+
69
+ interface TickContext {
70
+ /** Session cwd — where the activation file is looked for. */
71
+ cwd: string;
72
+ /**
73
+ * Whether a UI is attached — false in print/RPC mode, and false for every
74
+ * subagent. Typed as the SDK types it: `ExtensionContext.hasUI: boolean`,
75
+ * `@oh-my-pi/pi-coding-agent/src/extensibility/extensions/types.ts:424-425`.
76
+ */
77
+ hasUI: boolean;
78
+ ui: {
79
+ notify(message: string, type?: "info" | "warning" | "error"): void;
80
+ };
81
+ /** True while steering, follow-up or next-turn messages are still queued. */
82
+ hasPendingMessages(): boolean;
83
+ /**
84
+ * Managed timer: throws inside `callback` are contained and surfaced on the
85
+ * extension error channel, the handle is `unref`'d, and it is cleared on
86
+ * `session_shutdown`. Raw `setInterval` has none of that and a throwing tick
87
+ * would take the session down, so it is never used here.
88
+ */
89
+ setInterval(callback: () => void, ms?: number): unknown;
90
+ }
91
+
92
+ interface TickApi {
93
+ logger: TickLogger;
94
+ /**
95
+ * The currently active tool names. Typed as the SDK types it:
96
+ * `ExtensionAPI.getActiveTools(): string[]`,
97
+ * `@oh-my-pi/pi-coding-agent/src/extensibility/extensions/types.ts:1267-1268`.
98
+ */
99
+ getActiveTools(): string[];
100
+ on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
101
+ /**
102
+ * `deliverAs: "followUp"` + `triggerTurn: true`, verified against
103
+ * `AgentSession.sendCustomMessage` rather than assumed:
104
+ *
105
+ * - idle: the `nextTurn` branch is skipped, `triggerTurn` prompts immediately,
106
+ * so the tick starts a turn now;
107
+ * - streaming: the message is queued as a follow-up and drained when the
108
+ * current turn ends, which is exactly the intended "tick after this
109
+ * finishes" and is also what `hasPendingMessages()` sees, so the next tick
110
+ * coalesces instead of stacking.
111
+ *
112
+ * `attribution: "user"` bills the turn as operator-initiated work, which is
113
+ * what a heartbeat prompt is.
114
+ */
115
+ sendMessage(
116
+ message: {
117
+ customType: string;
118
+ content: string;
119
+ display: boolean;
120
+ attribution: "user" | "agent";
121
+ },
122
+ options: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
123
+ ): void;
124
+ }
125
+
126
+ /** Validated activation config. `armedFile` and `accessFile` are absolute once
127
+ * they get here. */
128
+ export interface TickConfig {
129
+ intervalSeconds: number;
130
+ armedFile?: string;
131
+ accessFile?: string;
132
+ message?: string;
133
+ }
134
+
135
+ /**
136
+ * Three outcomes, none of them an exception: an absent file is the normal case
137
+ * for every other session, and a broken one must not stop the rest of the
138
+ * extension host from loading.
139
+ */
140
+ export type TickConfigResult =
141
+ | { kind: "absent"; path: string }
142
+ | { kind: "invalid"; path: string; problem: string }
143
+ | { kind: "ok"; path: string; config: TickConfig };
144
+
145
+ /**
146
+ * The prompt when the config names none. The timestamp is what makes two
147
+ * consecutive ticks distinguishable in the session log.
148
+ *
149
+ * Deliberately silent about reporting volume: that clause is
150
+ * {@link TICK_SCOPE_CONSTRAINTS}, appended per tick from the configured scope.
151
+ * A second spelling of it here would contradict the first inside one prompt the
152
+ * moment a fleet chose `escalations`.
153
+ */
154
+ export function defaultTickMessage(now: Date): string {
155
+ return `Tick ${now.toISOString()}: run your standing loop from ORCHESTRATOR.md now.`;
156
+ }
157
+
158
+ /**
159
+ * The reporting contract, one line per scope, appended to the tick prompt.
160
+ *
161
+ * This is the whole of what `reporting.scope` does at tick time: it constrains
162
+ * what the turn is allowed to say, in the session that reads it. It is not an
163
+ * outbound filter — nothing downstream drops a report the orchestrator decides
164
+ * to send anyway.
165
+ *
166
+ * A mapped type rather than a plain object, so adding a member to
167
+ * `REPORT_SCOPES` fails to compile here instead of resolving to `undefined` at
168
+ * the point of use.
169
+ */
170
+ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
171
+ material: "Report material events per your brief.",
172
+ escalations:
173
+ "Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.",
174
+ };
175
+
176
+ /**
177
+ * The scope this tick carries, and — when it had to fall back — why.
178
+ *
179
+ * Read on every tick rather than cached at session start, for the reason the
180
+ * channel gate is: the operator re-runs `/conductor setup` while this session
181
+ * lives, and a heartbeat holding a startup snapshot would keep injecting the
182
+ * old contract until somebody restarted it.
183
+ *
184
+ * Every fault collapses to {@link DEFAULT_REPORT_SCOPE}: no config written yet,
185
+ * an unreadable or invalid one, or several projects with none named — the same
186
+ * ambiguity `findProject` refuses to guess through for `status`. Stopping the
187
+ * heartbeat over a reporting preference would be the worse trade.
188
+ */
189
+ export function resolveTickScope(): { scope: ReportScope; fallback?: string } {
190
+ try {
191
+ return { scope: findProject(loadConfig()).reporting?.scope ?? DEFAULT_REPORT_SCOPE };
192
+ } catch (err) {
193
+ return { scope: DEFAULT_REPORT_SCOPE, fallback: err instanceof Error ? err.message : String(err) };
194
+ }
195
+ }
196
+
197
+ /**
198
+ * An optional file-path field. Relative paths resolve against the session cwd
199
+ * so `state/armed` means what it looks like; a present-but-unusable value is a
200
+ * config error rather than something to ignore, because both paths this reads
201
+ * are safety gates and a silently dropped gate is an open one.
202
+ */
203
+ function optionalPath(raw: unknown, key: string, cwd: string, problems: string[]): string | undefined {
204
+ if (raw === undefined) return undefined;
205
+ if (typeof raw !== "string" || raw.trim().length === 0) {
206
+ problems.push(`${key} must be a non-empty string when present`);
207
+ return undefined;
208
+ }
209
+ return isAbsolute(raw) ? raw : resolve(cwd, raw);
210
+ }
211
+
212
+ /**
213
+ * Reads and validates `<cwd>/.conductor-tick.json`.
214
+ *
215
+ * Every fault is collected and reported in one message, the way ./config.ts
216
+ * does it: an operator fixing a config wants the whole list, not the first
217
+ * complaint followed by another edit-and-retry cycle.
218
+ */
219
+ export function readTickConfig(cwd: string): TickConfigResult {
220
+ const path = join(cwd, TICK_CONFIG_FILE);
221
+ if (!existsSync(path)) return { kind: "absent", path };
222
+
223
+ let parsed: unknown;
224
+ try {
225
+ parsed = JSON.parse(readFileSync(path, "utf8"));
226
+ } catch (err) {
227
+ return { kind: "invalid", path, problem: `not valid JSON (${err instanceof Error ? err.message : String(err)})` };
228
+ }
229
+
230
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
231
+ return { kind: "invalid", path, problem: "must be a JSON object" };
232
+ }
233
+
234
+ const raw = parsed as { readonly [key: string]: unknown };
235
+ const problems: string[] = [];
236
+
237
+ let intervalSeconds = 0;
238
+ const interval = raw["intervalSeconds"];
239
+ if (typeof interval !== "number" || !Number.isFinite(interval) || !Number.isInteger(interval)) {
240
+ problems.push("intervalSeconds must be a whole number of seconds");
241
+ } else if (interval < MIN_INTERVAL_SECONDS) {
242
+ problems.push(`intervalSeconds must be at least ${MIN_INTERVAL_SECONDS}`);
243
+ } else {
244
+ intervalSeconds = interval;
245
+ }
246
+
247
+ // Relative paths resolve against the session cwd, so the files can sit beside
248
+ // the config that names them (`state/armed`) without hard-coding /root/fleet.
249
+ const armedFile = optionalPath(raw["armedFile"], "armedFile", cwd, problems);
250
+ const accessFile = optionalPath(raw["accessFile"], "accessFile", cwd, problems);
251
+
252
+ const messageRaw = raw["message"];
253
+ let message: string | undefined;
254
+ if (messageRaw !== undefined) {
255
+ if (typeof messageRaw !== "string" || messageRaw.trim().length === 0) {
256
+ problems.push("message must be a non-empty string when present");
257
+ } else {
258
+ message = messageRaw;
259
+ }
260
+ }
261
+
262
+ if (problems.length > 0) return { kind: "invalid", path, problem: problems.join("; ") };
263
+
264
+ return {
265
+ kind: "ok",
266
+ path,
267
+ config: {
268
+ intervalSeconds,
269
+ ...(armedFile === undefined ? {} : { armedFile }),
270
+ ...(accessFile === undefined ? {} : { accessFile }),
271
+ ...(message === undefined ? {} : { message }),
272
+ },
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Whether this tick sends, and why — the whole decision, with no clock, no
278
+ * filesystem and no session in it. The interesting part of a heartbeat is the
279
+ * precedence between "paused", "not armed", "channel down" and "already
280
+ * pending", and that is worth being able to test without a session at all.
281
+ *
282
+ * `armed` and `channelOk` are the *satisfied* gates, not the files behind them:
283
+ * a config with no `armedFile` passes the first, and one with no `accessFile`
284
+ * passes the second. The fleet deploy always configures `accessFile` — an
285
+ * orchestrator that can page nobody must not dispatch — so an unconfigured
286
+ * channel gate means "this session is not the fleet", not "the check is off".
287
+ */
288
+ export function tickDecision(input: {
289
+ paused: boolean;
290
+ armed: boolean;
291
+ channelOk: boolean;
292
+ hasPending: boolean;
293
+ }): {
294
+ send: boolean;
295
+ reason: string;
296
+ } {
297
+ if (input.paused) return { send: false, reason: "paused" };
298
+ if (!input.armed) return { send: false, reason: "not armed" };
299
+ if (!input.channelOk) return { send: false, reason: "escalation channel down" };
300
+ if (input.hasPending) return { send: false, reason: "tick already pending" };
301
+ return { send: true, reason: "armed, nothing pending" };
302
+ }
303
+
304
+ /**
305
+ * Whether the Telegram bridge can still reach a person: enabled, with exactly
306
+ * one paired owner.
307
+ *
308
+ * Fail-closed, and every failure mode collapses to the same answer on purpose —
309
+ * missing file, truncated write, hand-edit that dropped `enabled`, a second
310
+ * chat id pasted in, or the pairing revoked. Distinguishing them would only
311
+ * tempt a future reader into treating one of them as benign, and none of them
312
+ * are: each one means a tier-2 escalation lands nowhere.
313
+ *
314
+ * Re-read on every tick rather than cached at session start, because the bridge
315
+ * is reconfigured by a long-lived operator out-of-band and a heartbeat that
316
+ * trusted a startup snapshot would keep dispatching for days after the channel
317
+ * went away.
318
+ */
319
+ function channelIsUp(path: string): boolean {
320
+ let parsed: unknown;
321
+ try {
322
+ parsed = JSON.parse(readFileSync(path, "utf8"));
323
+ } catch {
324
+ return false;
325
+ }
326
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
327
+ const access = parsed as { readonly [key: string]: unknown };
328
+ if (access["enabled"] !== true) return false;
329
+ const allowFrom = access["allowFrom"];
330
+ return Array.isArray(allowFrom) && allowFrom.length === 1;
331
+ }
332
+
333
+ /**
334
+ * One tick: gather the four facts, ask `tickDecision`, log the reason either
335
+ * way. Skips are deliberately silent in the UI — a paused fleet would otherwise
336
+ * emit a notification every interval, forever.
337
+ *
338
+ * `session` holds the only thing one tick remembers for the next: whether the
339
+ * scope fallback has been logged. Without it, a host with no conductor config
340
+ * would repeat the same line about the same missing file every interval, for as
341
+ * long as the session lives.
342
+ */
343
+ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: { scopeFallbackLogged: boolean }): void {
344
+ const decision = tickDecision({
345
+ paused: isPaused(),
346
+ armed: config.armedFile === undefined || existsSync(config.armedFile),
347
+ channelOk: config.accessFile === undefined || channelIsUp(config.accessFile),
348
+ hasPending: ctx.hasPendingMessages(),
349
+ });
350
+
351
+ if (!decision.send) {
352
+ pi.logger.info(`[omp-conductor] tick skipped: ${decision.reason}`, { reason: decision.reason });
353
+ return;
354
+ }
355
+
356
+ // A configured message owns the whole contract, reporting clause included: an
357
+ // operator who wrote their own prompt did not ask for ours appended to it.
358
+ let content = config.message;
359
+ if (content === undefined) {
360
+ const scope = resolveTickScope();
361
+ if (scope.fallback !== undefined && !session.scopeFallbackLogged) {
362
+ session.scopeFallbackLogged = true;
363
+ pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
364
+ }
365
+ content = `${defaultTickMessage(new Date())}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}`;
366
+ }
367
+
368
+ pi.sendMessage(
369
+ { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
370
+ { triggerTurn: true, deliverAs: "followUp" },
371
+ );
372
+ pi.logger.info(`[omp-conductor] tick sent: ${decision.reason}`, { reason: decision.reason });
373
+ }
374
+
375
+ export default function orchestratorTickExtension(pi: TickApi): void {
376
+ // Scoped to this registration rather than the module, so a second
377
+ // `session_start` cannot install a second heartbeat on the same session.
378
+ let armed = false;
379
+ // Held per registration for the same reason: the "using the default reporting
380
+ // scope, because ..." line is logged once for this heartbeat, and a second
381
+ // session in the same process starts with its own count.
382
+ const session = { scopeFallbackLogged: false };
383
+
384
+ pi.on("session_start", (_event, ctx) => {
385
+ if (armed) return;
386
+
387
+ // A subagent inherits the orchestrator's cwd, so it finds the same
388
+ // activation file and would arm a heartbeat of its own — one extra tick
389
+ // per worker, each prompting a session whose whole contract is to finish
390
+ // and yield. The discriminator is omp-telegram's, which has been running
391
+ // it in production (`isTaskSubagent`, ~/VSCode/omp/plugins/telegram/src/
392
+ // index.ts:87-90, applied at index.ts:1951-1952): task sessions are
393
+ // headless *and* always carry the `yield` tool. Neither half suffices
394
+ // alone — a headless root session (print/RPC mode) has no `yield`, and an
395
+ // interactive session may well have one. Checked before the config read
396
+ // so the overwhelmingly common case never touches the filesystem.
397
+ if (!ctx.hasUI && pi.getActiveTools().includes("yield")) {
398
+ pi.logger.info("[omp-conductor] tick inert: subagent session");
399
+ return;
400
+ }
401
+
402
+ const result = readTickConfig(ctx.cwd);
403
+
404
+ if (result.kind === "absent") {
405
+ pi.logger.info(`[omp-conductor] orchestrator tick inactive: no ${TICK_CONFIG_FILE} in ${ctx.cwd}`);
406
+ return;
407
+ }
408
+
409
+ if (result.kind === "invalid") {
410
+ const detail = `${result.path}: ${result.problem}`;
411
+ pi.logger.error(`[omp-conductor] orchestrator tick disabled — ${detail}`);
412
+ // The one notification this extension ever raises: a broken heartbeat
413
+ // config is silent failure otherwise, and silence is what it is for.
414
+ ctx.ui.notify(`omp-conductor: orchestrator tick disabled — ${detail}`, "error");
415
+ return;
416
+ }
417
+
418
+ const config = result.config;
419
+ ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
420
+ armed = true;
421
+ // Both gates are named at startup: "why is it not ticking?" is answered by
422
+ // looking at the files this line lists, and an unset channel gate on a fleet
423
+ // host is visible here rather than only in its absence.
424
+ const gates = [
425
+ config.armedFile === undefined ? undefined : `armed marker ${config.armedFile}`,
426
+ config.accessFile === undefined ? "no escalation-channel gate" : `escalation channel ${config.accessFile}`,
427
+ ].filter((g) => g !== undefined);
428
+ pi.logger.info(
429
+ `[omp-conductor] orchestrator tick active: every ${config.intervalSeconds}s, gated on ${gates.join(" + ")}`,
430
+ );
431
+ });
432
+ }