pi-famulus 0.1.0
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/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/pi-famulus.js +29 -0
- package/package.json +49 -0
- package/src/agents/builtins.ts +41 -0
- package/src/agents/definition.ts +221 -0
- package/src/agents/loader.ts +119 -0
- package/src/bash-override.ts +372 -0
- package/src/behavior-guidelines.ts +42 -0
- package/src/clock.ts +169 -0
- package/src/comms/comms.ts +199 -0
- package/src/comms/mailbox.ts +156 -0
- package/src/comms/registry-host.ts +73 -0
- package/src/comms/reply-command.ts +52 -0
- package/src/comms/routing.ts +23 -0
- package/src/comms/tools.ts +339 -0
- package/src/comms/types.ts +68 -0
- package/src/config.ts +266 -0
- package/src/events.ts +61 -0
- package/src/exit-notify-gate.ts +69 -0
- package/src/exit-watchdog.ts +60 -0
- package/src/file-tail.ts +48 -0
- package/src/format.ts +276 -0
- package/src/index.ts +700 -0
- package/src/manager-client.ts +839 -0
- package/src/monitor-batching.ts +191 -0
- package/src/monitor.ts +517 -0
- package/src/native-manager.d.ts +9 -0
- package/src/native-manager.js +39 -0
- package/src/notify.ts +231 -0
- package/src/shell-exec.ts +145 -0
- package/src/subagent/agent-records.ts +168 -0
- package/src/subagent/child-bash.ts +183 -0
- package/src/subagent/conversation.ts +76 -0
- package/src/subagent/fleet-widget.ts +176 -0
- package/src/subagent/model-spec.ts +108 -0
- package/src/subagent/pi-runtime.ts +295 -0
- package/src/subagent/pool.ts +190 -0
- package/src/subagent/registry.ts +575 -0
- package/src/subagent/runner.ts +709 -0
- package/src/subagent/tool.ts +741 -0
- package/src/subagent/transcript.ts +71 -0
- package/src/subagent/types.ts +125 -0
- package/src/task-exit-notify.ts +21 -0
- package/src/task-tools.ts +330 -0
- package/src/tui/message-renderers.ts +183 -0
- package/src/tui/pi-tui-load.ts +186 -0
- package/src/tui/scroll-detail-view.ts +190 -0
- package/src/tui/task-output-paths.ts +46 -0
- package/src/tui/tasks-command.ts +515 -0
- package/src/tui/tool-component.ts +29 -0
- package/src/wake.ts +219 -0
- package/src/work-index.ts +182 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live child transcripts for the CLI (`pi-famulus agent ch_…`, `show ch_…`).
|
|
3
|
+
*
|
|
4
|
+
* Layout: <home>/sessions/<session_id>/agents/<child_id>.jsonl, one JSON object
|
|
5
|
+
* per conversation turn: { ts, role, text, tool?, isError? }. Lines are only
|
|
6
|
+
* ever appended; a turn already written is never rewritten.
|
|
7
|
+
*/
|
|
8
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import type { Clock } from "../clock";
|
|
11
|
+
import { agentRecordsDir } from "./agent-records";
|
|
12
|
+
import type { ConversationTurn } from "./types";
|
|
13
|
+
|
|
14
|
+
/** Per-line text cap; keeps every line well under the 4 KiB event-line budget used elsewhere. */
|
|
15
|
+
const MAX_TEXT_CHARS = 3000;
|
|
16
|
+
|
|
17
|
+
export function transcriptPath(home: string, sessionId: string, childId: string): string {
|
|
18
|
+
return `${agentRecordsDir(home, sessionId)}/${childId}.jsonl`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function capText(text: string): string {
|
|
22
|
+
return text.length <= MAX_TEXT_CHARS ? text : `${text.slice(0, MAX_TEXT_CHARS)}… (${text.length} chars)`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `tool read` / `tool bash error` → tool name + error flag. */
|
|
26
|
+
function toolOf(role: string): { tool: string; isError: boolean } | undefined {
|
|
27
|
+
const m = /^tool (\S+)( error)?$/.exec(role);
|
|
28
|
+
return m ? { tool: m[1], isError: m[2] !== undefined } : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function transcriptLine(turn: ConversationTurn, ts: number): string {
|
|
32
|
+
const tool = toolOf(turn.role);
|
|
33
|
+
const modelError = turn.role === "assistant error";
|
|
34
|
+
const line = tool
|
|
35
|
+
? { ts, role: "tool", tool: tool.tool, text: capText(turn.text), ...(tool.isError ? { isError: true } : {}) }
|
|
36
|
+
: { ts, role: modelError ? "assistant" : turn.role, text: capText(turn.text), ...(modelError ? { isError: true } : {}) };
|
|
37
|
+
return `${JSON.stringify(line)}\n`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TranscriptWriter {
|
|
41
|
+
private readonly written = new Map<string, number>();
|
|
42
|
+
private readonly toolCalls = new Map<string, number>();
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly home: string,
|
|
46
|
+
private readonly clock: Clock,
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
/** Append turns not yet written. Returns the transcript path. */
|
|
50
|
+
sync(sessionId: string, childId: string, turns: readonly ConversationTurn[]): string {
|
|
51
|
+
const path = transcriptPath(this.home, sessionId, childId);
|
|
52
|
+
const done = this.written.get(childId) ?? 0;
|
|
53
|
+
if (turns.length <= done) return path;
|
|
54
|
+
const fresh = turns.slice(done);
|
|
55
|
+
const ts = this.clock.now();
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
58
|
+
appendFileSync(path, fresh.map((t) => transcriptLine(t, ts)).join(""));
|
|
59
|
+
} catch {
|
|
60
|
+
return path; // best-effort: a later sync retries the same turns
|
|
61
|
+
}
|
|
62
|
+
this.written.set(childId, turns.length);
|
|
63
|
+
const tools = fresh.filter((t) => toolOf(t.role) !== undefined).length;
|
|
64
|
+
this.toolCalls.set(childId, (this.toolCalls.get(childId) ?? 0) + tools);
|
|
65
|
+
return path;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
toolCallCount(childId: string): number {
|
|
69
|
+
return this.toolCalls.get(childId) ?? 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M3 subagent core types (design doc Appendix B).
|
|
3
|
+
*
|
|
4
|
+
* This module is pi-free: no runtime dependency on
|
|
5
|
+
* `@earendil-works/pi-coding-agent` (and no type imports either), so every
|
|
6
|
+
* consumer stays testable with plain fakes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mirror of the M5 `AgentDefinition` (design doc Appendix B, src/agents/).
|
|
11
|
+
* Structurally compatible with the M5 definition, so the real agent loader
|
|
12
|
+
* can be wired in at integration time without changes here.
|
|
13
|
+
*/
|
|
14
|
+
export interface AgentDefinition {
|
|
15
|
+
name: string; // ^[a-z][a-z0-9-]*$
|
|
16
|
+
description: string; // required, non-empty
|
|
17
|
+
tools: string[]; // default ["read","bash","edit","write"]
|
|
18
|
+
model?: string; // "provider:id" | bare id
|
|
19
|
+
thinking?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
20
|
+
systemPrompt: string; // frontmatter body, trimmed
|
|
21
|
+
source: "builtin" | "user" | "project";
|
|
22
|
+
path?: string; // builtins have no path
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ChildStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
|
|
26
|
+
|
|
27
|
+
export interface ChildResult {
|
|
28
|
+
status: "completed" | "failed" | "interrupted";
|
|
29
|
+
text: string; // getLastAssistantText() or "(no output)"
|
|
30
|
+
error?: string;
|
|
31
|
+
/** Non-fatal caveat, e.g. agent-definition model fell back to parent model. */
|
|
32
|
+
warning?: string;
|
|
33
|
+
/** Set when the model itself stopped with an error (provider failure). */
|
|
34
|
+
endReason?: "model-error";
|
|
35
|
+
/**
|
|
36
|
+
* Total generations run (1 + resumes + stall retries). Omitted when 1.
|
|
37
|
+
* Note: a user resume() also increments this; use `stalls` to count
|
|
38
|
+
* stall retries specifically.
|
|
39
|
+
*/
|
|
40
|
+
attempts?: number;
|
|
41
|
+
/** Stall detections in the user turn that produced this result. */
|
|
42
|
+
stalls?: number;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One turn of a child session, for the /tasks conversation view. */
|
|
47
|
+
export interface ConversationTurn {
|
|
48
|
+
role: string;
|
|
49
|
+
text: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ChildRunRequest {
|
|
53
|
+
childId: string; // assigned by the registry: "ch_" + 8
|
|
54
|
+
runId: string; // "run_" + 8
|
|
55
|
+
name: string; // display name (tasks[].name or agent name or ordinal)
|
|
56
|
+
prompt: string; // actual first prompt sent to the session, including agent preamble
|
|
57
|
+
/** User-authored task prompt without agent or model preamble. */
|
|
58
|
+
taskPrompt?: string;
|
|
59
|
+
agent: AgentDefinition; // already resolved
|
|
60
|
+
model?: string; // subagent() parameter-level override
|
|
61
|
+
timeoutMs: number;
|
|
62
|
+
depth: number; // main session = 0, child = 1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ChildHandle {
|
|
66
|
+
readonly childId: string;
|
|
67
|
+
readonly result: Promise<ChildResult>; // resolves exactly once per generation;
|
|
68
|
+
// after resume() the new generation's promise is exposed via
|
|
69
|
+
// registry.getResult() (and via this getter, which always returns the
|
|
70
|
+
// current generation's promise).
|
|
71
|
+
steer(message: string): Promise<void>; // while running; terminal -> throw
|
|
72
|
+
followUp(message: string): Promise<void>; // same, queued delivery
|
|
73
|
+
resume(message: string): Promise<void>; // terminal -> continue running
|
|
74
|
+
interrupt(): Promise<void>; // abort; result resolves as interrupted
|
|
75
|
+
status(): ChildStatus;
|
|
76
|
+
lastEventAt(): number; // for the stall watchdog / status display
|
|
77
|
+
/** Resolved `provider/id` once the child session has been constructed. */
|
|
78
|
+
resolvedModel(): string | undefined;
|
|
79
|
+
/** Live child transcript. Empty when the session never started. */
|
|
80
|
+
conversation(): ConversationTurn[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Child session adapter — the dynamic-import product of pi-runtime.ts is
|
|
85
|
+
* wrapped into this interface; tests use fakes.
|
|
86
|
+
*/
|
|
87
|
+
export interface ChildSessionAdapter {
|
|
88
|
+
/** Non-fatal setup caveat (e.g. model fallback); copied to ChildResult. */
|
|
89
|
+
readonly warning?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Resolved model label (`provider/id`) after session construction.
|
|
92
|
+
* Used for fleet/ls persistence and so the child prompt can name its model.
|
|
93
|
+
*/
|
|
94
|
+
readonly resolvedModel?: string;
|
|
95
|
+
prompt(text: string): Promise<void>;
|
|
96
|
+
steer(text: string): Promise<void>;
|
|
97
|
+
followUp(text: string): Promise<void>;
|
|
98
|
+
abort(): Promise<void>;
|
|
99
|
+
waitForIdle(): Promise<void>;
|
|
100
|
+
getLastAssistantText(): string | undefined;
|
|
101
|
+
/** Provider/model failure outcome of the last assistant turn, when any. */
|
|
102
|
+
getLastAssistantFailure?(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
|
|
103
|
+
getConversation(): ConversationTurn[];
|
|
104
|
+
/** Introspection for child-session orchestration and contract tests. */
|
|
105
|
+
getActiveToolNames?(): string[];
|
|
106
|
+
getSystemPrompt?(): string;
|
|
107
|
+
isStreaming(): boolean;
|
|
108
|
+
subscribe(listener: (event: { type: string }) => void): () => void;
|
|
109
|
+
dispose(): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type CreateSessionFn = (req: ChildRunRequest) => Promise<ChildSessionAdapter>;
|
|
113
|
+
|
|
114
|
+
export interface ChildRunner {
|
|
115
|
+
start(req: ChildRunRequest): Promise<ChildHandle>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extension beyond Appendix B: handles produced by InProcessRunner own a
|
|
120
|
+
* live session that must be disposed on run/session teardown. The registry
|
|
121
|
+
* duck-types this to release sessions in disposeRun().
|
|
122
|
+
*/
|
|
123
|
+
export interface DisposableChildHandle extends ChildHandle {
|
|
124
|
+
dispose(): void;
|
|
125
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether a manager task_exited should become a parent-session
|
|
3
|
+
* <pi-famulus-wake kind="task"> (design doc §4.2 / §4.5 / §4.6).
|
|
4
|
+
*
|
|
5
|
+
* - Monitors are handled by MonitorRegistry (not this path).
|
|
6
|
+
* - Parent bash only opts in via markNotifyOnExit when it actually
|
|
7
|
+
* backgrounded the command (budget exceeded / run_in_background / lost contact).
|
|
8
|
+
* - Child-bash always sync-waits and must not wake the parent — otherwise every
|
|
9
|
+
* subagent shell completion is mis-shown as a parent "Background command".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function shouldNotifyTaskExit(opts: {
|
|
13
|
+
taskId: string;
|
|
14
|
+
/** True when MonitorRegistry owns this task_id. */
|
|
15
|
+
isMonitor: boolean;
|
|
16
|
+
/** Task ids the parent bash marked for exit wake. */
|
|
17
|
+
notifyOnExit: ReadonlySet<string>;
|
|
18
|
+
}): boolean {
|
|
19
|
+
if (opts.isMonitor) return false;
|
|
20
|
+
return opts.notifyOnExit.has(opts.taskId);
|
|
21
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task_* tools (design doc §4.3): inspect and stop manager tasks.
|
|
3
|
+
*/
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { realClock, type Clock } from "./clock";
|
|
6
|
+
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { ManagerClient, TaskRecord } from "./manager-client";
|
|
8
|
+
import {
|
|
9
|
+
formatAgentCommand,
|
|
10
|
+
isAgentStatusActive,
|
|
11
|
+
loadAgentChildRecords,
|
|
12
|
+
} from "./subagent/agent-records";
|
|
13
|
+
import type { SubagentRegistry } from "./subagent/registry";
|
|
14
|
+
import type { WorkIndex, WorkItem } from "./work-index";
|
|
15
|
+
import { formatAge } from "./work-index";
|
|
16
|
+
|
|
17
|
+
export interface TaskToolsDeps {
|
|
18
|
+
getClient: () => ManagerClient | null;
|
|
19
|
+
/** Optional: merge in-process subagent children into task_list (§4.3). */
|
|
20
|
+
getRegistry?: () => SubagentRegistry | null;
|
|
21
|
+
/** Fleet /tasks / task_list share this index when present. */
|
|
22
|
+
getIndex?: () => WorkIndex | null;
|
|
23
|
+
home?: string;
|
|
24
|
+
sessionId?: () => string;
|
|
25
|
+
clock?: Clock;
|
|
26
|
+
/** Settle work the manager reports as ended (lost exit events). */
|
|
27
|
+
syncWithManager?: () => Promise<unknown>;
|
|
28
|
+
/** task_stop on a monitor: its leftover lines and exit should not wake the model. */
|
|
29
|
+
noteStopRequested?: (taskId: string) => (() => void) | void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requireClient(deps: TaskToolsDeps): Promise<ManagerClient> {
|
|
33
|
+
const client = deps.getClient();
|
|
34
|
+
if (!client) {
|
|
35
|
+
return Promise.reject(
|
|
36
|
+
new Error("pi-famulus is not available in this session; task tools are disabled"),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return client.ensureAvailable().then((ok) => {
|
|
40
|
+
if (!ok) {
|
|
41
|
+
throw new Error("pi-famulus is not available in this session; task tools are disabled");
|
|
42
|
+
}
|
|
43
|
+
return client;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function agentTextFor(deps: TaskToolsDeps, id: string): string | undefined {
|
|
48
|
+
const indexed = deps.getIndex?.()?.get(id);
|
|
49
|
+
const isAgent = indexed?.kind === "agent" || id.startsWith("ch_");
|
|
50
|
+
if (!isAgent && !deps.getRegistry?.()?.handle(id)) return undefined;
|
|
51
|
+
const handle = deps.getRegistry?.()?.handle(id);
|
|
52
|
+
const convo = handle?.conversation?.();
|
|
53
|
+
if (convo && convo.length > 0) {
|
|
54
|
+
const text = convo.map((t) => `${t.role}: ${t.text}`).join("\n");
|
|
55
|
+
return indexed?.error ? `${text}\n\nError: ${indexed.error}` : text;
|
|
56
|
+
}
|
|
57
|
+
if (indexed?.text || indexed?.error) {
|
|
58
|
+
return [indexed.text, indexed.error ? `Error: ${indexed.error}` : undefined].filter(Boolean).join("\n\n");
|
|
59
|
+
}
|
|
60
|
+
if (isAgent) return "";
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatDuration(startedAt: number, endedAt: number | null, now: number): string {
|
|
65
|
+
const end = endedAt ?? now;
|
|
66
|
+
const ms = Math.max(0, end - startedAt);
|
|
67
|
+
if (ms < 1000) return `${ms}ms`;
|
|
68
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
69
|
+
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatTaskLine(task: TaskRecord, now: number): string {
|
|
73
|
+
const exit =
|
|
74
|
+
task.status === "running"
|
|
75
|
+
? `pid=${task.pid}`
|
|
76
|
+
: task.exit_code !== null
|
|
77
|
+
? `exit=${task.exit_code}`
|
|
78
|
+
: (task.signal ?? "done");
|
|
79
|
+
const command = task.command.replace(/\s+/g, " ").trim();
|
|
80
|
+
const shortCommand = command.length > 100 ? `${command.slice(0, 99)}…` : command;
|
|
81
|
+
return (
|
|
82
|
+
`${task.task_id} [${task.kind}] ${task.status} (${exit}, ${formatDuration(task.started_at, task.ended_at, now)})` +
|
|
83
|
+
` "${shortCommand}"` +
|
|
84
|
+
`\n output: ${task.output_path} (${task.output_size} bytes)`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const END_TURN_HINT =
|
|
89
|
+
'The task is still running. Reply to the user now with no tool call; a <pi-famulus-wake> arrives when it finishes.';
|
|
90
|
+
const LIST_END_TURN_HINT =
|
|
91
|
+
"Running work wakes you with a <pi-famulus-wake> when it finishes. Reply to the user now with no tool call instead of checking again.";
|
|
92
|
+
|
|
93
|
+
const taskListParameters = Type.Object({
|
|
94
|
+
all: Type.Optional(
|
|
95
|
+
Type.Boolean({ description: "Also include finished work (default: running only). Always limited to this session." }),
|
|
96
|
+
),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export function createTaskListTool(
|
|
100
|
+
deps: TaskToolsDeps,
|
|
101
|
+
): ToolDefinition<typeof taskListParameters, { tasks: TaskRecord[]; agents: string[] }> {
|
|
102
|
+
return {
|
|
103
|
+
name: "task_list",
|
|
104
|
+
label: "Task List",
|
|
105
|
+
description:
|
|
106
|
+
"List background work this session started: shell/monitor tasks and subagent children. " +
|
|
107
|
+
"Work from other pi sessions is never shown.",
|
|
108
|
+
promptSnippet: "List background shell/monitor tasks and subagents",
|
|
109
|
+
parameters: taskListParameters,
|
|
110
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
111
|
+
const client = await requireClient(deps);
|
|
112
|
+
await deps.syncWithManager?.();
|
|
113
|
+
const tasks = await client.list(params.all === true);
|
|
114
|
+
const index = deps.getIndex?.() ?? null;
|
|
115
|
+
const now = (deps.clock ?? realClock).now();
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
let running = false;
|
|
119
|
+
|
|
120
|
+
const pushItem = (item: WorkItem) => {
|
|
121
|
+
if (seen.has(item.id)) return;
|
|
122
|
+
if (!params.all && item.status !== "running" && item.status !== "pending") return;
|
|
123
|
+
seen.add(item.id);
|
|
124
|
+
const age = formatAge(item.startedAt, item.endedAt, now);
|
|
125
|
+
running ||= item.status === "running" || item.status === "pending";
|
|
126
|
+
lines.push(`${item.id} [${item.kind}] ${item.status} (${age}) "${item.title}"`);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (index) {
|
|
130
|
+
for (const item of index.list()) pushItem(item);
|
|
131
|
+
}
|
|
132
|
+
for (const task of tasks) {
|
|
133
|
+
if (seen.has(task.task_id)) continue;
|
|
134
|
+
if (!params.all && task.status !== "running") continue;
|
|
135
|
+
// Sync-waited shells are not in the index and must not be listed as workers.
|
|
136
|
+
if (task.kind === "shell" && !index?.get(task.task_id)) continue;
|
|
137
|
+
seen.add(task.task_id);
|
|
138
|
+
running ||= task.status === "running";
|
|
139
|
+
lines.push(formatTaskLine(task, now));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const agentLines: string[] = [];
|
|
143
|
+
const live = deps.getRegistry?.()?.activeChildren() ?? [];
|
|
144
|
+
const liveIds = new Set(live.map((c) => c.childId));
|
|
145
|
+
if (deps.home) {
|
|
146
|
+
const own = deps.sessionId?.();
|
|
147
|
+
// No session id means no scope to apply: show nothing rather than everyone's agents.
|
|
148
|
+
// Live children are listed from the registry above. A "running" record
|
|
149
|
+
// it does not hold was left by an earlier pi process of this session.
|
|
150
|
+
const disk = !own ? [] : loadAgentChildRecords(deps.home, {
|
|
151
|
+
sessionId: own,
|
|
152
|
+
includeTerminal: params.all === true,
|
|
153
|
+
connectedSessionIds: new Set<string>(),
|
|
154
|
+
});
|
|
155
|
+
for (const rec of disk) {
|
|
156
|
+
if (liveIds.has(rec.child_id) || seen.has(rec.child_id)) continue;
|
|
157
|
+
if (!params.all && !isAgentStatusActive(rec.status)) continue;
|
|
158
|
+
running ||= isAgentStatusActive(rec.status);
|
|
159
|
+
agentLines.push(
|
|
160
|
+
`${rec.child_id} [agent] ${rec.status} (run=${rec.run_id}) "${formatAgentCommand(rec).replace(/^agent:/, "")}"` +
|
|
161
|
+
(rec.error ? ` — error: ${rec.error}` : ""),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (lines.length === 0 && agentLines.length === 0) {
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text", text: "No tasks." }],
|
|
169
|
+
details: { tasks: [], agents: [] },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const header = `${lines.length + agentLines.length} background item(s):`;
|
|
173
|
+
const body = [...lines, ...agentLines];
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text: [header, ...body, ...(running ? ["", LIST_END_TURN_HINT] : [])].join("\n") }],
|
|
176
|
+
details: { tasks, agents: agentLines },
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const taskOutputParameters = Type.Object({
|
|
183
|
+
task_id: Type.String({ description: "Task id, e.g. sh_a1b2c3d4" }),
|
|
184
|
+
cursor: Type.Optional(
|
|
185
|
+
Type.Number({
|
|
186
|
+
description:
|
|
187
|
+
"Byte offset to read from (for incremental reads). Omit to read the tail of the output.",
|
|
188
|
+
}),
|
|
189
|
+
),
|
|
190
|
+
max_bytes: Type.Optional(
|
|
191
|
+
Type.Number({ description: "Maximum bytes to return (default 65536)" }),
|
|
192
|
+
),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
export interface TaskOutputDetails {
|
|
196
|
+
task_id: string;
|
|
197
|
+
status: string;
|
|
198
|
+
exit_code: number | null;
|
|
199
|
+
cursor: number;
|
|
200
|
+
next_cursor: number;
|
|
201
|
+
total_size: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const DEFAULT_OUTPUT_BYTES = 65536;
|
|
205
|
+
|
|
206
|
+
export function createTaskOutputTool(
|
|
207
|
+
deps: TaskToolsDeps,
|
|
208
|
+
): ToolDefinition<typeof taskOutputParameters, TaskOutputDetails> {
|
|
209
|
+
// total_size at the last read of each running task. A repeat read that finds
|
|
210
|
+
// the same size is polling: the prompt forbids it, but models still loop.
|
|
211
|
+
const lastRunningSize = new Map<string, number>();
|
|
212
|
+
const guardPoll = (id: string, running: boolean, size: number) => {
|
|
213
|
+
if (running && lastRunningSize.get(id) === size) {
|
|
214
|
+
throw new Error(`No new output from ${id} since your last read. ${END_TURN_HINT}`);
|
|
215
|
+
}
|
|
216
|
+
if (running) lastRunningSize.set(id, size);
|
|
217
|
+
else lastRunningSize.delete(id);
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
name: "task_output",
|
|
221
|
+
label: "Task Output",
|
|
222
|
+
description:
|
|
223
|
+
"Read output of a background task. Without a cursor, returns the tail of the output " +
|
|
224
|
+
"plus the current file pointer; pass the returned next_cursor as cursor for incremental reads. " +
|
|
225
|
+
"Not a way to wait: a running task wakes you when it finishes, so reply with no tool call instead of calling this again.",
|
|
226
|
+
promptSnippet: "Read background task output",
|
|
227
|
+
parameters: taskOutputParameters,
|
|
228
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
229
|
+
const agentText = agentTextFor(deps, params.task_id);
|
|
230
|
+
if (agentText !== undefined) {
|
|
231
|
+
const status = deps.getIndex?.()?.get(params.task_id)?.status ?? "unknown";
|
|
232
|
+
const running = status === "pending" || status === "running";
|
|
233
|
+
guardPoll(params.task_id, running, agentText.length);
|
|
234
|
+
const body = (agentText || "(no output yet)") + (running ? `\n\n${END_TURN_HINT}` : "");
|
|
235
|
+
return {
|
|
236
|
+
content: [{ type: "text", text: body }],
|
|
237
|
+
details: {
|
|
238
|
+
task_id: params.task_id,
|
|
239
|
+
status,
|
|
240
|
+
exit_code: null,
|
|
241
|
+
cursor: 0,
|
|
242
|
+
next_cursor: 0,
|
|
243
|
+
total_size: body.length,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const client = await requireClient(deps);
|
|
248
|
+
const maxBytes =
|
|
249
|
+
params.max_bytes !== undefined && params.max_bytes > 0
|
|
250
|
+
? Math.floor(params.max_bytes)
|
|
251
|
+
: DEFAULT_OUTPUT_BYTES;
|
|
252
|
+
|
|
253
|
+
let cursor = params.cursor;
|
|
254
|
+
if (cursor === undefined) {
|
|
255
|
+
// Tail read: probe total size, then read the last max_bytes window.
|
|
256
|
+
const probe = await client.output(params.task_id, 0, 1);
|
|
257
|
+
cursor = Math.max(0, probe.total_size - maxBytes);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const res = await client.output(params.task_id, cursor, maxBytes);
|
|
261
|
+
const running = res.status === "running";
|
|
262
|
+
guardPoll(params.task_id, running, res.total_size);
|
|
263
|
+
const details: TaskOutputDetails = {
|
|
264
|
+
task_id: params.task_id,
|
|
265
|
+
status: res.status,
|
|
266
|
+
exit_code: res.exit_code,
|
|
267
|
+
cursor,
|
|
268
|
+
next_cursor: res.next_cursor,
|
|
269
|
+
total_size: res.total_size,
|
|
270
|
+
};
|
|
271
|
+
const body = res.chunk || "(no output yet)";
|
|
272
|
+
const footer =
|
|
273
|
+
`\n\n[task ${params.task_id} status=${res.status}` +
|
|
274
|
+
` exit_code=${res.exit_code === null ? "null" : res.exit_code}` +
|
|
275
|
+
` cursor=${cursor} next_cursor=${res.next_cursor} total_size=${res.total_size}]` +
|
|
276
|
+
(running ? `\n${END_TURN_HINT}` : "");
|
|
277
|
+
return { content: [{ type: "text", text: body + footer }], details };
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const taskStopParameters = Type.Object({
|
|
283
|
+
task_id: Type.String({ description: "Task id to stop, e.g. sh_a1b2c3d4" }),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
export function createTaskStopTool(
|
|
287
|
+
deps: TaskToolsDeps,
|
|
288
|
+
): ToolDefinition<typeof taskStopParameters, { task_id: string; stopped: boolean }> {
|
|
289
|
+
return {
|
|
290
|
+
name: "task_stop",
|
|
291
|
+
label: "Task Stop",
|
|
292
|
+
description:
|
|
293
|
+
"Stop a running background task (SIGTERM to the process group, SIGKILL after a 2s grace).",
|
|
294
|
+
promptSnippet: "Stop a background task",
|
|
295
|
+
parameters: taskStopParameters,
|
|
296
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx: ExtensionContext) {
|
|
297
|
+
const agent = deps.getRegistry?.()?.handle(params.task_id);
|
|
298
|
+
if (agent || params.task_id.startsWith("ch_") || deps.getIndex?.()?.get(params.task_id)?.kind === "agent") {
|
|
299
|
+
if (!agent) {
|
|
300
|
+
throw new Error(`no live subagent ${params.task_id}`);
|
|
301
|
+
}
|
|
302
|
+
await agent.interrupt();
|
|
303
|
+
return {
|
|
304
|
+
content: [{ type: "text", text: `Subagent ${params.task_id} interrupted.` }],
|
|
305
|
+
details: { task_id: params.task_id, stopped: true },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const client = await requireClient(deps);
|
|
309
|
+
const undoNote = deps.noteStopRequested?.(params.task_id);
|
|
310
|
+
try {
|
|
311
|
+
await client.stop(params.task_id, "tool");
|
|
312
|
+
} catch (err) {
|
|
313
|
+
undoNote?.();
|
|
314
|
+
throw err;
|
|
315
|
+
} finally {
|
|
316
|
+
// Stopping a task that already ended emits no new exit event.
|
|
317
|
+
await deps.syncWithManager?.();
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
content: [
|
|
321
|
+
{
|
|
322
|
+
type: "text",
|
|
323
|
+
text: `Task ${params.task_id} stopped (SIGTERM; SIGKILL after 2s grace if needed).`,
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
details: { task_id: params.task_id, stopped: true },
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|