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.
- package/CHANGELOG.md +23 -1
- package/README.ja.md +2 -1
- package/README.ko.md +2 -1
- package/README.md +2 -1
- package/README.zh.md +2 -1
- 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 +47 -7
- package/src/agent/tool-schemas.ts +16 -0
- package/src/agent/tools.ts +36 -4
- 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/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
|
|
@@ -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
|
|
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
|
|
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?.(
|
|
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 (
|
|
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, {
|
|
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.",
|
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}` };
|
|
@@ -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,
|
package/src/commands/launch.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|