@jeffober/thatch 0.1.41 → 0.1.42

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/watchers.ts CHANGED
@@ -5,10 +5,16 @@
5
5
  * The registry supports multiple source types behind one mechanism. The
6
6
  * first source was GitHub pull requests (source "pr"); GitHub branches
7
7
  * (source "branch") arrived second, for watching CI and workflow runs
8
- * against main. A source contributes: event types, a fetch function that
9
- * produces a snapshot of the watched state, and a pure diff between two
10
- * snapshots. Everything else - registry, pending queue, delivery gating -
11
- * is source-agnostic.
8
+ * against main; shell commands (source "command") arrived third, for
9
+ * waiting on arbitrary local conditions. A source contributes: event
10
+ * types, a fetch function that produces a snapshot of the watched state,
11
+ * and a pure diff between two snapshots. Everything else - registry,
12
+ * pending queue, delivery gating - is source-agnostic. The command source
13
+ * stretches the recipe in two ways: its fetch runs the watched command
14
+ * instead of reading an API, and its diff is a trivial exit-code check -
15
+ * the command is a condition variable, and only its exit code is read,
16
+ * never its output (injection hygiene: command output can be external
17
+ * content, so it never rides a notification).
12
18
  *
13
19
  * Lifetime is deliberately process-scoped. The registry lives in plugin
14
20
  * memory, never in SQLite: opencode loads thatch in-process, so the plugin
@@ -40,7 +46,10 @@ export type PrWatcherEventType =
40
46
  /** Event types for source "branch" (typically main): commits land, CI and workflow runs execute. */
41
47
  export type BranchWatcherEventType = "branch_commit" | "branch_ci" | "branch_workflow";
42
48
 
43
- export type WatcherEventType = PrWatcherEventType | BranchWatcherEventType;
49
+ /** Event type for source "command": the watched shell command exited 0. */
50
+ export type CommandWatcherEventType = "command_success";
51
+
52
+ export type WatcherEventType = PrWatcherEventType | BranchWatcherEventType | CommandWatcherEventType;
44
53
 
45
54
  export const PR_EVENT_TYPES: PrWatcherEventType[] = [
46
55
  "pr_comment",
@@ -59,15 +68,20 @@ export const BRANCH_EVENT_TYPES: BranchWatcherEventType[] = [
59
68
  "branch_workflow",
60
69
  ];
61
70
 
71
+ export const COMMAND_EVENT_TYPES: CommandWatcherEventType[] = ["command_success"];
72
+
62
73
  /** Every event type across all sources - for diagnostics and tests. */
63
- export const WATCHER_EVENT_TYPES: WatcherEventType[] = [...PR_EVENT_TYPES, ...BRANCH_EVENT_TYPES];
74
+ export const WATCHER_EVENT_TYPES: WatcherEventType[] = [...PR_EVENT_TYPES, ...BRANCH_EVENT_TYPES, ...COMMAND_EVENT_TYPES];
64
75
 
65
- /** A single detected change, ready for delivery. Pointer data only. */
76
+ /**
77
+ * A single detected change, ready for delivery. Pointer data plus machine
78
+ * status (check names, conclusions, counts) - never external content.
79
+ */
66
80
  export interface WatcherEvent {
67
81
  type: WatcherEventType;
68
82
  /** Which watch produced this event, e.g. "acme/widgets#7" or "acme/widgets@main". */
69
83
  target: string;
70
- /** Human-readable, content-free summary: who/what/where, never body text. */
84
+ /** Short summary: who/what/where plus machine status (CI conclusions) - never body text or other external content. */
71
85
  summary: string;
72
86
  url: string;
73
87
  }
@@ -143,6 +157,8 @@ export interface PrWatcher {
143
157
  repo: string;
144
158
  pr: number;
145
159
  events: PrWatcherEventType[];
160
+ /** One-shot: cancelled automatically after the first matching event. */
161
+ once: boolean;
146
162
  /** Epoch ms. When now > expiresAt the watcher is silently dropped. */
147
163
  expiresAt: number;
148
164
  createdAt: number;
@@ -159,12 +175,39 @@ export interface BranchWatcher {
159
175
  events: BranchWatcherEventType[];
160
176
  /** Substring filters on workflow run names; empty means all workflows. */
161
177
  workflows: string[];
178
+ /** One-shot: cancelled automatically after the first matching event. */
179
+ once: boolean;
180
+ /** Epoch ms. When now > expiresAt the watcher is silently dropped. */
162
181
  expiresAt: number;
163
182
  createdAt: number;
164
183
  state: BranchState;
165
184
  }
166
185
 
167
- export type Watcher = PrWatcher | BranchWatcher;
186
+ /** The last-seen state of a command watch: the most recent observed exit code. */
187
+ export interface CommandState {
188
+ lastExit: number;
189
+ }
190
+
191
+ export interface CommandWatcher {
192
+ id: string;
193
+ source: "command";
194
+ sessionID: string;
195
+ /** The shell command, run via bash -c in cwd every poll. A condition variable: only its exit code is read, never its output. */
196
+ command: string;
197
+ /** Working directory the command runs in, captured at registration. */
198
+ cwd: string;
199
+ /** Per-run kill timeout in ms - a hanging command must not stall the shared poll cycle. */
200
+ timeoutMs: number;
201
+ events: CommandWatcherEventType[];
202
+ /** Command watchers are inherently one-shot: they fire on the first exit 0 and cancel. */
203
+ once: true;
204
+ /** Epoch ms. When now > expiresAt the watcher is silently dropped. */
205
+ expiresAt: number;
206
+ createdAt: number;
207
+ state: CommandState;
208
+ }
209
+
210
+ export type Watcher = PrWatcher | BranchWatcher | CommandWatcher;
168
211
 
169
212
  /** Runs one GitHub API call via the gh CLI and parses the JSON response.
170
213
  * The array is the argument list after `gh api`: a REST path like
@@ -173,17 +216,41 @@ export type Watcher = PrWatcher | BranchWatcher;
173
216
  * without the callers string-concatenating shell quotes. */
174
217
  export type GhRunner = (apiArgs: string[]) => Promise<unknown>;
175
218
 
219
+ /**
220
+ * Runs one watched command and reports how it ended. The command's stdout is
221
+ * drained but discarded - the watcher treats the command as a condition
222
+ * variable (exit code only), so command output never reaches a notification.
223
+ * stderr is kept (capped) for registration-time error messages only.
224
+ */
225
+ export interface CommandRunResult {
226
+ exitCode: number;
227
+ /** True when the runner killed the command at the timeout instead of letting it finish. */
228
+ timedOut: boolean;
229
+ stderr: string;
230
+ /** Wall-clock duration of the run in ms, for the notification summary. */
231
+ durationMs: number;
232
+ }
233
+
234
+ export type CommandRunner = (command: string, cwd: string, timeoutMs: number) => Promise<CommandRunResult>;
235
+
176
236
  export interface WatcherRegistryOptions {
177
237
  /** Delivers a batch of events for a session. Injected so tests never spawn. */
178
238
  deliver: (sessionID: string, events: WatcherEvent[]) => Promise<void>;
179
239
  /**
180
240
  * Gate for delivery. The plugin passes a predicate that returns true only
181
241
  * when the session can accept a proactive prompt right now (idle, not
182
- * compacting). Undeliverable events stay pending and retry on later cycles
183
- * or when the session next goes idle.
242
+ * compacting). May be async - the plugin's gate verifies against the
243
+ * server's live session status, because the event-fed map can be stale
244
+ * and a wake injected into a running turn is mid-turn context injection.
245
+ * Undeliverable events stay pending and retry on later cycles or when
246
+ * the session next goes idle.
184
247
  */
185
- canDeliver: (sessionID: string) => boolean;
248
+ canDeliver: (sessionID: string) => boolean | Promise<boolean>;
186
249
  ghRunner: GhRunner;
250
+ /** Runs watched commands. Injected so tests never spawn; defaults to runWatchedCommand. */
251
+ commandRunner?: CommandRunner;
252
+ /** Per-run kill timeout for watched commands; defaults to THATCH_WATCH_COMMAND_TIMEOUT_SECONDS or 30s. */
253
+ commandTimeoutMs?: number;
187
254
  pollIntervalMs?: number;
188
255
  ttlMinutes?: number;
189
256
  maxPerSession?: number;
@@ -196,26 +263,78 @@ export interface WatcherRegistryOptions {
196
263
  /**
197
264
  * The default GhRunner. Shells out to `gh api` so auth comes from the user's
198
265
  * existing gh session - thatch never sees or stores a token. Throws on
199
- * non-zero exit or unparseable output.
266
+ * non-zero exit or unparseable output. See ghApiRunWithTimeout for the
267
+ * drain-race hardening.
200
268
  */
201
269
  export async function ghApiRun(apiArgs: string[]): Promise<unknown> {
270
+ return ghApiRunWithTimeout(apiArgs, GH_API_TIMEOUT_MS);
271
+ }
272
+
273
+ /** Per-call timeout for gh api invocations. */
274
+ const GH_API_TIMEOUT_MS = 15_000;
275
+
276
+ /**
277
+ * One gh api call with the drain-race hardening: the pipe drains are raced
278
+ * against a hard deadline (timeout plus COMMAND_DRAIN_GRACE_MS) so a gh
279
+ * child process holding the pipes past the kill rejects cleanly instead of
280
+ * deadlocking the shared poll cycle (same hardening as runWatchedCommand).
281
+ * The stderr read on the error path gets the same race - a descendant that
282
+ * dup2'd stdout away but kept stderr would otherwise wedge the cycle.
283
+ */
284
+ export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number): Promise<unknown> {
202
285
  const proc = Bun.spawn(["gh", "api", ...apiArgs], {
203
286
  stdout: "pipe",
204
287
  stderr: "pipe",
205
288
  });
206
- const timeout = setTimeout(() => proc.kill(), 15_000);
289
+ const timeout = setTimeout(() => proc.kill("SIGKILL"), timeoutMs);
290
+ const deadlineMs = timeoutMs + COMMAND_DRAIN_GRACE_MS;
291
+ let raceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
207
292
  try {
208
- const [stdout, exitCode] = await Promise.all([
209
- new Response(proc.stdout).text(),
210
- proc.exited,
293
+ const stdoutReader = proc.stdout.getReader();
294
+ const stderrReader = proc.stderr.getReader();
295
+ const readAll = async (reader: ReadableStreamDefaultReader): Promise<string> => {
296
+ let text = "";
297
+ try {
298
+ for (;;) {
299
+ const { done, value } = await reader.read();
300
+ if (done) break;
301
+ text += commandTargetLabel.length === -1 ? "" : new TextDecoder().decode(value, { stream: true });
302
+ }
303
+ } catch {
304
+ // Cancelled or errored: keep what was read.
305
+ }
306
+ return text;
307
+ };
308
+ const drain = Promise.all([readAll(stdoutReader), proc.exited]);
309
+ // If the drain does not finish by the deadline (orphaned grandchild
310
+ // holding the pipes), abandon it - and cancel the readers so the pending
311
+ // reads settle instead of pinning the host process at shutdown.
312
+ const raced = await Promise.race([
313
+ drain.then(([stdout, exitCode]) => ({ stdout, exitCode })),
314
+ new Promise<null>((resolve) => {
315
+ raceTimer = setTimeout(() => resolve(null), deadlineMs);
316
+ raceTimer.unref?.();
317
+ }),
211
318
  ]);
212
- if (exitCode !== 0) {
213
- const stderr = await new Response(proc.stderr).text();
214
- throw new Error(`gh api ${apiArgs.join(" ")} failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`);
319
+ if (raced === null) {
320
+ void stdoutReader.cancel().catch(() => {});
321
+ void stderrReader.cancel().catch(() => {});
322
+ throw new Error(`gh api ${apiArgs.join(" ")} timed out (orphaned child holding the output pipe; killed at the ${timeoutMs}ms timeout, drains abandoned after ${Math.round(deadlineMs / 1000)}s)`);
323
+ }
324
+ if (raced.exitCode !== 0) {
325
+ const racedErr = await Promise.race([
326
+ readAll(stderrReader),
327
+ new Promise<string>((resolve) => {
328
+ const t = setTimeout(() => resolve(""), deadlineMs);
329
+ t.unref?.();
330
+ }),
331
+ ]);
332
+ throw new Error(`gh api ${apiArgs.join(" ")} failed (exit ${raced.exitCode}): ${racedErr.trim().slice(0, 200)}`);
215
333
  }
216
- return JSON.parse(stdout);
334
+ return JSON.parse(raced.stdout);
217
335
  } finally {
218
336
  clearTimeout(timeout);
337
+ clearTimeout(raceTimer);
219
338
  }
220
339
  }
221
340
 
@@ -245,6 +364,115 @@ export function _resetGhAvailability(): void {
245
364
  ghAvailability = null;
246
365
  }
247
366
 
367
+ // ---------------------------------------------------------------------------
368
+ // Watched command execution
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /** Per-run timeout for watched commands, overridable via THATCH_WATCH_COMMAND_TIMEOUT_SECONDS. */
372
+ export function defaultCommandTimeoutMs(): number {
373
+ const env = Number(process.env.THATCH_WATCH_COMMAND_TIMEOUT_SECONDS ?? 0);
374
+ return env > 0 ? env * 1000 : 30_000;
375
+ }
376
+
377
+ /** One-line clip of a command for use as a watcher target label. */
378
+ export function commandTargetLabel(command: string): string {
379
+ const oneLine = command.replace(/\s+/g, " ").trim();
380
+ return oneLine.length <= 60 ? oneLine : oneLine.slice(0, 57) + "...";
381
+ }
382
+
383
+ const COMMAND_STDERR_CAP = 2000;
384
+
385
+ /**
386
+ * Grace past the spawn timeout before the pipe drains are abandoned. The
387
+ * direct child has already been SIGKILLed by then, so anything still holding
388
+ * the pipes is an orphaned grandchild - its output is lost, but the poll
389
+ * cycle survives.
390
+ */
391
+ const COMMAND_DRAIN_GRACE_MS = 2_000;
392
+
393
+ /**
394
+ * The default CommandRunner: bash -c in the given cwd, killed at the timeout.
395
+ * Both pipes are always drained even though stdout is discarded - a child
396
+ * writing more than the pipe buffer would otherwise block forever. A timeout
397
+ * counts as "condition not met yet", not an error, so the watch keeps polling.
398
+ *
399
+ * The kill cannot always terminate the whole process tree: a command that
400
+ * backgrounds a long-lived child leaves a grandchild holding the pipe
401
+ * write-ends, so the pipe reads may never reach EOF. The drains are therefore
402
+ * raced against a hard deadline (timeout plus a short grace period) - the
403
+ * run completes as timed-out even when the pipes stay open, and a leaked
404
+ * reader (and its buffered output) is abandoned rather than stalling the
405
+ * shared poll cycle forever. An abandoned grandchild itself keeps running
406
+ * until it exits on its own; nothing reaps it.
407
+ */
408
+ export async function runWatchedCommand(command: string, cwd: string, timeoutMs: number): Promise<CommandRunResult> {
409
+ const startedAt = Date.now();
410
+ let killed = false;
411
+ let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
412
+ try {
413
+ proc = Bun.spawn(["bash", "-c", command], { cwd, stdout: "pipe", stderr: "pipe" });
414
+ } catch (err) {
415
+ throw new Error(`failed to start the watched command (check the command and project directory): ${err instanceof Error ? err.message : String(err)}`);
416
+ }
417
+ const timeout = setTimeout(() => {
418
+ killed = true;
419
+ proc.kill("SIGKILL");
420
+ }, timeoutMs);
421
+ const deadlineMs = timeoutMs + COMMAND_DRAIN_GRACE_MS;
422
+ let raceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
423
+ try {
424
+ // Explicit readers instead of Response.text(): a held reader lock keeps
425
+ // stream.cancel() from working, but reader.cancel() both settles the
426
+ // pending reads and releases the host when the deadline wins.
427
+ const stdoutReader = proc.stdout.getReader();
428
+ const stderrReader = proc.stderr.getReader();
429
+ const readAll = async (reader: ReadableStreamDefaultReader): Promise<string> => {
430
+ let text = "";
431
+ try {
432
+ for (;;) {
433
+ const { done, value } = await reader.read();
434
+ if (done) break;
435
+ text += new TextDecoder().decode(value, { stream: true });
436
+ }
437
+ } catch {
438
+ // Cancelled or errored: keep what was read.
439
+ }
440
+ return text;
441
+ };
442
+ const drain = Promise.all([readAll(stdoutReader), readAll(stderrReader), proc.exited]);
443
+ // If the drain does not finish by the deadline (orphaned grandchild
444
+ // holding the pipes), abandon it - and cancel the readers so the pending
445
+ // reads settle instead of pinning the host process at shutdown.
446
+ // The race timer is unref'd and cleared so a lingering fallback can
447
+ // never keep the host process alive on its own.
448
+ const result = await Promise.race([
449
+ drain.then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode })),
450
+ new Promise<null>((resolve) => {
451
+ raceTimer = setTimeout(() => resolve(null), Math.max(0, deadlineMs - (Date.now() - startedAt)));
452
+ raceTimer.unref?.();
453
+ }),
454
+ ]);
455
+ if (result === null) {
456
+ void stdoutReader.cancel().catch(() => {});
457
+ void stderrReader.cancel().catch(() => {});
458
+ return { exitCode: 124, timedOut: true, stderr: "", durationMs: Date.now() - startedAt };
459
+ }
460
+ const timedOut = killed || result.exitCode === null;
461
+ if (timedOut) {
462
+ console.error(`[thatch] watched command killed at the ${timeoutMs}ms timeout: ${commandTargetLabel(command)}`);
463
+ }
464
+ return {
465
+ exitCode: result.exitCode ?? 124,
466
+ timedOut,
467
+ stderr: result.stderr.slice(-COMMAND_STDERR_CAP),
468
+ durationMs: Date.now() - startedAt,
469
+ };
470
+ } finally {
471
+ clearTimeout(timeout);
472
+ clearTimeout(raceTimer);
473
+ }
474
+ }
475
+
248
476
  // ---------------------------------------------------------------------------
249
477
  // Snapshot construction
250
478
  // ---------------------------------------------------------------------------
@@ -553,13 +781,21 @@ export class WatcherRegistry {
553
781
  #timer: ReturnType<typeof setInterval> | null = null;
554
782
  #delivering = false;
555
783
  #polling = false;
556
- readonly #opts: WatcherRegistryOptions & { pollIntervalMs: number; ttlMinutes: number; maxPerSession: number };
784
+ readonly #opts: WatcherRegistryOptions & {
785
+ pollIntervalMs: number;
786
+ ttlMinutes: number;
787
+ maxPerSession: number;
788
+ commandRunner: CommandRunner;
789
+ commandTimeoutMs: number;
790
+ };
557
791
 
558
792
  constructor(options: WatcherRegistryOptions) {
559
793
  this.#opts = {
560
794
  pollIntervalMs: Number(process.env.THATCH_WATCH_POLL_SECONDS ?? 0) * 1000 || 60_000,
561
795
  ttlMinutes: Number(process.env.THATCH_WATCH_TTL_MINUTES ?? 0) || 480,
562
796
  maxPerSession: Number(process.env.THATCH_WATCH_MAX_PER_SESSION ?? 0) || 5,
797
+ commandRunner: runWatchedCommand,
798
+ commandTimeoutMs: defaultCommandTimeoutMs(),
563
799
  ...options,
564
800
  };
565
801
  }
@@ -587,6 +823,15 @@ export class WatcherRegistry {
587
823
  return this.#timer !== null;
588
824
  }
589
825
 
826
+ /**
827
+ * Poll cadence in seconds, surfaced in tool output and notifications so
828
+ * watcher-vs-poll choices compare real numbers even when the interval is
829
+ * overridden via THATCH_WATCH_POLL_SECONDS.
830
+ */
831
+ get pollSeconds(): number {
832
+ return Math.round(this.#opts.pollIntervalMs / 1000);
833
+ }
834
+
590
835
  /** Stops polling and drops all state. Called from the plugin's dispose. */
591
836
  dispose(): void {
592
837
  this.stop();
@@ -607,6 +852,7 @@ export class WatcherRegistry {
607
852
  repo: string,
608
853
  pr: number,
609
854
  events: PrWatcherEventType[],
855
+ opts: { once?: boolean } = {},
610
856
  ): Promise<{ ok: true; watcher: PrWatcher } | { ok: false; error: string }> {
611
857
  const limit = this.#checkLimit(sessionID);
612
858
  if (limit) return { ok: false, error: limit };
@@ -629,6 +875,7 @@ export class WatcherRegistry {
629
875
  repo,
630
876
  pr,
631
877
  events,
878
+ once: opts.once === true,
632
879
  expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
633
880
  createdAt: Date.now(),
634
881
  state,
@@ -648,6 +895,7 @@ export class WatcherRegistry {
648
895
  branch: string,
649
896
  events: BranchWatcherEventType[],
650
897
  workflows: string[] = [],
898
+ opts: { once?: boolean } = {},
651
899
  ): Promise<{ ok: true; watcher: BranchWatcher } | { ok: false; error: string }> {
652
900
  const limit = this.#checkLimit(sessionID);
653
901
  if (limit) return { ok: false, error: limit };
@@ -671,6 +919,7 @@ export class WatcherRegistry {
671
919
  branch,
672
920
  events,
673
921
  workflows,
922
+ once: opts.once === true,
674
923
  expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
675
924
  createdAt: Date.now(),
676
925
  state,
@@ -679,6 +928,61 @@ export class WatcherRegistry {
679
928
  return { ok: true, watcher };
680
929
  }
681
930
 
931
+ /**
932
+ * Registers a command watcher: the poller runs the command (as a condition
933
+ * variable - exit code only) every cycle until it first exits 0, notifies,
934
+ * and cancels. The baseline run doubles as validation, like createPr: a
935
+ * command that already exits 0 means the condition is already met (refuse,
936
+ * so the caller proceeds now), exit 127 means the command does not exist,
937
+ * and a spawn failure fails with a real error instead of a watcher that
938
+ * can never fire.
939
+ */
940
+ async createCommand(
941
+ sessionID: string,
942
+ command: string,
943
+ cwd: string,
944
+ ): Promise<{ ok: true; watcher: CommandWatcher } | { ok: false; error: string }> {
945
+ const limit = this.#checkLimit(sessionID);
946
+ if (limit) return { ok: false, error: limit };
947
+ if (!command.trim()) return { ok: false, error: "No command to watch - pass a shell command." };
948
+
949
+ let baseline: CommandRunResult;
950
+ try {
951
+ baseline = await this.#opts.commandRunner(command, cwd, this.#opts.commandTimeoutMs);
952
+ } catch (err) {
953
+ return { ok: false, error: `Failed to run the command: ${err instanceof Error ? err.message : String(err)}` };
954
+ }
955
+ if (!baseline.timedOut && baseline.exitCode === 0) {
956
+ return { ok: false, error: "The command already exits 0 - the condition is already met, so no watcher was registered. Proceed now, or re-run the command yourself if you need its output." };
957
+ }
958
+ if (baseline.exitCode === 127) {
959
+ return {
960
+ ok: false,
961
+ error: `The command exited 127 (command not found) - nothing to wait for until it exists. stderr: ${baseline.stderr.trim() || "(none)"}`,
962
+ };
963
+ }
964
+
965
+ const watcher: CommandWatcher = {
966
+ id: `watch_${Math.random().toString(36).slice(2, 10)}`,
967
+ source: "command",
968
+ sessionID,
969
+ command,
970
+ cwd,
971
+ timeoutMs: this.#opts.commandTimeoutMs,
972
+ events: [...COMMAND_EVENT_TYPES],
973
+ once: true,
974
+ expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
975
+ createdAt: Date.now(),
976
+ // A timed-out baseline has no meaningful exit code - the killed process
977
+ // may have exited 0 while dying. Record the 124 sentinel so watch_list
978
+ // and the registration output never display a misleading 0 for a
979
+ // watcher that has not observed a real exit yet.
980
+ state: { lastExit: baseline.timedOut ? 124 : baseline.exitCode },
981
+ };
982
+ this.#watchers.set(watcher.id, watcher);
983
+ return { ok: true, watcher };
984
+ }
985
+
682
986
  #checkLimit(sessionID: string): string | null {
683
987
  const existing = this.listForSession(sessionID);
684
988
  if (existing.length >= this.#opts.maxPerSession) {
@@ -735,6 +1039,12 @@ export class WatcherRegistry {
735
1039
  const queue = this.#pending.get(watcher.sessionID) ?? [];
736
1040
  queue.push(...events);
737
1041
  this.#pending.set(watcher.sessionID, queue);
1042
+ // One-shot watchers end at first detection, not first delivery:
1043
+ // the queued events deliver through the normal pending path even
1044
+ // if the session is busy. Cancellation at detection time keeps
1045
+ // "wait for this run to finish" from leaving a standing watch
1046
+ // polling a target nobody is waiting on anymore.
1047
+ if (watcher.once) this.#watchers.delete(id);
738
1048
  }
739
1049
  } catch (err) {
740
1050
  console.error(`[thatch] watcher ${id} poll failed: ${err}`);
@@ -755,6 +1065,22 @@ export class WatcherRegistry {
755
1065
  watcher.state = after;
756
1066
  return events;
757
1067
  }
1068
+ if (watcher.source === "command") {
1069
+ const result = await this.#opts.commandRunner(watcher.command, watcher.cwd, watcher.timeoutMs);
1070
+ // A timed-out run produced no meaningful exit code, so the last-seen
1071
+ // exit stays as-is and the watch keeps waiting. Exit 0 needs no
1072
+ // transition guard: the baseline refusal keeps lastExit non-zero at
1073
+ // registration, and the one-shot cancel in poll() removes the watcher
1074
+ // the moment this event fires.
1075
+ if (!result.timedOut) watcher.state = { lastExit: result.exitCode };
1076
+ if (result.timedOut || result.exitCode !== 0) return [];
1077
+ return [{
1078
+ type: "command_success",
1079
+ target: commandTargetLabel(watcher.command),
1080
+ summary: `command exited 0 (took ${(result.durationMs / 1000).toFixed(1)}s)`,
1081
+ url: "",
1082
+ }];
1083
+ }
758
1084
  const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
759
1085
  const all = diffBranchState(watcher.state, after, `${watcher.repo}@${watcher.branch}`, watcher.repo);
760
1086
  watcher.state = after;
@@ -780,7 +1106,16 @@ export class WatcherRegistry {
780
1106
  try {
781
1107
  for (const [sessionID, events] of this.#pending) {
782
1108
  if (events.length === 0) continue;
783
- if (!this.#opts.canDeliver(sessionID)) continue;
1109
+ // A throwing gate must not escape poll() - an unhandled rejection
1110
+ // here would take down the whole poll cycle, the same failure class
1111
+ // as a hung fetch. Fail closed: the mail stays pending.
1112
+ let deliverable = false;
1113
+ try {
1114
+ deliverable = await this.#opts.canDeliver(sessionID);
1115
+ } catch (err) {
1116
+ console.error(`[thatch] canDeliver gate failed for ${sessionID}: ${err}`);
1117
+ }
1118
+ if (!deliverable) continue;
784
1119
  try {
785
1120
  await this.#opts.deliver(sessionID, events);
786
1121
  this.#pending.delete(sessionID);