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.
- package/CHANGELOG.md +37 -0
- package/README.ja.md +3 -2
- package/README.ko.md +3 -2
- package/README.md +3 -2
- package/README.zh.md +3 -2
- package/package.json +1 -1
- package/src/agent/ast-edit-tool.ts +2 -2
- package/src/agent/engine.ts +44 -7
- package/src/agent/job-registry.ts +186 -28
- package/src/agent/job-tool.ts +1 -1
- package/src/agent/lsp-rename-tool.ts +2 -2
- package/src/agent/monitor-tool.ts +51 -0
- package/src/agent/subagent-registry.ts +2 -2
- package/src/agent/subagent-tool.ts +1 -1
- package/src/agent/subagents.ts +2 -2
- package/src/agent/task-tool.ts +74 -12
- package/src/agent/tool-schemas.ts +16 -0
- package/src/agent/tools.ts +36 -4
- package/src/ai/providers/anthropic.ts +31 -6
- package/src/commands/launch/slash-handlers.ts +2 -0
- package/src/commands/launch/stream.ts +16 -1
- package/src/commands/launch.ts +38 -8
- package/src/tui/app.ts +57 -41
- package/src/tui/components/forge.ts +31 -0
- package/src/types/playwriter.d.ts +15 -0
|
@@ -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
|
|
15
|
-
*
|
|
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:
|
|
78
|
+
return { success: true, output: `${head}\n\n${recs.map(detailBlock).join("\n\n")}` };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
if (action === "cancel") {
|
package/src/agent/subagents.ts
CHANGED
|
@@ -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
|
}
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -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
|
|
174
|
-
|
|
175
|
-
`
|
|
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
|
|
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?.(
|
|
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
|
-
|
|
559
|
-
|
|
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 (
|
|
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, {
|
|
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.",
|
package/src/agent/tools.ts
CHANGED
|
@@ -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 <=
|
|
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
|
|
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,
|