jeo-code 0.8.41 → 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
@@ -167,12 +174,25 @@ function createSteerHub(drain?: () => string[]) {
167
174
  }
168
175
 
169
176
  /** One-line protocol description appended to the launch system prompt. Pass a
170
- * config so CONFIG-DECLARED custom roles are advertised to the model too. */
177
+ * config so CONFIG-DECLARED custom roles are advertised to the model too.
178
+ *
179
+ * The single-vs-fan-out shapes are spelled out as two SEPARATE literal
180
+ * examples (not the terse `task|tasks[]` shorthand this replaced) — live
181
+ * reproduction (multiple real models, including a native-tool-calling-capable
182
+ * one) showed models repeatedly sending `tasks` as a JSON-STRINGIFIED array
183
+ * or a single task string instead of a real JSON array, self-diagnosing
184
+ * "I'm passing tasks as a string instead of as an actual array" turn after
185
+ * turn until the loop guard stopped them. `task` has no native function-
186
+ * calling schema (its shape is too variable for one static schema) — EVERY
187
+ * provider, including native-schema-capable ones, learns this tool's shape
188
+ * from this exact prose line, so ambiguity here directly costs reliability
189
+ * across the board, not just for the JSON-in-prose fallback providers. */
171
190
  export function taskToolProtocolLine(config?: Pick<Config, "subagents">): string {
172
191
  return (
173
- `task {role, task|tasks[], context?} — delegate to a subagent ` +
174
- `(role: ${subagentRoleIds(config).join("|")}; executor can edit, planner/architect/critic are read-only). ` +
175
- `Pass 'tasks' (array) to fan out — ALL roles 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.`
192
+ `task single: {role?, task, context?, detached?:true} — fan-out: {role?, tasks:["task 1", "task 2", ...]} (fan-out cannot be detached) ` +
193
+ `— delegate to a subagent (role: ${subagentRoleIds(config).join("|")}; executor can edit, planner/architect/critic are read-only). ` +
194
+ `'tasks' MUST be a real JSON array (of strings, or {task, context?} objects) — NEVER a JSON-stringified array, NEVER a single task string. ` +
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.`
176
196
  );
177
197
  }
178
198
 
@@ -221,6 +241,8 @@ export interface RunSubagentOptions {
221
241
  steer?: () => string[];
222
242
  /** 1-based position within a fan-out/parallel batch, for event tagging. */
223
243
  slot?: { index: number; total: number };
244
+ /** Stable registry id for a detached run's lifecycle events. */
245
+ detachedId?: string;
224
246
  /** Pre-loaded project context to avoid re-scanning AGENTS.md per batch item. */
225
247
  projectContext?: ProjectContextFile[];
226
248
  /** BATCH-scoped (not per-call) credential scopes already known exhausted this
@@ -288,11 +310,14 @@ export async function runSubagentOnce(
288
310
  cwd: string,
289
311
  opts: RunSubagentOptions,
290
312
  ): Promise<SubagentRunResult> {
291
- const { steer, slot, projectContext: preloadedContext, signal } = opts;
292
- // Tag every live event with its fan-out slot so a parent monitor can tell
293
- // 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.
294
315
  const emit = (ev: TaskSubEvent) =>
295
- 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
+ });
296
321
  const initialModel = opts.modelOverride || resolveSubagentModel(role.id, opts.config);
297
322
  const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
298
323
  // gjc parity: a role may pin its own reasoning budget; absent = inherit the
@@ -524,6 +549,8 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
524
549
  extra: {
525
550
  steer?: () => string[];
526
551
  slot?: { index: number; total: number };
552
+ /** Stable registry id for detached-run lifecycle events. */
553
+ detachedId?: string;
527
554
  projectContext?: ProjectContextFile[];
528
555
  /** Overrides opts.signal — a detached run uses its own registry signal so it
529
556
  * is cancellable independently of the parent turn. */
@@ -541,6 +568,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
541
568
  onEvent: opts.onEvent,
542
569
  steer: extra.steer,
543
570
  slot: extra.slot,
571
+ detachedId: extra.detachedId,
544
572
  projectContext: extra.projectContext,
545
573
  excludedCredentialScopes: extra.excludedCredentialScopes,
546
574
  modelOverride: extra.modelOverride,
@@ -554,9 +582,32 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
554
582
  }
555
583
  const ctx = (c: unknown) => (typeof c === "string" && c.trim() ? `\n\nContext:\n${c.trim()}` : "");
556
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
+
557
592
  // Fan-out form: `tasks: [ "assignment" | {task|assignment|prompt, context?} ]`.
558
- if (Array.isArray(args.tasks)) {
559
- const items = (args.tasks as unknown[])
593
+ // Live-reproduced (v0.8.42, multiple real models across two different providers,
594
+ // including a native-tool-calling-capable one): even with an explicit, example-driven
595
+ // protocol line, models repeatedly send `tasks` as a JSON-STRINGIFIED array (correct
596
+ // content, wrong JSON type) rather than a real array — a classifiable, unambiguous
597
+ // mistake, not a genuinely different intent. Accept it rather than hard-reject: any
598
+ // string that itself parses to an array is exactly this mistake.
599
+ const tasksArg = typeof args.tasks === "string"
600
+ ? (() => { try { const p = JSON.parse(args.tasks as string); return Array.isArray(p) ? p : args.tasks; } catch { return args.tasks; } })()
601
+ : args.tasks;
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
+ }
610
+ const items = (tasksArg as unknown[])
560
611
  .map(entry => {
561
612
  if (typeof entry === "string") return { task: entry.trim(), context: "" };
562
613
  if (entry && typeof entry === "object") {
@@ -661,9 +712,20 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
661
712
  // parent can keep working, then list/inspect/await/cancel via the `subagent`
662
713
  // tool. Live peer messaging (steer/irc) DOES reach a detached run — it drains
663
714
  // its own registry inbox (registry.steerDrainFor(id)) between its own steps.
664
- 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
+ }
665
723
  const rec = opts.registry.launch(role.id, taskText, (signal, id) =>
666
- 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
+ }),
667
729
  );
668
730
  // Remote subagent visibility/control over Telegram (gjc daemon parity, see
669
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}` };
@@ -255,6 +255,7 @@ export function anthropicPayload(
255
255
  credential: Credential = { kind: "none", provider: "anthropic" },
256
256
  stripArtifacts = false,
257
257
  disableFallback = false,
258
+ disableEffort = false,
258
259
  ): string {
259
260
  const model = stripAnthropicPrefix(options.model);
260
261
  const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
@@ -306,16 +307,21 @@ export function anthropicPayload(
306
307
  // Opus/Sonnet 4.6+: the model decides how much to think. `display: "summarized"` is
307
308
  // REQUIRED on Opus 4.7+ or thinking content is omitted from the response (the empty-thought
308
309
  // bug); older adaptive models (4.6) reject the field, so it is gated. Effort rides
309
- // output_config — there is no budget_tokens on this transport.
310
+ // output_config — there is no budget_tokens on this transport. `disableEffort` (fail-safe
311
+ // retry after a live "does not support the effort parameter" 400 — some model variants
312
+ // classify as adaptive-transport by name/version but still reject this field) drops ONLY
313
+ // output_config; `thinking` alone is still valid, the model just uses its own default depth.
310
314
  payload.thinking = supportsAdaptiveThinkingDisplay(model)
311
315
  ? { type: "adaptive", display: "summarized" }
312
316
  : { type: "adaptive" };
313
- payload.output_config = { effort: anthropicAdaptiveEffort(effort) };
317
+ if (!disableEffort) payload.output_config = { effort: anthropicAdaptiveEffort(effort) };
314
318
  } else {
315
319
  // Budget-based extended thinking. `display: "summarized"` keeps human-readable thought
316
- // streaming. The 4.5 (budget-effort) transport also carries an output_config effort.
320
+ // streaming. The 4.5 (budget-effort) transport also carries an output_config effort —
321
+ // unless `disableEffort` is set (see the adaptive branch above for the same rationale;
322
+ // this mirrors Haiku 4.5's already-proven-working budget_tokens-without-effort combination).
317
323
  payload.thinking = { type: "enabled", budget_tokens: thinkingBudget, display: "summarized" };
318
- if (thinkingMode === "budget-effort") payload.output_config = { effort: anthropicAdaptiveEffort(effort) };
324
+ if (thinkingMode === "budget-effort" && !disableEffort) payload.output_config = { effort: anthropicAdaptiveEffort(effort) };
319
325
  }
320
326
  } else if (includeTemperature && options.temperature !== undefined) {
321
327
  payload.temperature = options.temperature;
@@ -345,13 +351,14 @@ export function anthropicRequest(
345
351
  includeTemperature: boolean,
346
352
  stripArtifacts = false,
347
353
  disableFallback = false,
354
+ disableEffort = false,
348
355
  ): { url: string; headers: Record<string, string>; body: string } {
349
356
  return {
350
357
  // Anthropic-compatible providers (z.ai, MiniMax, …) accept the Messages wire
351
358
  // format at their own host; an explicit baseUrl pins `${base}/v1/messages`.
352
359
  url: options.baseUrl ? `${options.baseUrl.replace(/\/$/, "")}/v1/messages` : ANTHROPIC_URL,
353
360
  headers: { ...headersFor(credential, stream, stripAnthropicPrefix(options.model), options.baseUrl, disableFallback), ...options.extraHeaders },
354
- body: anthropicPayload(messages, options, stream, includeTemperature, credential, stripArtifacts, disableFallback),
361
+ body: anthropicPayload(messages, options, stream, includeTemperature, credential, stripArtifacts, disableFallback, disableEffort),
355
362
  };
356
363
  }
357
364
 
@@ -376,6 +383,18 @@ function isFallbackUnsupportedError(status: number, detail: string): boolean {
376
383
  return status === 400 && /fallback/i.test(detail);
377
384
  }
378
385
 
386
+ /** A 400 naming "effort"/"output_config" means this specific model variant rejects the
387
+ * effort field even though its name/version classifies it onto a transport that normally
388
+ * carries one (live-reproduced: a rate-limit-fallback-selected dated snapshot,
389
+ * `claude-sonnet-4-5-20250929`, 400'd with "This model does not support the effort
390
+ * parameter" — the same wording already known for Haiku 4.5, but on a model this
391
+ * codebase's version-based classification did not expect to reject it). Retry once with
392
+ * `output_config` dropped (thinking/budget_tokens, if any, stay — only the effort field
393
+ * itself is unsupported) so the turn survives instead of dying on an avoidable 400. */
394
+ function isEffortUnsupportedError(status: number, detail: string): boolean {
395
+ return status === 400 && /effort/i.test(detail) && /output_config|not support/i.test(detail);
396
+ }
397
+
379
398
  /** gjc parity (getSafeAnthropicHeaderEvidence): fold Anthropic's rate-limit response
380
399
  * headers into a 429's error detail so the retry classifier can see them. The decisive
381
400
  * usage-exhaustion signal (`anthropic-ratelimit-unified-overage-disabled-reason:
@@ -401,8 +420,9 @@ async function postAnthropic(
401
420
  let includeTemperature = true;
402
421
  let stripArtifacts = false;
403
422
  let disableFallback = false;
423
+ let disableEffort = false;
404
424
 
405
- for (let attempt = 1; attempt <= 4; attempt++) {
425
+ for (let attempt = 1; attempt <= 5; attempt++) {
406
426
  const { url, headers, body } = anthropicRequest(
407
427
  messages,
408
428
  options,
@@ -411,6 +431,7 @@ async function postAnthropic(
411
431
  includeTemperature,
412
432
  stripArtifacts,
413
433
  disableFallback,
434
+ disableEffort,
414
435
  );
415
436
  const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
416
437
  if (response.ok) return response;
@@ -428,6 +449,10 @@ async function postAnthropic(
428
449
  disableFallback = true;
429
450
  continue;
430
451
  }
452
+ if (isEffortUnsupportedError(response.status, detail) && !disableEffort) {
453
+ disableEffort = true;
454
+ continue;
455
+ }
431
456
 
432
457
  // Unrecoverable error, or all retries for the active error type spent
433
458
  const enrichedDetail = response.status === 429 ? `${detail}${anthropicRateLimitEvidence(response.headers)}` : detail;
@@ -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,