jeo-code 0.8.42 → 0.8.43

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,51 @@
1
+ import type { ToolHandler } from "./engine";
2
+ import type { ToolResult } from "./tools";
3
+ import type { JobRegistry, MonitorCategory } from "./job-registry";
4
+
5
+ /** One-line protocol description appended to the launch system prompt. */
6
+ export const MONITOR_TOOL_PROTOCOL_LINE =
7
+ "monitor {command, kind, description, timeout?, persistent?} starts a turn-scoped stdout monitor. " +
8
+ "Each newline-terminated stdout line is a notification; non-persistent monitors stop after the first line. " +
9
+ "Use job {action:\"list\"|\"tail\"|\"await\"|\"cancel\", ids?} with its returned job id to inspect or cancel it.";
10
+
11
+ const MONITOR_CATEGORIES: readonly MonitorCategory[] = ["log", "poll", "watch", "other"];
12
+
13
+ export function createMonitorTool(registry: JobRegistry): ToolHandler {
14
+ return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
15
+ const command = typeof args.command === "string" ? args.command.trim() : "";
16
+ if (!command) {
17
+ return { success: false, output: "", error: "monitor requires a non-empty 'command'." };
18
+ }
19
+
20
+ const description = typeof args.description === "string" ? args.description.trim() : "";
21
+ if (!description) {
22
+ return { success: false, output: "", error: "monitor requires a non-empty 'description'." };
23
+ }
24
+
25
+ const kind = args.kind;
26
+ if (typeof kind !== "string" || !MONITOR_CATEGORIES.includes(kind as MonitorCategory)) {
27
+ return { success: false, output: "", error: "monitor requires 'kind' to be exactly log, poll, watch, or other." };
28
+ }
29
+
30
+ if (args.timeout !== undefined && (typeof args.timeout !== "number" || !Number.isFinite(args.timeout) || args.timeout <= 0)) {
31
+ return { success: false, output: "", error: "monitor 'timeout' must be a finite positive number of seconds." };
32
+ }
33
+
34
+ if (args.persistent !== undefined && typeof args.persistent !== "boolean") {
35
+ return { success: false, output: "", error: "monitor 'persistent' must be a boolean." };
36
+ }
37
+
38
+ const resolvedCwd = typeof args.cwd === "string" && args.cwd.trim() ? args.cwd : cwd;
39
+ const record = registry.startMonitor(command, resolvedCwd, {
40
+ category: kind as MonitorCategory,
41
+ description,
42
+ persistent: args.persistent ?? false,
43
+ timeout: args.timeout,
44
+ });
45
+ return {
46
+ success: true,
47
+ output: `[monitor] started '${record.id}' (persistent: ${record.persistent}). ` +
48
+ "Use the job tool to inspect, await, or cancel it.",
49
+ };
50
+ };
51
+ }
@@ -11,8 +11,8 @@
11
11
  * can push a message into a running detached subagent's inbox; the subagent's own
12
12
  * agent loop drains it between steps, same mechanism as ordinary task steering.
13
13
  *
14
- * Lifecycle is bounded to the turn that created the registry — `cancelAll()` on
15
- * turn teardown guarantees no background promise leaks into the next turn.
14
+ * Lifecycle is bounded to the originating turn: `cancelAll()` requests cooperative
15
+ * abort on teardown. A filesystem operation already committing cannot be rolled back.
16
16
  */
17
17
  import type { ToolResult } from "./tools";
18
18
 
@@ -75,7 +75,7 @@ export function createSubagentTool(registry: SubagentRegistry): ToolHandler {
75
75
  const head = stillRunning > 0
76
76
  ? `Awaited ${recs.length} subagent(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again or cancel.`
77
77
  : `Awaited ${recs.length} subagent(s); all settled.`;
78
- return { success: stillRunning === 0, output: `${head}\n\n${recs.map(detailBlock).join("\n\n")}` };
78
+ return { success: true, output: `${head}\n\n${recs.map(detailBlock).join("\n\n")}` };
79
79
  }
80
80
 
81
81
  if (action === "cancel") {
@@ -302,7 +302,7 @@ export function subagentToolset(role: SubagentRole): Record<string, ToolHandler>
302
302
  if (prefixes && prefixes.length > 0) {
303
303
  const inner = DEFAULT_TOOLS.bash;
304
304
  const guarded: Record<string, ToolHandler> = { ...DEFAULT_TOOLS };
305
- guarded.bash = async (a, cwd) => {
305
+ guarded.bash = async (a, cwd, onProgress, signal) => {
306
306
  const command = String(a.command ?? a.cmd ?? "");
307
307
  if (!bashCommandAllowed(command, prefixes)) {
308
308
  return {
@@ -313,7 +313,7 @@ export function subagentToolset(role: SubagentRole): Record<string, ToolHandler>
313
313
  `Received: ${command.trim().slice(0, 120)}`,
314
314
  };
315
315
  }
316
- return inner(a, cwd);
316
+ return inner(a, cwd, onProgress, signal);
317
317
  };
318
318
  return guarded;
319
319
  }
@@ -52,6 +52,10 @@ export type SubagentTaskConfig = RoutingConfig & Pick<Config, "subagents" | "thi
52
52
  /** Lifecycle event emitted while a delegated subagent runs. */
53
53
  export interface TaskSubEvent {
54
54
  role: string;
55
+ /** Stable registry id for a detached run; absent for synchronous work. */
56
+ id?: string;
57
+ /** True when this event belongs to a detached registry run. */
58
+ detached?: boolean;
55
59
  /** `"thinking"` streams the subagent's live reasoning/thought text (native
56
60
  * extended-thinking models) — a transient live-preview beat, not persisted to
57
61
  * the ledger (mirrors the main turn's dimmed "Thinking" block, scoped per
@@ -108,6 +112,9 @@ export interface TaskToolOptions {
108
112
  * should either run sequentially (separate `task` calls) or coordinate — jeo has
109
113
  * no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
110
114
  export const MAX_FANOUT = 4;
115
+ /** Task-tool policy: cap independently launched detached runs at four per turn.
116
+ * The registry remains reusable by other execution paths with their own limits. */
117
+ const MAX_ACTIVE_DETACHED_SUBAGENTS = 4;
111
118
 
112
119
  /** Small bounded reroute budget for a SUBAGENT's own 429 (distinct from
113
120
  * launch.ts's `ROUTE_FALLBACK_MAX_ATTEMPTS`, default 3): a subagent is already
@@ -182,7 +189,7 @@ function createSteerHub(drain?: () => string[]) {
182
189
  * across the board, not just for the JSON-in-prose fallback providers. */
183
190
  export function taskToolProtocolLine(config?: Pick<Config, "subagents">): string {
184
191
  return (
185
- `task single: {role?, task, context?} — fan-out: {role?, tasks:["task 1", "task 2", ...]} ` +
192
+ `task single: {role?, task, context?, detached?:true} — fan-out: {role?, tasks:["task 1", "task 2", ...]} (fan-out cannot be detached) ` +
186
193
  `— delegate to a subagent (role: ${subagentRoleIds(config).join("|")}; executor can edit, planner/architect/critic are read-only). ` +
187
194
  `'tasks' MUST be a real JSON array (of strings, or {task, context?} objects) — NEVER a JSON-stringified array, NEVER a single task string. ` +
188
195
  `All items in 'tasks' run concurrently (bounded); scope each executor task to disjoint files (no shared-file coordination channel between concurrent tasks — use sequential task calls if scopes overlap). Integrate the findings yourself.`
@@ -234,6 +241,8 @@ export interface RunSubagentOptions {
234
241
  steer?: () => string[];
235
242
  /** 1-based position within a fan-out/parallel batch, for event tagging. */
236
243
  slot?: { index: number; total: number };
244
+ /** Stable registry id for a detached run's lifecycle events. */
245
+ detachedId?: string;
237
246
  /** Pre-loaded project context to avoid re-scanning AGENTS.md per batch item. */
238
247
  projectContext?: ProjectContextFile[];
239
248
  /** BATCH-scoped (not per-call) credential scopes already known exhausted this
@@ -301,11 +310,14 @@ export async function runSubagentOnce(
301
310
  cwd: string,
302
311
  opts: RunSubagentOptions,
303
312
  ): Promise<SubagentRunResult> {
304
- const { steer, slot, projectContext: preloadedContext, signal } = opts;
305
- // Tag every live event with its fan-out slot so a parent monitor can tell
306
- // task 1 from task 3 when several same-role subagents stream concurrently.
313
+ const { steer, slot, detachedId, projectContext: preloadedContext, signal } = opts;
314
+ // Tag live events with their fan-out slot and/or detached registry identity.
307
315
  const emit = (ev: TaskSubEvent) =>
308
- opts.onEvent?.(slot ? { ...ev, index: slot.index, total: slot.total } : ev);
316
+ opts.onEvent?.({
317
+ ...ev,
318
+ ...(slot ? { index: slot.index, total: slot.total } : {}),
319
+ ...(detachedId ? { id: detachedId, detached: true } : {}),
320
+ });
309
321
  const initialModel = opts.modelOverride || resolveSubagentModel(role.id, opts.config);
310
322
  const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
311
323
  // gjc parity: a role may pin its own reasoning budget; absent = inherit the
@@ -537,6 +549,8 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
537
549
  extra: {
538
550
  steer?: () => string[];
539
551
  slot?: { index: number; total: number };
552
+ /** Stable registry id for detached-run lifecycle events. */
553
+ detachedId?: string;
540
554
  projectContext?: ProjectContextFile[];
541
555
  /** Overrides opts.signal — a detached run uses its own registry signal so it
542
556
  * is cancellable independently of the parent turn. */
@@ -554,6 +568,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
554
568
  onEvent: opts.onEvent,
555
569
  steer: extra.steer,
556
570
  slot: extra.slot,
571
+ detachedId: extra.detachedId,
557
572
  projectContext: extra.projectContext,
558
573
  excludedCredentialScopes: extra.excludedCredentialScopes,
559
574
  modelOverride: extra.modelOverride,
@@ -567,6 +582,13 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
567
582
  }
568
583
  const ctx = (c: unknown) => (typeof c === "string" && c.trim() ? `\n\nContext:\n${c.trim()}` : "");
569
584
 
585
+ // Native/function-call adapters normally preserve boolean values, but the prose-only
586
+ // protocol can still yield the exact string "true" from otherwise valid model JSON.
587
+ // This is an unambiguous serialization mistake; treating it as synchronous defeats
588
+ // detached dispatch while the parent waits for the very task it intended to background.
589
+ const detached = args.detached === true
590
+ || (typeof args.detached === "string" && args.detached.trim().toLowerCase() === "true");
591
+
570
592
  // Fan-out form: `tasks: [ "assignment" | {task|assignment|prompt, context?} ]`.
571
593
  // Live-reproduced (v0.8.42, multiple real models across two different providers,
572
594
  // including a native-tool-calling-capable one): even with an explicit, example-driven
@@ -578,6 +600,13 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
578
600
  ? (() => { try { const p = JSON.parse(args.tasks as string); return Array.isArray(p) ? p : args.tasks; } catch { return args.tasks; } })()
579
601
  : args.tasks;
580
602
  if (Array.isArray(tasksArg)) {
603
+ if (detached) {
604
+ return {
605
+ success: false,
606
+ output: "",
607
+ error: "Detached task fan-out is not supported. Launch each independent task with detached:true so each has its own lifecycle id and control handle.",
608
+ };
609
+ }
581
610
  const items = (tasksArg as unknown[])
582
611
  .map(entry => {
583
612
  if (typeof entry === "string") return { task: entry.trim(), context: "" };
@@ -683,9 +712,20 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
683
712
  // parent can keep working, then list/inspect/await/cancel via the `subagent`
684
713
  // tool. Live peer messaging (steer/irc) DOES reach a detached run — it drains
685
714
  // its own registry inbox (registry.steerDrainFor(id)) between its own steps.
686
- if (args.detached === true && opts.registry) {
715
+ if (detached && opts.registry) {
716
+ if (opts.registry.running().length >= MAX_ACTIVE_DETACHED_SUBAGENTS) {
717
+ return {
718
+ success: false,
719
+ output: "",
720
+ error: `Detached subagent limit reached (${MAX_ACTIVE_DETACHED_SUBAGENTS} running). Await or cancel a running subagent before launching another.`,
721
+ };
722
+ }
687
723
  const rec = opts.registry.launch(role.id, taskText, (signal, id) =>
688
- runOne(role, taskText, ctx(args.context), cwd, { signal, steer: opts.registry!.steerDrainFor(id) }),
724
+ runOne(role, taskText, ctx(args.context), cwd, {
725
+ signal,
726
+ steer: opts.registry!.steerDrainFor(id),
727
+ detachedId: id,
728
+ }),
689
729
  );
690
730
  // Remote subagent visibility/control over Telegram (gjc daemon parity, see
691
731
  // `src/agent/notify/`): best-effort, no-op unless `notifications.enabled`.
@@ -116,6 +116,22 @@ const SCHEMAS: Record<string, NativeToolSchema> = {
116
116
  required: ["calculations"],
117
117
  },
118
118
  },
119
+ monitor: {
120
+ name: "monitor",
121
+ description: "Start a bounded background stdout monitor. Non-persistent monitors stop after their first output line; control the returned job id with the job tool.",
122
+ parameters: {
123
+ type: "object",
124
+ properties: {
125
+ command: STRING,
126
+ kind: { type: "string", enum: ["log", "poll", "watch", "other"] },
127
+ description: STRING,
128
+ timeout: { type: "number" },
129
+ persistent: { type: "boolean" },
130
+ cwd: STRING,
131
+ },
132
+ required: ["command", "kind", "description"],
133
+ },
134
+ },
119
135
  done: {
120
136
  name: "done",
121
137
  description: "Call when the task is fully implemented AND verified. The reason is shown to the user as your message.",
@@ -111,6 +111,10 @@ export async function assertBashAllowed(
111
111
  }
112
112
  }
113
113
 
114
+ function cancelledMutationResult(signal?: AbortSignal): ToolResult | undefined {
115
+ return signal?.aborted ? { success: false, output: "", error: "Operation cancelled" } : undefined;
116
+ }
117
+
114
118
  /**
115
119
  * Parse a read line selector into sorted, merged, inclusive [start,end] ranges.
116
120
  * Segments are comma-separated; each is one of: "a-b" (range), "a-" (a→EOF),
@@ -312,15 +316,22 @@ export async function readTool(
312
316
  export async function writeTool(
313
317
  filePath: string,
314
318
  content: string,
315
- cwd: string = process.cwd()
319
+ cwd: string = process.cwd(),
320
+ signal?: AbortSignal,
316
321
  ): Promise<ToolResult> {
317
322
  try {
323
+ const aborted = cancelledMutationResult(signal);
324
+ if (aborted) return aborted;
318
325
  if (typeof filePath !== "string" || filePath.trim() === "") {
319
326
  return { success: false, output: "", error: 'write requires a non-empty "filePath".' };
320
327
  }
321
328
  await assertMutationAllowed(filePath, cwd);
329
+ const abortedAfterGuard = cancelledMutationResult(signal);
330
+ if (abortedAfterGuard) return abortedAfterGuard;
322
331
  const absPath = path.resolve(cwd, filePath);
323
332
  await fs.mkdir(path.dirname(absPath), { recursive: true });
333
+ const abortedBeforeWrite = cancelledMutationResult(signal);
334
+ if (abortedBeforeWrite) return abortedBeforeWrite;
324
335
  await fs.writeFile(absPath, content, "utf-8");
325
336
  markRead(absPath); // own change counts as having seen the file
326
337
  return { success: true, output: `Successfully wrote ${content.length} characters to ${filePath}` };
@@ -360,9 +371,12 @@ export function parseEditHunks(block: string): { search: string; replace: string
360
371
  export async function editTool(
361
372
  filePath: string,
362
373
  editBlock: string,
363
- cwd: string = process.cwd()
374
+ cwd: string = process.cwd(),
375
+ signal?: AbortSignal,
364
376
  ): Promise<ToolResult> {
365
377
  try {
378
+ const aborted = cancelledMutationResult(signal);
379
+ if (aborted) return aborted;
366
380
  if (typeof filePath !== "string" || filePath.trim() === "") {
367
381
  return { success: false, output: "", error: 'edit requires a non-empty "filePath".' };
368
382
  }
@@ -370,6 +384,8 @@ export async function editTool(
370
384
  return { success: false, output: "", error: 'edit requires a non-empty "editBlock" (≔ directive or <<<<<<< SEARCH block).' };
371
385
  }
372
386
  await assertMutationAllowed(filePath, cwd);
387
+ const abortedAfterGuard = cancelledMutationResult(signal);
388
+ if (abortedAfterGuard) return abortedAfterGuard;
373
389
  const absPath = path.resolve(cwd, filePath);
374
390
  let content = await fs.readFile(absPath, "utf-8");
375
391
 
@@ -581,6 +597,8 @@ export async function editTool(
581
597
  };
582
598
  }
583
599
 
600
+ const abortedBeforeWrite = cancelledMutationResult(signal);
601
+ if (abortedBeforeWrite) return abortedBeforeWrite;
584
602
  await fs.writeFile(absPath, content, "utf-8");
585
603
  markRead(absPath); // own change counts as having seen the file
586
604
  return { success: true, output: `Successfully updated ${filePath}` };
@@ -1076,18 +1094,25 @@ export async function lsTool(
1076
1094
  */
1077
1095
  export async function mkdirTool(
1078
1096
  dirPath: string,
1079
- cwd: string = process.cwd()
1097
+ cwd: string = process.cwd(),
1098
+ signal?: AbortSignal,
1080
1099
  ): Promise<ToolResult> {
1081
1100
  try {
1101
+ const aborted = cancelledMutationResult(signal);
1102
+ if (aborted) return aborted;
1082
1103
  if (typeof dirPath !== "string" || dirPath.trim() === "") {
1083
1104
  return { success: false, output: "", error: 'mkdir requires a non-empty "dirPath".' };
1084
1105
  }
1085
1106
  await assertMutationAllowed(dirPath, cwd);
1107
+ const abortedAfterGuard = cancelledMutationResult(signal);
1108
+ if (abortedAfterGuard) return abortedAfterGuard;
1086
1109
  const abs = path.resolve(cwd, dirPath);
1087
1110
  const existing = await fs.stat(abs).catch(() => null);
1088
1111
  if (existing && !existing.isDirectory()) {
1089
1112
  return { success: false, output: "", error: `Path exists and is not a directory: ${dirPath}` };
1090
1113
  }
1114
+ const abortedBeforeMkdir = cancelledMutationResult(signal);
1115
+ if (abortedBeforeMkdir) return abortedBeforeMkdir;
1091
1116
  await fs.mkdir(abs, { recursive: true });
1092
1117
  return { success: true, output: `Directory ready: ${dirPath}` };
1093
1118
  } catch (err: any) {
@@ -1105,13 +1130,18 @@ export async function mkdirTool(
1105
1130
  export async function deleteTool(
1106
1131
  targetPath: string,
1107
1132
  cwd: string = process.cwd(),
1108
- recursive: boolean = false
1133
+ recursive: boolean = false,
1134
+ signal?: AbortSignal,
1109
1135
  ): Promise<ToolResult> {
1110
1136
  try {
1137
+ const aborted = cancelledMutationResult(signal);
1138
+ if (aborted) return aborted;
1111
1139
  if (typeof targetPath !== "string" || targetPath.trim() === "") {
1112
1140
  return { success: false, output: "", error: 'delete requires a non-empty "path".' };
1113
1141
  }
1114
1142
  await assertMutationAllowed(targetPath, cwd);
1143
+ const abortedAfterGuard = cancelledMutationResult(signal);
1144
+ if (abortedAfterGuard) return abortedAfterGuard;
1115
1145
  const abs = path.resolve(cwd, targetPath);
1116
1146
  if (abs === path.resolve(cwd)) {
1117
1147
  return { success: false, output: "", error: "Refusing to delete the working directory itself." };
@@ -1123,6 +1153,8 @@ export async function deleteTool(
1123
1153
  if (st.isDirectory() && !recursive) {
1124
1154
  return { success: false, output: "", error: `${targetPath} is a directory — pass recursive:true to remove it and its contents.` };
1125
1155
  }
1156
+ const abortedBeforeDelete = cancelledMutationResult(signal);
1157
+ if (abortedBeforeDelete) return abortedBeforeDelete;
1126
1158
  await fs.rm(abs, { recursive, force: false });
1127
1159
  readThisSession.delete(abs); // a future edit against this path must read it again first
1128
1160
  return { success: true, output: `Deleted ${st.isDirectory() ? "directory" : "file"}: ${targetPath}` };
@@ -10,6 +10,7 @@ import { taskToolProtocolLine } from "../../agent/task-tool";
10
10
  import { TODO_TOOL_PROTOCOL_LINE } from "../../agent/todo-tool";
11
11
  import { SUBAGENT_TOOL_PROTOCOL_LINE } from "../../agent/subagent-tool";
12
12
  import { JOB_TOOL_PROTOCOL_LINE } from "../../agent/job-tool";
13
+ import { MONITOR_TOOL_PROTOCOL_LINE } from "../../agent/monitor-tool";
13
14
  import { IRC_TOOL_PROTOCOL_LINE } from "../../agent/irc-tool";
14
15
  import { GOAL_TOOL_PROTOCOL_LINE } from "../../agent/goal-tool";
15
16
  import { hotkeysLines, contextUsageLines } from "./slash-views";
@@ -50,6 +51,7 @@ export async function handleTools(ctx: SlashContext): Promise<SlashResult> {
50
51
  lines.push(` ${TODO_TOOL_PROTOCOL_LINE}`);
51
52
  lines.push(` ${SUBAGENT_TOOL_PROTOCOL_LINE}`);
52
53
  lines.push(` ${JOB_TOOL_PROTOCOL_LINE}`);
54
+ lines.push(` ${MONITOR_TOOL_PROTOCOL_LINE}`);
53
55
  lines.push(` ${IRC_TOOL_PROTOCOL_LINE}`);
54
56
  lines.push(` ${GOAL_TOOL_PROTOCOL_LINE}`);
55
57
  return { lines };
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { type AgentLoopEvents } from "../../agent/engine";
3
3
  import { type TaskSubEvent } from "../../agent/task-tool";
4
+ import { type MonitorJobEvent } from "../../agent/job-registry";
4
5
  import { categoryBadge } from "../../tui/components/category-index";
5
6
  import { summarizeForgeInvocation } from "../../tui/components/forge";
6
7
  import { formatDuration, formatUsage } from "../../tui/components/duration";
@@ -44,7 +45,8 @@ function streamResultSuffix(tool: string, ok: boolean, output: string | undefine
44
45
 
45
46
  export function formatTaskSubEvent(e: TaskSubEvent): string {
46
47
  const role = e.role || "subagent";
47
- const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
48
+ const detachedLabel = e.detached && e.id ? ` [${e.id}]` : "";
49
+ const roleLabel = `${e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase()}${detachedLabel}`;
48
50
  const tokTag = e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : "";
49
51
  const detail = firstOutputLine(e.detail);
50
52
  const summary = e.summary ? ` — ${e.summary}` : "";
@@ -66,6 +68,19 @@ export function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (
66
68
  const line = formatTaskSubEvent(e);
67
69
  if (line) log(line);
68
70
  }
71
+ export function formatMonitorJobEvent(e: MonitorJobEvent): string {
72
+ const label = `${e.record.id} · ${e.record.category} · ${e.record.description}`;
73
+ if (e.type === "start") return `${categoryBadge("progress")} ${chalk.cyan("monitor ▸")} ${label}`.slice(0, 240);
74
+ if (e.type === "line") {
75
+ const line = firstOutputLine(e.line);
76
+ return `${categoryBadge("progress")} ${chalk.cyan("monitor │")} ${label}${line ? ` — ${line}` : ""}`.slice(0, 240);
77
+ }
78
+ return `${categoryBadge("progress")} ${chalk.cyan("monitor └")} ${label} ${chalk.dim("done")}`.slice(0, 240);
79
+ }
80
+
81
+ export function logMonitorJobEvent(e: MonitorJobEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
82
+ log(formatMonitorJobEvent(e));
83
+ }
69
84
 
70
85
  export function createStreamEvents(
71
86
  _maxSteps: number,
@@ -19,7 +19,8 @@ import { parseInThreadConfigCommand } from "../agent/notify/config-commands";
19
19
 
20
20
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
21
21
  import { createJobTool, JOB_TOOL_PROTOCOL_LINE } from "../agent/job-tool";
22
- import { JobRegistry } from "../agent/job-registry";
22
+ import { JobRegistry, type MonitorJobEvent } from "../agent/job-registry";
23
+ import { createMonitorTool, MONITOR_TOOL_PROTOCOL_LINE } from "../agent/monitor-tool";
23
24
  import { createIrcTool, IRC_TOOL_PROTOCOL_LINE } from "../agent/irc-tool";
24
25
  import { createGoalTool, GOAL_TOOL_PROTOCOL_LINE } from "../agent/goal-tool";
25
26
  import { createApproveTool, APPROVE_TOOL_PROTOCOL_LINE } from "../agent/approve-tool";
@@ -185,6 +186,7 @@ import {
185
186
  gatedStdout,
186
187
  formatTaskSubEvent,
187
188
  logTaskSubEvent,
189
+ formatMonitorJobEvent,
188
190
  createStreamEvents,
189
191
  shouldUseOneShotTui,
190
192
  } from "./launch/stream";
@@ -560,7 +562,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
560
562
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
561
563
  const contextFiles = await loadProjectContext(cwd);
562
564
 
563
- const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "eval", "todo", "subagent", "job", "irc", "goal", "approve", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
565
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "eval", "todo", "subagent", "job", "monitor", "irc", "goal", "approve", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
564
566
  let allowedTools = new Set(KNOWN_TOOLS);
565
567
 
566
568
  if (flags.noTools) {
@@ -633,6 +635,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
633
635
  (allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
634
636
  " Launch background work with task {\"detached\": true, \"role\": <role>, \"task\": <assignment>}; it returns a subagent id immediately so you can keep working and collect the result later." : "") +
635
637
  (allowedTools.has("job") ? "\n\nBackground jobs: " + JOB_TOOL_PROTOCOL_LINE : "") +
638
+ (allowedTools.has("monitor") ? "\n\nBackground monitors: " + MONITOR_TOOL_PROTOCOL_LINE : "") +
636
639
  (allowedTools.has("irc") ? "\n\nPeer messaging: " + IRC_TOOL_PROTOCOL_LINE : "") +
637
640
  (allowedTools.has("goal") ? "\n\nGoal tracking: " + GOAL_TOOL_PROTOCOL_LINE : "") +
638
641
  (allowedTools.has("approve") ? "\n\nPlan approval: " + APPROVE_TOOL_PROTOCOL_LINE : "") +
@@ -1422,7 +1425,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1422
1425
  // #9: per-turn registry for DETACHED subagents (task{detached:true}); the
1423
1426
  // `subagent` tool controls them and cancelAll() in finally prevents orphans.
1424
1427
  const subagentRegistry = new SubagentRegistry();
1428
+ // TUI callbacks are scoped to this registry. cancelAll() may trigger a detached
1429
+ // run's final event after the turn is complete; do not let it paint a freshly
1430
+ // reset later TUI. Non-TUI streams retain direct event logging.
1431
+ let acceptingTuiSubagentEvents = true;
1432
+ const onSubagentEvent = useTui
1433
+ ? (e: TaskSubEvent) => {
1434
+ if (acceptingTuiSubagentEvents) tui?.onSubagentEvent(e);
1435
+ }
1436
+ : (e: TaskSubEvent) => logTaskSubEvent(e);
1425
1437
  const jobRegistry = new JobRegistry();
1438
+ // Monitor output stays bounded and turn-scoped. It deliberately feeds the
1439
+ // engine's observational inbox rather than the steering inbox.
1440
+ const monitorInbox: string[] = [];
1441
+ const MONITOR_INBOX_CAP = 32;
1442
+ let acceptingMonitorEvents = true;
1443
+ const onMonitorEvent = (e: MonitorJobEvent) => {
1444
+ if (!acceptingMonitorEvents) return;
1445
+ if (useTui) tui?.onMonitorEvent(e);
1446
+ else console.log(formatMonitorJobEvent(e));
1447
+ if (e.type !== "line" || monitorInbox.length >= MONITOR_INBOX_CAP) return;
1448
+ const description = e.record.description.replace(/\s+/g, " ").trim().slice(0, 240);
1449
+ const line = e.line.slice(0, 2_000);
1450
+ monitorInbox.push(`[monitor ${e.record.id} · ${e.record.category} · ${description}]\n${line}`);
1451
+ };
1452
+ const unsubscribeMonitor = jobRegistry.subscribeMonitor(onMonitorEvent);
1453
+ const drainMonitor = () => monitorInbox.splice(0, monitorInbox.length);
1426
1454
  sessionNotifyEndpoint?.attachRegistry(subagentRegistry);
1427
1455
  currentTurnSteer = { push: line => steerInbox.push(line), flushCard: line => tui?.flushSteerCard(line) };
1428
1456
  if (sessionNotifyEndpoint && !notifyRedact) {
@@ -1495,21 +1523,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1495
1523
  steer: drainSteer,
1496
1524
  registry: subagentRegistry,
1497
1525
  sessionEndpoint: sessionNotifyEndpoint,
1498
- onEvent: useTui
1499
- ? (e => tui?.onSubagentEvent(e))
1500
- : (e => logTaskSubEvent(e)),
1526
+ onEvent: onSubagentEvent,
1501
1527
  }),
1502
1528
  eval: createEvalTool({
1503
1529
  config: { ...turnConfig, defaultModel: activeModel },
1504
1530
  signal: ac.signal,
1505
1531
  steer: drainSteer,
1506
- onEvent: useTui
1507
- ? (e => tui?.onSubagentEvent(e))
1508
- : (e => logTaskSubEvent(e)),
1532
+ onEvent: onSubagentEvent,
1509
1533
  }),
1510
1534
  todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1511
1535
  subagent: createSubagentTool(subagentRegistry),
1512
1536
  job: createJobTool(jobRegistry),
1537
+ monitor: createMonitorTool(jobRegistry),
1513
1538
  irc: createIrcTool(subagentRegistry),
1514
1539
  goal: createGoalTool(),
1515
1540
  approve: createApproveTool(),
@@ -1615,6 +1640,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1615
1640
  signal: ac.signal,
1616
1641
  sessionKey: cacheKey,
1617
1642
  steer: drainSteer,
1643
+ monitor: drainMonitor,
1618
1644
  rateLimitFallbackAvailable,
1619
1645
  safetyFallbackAvailable,
1620
1646
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
@@ -1748,6 +1774,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1748
1774
  signal: ac.signal,
1749
1775
  sessionKey: turnSessionKey,
1750
1776
  steer: drainSteer,
1777
+ monitor: drainMonitor,
1751
1778
  // Deliberately NOT wiring rateLimitFallbackAvailable here: this repair
1752
1779
  // retry has no reroute loop around it (unlike runLoopWithModel's
1753
1780
  // routeFallbackAttempt loop) — bailing the retry ladder early on a 429
@@ -1772,11 +1799,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1772
1799
  await opik.endTurn({ done: result.done, steps: result.steps, output: result.doneReason });
1773
1800
  } finally {
1774
1801
  harness.dispose();
1802
+ acceptingTuiSubagentEvents = false;
1803
+ acceptingMonitorEvents = false;
1775
1804
  subagentRegistry.cancelAll(); // #9: no detached run leaks past the turn
1776
1805
  sessionNotifyEndpoint?.detachRegistry(); // this turn's registry is gone — subagent frames stop applying to it
1777
1806
  currentTurnSteer = undefined; // a remote message after this point queues instead of steering a dead turn
1778
1807
  await stopSessionNotifyEndpoint(subagentRegistry); // tear down the FALLBACK lazy endpoint, if one was started (no-op when sessionNotifyEndpoint owns the registry)
1779
1808
  jobRegistry.cancelAll(); // background jobs are turn-scoped too — no orphaned processes
1809
+ unsubscribeMonitor();
1780
1810
 
1781
1811
  // Steering typed but never drained (e.g. entered just after the final step)
1782
1812
  // must not be lost — fold it into the next prompt draft so it runs next.