@inceptionstack/roundhouse 0.5.17 → 0.5.20

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `@inceptionstack/roundhouse` are documented here.
4
4
 
5
+ ## [0.5.19] — 2026-05-10
6
+ - Sub-agent orchestrator: spawn background Pi agents for review/research/scout/implementation
7
+ - CLI: `roundhouse subagent spawn/status/list/abort`
8
+ - Telegram notifications on sub-agent completion (✅/⏰/❌)
9
+ - Security: UUID-only run IDs, path traversal guard, SIGKILL escalation
10
+ - Boot turn: agent greets in-character on startup
11
+ - /status shows configured model after /model switch
12
+ - TUI: fresh session support on new deploys
13
+
5
14
  ## [0.5.14] — 2026-05-10
6
15
 
7
16
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.17",
3
+ "version": "0.5.20",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -521,6 +521,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
521
521
  getInfo(threadId?: string): Record<string, unknown> {
522
522
  // Get model from the requested thread's session, or most recently used
523
523
  let modelInfo: string | undefined;
524
+ let hasActiveSession = false;
524
525
  let contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
525
526
  const threadEntry = threadId ? sessions.get(threadId) : undefined;
526
527
 
@@ -528,6 +529,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
528
529
  const model = threadEntry.session.model;
529
530
  if (model) modelInfo = `${model.provider}/${model.id}`;
530
531
  contextUsage = threadEntry.session.getContextUsage() ?? undefined;
532
+ hasActiveSession = true;
531
533
  }
532
534
 
533
535
  if (!modelInfo) {
@@ -542,17 +544,18 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
542
544
  }
543
545
  }
544
546
 
545
- // Fall back to configured default from settings.json
546
- if (!modelInfo) {
547
- try {
548
- const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
549
- const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
550
- if (settings.defaultProvider && settings.defaultModel) {
551
- modelInfo = `${settings.defaultProvider}/${settings.defaultModel}`;
552
- }
553
- } catch (err) {
554
- console.warn(`[pi-agent] could not read settings.json for model info:`, (err as Error).message);
547
+ // Read configured model from settings.json (used for fallback + configuredModel field)
548
+ let configuredModel = "";
549
+ try {
550
+ const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
551
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
552
+ if (settings.defaultProvider && settings.defaultModel) {
553
+ configuredModel = `${settings.defaultProvider}/${settings.defaultModel}`;
555
554
  }
555
+ } catch {}
556
+
557
+ if (!modelInfo && configuredModel) {
558
+ modelInfo = configuredModel;
556
559
  }
557
560
 
558
561
  // Read agent version
@@ -565,6 +568,8 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
565
568
  return {
566
569
  version,
567
570
  model: modelInfo ?? "unknown",
571
+ hasActiveSession,
572
+ configuredModel: configuredModel || modelInfo || "unknown",
568
573
  activeSessions: sessions.size,
569
574
  cwd,
570
575
  contextTokens: contextUsage?.tokens ?? null,
package/src/cli/cli.ts CHANGED
@@ -322,20 +322,20 @@ async function cmdTui() {
322
322
  })
323
323
  .sort((a, b) => b.mtime - a.mtime);
324
324
  } catch {
325
- console.error(`No session directory found at ${threadPath}.`);
326
- process.exit(1);
325
+ // No session directory will start fresh below
327
326
  }
328
327
 
329
- if (candidates.length === 0) {
330
- console.error(`No session files found at ${threadPath}.`);
331
- process.exit(1);
328
+ let piArgs: string[];
329
+ if (candidates.length > 0) {
330
+ const selected = candidates[0];
331
+ console.log(`\nResuming: ${selected.sessionFile}\n`);
332
+ piArgs = ["--resume", selected.sessionFile];
333
+ } else {
334
+ console.log(`\nNo existing sessions for thread "${threadId}". Starting fresh.\n`);
335
+ piArgs = ["--session-dir", threadPath];
332
336
  }
333
337
 
334
- const selected = candidates[0];
335
-
336
- console.log(`\nOpening: ${selected.sessionFile}\n`);
337
-
338
- const child = spawn("pi", ["--resume", selected.sessionFile], { stdio: "inherit" });
338
+ const child = spawn("pi", piArgs, { stdio: "inherit" });
339
339
  child.on("error", (err) => {
340
340
  console.error((err as any).code === "ENOENT" ? "'pi' not found in PATH." : `Failed: ${err.message}`);
341
341
  process.exit(1);
@@ -390,6 +390,7 @@ import { cmdAgent } from "./agent-command";
390
390
  import { cmdCron } from "./cron";
391
391
  import { cmdSetup, cmdPair } from "./setup";
392
392
  import { cmdMessage } from "./message";
393
+ import { handleSubagentCommand } from "./subagent-command";
393
394
 
394
395
  const command = process.argv[2];
395
396
 
@@ -410,6 +411,7 @@ const commands: Record<string, () => void | Promise<void>> = {
410
411
  doctor: () => cmdDoctor(process.argv.slice(3)),
411
412
  cron: () => cmdCron(process.argv.slice(3)),
412
413
  message: () => cmdMessage(process.argv.slice(3)),
414
+ subagent: () => handleSubagentCommand(process.argv.slice(3)),
413
415
  agent: cmdAgent,
414
416
  };
415
417
 
@@ -0,0 +1,171 @@
1
+ /**
2
+ * cli/subagent-command.ts — CLI interface for sub-agent delegation
3
+ *
4
+ * Thin disk-state client that reads/writes ~/.roundhouse/subagents/ directly.
5
+ * The gateway's watcher handles lifecycle (timeout, completion notification).
6
+ * spawn() creates the process and persists state; the gateway adopts it on next poll.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { SubAgentOrchestratorImpl } from "../subagents/orchestrator";
13
+ import { validateRunId } from "../subagents/run-store";
14
+ import type { SpawnSpec, SubAgentRole, RoutingInfo } from "../subagents/types";
15
+
16
+ const ROUNDHOUSE_DIR = join(homedir(), ".roundhouse");
17
+
18
+ function loadGatewayConfig(): { notifyChatIds: number[] } {
19
+ try {
20
+ const raw = readFileSync(join(ROUNDHOUSE_DIR, "gateway.config.json"), "utf8");
21
+ const cfg = JSON.parse(raw);
22
+ return { notifyChatIds: cfg?.chat?.notifyChatIds ?? [] };
23
+ } catch {
24
+ return { notifyChatIds: [] };
25
+ }
26
+ }
27
+
28
+ function buildRouting(): RoutingInfo {
29
+ const cfg = loadGatewayConfig();
30
+ const chatId = String(cfg.notifyChatIds[0] ?? "");
31
+ if (!chatId) {
32
+ console.error("Error: no Telegram chat configured. Run 'roundhouse setup' first.");
33
+ process.exit(1);
34
+ }
35
+ return {
36
+ transport: "telegram",
37
+ chatId,
38
+ parentThreadId: `telegram:${chatId}:main`,
39
+ };
40
+ }
41
+
42
+ export async function handleSubagentCommand(args: string[]): Promise<void> {
43
+ const subcommand = args[0];
44
+
45
+ const orchestrator = new SubAgentOrchestratorImpl();
46
+
47
+ switch (subcommand) {
48
+ case "spawn": {
49
+ const role = getFlag(args, "--role") as SubAgentRole | undefined;
50
+ const task = getFlag(args, "--task");
51
+ const cwd = getFlag(args, "--cwd") || process.cwd();
52
+ const model = getFlag(args, "--model");
53
+ const timeoutStr = getFlag(args, "--timeout");
54
+
55
+ if (!role || !task) {
56
+ console.error("Usage: roundhouse subagent spawn --role <role> --task \"...\" [--cwd <dir>] [--model <id>] [--timeout <ms>]");
57
+ process.exit(1);
58
+ }
59
+
60
+ const validRoles: SubAgentRole[] = ["review", "research", "scout", "implementation"];
61
+ if (!validRoles.includes(role)) {
62
+ console.error(`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`);
63
+ process.exit(1);
64
+ }
65
+
66
+ let timeoutMs: number | undefined;
67
+ if (timeoutStr) {
68
+ const n = Number(timeoutStr);
69
+ if (!Number.isFinite(n) || n <= 0) {
70
+ console.error(`Invalid timeout: ${timeoutStr}. Must be a positive number (milliseconds).`);
71
+ process.exit(1);
72
+ }
73
+ timeoutMs = n;
74
+ }
75
+
76
+ const spec: SpawnSpec = {
77
+ role,
78
+ task,
79
+ cwd,
80
+ routing: buildRouting(),
81
+ model: model || undefined,
82
+ timeoutMs,
83
+ };
84
+
85
+ try {
86
+ const runId = await orchestrator.spawn(spec);
87
+ console.log(JSON.stringify({ runId, status: "spawned", role, cwd }));
88
+ } catch (err) {
89
+ console.error(`Spawn failed: ${(err as Error).message}`);
90
+ process.exit(1);
91
+ }
92
+ break;
93
+ }
94
+
95
+ case "status": {
96
+ const runId = args[1];
97
+ if (!runId) {
98
+ console.error("Usage: roundhouse subagent status <runId>");
99
+ process.exit(1);
100
+ }
101
+ validateCliRunId(runId);
102
+ const status = await orchestrator.status(runId);
103
+ if (!status) {
104
+ console.error(`Run not found: ${runId}`);
105
+ process.exit(1);
106
+ }
107
+ console.log(JSON.stringify(status, null, 2));
108
+ break;
109
+ }
110
+
111
+ case "list": {
112
+ const statuses = await orchestrator.list();
113
+ if (statuses.length === 0) {
114
+ console.log("No sub-agent runs.");
115
+ } else {
116
+ for (const s of statuses) {
117
+ const duration = s.completedAt
118
+ ? `${Math.round((Date.parse(s.completedAt) - Date.parse(s.startedAt)) / 1000)}s`
119
+ : "running";
120
+ console.log(`${s.status.padEnd(8)} ${s.role.padEnd(14)} ${duration.padEnd(8)} ${s.runId.slice(0, 8)}`);
121
+ }
122
+ }
123
+ break;
124
+ }
125
+
126
+ case "abort": {
127
+ const runId = args[1];
128
+ if (!runId) {
129
+ console.error("Usage: roundhouse subagent abort <runId>");
130
+ process.exit(1);
131
+ }
132
+ validateCliRunId(runId);
133
+ const current = await orchestrator.status(runId);
134
+ if (!current) {
135
+ console.error(`Run not found: ${runId}`);
136
+ process.exit(1);
137
+ }
138
+ if (current.status !== "running") {
139
+ console.log(`Run already ${current.status}: ${runId.slice(0, 8)}`);
140
+ break;
141
+ }
142
+ await orchestrator.abort(runId);
143
+ const status = await orchestrator.status(runId);
144
+ if (status?.status === "running") {
145
+ console.log(`Abort requested: ${runId.slice(0, 8)}`);
146
+ } else {
147
+ console.log(`Aborted: ${runId.slice(0, 8)}`);
148
+ }
149
+ break;
150
+ }
151
+
152
+ default:
153
+ console.error("Usage: roundhouse subagent <spawn|status|list|abort>");
154
+ process.exit(1);
155
+ }
156
+ }
157
+
158
+ function getFlag(args: string[], flag: string): string | undefined {
159
+ const idx = args.indexOf(flag);
160
+ if (idx === -1 || idx + 1 >= args.length) return undefined;
161
+ return args[idx + 1];
162
+ }
163
+
164
+ function validateCliRunId(runId: string): void {
165
+ try {
166
+ validateRunId(runId);
167
+ } catch (err) {
168
+ console.error((err as Error).message);
169
+ process.exit(1);
170
+ }
171
+ }
@@ -211,7 +211,17 @@ export async function handleStatus(ctx: CommandContext): Promise<void> {
211
211
  `🤖 Agent: ${agentLabel}`,
212
212
  ];
213
213
 
214
- if (info.model) lines.push(`🧠 Model: \`${info.model}\``);
214
+ if (info.model && info.model !== "unknown") {
215
+ const configuredModel = info.configuredModel as string | undefined;
216
+ if (configuredModel && configuredModel !== info.model && info.hasActiveSession) {
217
+ lines.push(`🧠 Model: \`${configuredModel}\` (configured)`);
218
+ lines.push(` ↳ session using: \`${info.model}\` (until /new)`);
219
+ } else {
220
+ lines.push(`🧠 Model: \`${configuredModel || info.model}\``);
221
+ }
222
+ } else if (info.configuredModel) {
223
+ lines.push(`🧠 Model: \`${info.configuredModel}\``);
224
+ }
215
225
  if (info.activeSessions !== undefined) lines.push(`💬 Active sessions: ${info.activeSessions}`);
216
226
 
217
227
  lines.push(
@@ -28,6 +28,8 @@ import { handleLater } from "./later-command";
28
28
  import { handleTopic, applyTopicOverride } from "./topic-command";
29
29
  import { TelegramAdapter } from "../transports";
30
30
  import type { TransportAdapter } from "../transports";
31
+ import { SubAgentOrchestratorImpl, SubAgentWatcher } from "../subagents";
32
+ import type { RunStatus, RoutingInfo } from "../subagents";
31
33
  import { hostname } from "node:os";
32
34
  import { join } from "node:path";
33
35
  import { injectToolsSection } from "./tools-inject";
@@ -89,6 +91,8 @@ export class Gateway {
89
91
  private sttService: SttService | null = null;
90
92
  private cronScheduler: CronSchedulerService | null = null;
91
93
  private ipcServer: IpcServer | null = null;
94
+ private subagentOrchestrator: SubAgentOrchestratorImpl | null = null;
95
+ private subagentWatcher: SubAgentWatcher | null = null;
92
96
 
93
97
  constructor(router: AgentRouter, config: GatewayConfig) {
94
98
  this.router = router;
@@ -362,8 +366,22 @@ export class Gateway {
362
366
  console.error("[roundhouse] IPC server start failed:", (err as Error).message);
363
367
  }
364
368
 
369
+ // Start sub-agent orchestrator + watcher
370
+ this.subagentOrchestrator = new SubAgentOrchestratorImpl();
371
+ this.subagentWatcher = new SubAgentWatcher(
372
+ this.subagentOrchestrator,
373
+ async (status, routing) => {
374
+ await this.handleSubagentCompletion(status, routing);
375
+ },
376
+ );
377
+ this.subagentWatcher.start();
378
+ console.log("[roundhouse] sub-agent watcher started");
379
+
365
380
  // Send startup notification (after cron init so we can include job counts)
366
381
  await this.notifyStartup(platforms);
382
+
383
+ // Fire boot turn — agent says hello (seeds session so it's never empty)
384
+ await this.fireBootTurn(verboseThreads, threadLocks, abortControllers);
367
385
  }
368
386
 
369
387
  /**
@@ -782,7 +800,62 @@ export class Gateway {
782
800
  }
783
801
  }
784
802
 
803
+ /**
804
+ * Fire a boot turn — send a prompt to the agent so it greets in-character.
805
+ * Seeds the session on startup so context is never empty.
806
+ */
807
+ private async fireBootTurn(
808
+ verboseThreads: Set<string>,
809
+ threadLocks: Map<string, Promise<void>>,
810
+ abortControllers: Map<string, AbortController>,
811
+ ) {
812
+ const chatIds = this.config.chat.notifyChatIds;
813
+ if (!chatIds?.length) return;
814
+
815
+ // Only fire for the primary (first) chat
816
+ const primaryChatId = chatIds[0];
817
+ const threadId = `telegram:${primaryChatId}`;
818
+ const agentThreadId = "main";
819
+
820
+ // Create a synthetic Telegram-compatible thread so streaming, HTML conversion,
821
+ // message splitting, and progressive edits all work identically to real chat threads.
822
+ const token = process.env.TELEGRAM_BOT_TOKEN;
823
+ const syntheticThread = {
824
+ id: threadId,
825
+ adapter: {
826
+ telegramFetch: async (method: string, payload: Record<string, unknown>) => {
827
+ if (!token) return null;
828
+ const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
829
+ method: "POST",
830
+ headers: { "Content-Type": "application/json" },
831
+ body: JSON.stringify({ chat_id: primaryChatId, ...payload }),
832
+ signal: AbortSignal.timeout(30_000),
833
+ });
834
+ if (!res.ok) return null;
835
+ const json = await res.json() as { result?: unknown };
836
+ return json.result ?? null;
837
+ },
838
+ },
839
+ post: async (content: string | { markdown: string }) => {
840
+ const text = typeof content === "string" ? content : content.markdown;
841
+ await this.transport.notify([primaryChatId], text);
842
+ },
843
+ startTyping: async () => {},
844
+ };
845
+
846
+ const bootPrompt = "You just came online after a restart. Say a brief hello in-character (1–2 sentences max). Check your workspace for any pending tasks.";
847
+
848
+ try {
849
+ await this.handleAgentTurn(syntheticThread, agentThreadId, bootPrompt, [], verboseThreads, threadLocks, abortControllers);
850
+ } catch (err) {
851
+ console.error("[roundhouse] boot turn failed:", (err as Error).message);
852
+ }
853
+ }
854
+
785
855
  async stop() {
856
+ if (this.subagentWatcher) {
857
+ this.subagentWatcher.stop();
858
+ }
786
859
  if (this.ipcServer) {
787
860
  this.ipcServer.stop();
788
861
  }
@@ -793,4 +866,22 @@ export class Gateway {
793
866
  await this.router.dispose();
794
867
  console.log("[roundhouse] stopped");
795
868
  }
869
+
870
+ /** Handle sub-agent completion — post result to originating thread */
871
+ private async handleSubagentCompletion(status: RunStatus, routing: RoutingInfo): Promise<void> {
872
+ const emoji = status.status === "complete" ? "✅" : status.status === "timeout" ? "⏰" : "❌";
873
+ const duration = status.completedAt && status.startedAt
874
+ ? Math.round((Date.parse(status.completedAt) - Date.parse(status.startedAt)) / 1000)
875
+ : 0;
876
+ const summary = `${emoji} <b>Sub-agent ${status.status}</b> (${status.role})\n⏱ ${duration}s | run: <code>${status.runId.slice(0, 8)}</code>`;
877
+
878
+ try {
879
+ const chatId = Number(routing.chatId);
880
+ if (chatId) {
881
+ await this.transport.notify([chatId], summary, { parseMode: "HTML" });
882
+ }
883
+ } catch (err) {
884
+ console.error("[roundhouse] sub-agent completion notification failed:", err);
885
+ }
886
+ }
796
887
  }
@@ -176,3 +176,42 @@ aws s3 ls
176
176
  aws logs tail /aws/lambda/<name> --since 1h
177
177
  aws cloudformation describe-stacks --stack-name <name>
178
178
  ```
179
+
180
+ ## Sub-Agent Delegation
181
+
182
+ Spawn background sub-agents for tasks that take time. The main conversation stays responsive.
183
+
184
+ **Usage:**
185
+ ```bash
186
+ # Spawn a background agent
187
+ roundhouse subagent spawn --role <role> --task "..." --cwd <dir>
188
+
189
+ # Check status
190
+ roundhouse subagent status <runId>
191
+ roundhouse subagent list
192
+
193
+ # Cancel a running agent
194
+ roundhouse subagent abort <runId>
195
+ ```
196
+
197
+ **Roles:**
198
+ - `review` — Code review, architecture review, PR review
199
+ - `research` — Read docs, explore APIs, gather information
200
+ - `scout` — Search codebase, find patterns, map structure
201
+ - `implementation` — Write code, fix bugs, refactor
202
+
203
+ **Behavior:**
204
+ - Sub-agents run as background processes (15 min timeout default)
205
+ - You get notified when they complete
206
+ - Multiple agents can run concurrently
207
+ - They share the filesystem — no isolation between them
208
+
209
+ **When to delegate:**
210
+ - Task will take several minutes
211
+ - User wants to keep chatting about something else
212
+ - Independent work that doesn't need live interaction
213
+
214
+ **When NOT to delegate:**
215
+ - Quick tasks (< 30 seconds)
216
+ - Tasks needing user input mid-way
217
+ - You can just do it directly in the current turn
@@ -0,0 +1,28 @@
1
+ import type { SpawnSpec } from "./types";
2
+
3
+ export function buildBrief(spec: SpawnSpec): string {
4
+ const sections: string[] = [
5
+ "# Role",
6
+ spec.role,
7
+ "",
8
+ "# Task",
9
+ spec.task,
10
+ "",
11
+ "# Working Directory",
12
+ spec.cwd,
13
+ ];
14
+
15
+ if (spec.context?.briefing) {
16
+ sections.push("", "# Context", spec.context.briefing);
17
+ }
18
+
19
+ if (spec.context?.targetFiles?.length) {
20
+ sections.push("", "# Target Files", ...spec.context.targetFiles.map((file) => `- ${file}`));
21
+ }
22
+
23
+ if (spec.context?.completionContract) {
24
+ sections.push("", "# Done When", spec.context.completionContract);
25
+ }
26
+
27
+ return sections.join("\n") + "\n";
28
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./brief";
2
+ export * from "./orchestrator";
3
+ export * from "./pid";
4
+ export * from "./process-launcher";
5
+ export * from "./run-store";
6
+ export * from "./types";
7
+ export * from "./watcher";
8
+ // Internal modules (not re-exported): run-finalizer.ts, termination-handler.ts
@@ -0,0 +1,185 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, stat } from "node:fs/promises";
3
+ import type { ChildProcess } from "node:child_process";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { buildBrief } from "./brief";
7
+ import { isProcessAlive as defaultIsProcessAlive } from "./pid";
8
+ import { ProcessLauncher, type ProcessLauncherOptions } from "./process-launcher";
9
+ import { RunFinalizer } from "./run-finalizer";
10
+ import { RunStore } from "./run-store";
11
+ import { TerminationHandler } from "./termination-handler";
12
+ import type { RunStatus, SpawnSpec, SubAgentLifecycle, SubAgentOrchestrator } from "./types";
13
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
14
+
15
+ export interface OrchestratorOptions extends ProcessLauncherOptions {
16
+ dataRoot?: string;
17
+ isProcessAlive?: (pid: number, expectedTicks: string) => Promise<boolean>;
18
+ now?: () => Date;
19
+ }
20
+
21
+ export class SubAgentOrchestratorImpl implements SubAgentOrchestrator, SubAgentLifecycle {
22
+ private readonly store: RunStore;
23
+ private readonly launcher: ProcessLauncher;
24
+ private readonly isProcessAlive: (pid: number, expectedTicks: string) => Promise<boolean>;
25
+ private readonly now: () => Date;
26
+ private readonly finalizer: RunFinalizer;
27
+ private readonly terminationHandler: TerminationHandler;
28
+ private readonly children = new Map<string, { pid: number }>();
29
+ private readonly statusReady = new Map<string, Promise<void>>();
30
+
31
+ constructor(options: OrchestratorOptions = {}) {
32
+ const dataRoot = options.dataRoot ?? join(homedir(), ".roundhouse");
33
+ this.store = new RunStore(dataRoot);
34
+ this.launcher = new ProcessLauncher(options);
35
+ this.isProcessAlive = options.isProcessAlive ?? defaultIsProcessAlive;
36
+ this.now = options.now ?? (() => new Date());
37
+ this.finalizer = new RunFinalizer({ store: this.store, now: this.now });
38
+ this.terminationHandler = new TerminationHandler({
39
+ store: this.store,
40
+ isProcessAlive: this.isProcessAlive,
41
+ signalProcess: this.launcher.signalProcess,
42
+ finalizeRun: this.finalizer.finalizeRun.bind(this.finalizer),
43
+ });
44
+ }
45
+ onCompletion(listener: (status: RunStatus) => Promise<void> | void): () => void { return this.finalizer.onCompletion(listener); }
46
+ isRunManagedInProcess(runId: string): boolean { return this.children.has(runId); }
47
+
48
+ async spawn(spec: SpawnSpec): Promise<string> {
49
+ await assertDirectoryExists(spec.cwd);
50
+ this.launcher.assertAvailable();
51
+
52
+ const runId = randomUUID();
53
+ const runDir = this.store.getRunDir(runId);
54
+ const brief = buildBrief(spec);
55
+ const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
56
+ const startedAt = this.now();
57
+
58
+ await mkdir(runDir, { recursive: true });
59
+ await this.store.writeFile(runId, "brief.md", brief);
60
+ if (spec.model) {
61
+ await this.store.writeJson(runId, "settings.json", { defaultModel: spec.model });
62
+ }
63
+
64
+ let resolveReady: () => void;
65
+ const readyPromise = new Promise<void>((r) => { resolveReady = r; });
66
+ this.statusReady.set(runId, readyPromise);
67
+ let launchedChild: ChildProcess | undefined;
68
+ let launchedPid: number | undefined;
69
+
70
+ try {
71
+ const { pid, spawnClockTicks } = await this.launcher.launch(runDir, spec.cwd, (child) => {
72
+ launchedChild = child;
73
+ child.on("exit", (exitCode) => {
74
+ void this.handleChildExit(runId, exitCode);
75
+ });
76
+ if (child.exitCode !== null) {
77
+ void this.handleChildExit(runId, child.exitCode);
78
+ }
79
+ });
80
+ launchedPid = pid;
81
+
82
+ const initialStatus: RunStatus = {
83
+ runId,
84
+ role: spec.role,
85
+ cwd: spec.cwd,
86
+ routing: spec.routing,
87
+ status: "running",
88
+ pid,
89
+ startedAt: startedAt.toISOString(),
90
+ deadlineAt: new Date(startedAt.getTime() + timeoutMs).toISOString(),
91
+ spawnClockTicks,
92
+ };
93
+
94
+ this.children.set(runId, { pid });
95
+ await this.store.write(initialStatus);
96
+ resolveReady!();
97
+ return runId;
98
+ } catch (err) {
99
+ this.children.delete(runId);
100
+ if (typeof launchedPid === "number") {
101
+ // Defense in depth: the launcher handles /proc/bootstrap failures, while the orchestrator
102
+ // still owns cleanup for later failures such as status.json persistence after launch.
103
+ try {
104
+ this.launcher.signalProcess(launchedPid, "SIGTERM");
105
+ } catch {}
106
+ } else if (typeof launchedChild?.pid === "number") {
107
+ try {
108
+ this.launcher.signalProcess(launchedChild.pid, "SIGTERM");
109
+ } catch {}
110
+ }
111
+ resolveReady!();
112
+ this.statusReady.delete(runId);
113
+ throw err;
114
+ }
115
+ }
116
+
117
+ async status(runId: string): Promise<RunStatus | null> {
118
+ const current = await this.store.read(runId);
119
+ if (!current) return null;
120
+ if (current.status !== "running") return current;
121
+ return this.refreshRunningStatus(current);
122
+ }
123
+
124
+ async list(): Promise<RunStatus[]> { return this.listRuns(true); }
125
+ async listRaw(): Promise<RunStatus[]> { return this.listRuns(false); }
126
+
127
+ private async listRuns(refresh: boolean): Promise<RunStatus[]> {
128
+ const dirs = await this.store.listDirs();
129
+ const statuses = await Promise.all(
130
+ dirs.map((id) => refresh ? this.status(id) : this.store.read(id)),
131
+ );
132
+ return statuses.filter((s): s is RunStatus => s !== null);
133
+ }
134
+
135
+ async abort(runId: string): Promise<void> { await this.terminationHandler.terminateRun(runId, "aborted"); }
136
+
137
+ async enforceTimeout(runId: string): Promise<RunStatus | null> {
138
+ const current = await this.store.read(runId);
139
+ if (!current || current.status !== "running") return current;
140
+ return this.terminationHandler.terminateRun(runId, "timeout");
141
+ }
142
+
143
+ async recoverRun(runId: string): Promise<RunStatus | null> {
144
+ const current = await this.store.read(runId);
145
+ if (!current || current.status !== "running") return current;
146
+ return this.refreshRunningStatus(current, true);
147
+ }
148
+
149
+ private async refreshRunningStatus(current: RunStatus, notify = false): Promise<RunStatus> {
150
+ const alive = await this.isProcessAlive(current.pid, current.spawnClockTicks);
151
+ if (alive) return current;
152
+
153
+ const outcome = this.terminationHandler.terminalStatusFor(current);
154
+ return this.finalizer.finalizeRun(current.runId, outcome, { notify });
155
+ }
156
+
157
+ private async handleChildExit(runId: string, exitCode: number | null): Promise<void> {
158
+ const ready = this.statusReady.get(runId);
159
+ if (ready) {
160
+ await ready;
161
+ this.statusReady.delete(runId);
162
+ }
163
+
164
+ this.children.delete(runId);
165
+
166
+ const current = await this.store.read(runId);
167
+ if (!current || current.status !== "running") return;
168
+
169
+ const outcome = current.requestedOutcome
170
+ ? this.terminationHandler.terminalStatusFor(current)
171
+ : (exitCode === 0 ? "complete" : "failed");
172
+
173
+ await this.finalizer.finalizeRun(runId, outcome, { exitCode: exitCode ?? undefined });
174
+ }
175
+ }
176
+
177
+ async function assertDirectoryExists(path: string): Promise<void> {
178
+ try {
179
+ const info = await stat(path);
180
+ if (!info.isDirectory()) throw new Error(`Sub-agent cwd is not a directory: ${path}`);
181
+ } catch (err: any) {
182
+ if (err?.code === "ENOENT") throw new Error(`Sub-agent cwd does not exist: ${path}`);
183
+ throw err;
184
+ }
185
+ }
@@ -0,0 +1,52 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ export interface ParsedStatFile {
4
+ state: string;
5
+ starttime: string;
6
+ isZombie: boolean;
7
+ }
8
+
9
+ export function parseStatFile(content: string): ParsedStatFile {
10
+ const trimmed = content.trim();
11
+ // Field 2 (comm) is parenthesized and can contain spaces/parens.
12
+ // Strip everything through the LAST ") " to safely reach field 3+.
13
+ const boundary = trimmed.lastIndexOf(") ");
14
+ if (boundary === -1) {
15
+ throw new Error("Invalid /proc stat format");
16
+ }
17
+
18
+ // After stripping pid + comm, remainder starts at field 3.
19
+ // fields[0] = state (field 3), fields[19] = starttime (field 22).
20
+ const remainder = trimmed.slice(boundary + 2).trim();
21
+ const fields = remainder.split(/\s+/);
22
+ if (fields.length < 20) {
23
+ throw new Error("Incomplete /proc stat format");
24
+ }
25
+
26
+ const state = fields[0];
27
+ const starttime = fields[19]; // Original /proc field 22 (starttime)
28
+ if (!state || !starttime) {
29
+ throw new Error("Missing required /proc stat fields");
30
+ }
31
+
32
+ return {
33
+ state,
34
+ starttime,
35
+ isZombie: state === "Z",
36
+ };
37
+ }
38
+
39
+ export async function readSpawnClockTicks(pid: number): Promise<string> {
40
+ const content = await readFile(`/proc/${pid}/stat`, "utf8");
41
+ return parseStatFile(content).starttime;
42
+ }
43
+
44
+ export async function isProcessAlive(pid: number, expectedTicks: string): Promise<boolean> {
45
+ try {
46
+ const content = await readFile(`/proc/${pid}/stat`, "utf8");
47
+ const parsed = parseStatFile(content);
48
+ return !parsed.isZombie && parsed.starttime === expectedTicks;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
@@ -0,0 +1,102 @@
1
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { open } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { readSpawnClockTicks as defaultReadSpawnClockTicks } from "./pid";
5
+
6
+ export interface LaunchResult {
7
+ child: ChildProcess;
8
+ pid: number;
9
+ spawnClockTicks: string;
10
+ }
11
+
12
+ export interface ProcessLauncherOptions {
13
+ spawnProcess?: typeof spawn;
14
+ checkPiAvailable?: () => void;
15
+ readSpawnClockTicks?: (pid: number) => Promise<string>;
16
+ signalProcess?: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
17
+ }
18
+
19
+ export class ProcessLauncher {
20
+ private readonly spawnProcess: typeof spawn;
21
+ private readonly checkPiAvailable: () => void;
22
+ private readonly readSpawnClockTicksFn: (pid: number) => Promise<string>;
23
+ readonly signalProcess: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
24
+
25
+ constructor(options: ProcessLauncherOptions = {}) {
26
+ this.spawnProcess = options.spawnProcess ?? spawn;
27
+ this.checkPiAvailable = options.checkPiAvailable ?? defaultPiAvailabilityCheck;
28
+ this.readSpawnClockTicksFn = options.readSpawnClockTicks ?? defaultReadSpawnClockTicks;
29
+ this.signalProcess = options.signalProcess ?? defaultSignalProcess;
30
+ }
31
+
32
+ assertAvailable(): void {
33
+ this.checkPiAvailable();
34
+ }
35
+
36
+ async launch(runDir: string, cwd: string, onChildCreated?: (child: ChildProcess) => void): Promise<LaunchResult> {
37
+ const stdoutHandle = await open(join(runDir, "stdout.log"), "a");
38
+ const stderrHandle = await open(join(runDir, "stderr.log"), "a");
39
+ let child: ChildProcess | undefined;
40
+
41
+ try {
42
+ child = this.spawnProcess("pi", ["--session-dir", runDir, "-p", `@${join(runDir, "brief.md")}`], {
43
+ cwd,
44
+ detached: true,
45
+ stdio: ["ignore", stdoutHandle.fd, stderrHandle.fd],
46
+ });
47
+ onChildCreated?.(child);
48
+
49
+ await waitForChildSpawn(child);
50
+
51
+ if (typeof child.pid !== "number") {
52
+ throw new Error("Sub-agent process did not expose a PID");
53
+ }
54
+
55
+ const spawnClockTicks = await this.readSpawnClockTicksFn(child.pid);
56
+ child.unref();
57
+
58
+ return { child, pid: child.pid, spawnClockTicks };
59
+ } catch (err) {
60
+ if (typeof child?.pid === "number") {
61
+ try {
62
+ this.signalProcess(child.pid, "SIGTERM");
63
+ } catch {}
64
+ }
65
+ throw err;
66
+ } finally {
67
+ await Promise.allSettled([stdoutHandle.close(), stderrHandle.close()]);
68
+ }
69
+ }
70
+ }
71
+
72
+ function defaultPiAvailabilityCheck(): void {
73
+ if (piAvailableCache === true) return;
74
+ // No negative caching — retry on every spawn so installing pi mid-session works
75
+ try {
76
+ execFileSync("which", ["pi"], { stdio: "pipe" });
77
+ piAvailableCache = true;
78
+ } catch {
79
+ throw new Error("pi executable not found in PATH");
80
+ }
81
+ }
82
+
83
+ let piAvailableCache: boolean | undefined;
84
+
85
+ function defaultSignalProcess(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
86
+ process.kill(pid, signal);
87
+ }
88
+
89
+ async function waitForChildSpawn(child: ChildProcess): Promise<void> {
90
+ await new Promise<void>((resolve, reject) => {
91
+ const onSpawn = (): void => {
92
+ child.off("error", onError);
93
+ resolve();
94
+ };
95
+ const onError = (err: Error): void => {
96
+ child.off("spawn", onSpawn);
97
+ reject(err);
98
+ };
99
+ child.once("spawn", onSpawn);
100
+ child.once("error", onError);
101
+ });
102
+ }
@@ -0,0 +1,64 @@
1
+ import { RunStore } from "./run-store";
2
+ import type { RunStatus, TerminalStatus } from "./types";
3
+
4
+ type CompletionListener = (status: RunStatus) => Promise<void> | void;
5
+
6
+ export interface RunFinalizerOptions {
7
+ store: RunStore;
8
+ now: () => Date;
9
+ }
10
+
11
+ export class RunFinalizer {
12
+ private readonly store: RunStore;
13
+ private readonly now: () => Date;
14
+ private readonly completionListeners = new Set<CompletionListener>();
15
+ private readonly finalizingRuns = new Map<string, Promise<RunStatus>>();
16
+
17
+ constructor(options: RunFinalizerOptions) {
18
+ this.store = options.store;
19
+ this.now = options.now;
20
+ }
21
+
22
+ onCompletion(listener: CompletionListener): () => void {
23
+ this.completionListeners.add(listener);
24
+ return () => { this.completionListeners.delete(listener); };
25
+ }
26
+
27
+ async finalizeRun(
28
+ runId: string,
29
+ status: TerminalStatus,
30
+ extra: { exitCode?: number; notify?: boolean },
31
+ ): Promise<RunStatus> {
32
+ const inFlight = this.finalizingRuns.get(runId);
33
+ if (inFlight) return inFlight;
34
+
35
+ const finalization = (async (): Promise<RunStatus> => {
36
+ const latest = await this.store.read(runId);
37
+ if (!latest) throw new Error(`Unknown sub-agent run: ${runId}`);
38
+ if (latest.status !== "running") return latest;
39
+
40
+ const updated: RunStatus = {
41
+ ...latest,
42
+ status,
43
+ completedAt: this.now().toISOString(),
44
+ exitCode: extra.exitCode ?? latest.exitCode,
45
+ };
46
+
47
+ await this.store.write(updated);
48
+ if (extra.notify !== false) {
49
+ await Promise.allSettled(
50
+ [...this.completionListeners].map((listener) => Promise.resolve(listener(updated))),
51
+ );
52
+ }
53
+ return updated;
54
+ })();
55
+
56
+ this.finalizingRuns.set(runId, finalization);
57
+ try {
58
+ return await finalization;
59
+ } finally {
60
+ this.finalizingRuns.delete(runId);
61
+ }
62
+ }
63
+ }
64
+
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { RunStatus } from "./types";
5
+
6
+ const RUN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7
+
8
+ export function validateRunId(runId: string): string {
9
+ if (!RUN_ID_RE.test(runId)) {
10
+ throw new Error(`Invalid sub-agent run ID: ${runId}`);
11
+ }
12
+ return runId;
13
+ }
14
+
15
+ export class RunStore {
16
+ private readonly subagentsRoot: string;
17
+
18
+ constructor(dataRoot: string) {
19
+ this.subagentsRoot = join(dataRoot, "subagents");
20
+ }
21
+
22
+ getRunDir(runId: string): string {
23
+ return join(this.subagentsRoot, validateRunId(runId));
24
+ }
25
+
26
+ async read(runId: string): Promise<RunStatus | null> {
27
+ try {
28
+ const raw = await readFile(join(this.getRunDir(runId), "status.json"), "utf8");
29
+ return JSON.parse(raw) as RunStatus;
30
+ } catch (err: any) {
31
+ if (err?.code === "ENOENT") return null;
32
+ throw err;
33
+ }
34
+ }
35
+
36
+ async write(status: RunStatus): Promise<void> {
37
+ const dir = this.getRunDir(status.runId);
38
+ await mkdir(dir, { recursive: true });
39
+ await atomicWriteJson(join(dir, "status.json"), status);
40
+ }
41
+
42
+ async listDirs(): Promise<string[]> {
43
+ try {
44
+ const entries = await readdir(this.subagentsRoot, { withFileTypes: true });
45
+ return entries
46
+ .filter((e) => e.isDirectory() && RUN_ID_RE.test(e.name))
47
+ .map((e) => e.name);
48
+ } catch (err: any) {
49
+ if (err?.code === "ENOENT") return [];
50
+ throw err;
51
+ }
52
+ }
53
+
54
+ async writeFile(runId: string, filename: string, content: string): Promise<void> {
55
+ const dir = this.getRunDir(runId);
56
+ await mkdir(dir, { recursive: true });
57
+ await atomicWriteText(join(dir, filename), content);
58
+ }
59
+
60
+ async writeJson(runId: string, filename: string, value: unknown): Promise<void> {
61
+ const dir = this.getRunDir(runId);
62
+ await mkdir(dir, { recursive: true });
63
+ await atomicWriteJson(join(dir, filename), value);
64
+ }
65
+ }
66
+
67
+ async function atomicWriteJson(path: string, value: unknown): Promise<void> {
68
+ await atomicWriteText(path, JSON.stringify(value, null, 2) + "\n");
69
+ }
70
+
71
+ async function atomicWriteText(path: string, content: string): Promise<void> {
72
+ const tmp = `${path}.tmp.${randomUUID()}`;
73
+ try {
74
+ await writeFile(tmp, content, { mode: 0o600 });
75
+ await rename(tmp, path);
76
+ } catch (err) {
77
+ try { await unlink(tmp); } catch {}
78
+ throw err;
79
+ }
80
+ }
@@ -0,0 +1,87 @@
1
+ import type { RunStatus, TerminalStatus } from "./types";
2
+ import { RunStore } from "./run-store";
3
+
4
+ const TERMINATE_GRACE_MS = 10_000;
5
+
6
+ type RequestedOutcome = NonNullable<RunStatus["requestedOutcome"]>;
7
+
8
+ export interface TerminationHandlerOptions {
9
+ store: RunStore;
10
+ isProcessAlive: (pid: number, expectedTicks: string) => Promise<boolean>;
11
+ signalProcess: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
12
+ finalizeRun: (
13
+ runId: string,
14
+ status: TerminalStatus,
15
+ extra: { exitCode?: number; notify?: boolean },
16
+ ) => Promise<RunStatus>;
17
+ }
18
+
19
+ export class TerminationHandler {
20
+ private readonly store: RunStore;
21
+ private readonly isProcessAlive: (pid: number, expectedTicks: string) => Promise<boolean>;
22
+ private readonly signalProcess: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
23
+ private readonly finalizeRun: (
24
+ runId: string,
25
+ status: TerminalStatus,
26
+ extra: { exitCode?: number; notify?: boolean },
27
+ ) => Promise<RunStatus>;
28
+
29
+ constructor(options: TerminationHandlerOptions) {
30
+ this.store = options.store;
31
+ this.isProcessAlive = options.isProcessAlive;
32
+ this.signalProcess = options.signalProcess;
33
+ this.finalizeRun = options.finalizeRun;
34
+ }
35
+
36
+ async terminateRun(runId: string, outcome: RequestedOutcome): Promise<RunStatus | null> {
37
+ const current = await this.store.read(runId);
38
+ if (!current || current.status !== "running") return current;
39
+ const updated = await this.persistRequestedOutcome(current, outcome);
40
+
41
+ const alive = await this.isProcessAlive(updated.pid, updated.spawnClockTicks);
42
+ if (!alive) {
43
+ return this.finalizeRun(runId, this.terminalStatusFor(updated), {});
44
+ }
45
+
46
+ try {
47
+ this.signalProcess(updated.pid, "SIGTERM");
48
+ } catch {
49
+ return this.finalizeRun(runId, this.terminalStatusFor(updated), {});
50
+ }
51
+
52
+ setTimeout(() => {
53
+ void this.escalateTermination(runId, updated.pid, updated.spawnClockTicks);
54
+ }, TERMINATE_GRACE_MS);
55
+
56
+ return updated;
57
+ }
58
+
59
+ async escalateTermination(runId: string, pid: number, spawnClockTicks: string): Promise<void> {
60
+ const current = await this.store.read(runId);
61
+ if (!current || current.status !== "running") return;
62
+ if (current.pid !== pid || current.spawnClockTicks !== spawnClockTicks) return;
63
+
64
+ const alive = await this.isProcessAlive(pid, spawnClockTicks);
65
+ if (!alive) return;
66
+
67
+ try {
68
+ this.signalProcess(pid, "SIGKILL");
69
+ } catch {}
70
+ }
71
+
72
+ terminalStatusFor(status: RunStatus): TerminalStatus {
73
+ if (status.requestedOutcome === "timeout") return "timeout";
74
+ // "aborted" maps to "failed" because there's no "aborted" terminal status in the
75
+ // RunStatus union — abort is an intent, "failed" is the observable outcome.
76
+ if (status.requestedOutcome === "aborted") return "failed";
77
+ return "failed";
78
+ }
79
+
80
+ async persistRequestedOutcome(current: RunStatus, requestedOutcome: RequestedOutcome): Promise<RunStatus> {
81
+ if (current.requestedOutcome === requestedOutcome) return current;
82
+ const updated: RunStatus = { ...current, requestedOutcome };
83
+ await this.store.write(updated);
84
+ return updated;
85
+ }
86
+ }
87
+
@@ -0,0 +1,57 @@
1
+ export type SubAgentRole = "review" | "research" | "scout" | "implementation";
2
+
3
+ /** Terminal states for a sub-agent run (excludes "running") */
4
+ export type TerminalStatus = Exclude<RunStatus["status"], "running">;
5
+
6
+ export interface RoutingInfo {
7
+ transport: "telegram";
8
+ chatId: string;
9
+ topicId?: string;
10
+ parentThreadId: string;
11
+ }
12
+
13
+ export interface SpawnSpec {
14
+ role: SubAgentRole;
15
+ task: string;
16
+ cwd: string;
17
+ routing: RoutingInfo;
18
+ context?: {
19
+ briefing?: string;
20
+ targetFiles?: string[];
21
+ completionContract?: string;
22
+ };
23
+ model?: string;
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ export interface RunStatus {
28
+ runId: string;
29
+ role: SubAgentRole;
30
+ cwd: string;
31
+ routing: RoutingInfo;
32
+ status: "running" | "complete" | "failed" | "timeout";
33
+ requestedOutcome?: "aborted" | "timeout";
34
+ pid: number;
35
+ startedAt: string;
36
+ deadlineAt?: string;
37
+ completedAt?: string;
38
+ exitCode?: number;
39
+ spawnClockTicks: string;
40
+ }
41
+
42
+ /** Public API for consumers (gateway, commands, agent tools) */
43
+ export interface SubAgentOrchestrator {
44
+ spawn(spec: SpawnSpec): Promise<string>;
45
+ status(runId: string): Promise<RunStatus | null>;
46
+ list(): Promise<RunStatus[]>;
47
+ abort(runId: string): Promise<void>;
48
+ }
49
+
50
+ /** Internal API used by SubAgentWatcher for lifecycle management */
51
+ export interface SubAgentLifecycle {
52
+ listRaw(): Promise<RunStatus[]>;
53
+ recoverRun(runId: string): Promise<RunStatus | null>;
54
+ enforceTimeout(runId: string): Promise<RunStatus | null>;
55
+ isRunManagedInProcess(runId: string): boolean;
56
+ onCompletion(listener: (status: RunStatus) => Promise<void> | void): () => void;
57
+ }
@@ -0,0 +1,63 @@
1
+ import type { RoutingInfo, RunStatus, SubAgentLifecycle } from "./types";
2
+
3
+ export class SubAgentWatcher {
4
+ private timer: ReturnType<typeof setInterval> | null = null;
5
+ private unsubscribe: (() => void) | null = null;
6
+ private polling = false;
7
+
8
+ constructor(
9
+ private readonly orchestrator: SubAgentLifecycle,
10
+ private readonly notifyCompletion: (status: RunStatus, routing: RoutingInfo) => Promise<void> | void,
11
+ private readonly pollIntervalMs = 5000,
12
+ ) {}
13
+
14
+ start(): void {
15
+ if (this.timer) return;
16
+
17
+ this.unsubscribe = this.orchestrator.onCompletion((status) =>
18
+ this.notifyCompletion(status, status.routing),
19
+ );
20
+
21
+ this.timer = setInterval(() => {
22
+ void this.poll().catch((err) => {
23
+ console.error("[roundhouse] subagent watcher poll error:", err);
24
+ });
25
+ }, this.pollIntervalMs);
26
+ }
27
+
28
+ stop(): void {
29
+ if (this.timer) {
30
+ clearInterval(this.timer);
31
+ this.timer = null;
32
+ }
33
+
34
+ if (this.unsubscribe) {
35
+ this.unsubscribe();
36
+ this.unsubscribe = null;
37
+ }
38
+ }
39
+
40
+ private async poll(): Promise<void> {
41
+ if (this.polling) return;
42
+ this.polling = true;
43
+
44
+ try {
45
+ const statuses = await this.orchestrator.listRaw();
46
+ const now = Date.now();
47
+
48
+ for (const status of statuses) {
49
+ if (status.status !== "running") continue;
50
+ if (this.orchestrator.isRunManagedInProcess(status.runId)) continue;
51
+
52
+ if (status.deadlineAt && Date.parse(status.deadlineAt) <= now) {
53
+ await this.orchestrator.enforceTimeout(status.runId);
54
+ } else {
55
+ // Detect crashed out-of-process children and finalize with notification
56
+ await this.orchestrator.recoverRun(status.runId);
57
+ }
58
+ }
59
+ } finally {
60
+ this.polling = false;
61
+ }
62
+ }
63
+ }
@@ -50,12 +50,12 @@ export class TelegramAdapter implements TransportAdapter {
50
50
  return isTelegramThread(thread as any);
51
51
  }
52
52
 
53
- async notify(chatIds: number[], text: string): Promise<void> {
53
+ async notify(chatIds: number[], text: string, options?: { parseMode?: string }): Promise<void> {
54
54
  if (!process.env.TELEGRAM_BOT_TOKEN) {
55
55
  console.warn("[roundhouse] TELEGRAM_BOT_TOKEN not set — skipping notification");
56
56
  return;
57
57
  }
58
- await sendTelegramToMany(chatIds, text);
58
+ await sendTelegramToMany(chatIds, text, options);
59
59
  }
60
60
 
61
61
  async isPairingPending(): Promise<boolean> {
@@ -54,7 +54,7 @@ export interface TransportAdapter {
54
54
  ownsThread(thread: ChatThread): boolean;
55
55
 
56
56
  /** Send notifications to configured recipients */
57
- notify(chatIds: number[], text: string): Promise<void>;
57
+ notify(chatIds: number[], text: string, options?: { parseMode?: string }): Promise<void>;
58
58
 
59
59
  /**
60
60
  * Check if a pairing flow is pending.