pi-famulus 0.1.7 → 0.1.9
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/README.md +14 -7
- package/bin/pi-famulus.js +2 -2
- package/package.json +8 -8
- package/src/bash-override.ts +34 -26
- package/src/bounded-output.ts +167 -0
- package/src/comms/reply-command.ts +1 -1
- package/src/config.ts +2 -6
- package/src/format.ts +33 -7
- package/src/index.ts +39 -8
- package/src/manager-client.ts +24 -2
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/native-manager.d.ts +30 -0
- package/src/native-manager.js +260 -11
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- package/src/shell-exec.ts +28 -2
- package/src/shell-start.ts +25 -0
- package/src/subagent/child-bash.ts +2 -1
- package/src/subagent/conversation.ts +57 -12
- package/src/subagent/fleet-widget.ts +8 -8
- package/src/subagent/runner.ts +2 -2
- package/src/subagent/tool.ts +2 -1
- package/src/task-tools.ts +32 -13
- package/src/tui/conversation-view.ts +402 -0
- package/src/tui/message-renderers.ts +16 -11
- package/src/tui/scroll-detail-view.ts +218 -99
- package/src/tui/task-detail-view.ts +417 -0
- package/src/tui/tasks-command.ts +117 -84
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
package/src/shell-exec.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Previously copied in both files.
|
|
4
4
|
*/
|
|
5
5
|
import type { BashToolDetails } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { shellStartStatus } from "./shell-start";
|
|
6
7
|
import { truncateTail } from "./format";
|
|
7
8
|
import type { ManagerClient } from "./manager-client";
|
|
8
9
|
import { fill } from "./prompts.generated";
|
|
@@ -134,9 +135,34 @@ export function appendStatus(text: string, status: string): string {
|
|
|
134
135
|
* kill for a command still running (eval batch 4).
|
|
135
136
|
*/
|
|
136
137
|
export async function killedStatus(client: ManagerClient, taskId: string, timeoutSeconds: number | undefined): Promise<string> {
|
|
137
|
-
const record =
|
|
138
|
+
const record = await taskRecord(client, taskId);
|
|
138
139
|
if (record?.end_reason === "timeout" && timeoutSeconds !== undefined) return timedOutStatus(timeoutSeconds);
|
|
139
|
-
return killedBy(record?.signal);
|
|
140
|
+
return shellStartStatus(record?.end_reason) ?? killedBy(record?.signal);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Status line for a non-zero exit. On Windows the manager marks a Git Bash
|
|
145
|
+
* that crashed while starting (shell-start-failed); elsewhere that cannot
|
|
146
|
+
* happen, so skip the record lookup.
|
|
147
|
+
*/
|
|
148
|
+
export async function exitedStatus(
|
|
149
|
+
client: ManagerClient,
|
|
150
|
+
taskId: string,
|
|
151
|
+
exitCode: number,
|
|
152
|
+
platform: NodeJS.Platform = process.platform,
|
|
153
|
+
): Promise<string> {
|
|
154
|
+
const plain = `Command exited with code ${exitCode}`;
|
|
155
|
+
if (platform !== "win32") return plain;
|
|
156
|
+
const hint = shellStartStatus((await taskRecord(client, taskId))?.end_reason);
|
|
157
|
+
return hint ? `${hint} (exit code ${exitCode})` : plain;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function taskRecord(client: ManagerClient, taskId: string) {
|
|
161
|
+
try {
|
|
162
|
+
return (await client.list(true)).find((t) => t.task_id === taskId);
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
140
166
|
}
|
|
141
167
|
|
|
142
168
|
/** The one wording of a timeout kill, on every bash path. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows Git Bash start-up failures (manager end_reasons, see
|
|
3
|
+
* manager/src/shellstart.rs). Concurrent MSYS cold starts race while building
|
|
4
|
+
* the shared mount table (git-for-windows/git#6368). These reasons are
|
|
5
|
+
* inferred: a nested shell can print the same fatal text after the command
|
|
6
|
+
* has already had side effects, and a silent builtin-only command looks like
|
|
7
|
+
* a hung start. Say that, and do not tell the model the command did not run.
|
|
8
|
+
*/
|
|
9
|
+
export const SHELL_START_FAILED = "shell-start-failed";
|
|
10
|
+
export const SHELL_START_HUNG = "shell-start-hung";
|
|
11
|
+
|
|
12
|
+
/** The status line for a shell start-up failure, or undefined. */
|
|
13
|
+
export function shellStartStatus(endReason: string | undefined): string | undefined {
|
|
14
|
+
if (endReason === SHELL_START_FAILED) {
|
|
15
|
+
return "Git Bash reported a Windows runtime startup failure; the command may have partially run. Check for side effects before retrying.";
|
|
16
|
+
}
|
|
17
|
+
if (endReason === SHELL_START_HUNG) {
|
|
18
|
+
return (
|
|
19
|
+
"Git Bash showed no output and only the shell process for 20s, so it was killed. " +
|
|
20
|
+
"This is usually a hung start, but a silent builtin-only command looks the same and may have been running. " +
|
|
21
|
+
"Check for side effects before retrying."
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
bareSleepError,
|
|
33
33
|
collectOutput,
|
|
34
34
|
formatFinishedOutput,
|
|
35
|
+
exitedStatus,
|
|
35
36
|
killedStatus,
|
|
36
37
|
timedOutStatus,
|
|
37
38
|
SHELL_MAX_BYTES,
|
|
@@ -172,7 +173,7 @@ export function createChildBashTool(
|
|
|
172
173
|
const { text, details } = formatFinishedOutput(collected, outputPath);
|
|
173
174
|
const exitCode = waitResult.exit_code ?? null;
|
|
174
175
|
if (exitCode !== 0 && exitCode !== null) {
|
|
175
|
-
throw new Error(appendStatus(text,
|
|
176
|
+
throw new Error(appendStatus(text, await exitedStatus(client, start.task_id, exitCode)));
|
|
176
177
|
}
|
|
177
178
|
// No exit code: killed (timeout, stop, crash), unless the manager
|
|
178
179
|
// finished it as completed with its runner status unobservable.
|
|
@@ -7,6 +7,7 @@ import type { ConversationTurn } from "./types";
|
|
|
7
7
|
interface LooseBlock {
|
|
8
8
|
type?: string;
|
|
9
9
|
text?: string;
|
|
10
|
+
thinking?: string;
|
|
10
11
|
name?: string;
|
|
11
12
|
arguments?: unknown;
|
|
12
13
|
}
|
|
@@ -21,7 +22,8 @@ interface LooseMessage {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
function blockText(block: LooseBlock): string {
|
|
24
|
-
if (block.type === "
|
|
25
|
+
if (block.type === "thinking") return block.thinking ?? block.text ?? "";
|
|
26
|
+
if (block.type === "text") return block.text ?? "";
|
|
25
27
|
if (block.type === "toolCall") {
|
|
26
28
|
const args = block.arguments === undefined ? "" : ` ${JSON.stringify(block.arguments)}`;
|
|
27
29
|
return `tool ${block.name ?? "?"}${args}`;
|
|
@@ -45,32 +47,75 @@ function contentText(content: unknown): string {
|
|
|
45
47
|
.join("\n");
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
function emitAssistantBlocks(message: LooseMessage, turns: ConversationTurn[]): void {
|
|
51
|
+
if (!Array.isArray(message.content)) {
|
|
52
|
+
const text = contentText(message.content);
|
|
53
|
+
if (text) turns.push({ role: message.role ?? "message", text });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let thinking = "";
|
|
57
|
+
let text = "";
|
|
58
|
+
const flushThinking = () => {
|
|
59
|
+
const t = thinking.trim();
|
|
60
|
+
if (t) turns.push({ role: "thinking", text: t });
|
|
61
|
+
thinking = "";
|
|
62
|
+
};
|
|
63
|
+
const flushText = () => {
|
|
64
|
+
const t = text.trim();
|
|
65
|
+
if (t) turns.push({ role: message.role ?? "assistant", text: t });
|
|
66
|
+
text = "";
|
|
67
|
+
};
|
|
68
|
+
for (const raw of message.content) {
|
|
69
|
+
const block = raw && typeof raw === "object" ? (raw as LooseBlock) : null;
|
|
70
|
+
if (!block?.type) continue;
|
|
71
|
+
if (block.type === "thinking") {
|
|
72
|
+
flushText();
|
|
73
|
+
const part = (block.thinking ?? block.text ?? "").trim();
|
|
74
|
+
if (part) thinking = thinking ? `${thinking}\n\n${part}` : part;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (block.type === "toolCall") {
|
|
78
|
+
flushThinking();
|
|
79
|
+
flushText();
|
|
80
|
+
const part = blockText(block);
|
|
81
|
+
if (part) turns.push({ role: message.role ?? "assistant", text: part });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (block.type === "text") {
|
|
85
|
+
flushThinking();
|
|
86
|
+
const part = block.text ?? "";
|
|
87
|
+
if (part) text = text ? `${text}\n${part}` : part;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
flushThinking();
|
|
91
|
+
flushText();
|
|
92
|
+
}
|
|
93
|
+
|
|
48
94
|
export function turnsFromMessages(messages: readonly LooseMessage[]): ConversationTurn[] {
|
|
49
95
|
const turns: ConversationTurn[] = [];
|
|
50
96
|
let firstUserTurn = true;
|
|
51
97
|
for (const message of messages) {
|
|
52
98
|
if (message.role === "system") continue;
|
|
53
|
-
const rawText = contentText(message.content);
|
|
54
|
-
const text = message.role === "user" && firstUserTurn ? visibleUserText(rawText) : rawText;
|
|
55
|
-
if (message.role === "user") firstUserTurn = false;
|
|
56
99
|
if (message.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
|
|
57
100
|
// A failed model call often has no text; its error is the only record of what happened.
|
|
101
|
+
const text = contentText(message.content);
|
|
58
102
|
if (text) turns.push({ role: "assistant", text });
|
|
59
103
|
turns.push({ role: "assistant error", text: message.errorMessage?.trim() || `model stopped: ${message.stopReason}` });
|
|
60
104
|
continue;
|
|
61
105
|
}
|
|
62
|
-
if (!text) continue;
|
|
63
106
|
if (message.role === "toolResult") {
|
|
64
107
|
const name = message.toolName ?? "tool";
|
|
65
|
-
turns.push({ role: message.isError ? `tool ${name} error` : `tool ${name}`, text });
|
|
108
|
+
turns.push({ role: message.isError ? `tool ${name} error` : `tool ${name}`, text: contentText(message.content) });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (message.role === "user") {
|
|
112
|
+
const rawText = contentText(message.content);
|
|
113
|
+
const text = firstUserTurn ? visibleUserText(rawText) : rawText;
|
|
114
|
+
firstUserTurn = false;
|
|
115
|
+
if (text) turns.push({ role: "user", text });
|
|
66
116
|
continue;
|
|
67
117
|
}
|
|
68
|
-
|
|
118
|
+
emitAssistantBlocks(message, turns);
|
|
69
119
|
}
|
|
70
120
|
return turns;
|
|
71
121
|
}
|
|
72
|
-
|
|
73
|
-
export function formatConversation(turns: readonly ConversationTurn[]): string {
|
|
74
|
-
if (turns.length === 0) return "(no messages yet)";
|
|
75
|
-
return turns.map((turn) => `── ${turn.role} ──\n${turn.text}`).join("\n\n");
|
|
76
|
-
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Passive fleet
|
|
2
|
+
* Passive fleet line below the editor — live work only, no keyboard hooks.
|
|
3
3
|
*
|
|
4
|
-
* Format:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Format: `● 2 shells · 1 monitor · scout 12s /tasks`
|
|
5
|
+
* shells = backgrounded shell tasks (not sync-waited)
|
|
6
|
+
* monitors = active monitors
|
|
7
|
+
* then each running subagent by name with its age
|
|
8
8
|
*
|
|
9
9
|
* Driven by WorkIndex changes. Does not poll the manager.
|
|
10
10
|
*/
|
|
11
11
|
import { realClock, type Clock, type ClockTimer } from "../clock";
|
|
12
12
|
import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
|
|
13
13
|
import { truncateToWidth } from "../tui/pi-tui-load";
|
|
14
|
+
import { statusGlyph } from "../tui/tool-component";
|
|
14
15
|
|
|
15
16
|
export const FLEET_WIDGET_KEY = "pi-famulus-fleet";
|
|
16
17
|
|
|
@@ -207,10 +208,9 @@ export class FleetWidget {
|
|
|
207
208
|
parts.push(`${name} ${theme.fg("dim", formatAge(item.startedAt, item.endedAt, now))}`);
|
|
208
209
|
}
|
|
209
210
|
if (parts.length === 0) return [];
|
|
210
|
-
const
|
|
211
|
+
const live = statusGlyph("running");
|
|
212
|
+
const line = ` ${theme.fg(live.color, live.glyph)} ${parts.join(theme.fg("dim", " · "))} ${theme.fg("dim", "/tasks")}`;
|
|
211
213
|
return [truncateToWidth(line, Math.max(1, width), "…")];
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
|
|
215
|
-
/** @deprecated Use FleetWidget — kept as alias for older imports. */
|
|
216
|
-
export { FleetWidget as FleetStatus };
|
package/src/subagent/runner.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* the next one. See design.md §4.6 "Child lifecycle".
|
|
17
17
|
* - `hardTimeoutMs` (opt-in, default off) is the abort ceiling per turn:
|
|
18
18
|
* abort -> result {status:"interrupted", error:"timeout"};
|
|
19
|
-
* - a stall watchdog aborts the child after `stallMs` (default
|
|
19
|
+
* - a stall watchdog aborts the child after `stallMs` (default 5min) without
|
|
20
20
|
* any session event. A stall is treated as transient (a silently dropped
|
|
21
21
|
* provider stream): the child is aborted and auto-resumed on the SAME
|
|
22
22
|
* session with a continuation prompt, up to `stallRetries` times
|
|
@@ -79,7 +79,7 @@ export interface AdmissionTicket {
|
|
|
79
79
|
|
|
80
80
|
export interface InProcessRunnerOptions {
|
|
81
81
|
createSession: CreateSessionFn;
|
|
82
|
-
/** Stall watchdog timeout (ms). Default
|
|
82
|
+
/** Stall watchdog timeout (ms). Default 5 minutes. */
|
|
83
83
|
stallMs?: number;
|
|
84
84
|
/** Auto-resume attempts after a stall. Default 1; 0 settles stalled at once. */
|
|
85
85
|
stallRetries?: number;
|
package/src/subagent/tool.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { realClock, type Clock, type ClockTimer } from "../clock";
|
|
|
21
21
|
|
|
22
22
|
import type { NotifyCenter } from "../notify";
|
|
23
23
|
import { statusGlyph, toolComponent } from "../tui/tool-component";
|
|
24
|
+
import { scrubTty } from "../tui/conversation-view";
|
|
24
25
|
import type { WorkIndex } from "../work-index";
|
|
25
26
|
import { fill, PROMPTS } from "../prompts.generated";
|
|
26
27
|
import { runChain, runTasks, validateChainSteps } from "./pool";
|
|
@@ -706,7 +707,7 @@ export function createSubagentTool(
|
|
|
706
707
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
707
708
|
.map((c) => c.text)
|
|
708
709
|
.join("\n");
|
|
709
|
-
const all = text.split("\n");
|
|
710
|
+
const all = scrubTty(text).split("\n");
|
|
710
711
|
const shown = expanded ? all : all.slice(0, 10);
|
|
711
712
|
const out = shown.map((l) => theme.fg("toolOutput", l));
|
|
712
713
|
if (shown.length < all.length) out.push(theme.fg("muted", `... (${all.length - shown.length} more lines, ctrl+o to expand)`));
|
package/src/task-tools.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Type } from "typebox";
|
|
|
5
5
|
import { realClock, type Clock } from "./clock";
|
|
6
6
|
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type { ManagerClient, TaskRecord } from "./manager-client";
|
|
8
|
+
import { fnv1a64 } from "./config";
|
|
8
9
|
import { fill, PROMPTS } from "./prompts.generated";
|
|
9
10
|
import {
|
|
10
11
|
formatAgentCommand,
|
|
@@ -196,19 +197,32 @@ export interface TaskOutputDetails {
|
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
const DEFAULT_OUTPUT_BYTES = 65536;
|
|
200
|
+
// manager/src/daemon.rs MAX_OUTPUT_READ: clamp before selecting a tail window.
|
|
201
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
199
202
|
|
|
200
203
|
export function createTaskOutputTool(
|
|
201
204
|
deps: TaskToolsDeps,
|
|
202
205
|
): ToolDefinition<typeof taskOutputParameters, TaskOutputDetails> {
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
206
|
+
// Reading another page is retrieval, not polling. Remember actual returned
|
|
207
|
+
// ranges so tail/explicit reads (or different limits hitting EOF) agree.
|
|
208
|
+
// Bound both task and range histories for long-lived extension sessions.
|
|
209
|
+
const runningReads = new Map<string, { size: number; ranges: Set<string> }>();
|
|
210
|
+
const guardPoll = (id: string, running: boolean, size: number, start: number, end: number, identity = "") => {
|
|
211
|
+
if (!running) {
|
|
212
|
+
runningReads.delete(id);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const previous = runningReads.get(id);
|
|
216
|
+
const entry = previous?.size === size ? previous : { size, ranges: new Set<string>() };
|
|
217
|
+
const range = `${start === end ? "empty" : `${start}:${end}`}:${identity}`;
|
|
218
|
+
if (entry.ranges.has(range)) {
|
|
208
219
|
throw new Error(fill("tools.task_output.error.no-new-output", { taskId: id, hint: END_TURN_HINT }));
|
|
209
220
|
}
|
|
210
|
-
|
|
211
|
-
|
|
221
|
+
entry.ranges.add(range);
|
|
222
|
+
if (entry.ranges.size > 256) entry.ranges.delete(entry.ranges.values().next().value!);
|
|
223
|
+
runningReads.delete(id);
|
|
224
|
+
runningReads.set(id, entry);
|
|
225
|
+
if (runningReads.size > 128) runningReads.delete(runningReads.keys().next().value!);
|
|
212
226
|
};
|
|
213
227
|
return {
|
|
214
228
|
name: "task_output",
|
|
@@ -217,11 +231,19 @@ export function createTaskOutputTool(
|
|
|
217
231
|
promptSnippet: PROMPTS["tools.task_output.snippet"],
|
|
218
232
|
parameters: taskOutputParameters,
|
|
219
233
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
234
|
+
for (const field of ["cursor", "max_bytes"] as const) {
|
|
235
|
+
const value = params[field];
|
|
236
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
|
|
237
|
+
throw new TypeError(`${field} must be a nonnegative safe integer`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
220
240
|
const agentText = agentTextFor(deps, params.task_id);
|
|
221
241
|
if (agentText !== undefined) {
|
|
222
242
|
const status = deps.getIndex?.()?.get(params.task_id)?.status ?? "unknown";
|
|
223
243
|
const running = status === "pending" || status === "running";
|
|
224
|
-
|
|
244
|
+
// Length alone hides a same-length conversation rewrite. Hash the
|
|
245
|
+
// snapshot so a changed child transcript is a new range.
|
|
246
|
+
guardPoll(params.task_id, running, agentText.length, 0, agentText.length, fnv1a64(agentText));
|
|
225
247
|
const body = (agentText || "(no output yet)") + (running ? `\n\n${END_TURN_HINT}` : "");
|
|
226
248
|
return {
|
|
227
249
|
content: [{ type: "text", text: body }],
|
|
@@ -236,10 +258,7 @@ export function createTaskOutputTool(
|
|
|
236
258
|
};
|
|
237
259
|
}
|
|
238
260
|
const client = await requireClient(deps);
|
|
239
|
-
const maxBytes =
|
|
240
|
-
params.max_bytes !== undefined && params.max_bytes > 0
|
|
241
|
-
? Math.floor(params.max_bytes)
|
|
242
|
-
: DEFAULT_OUTPUT_BYTES;
|
|
261
|
+
const maxBytes = Math.min(params.max_bytes || DEFAULT_OUTPUT_BYTES, MAX_OUTPUT_BYTES);
|
|
243
262
|
|
|
244
263
|
let cursor = params.cursor;
|
|
245
264
|
if (cursor === undefined) {
|
|
@@ -250,7 +269,7 @@ export function createTaskOutputTool(
|
|
|
250
269
|
|
|
251
270
|
const res = await client.output(params.task_id, cursor, maxBytes);
|
|
252
271
|
const running = res.status === "running";
|
|
253
|
-
guardPoll(params.task_id, running, res.total_size);
|
|
272
|
+
guardPoll(params.task_id, running, res.total_size, cursor, res.next_cursor);
|
|
254
273
|
const details: TaskOutputDetails = {
|
|
255
274
|
task_id: params.task_id,
|
|
256
275
|
status: res.status,
|