pi-famulus 0.1.6 → 0.1.8
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 +154 -25
- package/package.json +32 -7
- package/src/bash-override.ts +34 -26
- package/src/bounded-output.ts +167 -0
- package/src/comms/reply-command.ts +1 -1
- package/src/format.ts +33 -7
- package/src/index.ts +96 -18
- package/src/manager-client.ts +38 -3
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/native-manager.js +0 -2
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- package/src/reload-handoff.ts +137 -0
- 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/famulus-command.ts +71 -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 -88
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
package/src/notify.ts
CHANGED
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* ever delivered once.
|
|
15
15
|
*/
|
|
16
16
|
import { formatMonitorEvent, formatTaskNotification, type TaskExitInfo } from "./format";
|
|
17
|
-
import { FAMULUS_WAKE_CUSTOM_TYPE, stampWakeAsOf, wakeIds, type WakeItem } from "./wake";
|
|
17
|
+
import { FAMULUS_WAKE_CUSTOM_TYPE, stampWakeAsOf, wakeIds, type MonitorOutputInfo, type WakeItem } from "./wake";
|
|
18
18
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
type MonitorDeliveryAck = (visiblePreview?: string) => void;
|
|
22
|
+
|
|
21
23
|
export interface NotifyMessage {
|
|
22
24
|
customType: string;
|
|
23
25
|
content: string;
|
|
@@ -42,6 +44,64 @@ export interface NotifyCenterDeps {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
|
|
47
|
+
// Reserve 100 of the 3000 preview characters for the event-count prefix and
|
|
48
|
+
// omission marker. No raw pending text grows with the number/size of events.
|
|
49
|
+
const MONITOR_PREVIEW_CHARS = 2900;
|
|
50
|
+
const MONITOR_PREVIEW_HALF = MONITOR_PREVIEW_CHARS / 2;
|
|
51
|
+
interface MonitorPreview {
|
|
52
|
+
head: string;
|
|
53
|
+
tail: string;
|
|
54
|
+
omittedChars: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clipUtf16Start(text: string, max: number): string {
|
|
58
|
+
if (text.length <= max) return text;
|
|
59
|
+
const out = text.slice(0, max);
|
|
60
|
+
const last = out.charCodeAt(out.length - 1);
|
|
61
|
+
return last >= 0xd800 && last <= 0xdbff ? out.slice(0, -1) : out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clipUtf16End(text: string, max: number): string {
|
|
65
|
+
if (text.length <= max) return text;
|
|
66
|
+
const out = text.slice(-max);
|
|
67
|
+
const first = out.charCodeAt(0);
|
|
68
|
+
return first >= 0xdc00 && first <= 0xdfff ? out.slice(1) : out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendMonitorPreview(previous: MonitorPreview | undefined, event: string): MonitorPreview {
|
|
72
|
+
const total = previous
|
|
73
|
+
? previous.head.length + previous.tail.length + previous.omittedChars + 1 + event.length
|
|
74
|
+
: event.length;
|
|
75
|
+
if (total <= MONITOR_PREVIEW_CHARS) {
|
|
76
|
+
return { head: previous ? `${previous.head}\n${event}` : event, tail: "", omittedChars: 0 };
|
|
77
|
+
}
|
|
78
|
+
const head = previous
|
|
79
|
+
? clipUtf16Start(`${previous.head}\n${clipUtf16Start(event, MONITOR_PREVIEW_HALF)}`, MONITOR_PREVIEW_HALF)
|
|
80
|
+
: clipUtf16Start(event, MONITOR_PREVIEW_HALF);
|
|
81
|
+
const tail = event.length >= MONITOR_PREVIEW_HALF
|
|
82
|
+
? clipUtf16End(event, MONITOR_PREVIEW_HALF)
|
|
83
|
+
: clipUtf16End(`${previous?.tail || previous?.head || ""}\n${event}`, MONITOR_PREVIEW_HALF);
|
|
84
|
+
return { head, tail, omittedChars: total - head.length - tail.length };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mergeMonitorOutput(previous: MonitorOutputInfo | undefined, next: MonitorOutputInfo | undefined): MonitorOutputInfo | undefined {
|
|
88
|
+
if (!previous && !next) return undefined;
|
|
89
|
+
const output = { ...previous };
|
|
90
|
+
for (const key of ["firstObservedAt", "cursorStart"] as const) {
|
|
91
|
+
const value = next?.[key];
|
|
92
|
+
if (value !== undefined) output[key] = Math.min(previous?.[key] ?? value, value);
|
|
93
|
+
}
|
|
94
|
+
for (const key of ["lastObservedAt", "cursorEnd"] as const) {
|
|
95
|
+
const value = next?.[key];
|
|
96
|
+
if (value !== undefined) output[key] = Math.max(previous?.[key] ?? value, value);
|
|
97
|
+
}
|
|
98
|
+
for (const key of ["suppressedCount", "omittedChars"] as const) {
|
|
99
|
+
if (previous?.[key] !== undefined || next?.[key] !== undefined) output[key] = (previous?.[key] ?? 0) + (next?.[key] ?? 0);
|
|
100
|
+
}
|
|
101
|
+
if (previous?.truncated !== undefined || next?.truncated !== undefined) output.truncated = !!previous?.truncated || !!next?.truncated;
|
|
102
|
+
return output;
|
|
103
|
+
}
|
|
104
|
+
|
|
45
105
|
export class NotifyCenter {
|
|
46
106
|
private readonly deps: NotifyCenterDeps;
|
|
47
107
|
private readonly batchMs: number;
|
|
@@ -49,14 +109,14 @@ export class NotifyCenter {
|
|
|
49
109
|
private pendingExits: TaskExitInfo[] = [];
|
|
50
110
|
private readonly pendingMonitors = new Map<
|
|
51
111
|
string,
|
|
52
|
-
{ description: string; eventCount: number;
|
|
112
|
+
{ description: string; eventCount: number; preview: MonitorPreview; droppedLines: number; quiet: boolean; output?: MonitorOutputInfo; onDelivered?: MonitorDeliveryAck }
|
|
53
113
|
>();
|
|
54
114
|
private readonly seen = new Set<string>();
|
|
55
115
|
private timer: ClockTimer | null = null;
|
|
56
116
|
private disposed = false;
|
|
57
117
|
/** A wake started or steered a run that has not settled yet. */
|
|
58
118
|
private runPending = false;
|
|
59
|
-
private held: NotifyMessage[] = [];
|
|
119
|
+
private held: { message: NotifyMessage; onDelivered?: MonitorDeliveryAck; sourcePreview?: string }[] = [];
|
|
60
120
|
|
|
61
121
|
constructor(deps: NotifyCenterDeps) {
|
|
62
122
|
this.deps = deps;
|
|
@@ -94,13 +154,17 @@ export class NotifyCenter {
|
|
|
94
154
|
if (wake?.kind === "monitor" && typeof wake.id === "string") this.flushMonitor(wake.id, opts.quietEvents);
|
|
95
155
|
if (!opts.passive) {
|
|
96
156
|
this.deliver(message);
|
|
97
|
-
} else if (this.runPending || !this.deps.isIdle()) {
|
|
98
|
-
this.held.push(message);
|
|
99
157
|
} else {
|
|
100
|
-
this.
|
|
158
|
+
this.deliverPassive(message);
|
|
101
159
|
}
|
|
102
160
|
}
|
|
103
161
|
|
|
162
|
+
private deliverPassive(message: NotifyMessage, onDelivered?: MonitorDeliveryAck, sourcePreview?: string): void {
|
|
163
|
+
message = stampWakeAsOf(message, this.clock.now());
|
|
164
|
+
if (this.runPending || !this.deps.isIdle()) this.held.push({ message, onDelivered, sourcePreview });
|
|
165
|
+
else this.deliver(message, true, onDelivered, sourcePreview);
|
|
166
|
+
}
|
|
167
|
+
|
|
104
168
|
/**
|
|
105
169
|
* The agent settled: the run any earlier wake started is over. Flush
|
|
106
170
|
* coalesced monitor output, then, unless that started another run, the
|
|
@@ -111,7 +175,7 @@ export class NotifyCenter {
|
|
|
111
175
|
this.runPending = false;
|
|
112
176
|
this.flushMonitorEvents();
|
|
113
177
|
if (this.runPending) return;
|
|
114
|
-
for (const message of this.held.splice(0)) this.deliver(message, true);
|
|
178
|
+
for (const { message, onDelivered, sourcePreview } of this.held.splice(0)) this.deliver(message, true, onDelivered, sourcePreview);
|
|
115
179
|
}
|
|
116
180
|
|
|
117
181
|
/**
|
|
@@ -119,21 +183,30 @@ export class NotifyCenter {
|
|
|
119
183
|
* monitor. `quiet`: output of a monitor the model stopped; it is only
|
|
120
184
|
* coalesced, and its summary is delivered without a turn (with the exit
|
|
121
185
|
* notice, or at settle), pending events of that monitor included.
|
|
186
|
+
* `onDelivered` receives surviving source lines after sendMessage returns, not
|
|
187
|
+
* enqueue; generated headings/omission markers are never acknowledgement
|
|
188
|
+
* evidence. Callers must reuse one stable callback per monitor: retaining one
|
|
189
|
+
* callback bounds pending acknowledgements without silently dropping any.
|
|
122
190
|
*/
|
|
123
|
-
notifyMonitorEvent(description: string, taskId: string, event: string, droppedLines = 0, opts: { quiet?: boolean } = {}): void {
|
|
191
|
+
notifyMonitorEvent(description: string, taskId: string, event: string, droppedLines = 0, opts: { quiet?: boolean; onDelivered?: MonitorDeliveryAck; output?: MonitorOutputInfo } = {}): void {
|
|
124
192
|
if (this.disposed) return;
|
|
125
193
|
const pending = this.pendingMonitors.get(taskId);
|
|
194
|
+
if (pending?.onDelivered && opts.onDelivered && pending.onDelivered !== opts.onDelivered) {
|
|
195
|
+
throw new Error(`Monitor ${taskId} requires a stable onDelivered callback while pending`);
|
|
196
|
+
}
|
|
126
197
|
if (this.deps.isIdle() && !pending && !opts.quiet) {
|
|
127
|
-
const wake = formatMonitorEvent(description, taskId, event, undefined, { droppedLines });
|
|
128
|
-
this.deliver({ customType: wake.customType, content: wake.content, details: wake.details });
|
|
198
|
+
const wake = formatMonitorEvent(description, taskId, event, undefined, { droppedLines, output: opts.output });
|
|
199
|
+
this.deliver({ customType: wake.customType, content: wake.content, details: wake.details }, false, opts.onDelivered);
|
|
129
200
|
return;
|
|
130
201
|
}
|
|
131
202
|
this.pendingMonitors.set(taskId, {
|
|
132
203
|
description,
|
|
133
204
|
eventCount: (pending?.eventCount ?? 0) + 1,
|
|
134
|
-
|
|
205
|
+
preview: appendMonitorPreview(pending?.preview, event),
|
|
135
206
|
droppedLines: (pending?.droppedLines ?? 0) + droppedLines,
|
|
136
207
|
quiet: (pending?.quiet ?? false) || opts.quiet === true,
|
|
208
|
+
output: mergeMonitorOutput(pending?.output, opts.output),
|
|
209
|
+
onDelivered: pending?.onDelivered ?? opts.onDelivered,
|
|
137
210
|
});
|
|
138
211
|
if (this.deps.isIdle() && !opts.quiet) this.flushMonitorEvents();
|
|
139
212
|
}
|
|
@@ -149,16 +222,25 @@ export class NotifyCenter {
|
|
|
149
222
|
const item = this.pendingMonitors.get(taskId);
|
|
150
223
|
if (!item) return;
|
|
151
224
|
this.pendingMonitors.delete(taskId);
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
225
|
+
const { head, tail, omittedChars } = item.preview;
|
|
226
|
+
const preview = omittedChars > 0 ? `${head}\n[... ${omittedChars} characters omitted ...]\n${tail}` : head;
|
|
227
|
+
const summary = item.eventCount > 1 ? `${item.eventCount} events\n${preview}` : preview;
|
|
155
228
|
const wake = formatMonitorEvent(item.description, taskId, summary, undefined, {
|
|
156
229
|
eventCount: item.eventCount,
|
|
157
230
|
droppedLines: item.droppedLines,
|
|
231
|
+
output: mergeMonitorOutput(item.output, omittedChars > 0 ? { truncated: true, omittedChars } : undefined),
|
|
158
232
|
});
|
|
159
233
|
const message = { customType: wake.customType, content: wake.content, details: wake.details };
|
|
160
|
-
|
|
161
|
-
|
|
234
|
+
// Exclude generated text and boundary fragments: a clipped middle source
|
|
235
|
+
// line named e.g. "30 events" was not seen just because our heading says it.
|
|
236
|
+
const sourcePreview = omittedChars > 0
|
|
237
|
+
? [
|
|
238
|
+
head.slice(0, Math.max(0, head.lastIndexOf("\n"))),
|
|
239
|
+
tail.includes("\n") ? tail.slice(tail.indexOf("\n") + 1) : "",
|
|
240
|
+
].filter(Boolean).join("\n")
|
|
241
|
+
: head;
|
|
242
|
+
if (quiet || item.quiet) this.deliverPassive(message, item.onDelivered, sourcePreview);
|
|
243
|
+
else this.deliver(message, false, item.onDelivered, sourcePreview);
|
|
162
244
|
}
|
|
163
245
|
|
|
164
246
|
/** Flush any pending task exit notifications now. */
|
|
@@ -204,7 +286,7 @@ export class NotifyCenter {
|
|
|
204
286
|
});
|
|
205
287
|
}
|
|
206
288
|
|
|
207
|
-
private deliver(message: NotifyMessage, passive = false): void {
|
|
289
|
+
private deliver(message: NotifyMessage, passive = false, onDelivered?: MonitorDeliveryAck, sourcePreview?: string): void {
|
|
208
290
|
// Wakes built here (task batches, monitor events) are stamped now.
|
|
209
291
|
message = stampWakeAsOf(message, this.clock.now());
|
|
210
292
|
const msg = { ...message, display: true };
|
|
@@ -227,5 +309,6 @@ export class NotifyCenter {
|
|
|
227
309
|
} else {
|
|
228
310
|
this.deps.sendMessage(msg, { deliverAs: "steer" });
|
|
229
311
|
}
|
|
312
|
+
onDelivered?.(sourcePreview ?? (details?.kind === "monitor" ? (message.details as { event?: string }).event : undefined));
|
|
230
313
|
}
|
|
231
314
|
}
|
package/src/prompts.generated.ts
CHANGED
|
@@ -5,8 +5,8 @@ export const PROMPTS = {
|
|
|
5
5
|
"agents.explorer.system-prompt": "You are an explorer agent. Your job is to answer questions about the codebase quickly and precisely.\n\nRules:\n- Return findings, never change code. You are strictly read-only: do not edit, write, or create files.\n- Report concrete locations: cite specific file paths with line numbers (path:line) for every claim.\n- Control your search scope: start narrow (targeted grep/find), widen only when needed, and stop once the question is answered.\n- Prefer a short, factual summary over exhaustive dumps. List what you found and where.",
|
|
6
6
|
"agents.worker.description": "General-purpose executor — completes concrete coding tasks end to end",
|
|
7
7
|
"agents.worker.system-prompt": "You are a worker agent. Your job is to complete the concrete task you were given, end to end.\n\nRules:\n- Do the task, don't just describe it. Make the actual edits and run the actual commands.\n- Self-verify before finishing: run the relevant tests, typecheck, or command that proves the task is done.\n- Stay within the task's scope; do not refactor unrelated code.\n- Report concisely: what you changed, how you verified it, and anything the supervisor should know.",
|
|
8
|
-
"guidelines.parent": "## Background tasks and notifications (pi-famulus)\n\n- Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind=\"task\"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands.\n- When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. Do not wait for further user input when the next step is clear; do not merely acknowledge and stop.\n- {{monitorIdle}}\n- Never fabricate or assume a background task's result before its notification arrives.\n- Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue.\n- A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.\n- Use agent_message to steer running subagents and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). Do not use agent_message to resume a finished child — that is subagent({action:\"resume\"}). Supervisor wakes are not user messages — still act on them.",
|
|
9
|
-
"guidelines.monitor-idle": "After starting or re-arming a monitor, or handling a monitor event, if no work remains, end your turn with
|
|
8
|
+
"guidelines.parent": "## Background tasks and notifications (pi-famulus)\n\n- Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind=\"task\"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands.\n- When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. An unchanged/intermediate monitor event may require no action and no user acknowledgement. Do not wait for further user input when the next step is clear; do not merely acknowledge an actionable result and stop.\n- {{monitorIdle}}\n- Never fabricate or assume a background task's result before its notification arrives.\n- Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue.\n- A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.\n- Use agent_message to steer running subagents and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). Do not use agent_message to resume a finished child — that is subagent({action:\"resume\"}). Supervisor wakes are not user messages — still act on them.",
|
|
9
|
+
"guidelines.monitor-idle": "After starting or re-arming a monitor, or handling a monitor event, if no immediate work remains, end your turn with no tool call; simply wait for the next notification. Handling an event does not require a user-visible reply. Do not repeat unchanged/intermediate status or narrate your decision to stay quiet; notify the user when their requested condition is met, their input is needed, or a failure prevents further progress. Read original output with task_output only when details are needed (including paging a truncated preview), never to poll or wait. Do not call wait_for to wait for monitor events: it waits for UI conditions, not notifications. Do not invent a stateId or make a failing tool call to yield.",
|
|
10
10
|
"guidelines.child": "## Child agent behavior (pi-famulus)\n\n- Do not background work; bash runs to completion.\n- When blocked, use contact_supervisor with need_decision; use progress_update for status.\n- Use agent_message only to communicate with siblings in this same run.\n- Your final message is your result.",
|
|
11
11
|
"tools.agent_message.description": "Message your subagents. \"send\" steers or queues a running child. Sending to a finished child errors (it does not resume) and points at subagent({ action: \"resume\", run_id, child_id, message }). \"reply\" answers a child's pending decision request; \"broadcast\" steers every running child of a run (to = run_id); \"list\" shows children, pending decision requests, and recent traffic.",
|
|
12
12
|
"tools.agent_message.snippet": "Send/reply/broadcast messages to subagents",
|
|
@@ -34,14 +34,16 @@ export const PROMPTS = {
|
|
|
34
34
|
"tools.child.contact_supervisor.snippet": "Ask the parent agent for a decision or report progress",
|
|
35
35
|
"tools.child.contact_supervisor.param.reason": "\"need_decision\" blocks until the supervisor (parent agent) replies; \"progress_update\" is fire-and-forget.",
|
|
36
36
|
"tools.child.contact_supervisor.param.message": "Message for the supervisor.",
|
|
37
|
-
"tools.monitor.description": "Start a background monitor process whose stdout lines are injected back to you as <pi-famulus-wake kind=\"monitor\"> messages (batched over 200ms, rate-limited). The command must be line-buffered: each event must be a single line. It must keep running and follow its source, e.g. `tail -n +1 -F file | grep --line-buffered PATTERN`; a command that reads once and exits (a plain grep or cat) only reports what is there now. Add `-m1` to grep to stop after the first match. Silence is not success:
|
|
37
|
+
"tools.monitor.description": "Start a background monitor process whose stdout lines are injected back to you as <pi-famulus-wake kind=\"monitor\"> messages (batched over 200ms, rate-limited). The command must be line-buffered: each event must be a single line. It must keep running and follow its source, e.g. `tail -n +1 -F file | grep --line-buffered PATTERN`; a command that reads once and exits (a plain grep or cat) only reports what is there now. Add `-m1` to grep to stop after the first match. Silence is not success: print explicit success and terminal failures. Routine unchanged retries may stay silent; do not print a heartbeat on every check. Events arrive as system wakes (not new user messages). Handle each <pi-famulus-wake kind=\"monitor\"> before other work. Do not poll. Repeated Rust-framed lines are suppressed within dedup_window_ms (default 60000); exact matching is the default. Enable ignore-timestamps only when edge ISO timestamps are incidental. Wakes contain bounded head/tail previews and original-byte references; use task_output for missing details, not polling. {{monitorIdle}}",
|
|
38
38
|
"tools.monitor.snippet": "Watch a command's line stream and get injected events",
|
|
39
39
|
"tools.monitor.rules": ["Use the monitor tool to watch for conditions instead of running sleep/poll loops in bash.","{{monitorIdle}}","When woken by a <pi-famulus-wake kind=\"monitor\">, handle the <event> before doing anything else — it is not a new user request and not user confirmation."],
|
|
40
40
|
"tools.monitor.param.command": "Command producing one event per line on stdout. Must be line-buffered (e.g. use `stdbuf -oL` / `grep --line-buffered` where needed).",
|
|
41
41
|
"tools.monitor.param.description": "Short human-readable description of what is being watched",
|
|
42
42
|
"tools.monitor.param.timeout_ms": "Stop the monitor after this many milliseconds (default {{defaultMs}}, min {{minMs}}, max {{maxMs}})",
|
|
43
43
|
"tools.monitor.param.persistent": "Keep the monitor alive until the session ends (no timeout). Default false.",
|
|
44
|
-
"tools.monitor.
|
|
44
|
+
"tools.monitor.param.dedup_window_ms": "Deduplication window for Rust-framed complete lines (default 60000ms; 0 disables dedup; maximum 3600000). Starts when a surviving preview is delivered; repeats do not extend it. Missing metadata, capacity eviction, and omitted representatives fail open rather than hiding unseen output.",
|
|
45
|
+
"tools.monitor.param.fingerprint_mode": "Default exact. Explicit ignore-timestamps mode ignores only supported leading/trailing ISO timestamps when comparing Rust-framed lines; original output is unchanged. Numbers, paths, resource identities, and interior timestamps remain significant.",
|
|
46
|
+
"tools.monitor.result.started": "You will get <pi-famulus-wake kind=\"monitor\"> notifications for admitted output and a notice when it exits or times out. Repeated lines may be suppressed and previews may be truncated; original output remains available through task_output byte ranges while the task log is retained. Read details only when needed; do not repeatedly poll task_list, task_output, or the watched source, and do not sleep to wait. {{monitorIdle}}",
|
|
45
47
|
"tools.subagent.description": "Run subagents in parallel (tasks) or sequentially (chain with {previous}/{outputs.<label>} interpolation). By default the call waits up to a foreground budget (default 45s); longer runs continue in the background. Each child that finishes while others are still running wakes you with <pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. Never poll or sleep to wait. A subagent still running past its timeout_ms is not stopped; it wakes you with <pi-famulus-wake kind=\"subagent-overrun\"> so you can extend, steer, or interrupt it. Use action=list/get/status/interrupt/resume/steer/extend to manage existing runs.",
|
|
46
48
|
"tools.subagent.snippet": "Fan out subagents in parallel or sequence them in a chain",
|
|
47
49
|
"tools.subagent.rules": ["When a <pi-famulus-wake kind=\"subagent-handover\"> arrives, read <prompt> and <result> immediately and continue: subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.","Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.","A failed subagent does not fail the whole run; inspect per-subagent sections in the result.","<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run."],
|
|
@@ -72,17 +74,18 @@ export const PROMPTS = {
|
|
|
72
74
|
"tools.task_list.snippet": "List background shell/monitor tasks and subagents",
|
|
73
75
|
"tools.task_list.param.all": "Also include finished work (default: running only). Always limited to this session.",
|
|
74
76
|
"tools.task_list.hint.running": "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.",
|
|
75
|
-
"tools.task_output.description": "Read output of a background task. Without a cursor, returns the tail
|
|
77
|
+
"tools.task_output.description": "Read original output of a background task, including a monitor's truncated or deduplicated output. Without a cursor, returns the tail plus the current file pointer; pass next_cursor as cursor to page forward. Use small max_bytes ranges for details. Reading a different range is allowed; while a task is running, recently read ranges may be rejected if the output size is unchanged. This is not a way to wait: when no further details are needed, end your turn with no tool call and resume on the next notification.",
|
|
76
78
|
"tools.task_output.snippet": "Read background task output",
|
|
77
79
|
"tools.task_output.param.task_id": "Task id, e.g. sh_a1b2c3d4",
|
|
78
80
|
"tools.task_output.param.cursor": "Byte offset to read from (for incremental reads). Omit to read the tail of the output.",
|
|
79
|
-
"tools.task_output.param.max_bytes": "Maximum bytes to return (default 65536)",
|
|
80
|
-
"tools.task_output.hint.still-running": "The task is still running.
|
|
81
|
+
"tools.task_output.param.max_bytes": "Maximum bytes to return (default 65536, capped at 1048576); use a smaller range to avoid flooding context. Zero selects the default.",
|
|
82
|
+
"tools.task_output.hint.still-running": "The task is still running. Read another byte range only if details are needed; otherwise end your turn with no tool call. Do not repeatedly check for progress; a <pi-famulus-wake> arrives when it finishes.",
|
|
81
83
|
"tools.task_output.error.no-new-output": "No new output from {{taskId}} since your last read. {{hint}}",
|
|
82
84
|
"tools.task_stop.description": "Stop a running background task (SIGTERM to the process group, SIGKILL after a 2s grace).",
|
|
83
85
|
"tools.task_stop.snippet": "Stop a background task",
|
|
84
86
|
"tools.task_stop.param.task_id": "Task id to stop, e.g. sh_a1b2c3d4",
|
|
85
87
|
"wakes.lead-in": "System wake — not a new user message. Handle this <pi-famulus-wake> before other work.",
|
|
88
|
+
"wakes.monitor.output-hint": "Read original bytes with task_output using this monitor id and cursor-start, with a bounded max_bytes (for example 8192); next_cursor continues the read. The range may include interleaved events. A preview is not the full log.",
|
|
86
89
|
"wakes.handover.changed-active": "Its result arrives as a new wake when it finishes.",
|
|
87
90
|
"wakes.done.changed-active": "The run is active again; another subagent-done arrives when it finishes.",
|
|
88
91
|
"wakes.overrun.summary": "{{name}} has run {{elapsed}} in this turn, past its {{budget}} budget, and is still running{{shell}}.",
|
|
@@ -151,12 +154,12 @@ export function fillList<K extends keyof PromptVars>(id: K, vars: Record<PromptV
|
|
|
151
154
|
/** Ablatable spans (eval manifest segment ids). */
|
|
152
155
|
export const PROMPT_SEGMENTS: Record<string, { prompt: PromptId; text?: string; pattern?: string }> = {
|
|
153
156
|
"guidelines.end-turn-after-bg": {"prompt":"guidelines.parent","text":"Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind=\"task\"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands."},
|
|
154
|
-
"guidelines.wake-handling": {"prompt":"guidelines.parent","text":"When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. Do not wait for further user input when the next step is clear; do not merely acknowledge and stop."},
|
|
157
|
+
"guidelines.wake-handling": {"prompt":"guidelines.parent","text":"When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. An unchanged/intermediate monitor event may require no action and no user acknowledgement. Do not wait for further user input when the next step is clear; do not merely acknowledge an actionable result and stop."},
|
|
155
158
|
"guidelines.no-fabrication": {"prompt":"guidelines.parent","text":"Never fabricate or assume a background task's result before its notification arrives."},
|
|
156
159
|
"guidelines.subagent-handover": {"prompt":"guidelines.parent","text":"Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue."},
|
|
157
160
|
"guidelines.subagent-overrun": {"prompt":"guidelines.parent","text":"A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again."},
|
|
158
161
|
"guidelines.agent-message": {"prompt":"guidelines.parent","text":"Use agent_message to steer running subagents and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). Do not use agent_message to resume a finished child — that is subagent({action:\"resume\"}). Supervisor wakes are not user messages — still act on them."},
|
|
159
|
-
"guidelines.monitor-end-turn": {"prompt":"guidelines.monitor-idle","text":"After starting or re-arming a monitor, or handling a monitor event, if no work remains, end your turn with
|
|
162
|
+
"guidelines.monitor-end-turn": {"prompt":"guidelines.monitor-idle","text":"After starting or re-arming a monitor, or handling a monitor event, if no immediate work remains, end your turn with no tool call; simply wait for the next notification. Handling an event does not require a user-visible reply. Do not repeat unchanged/intermediate status or narrate your decision to stay quiet; notify the user when their requested condition is met, their input is needed, or a failure prevents further progress. Read original output with task_output only when details are needed (including paging a truncated preview), never to poll or wait. Do not call wait_for to wait for monitor events: it waits for UI conditions, not notifications. Do not invent a stateId or make a failing tool call to yield."},
|
|
160
163
|
"child.guidelines": {"prompt":"guidelines.child","text":"## Child agent behavior (pi-famulus)\n\n- Do not background work; bash runs to completion.\n- When blocked, use contact_supervisor with need_decision; use progress_update for status.\n- Use agent_message only to communicate with siblings in this same run.\n- Your final message is your result."},
|
|
161
164
|
"tooldesc.agent-message-no-resume": {"prompt":"tools.agent_message.description","text":"Sending to a finished child errors (it does not resume) and points at subagent({ action: \"resume\", run_id, child_id, message }). "},
|
|
162
165
|
"result.agent-message-use-resume": {"prompt":"tools.agent_message.error.finished-child","pattern":"agent_message does not resume children\\. Use subagent\\(\\{ action: \"resume\", run_id: \".*?\", child_id: \".*?\", message: \"\\.\\.\\.\" \\}\\)\\."},
|
|
@@ -166,11 +169,11 @@ export const PROMPT_SEGMENTS: Record<string, { prompt: PromptId; text?: string;
|
|
|
166
169
|
"result.bash-bg-instruction": {"prompt":"tools.bash.result.backgrounded","text":"\nYou will be notified when it completes, even if other commands are still running. Do not poll or sleep: reply to the user now with no tool call, and continue from the <pi-famulus-wake kind=\"task\"> when it arrives."},
|
|
167
170
|
"result.sleep-block-advice": {"prompt":"tools.bash.error.bare-sleep","text":" Do not sleep to wait for background work; completion is delivered via notification. Use the monitor tool to watch for a condition, or run_in_background for long commands."},
|
|
168
171
|
"tooldesc.monitor-follow": {"prompt":"tools.monitor.description","text":"It must keep running and follow its source, e.g. `tail -n +1 -F file | grep --line-buffered PATTERN`; a command that reads once and exits (a plain grep or cat) only reports what is there now. Add `-m1` to grep to stop after the first match. "},
|
|
169
|
-
"tooldesc.monitor-silence": {"prompt":"tools.monitor.description","text":"Silence is not success:
|
|
172
|
+
"tooldesc.monitor-silence": {"prompt":"tools.monitor.description","text":"Silence is not success: print explicit success and terminal failures. Routine unchanged retries may stay silent; do not print a heartbeat on every check. "},
|
|
170
173
|
"tooldesc.monitor-wake": {"prompt":"tools.monitor.description","text":"Events arrive as system wakes (not new user messages). Handle each <pi-famulus-wake kind=\"monitor\"> before other work. Do not poll."},
|
|
171
174
|
"rules.monitor-use": {"prompt":"tools.monitor.rules","text":"Use the monitor tool to watch for conditions instead of running sleep/poll loops in bash."},
|
|
172
175
|
"rules.monitor-wake": {"prompt":"tools.monitor.rules","text":"When woken by a <pi-famulus-wake kind=\"monitor\">, handle the <event> before doing anything else — it is not a new user request and not user confirmation."},
|
|
173
|
-
"result.monitor-started-instruction": {"prompt":"tools.monitor.result.started","text":"You will get
|
|
176
|
+
"result.monitor-started-instruction": {"prompt":"tools.monitor.result.started","text":"You will get <pi-famulus-wake kind=\"monitor\"> notifications for admitted output and a notice when it exits or times out. Repeated lines may be suppressed and previews may be truncated; original output remains available through task_output byte ranges while the task log is retained. Read details only when needed; do not repeatedly poll task_list, task_output, or the watched source, and do not sleep to wait."},
|
|
174
177
|
"tooldesc.subagent-wakes": {"prompt":"tools.subagent.description","text":"Each child that finishes while others are still running wakes you with <pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. Never poll or sleep to wait. "},
|
|
175
178
|
"tooldesc.subagent-overrun": {"prompt":"tools.subagent.description","text":"A subagent still running past its timeout_ms is not stopped; it wakes you with <pi-famulus-wake kind=\"subagent-overrun\"> so you can extend, steer, or interrupt it. "},
|
|
176
179
|
"rules.subagent-handover": {"prompt":"tools.subagent.rules","text":"When a <pi-famulus-wake kind=\"subagent-handover\"> arrives, read <prompt> and <result> immediately and continue: subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run."},
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /reload keeps background work alive.
|
|
3
|
+
*
|
|
4
|
+
* pi's /reload emits session_shutdown(reason "reload") on the old extension
|
|
5
|
+
* instance, re-imports every extension (fresh module scope), then emits
|
|
6
|
+
* session_start(reason "reload") on the new instances. Shells, monitors and
|
|
7
|
+
* subagents belong to the pi session, not to one module instance, so the old
|
|
8
|
+
* instance parks its runtime here instead of tearing it down, and the new
|
|
9
|
+
* instance adopts it.
|
|
10
|
+
*
|
|
11
|
+
* The runtime registers its tools, commands and handlers on a PiBinding: a
|
|
12
|
+
* stand-in ExtensionAPI that records registrations and replays them on every
|
|
13
|
+
* pi it is attached to. Other calls go to the attached pi; sendMessage is
|
|
14
|
+
* queued while no live pi can take it (between the old instance going stale
|
|
15
|
+
* and the new one's session_start).
|
|
16
|
+
*/
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { realClock, type Clock } from "./clock";
|
|
19
|
+
|
|
20
|
+
const HANDOFF_KEY = Symbol.for("pi-famulus.reload-handoff");
|
|
21
|
+
|
|
22
|
+
/** A parked runtime nobody adopts (pi-famulus removed from the config) is torn down after this. */
|
|
23
|
+
export const HANDOFF_EXPIRY_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
const REGISTRATIONS = new Set<PropertyKey>([
|
|
26
|
+
"on",
|
|
27
|
+
"registerTool",
|
|
28
|
+
"registerCommand",
|
|
29
|
+
"registerShortcut",
|
|
30
|
+
"registerFlag",
|
|
31
|
+
"registerMessageRenderer",
|
|
32
|
+
"registerProvider",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export interface PiBinding {
|
|
36
|
+
/** Pass this to the runtime in place of the real pi. */
|
|
37
|
+
readonly api: ExtensionAPI;
|
|
38
|
+
/** Replay every registration on `pi` and route calls to it. */
|
|
39
|
+
attach(pi: ExtensionAPI): void;
|
|
40
|
+
/** Deliver queued messages; call once the attached pi's session has started. */
|
|
41
|
+
ready(): void;
|
|
42
|
+
/** Stop calling the attached pi (it is about to go stale). */
|
|
43
|
+
detach(): void;
|
|
44
|
+
/** Drop queued messages; they belong to a session that is not coming back. */
|
|
45
|
+
discardQueued(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createPiBinding(): PiBinding {
|
|
49
|
+
const registrations: Array<{ method: PropertyKey; args: unknown[] }> = [];
|
|
50
|
+
const queued: unknown[][] = [];
|
|
51
|
+
let current: ExtensionAPI | null = null;
|
|
52
|
+
let live = false;
|
|
53
|
+
|
|
54
|
+
const call = (pi: ExtensionAPI, method: PropertyKey, args: unknown[]): unknown =>
|
|
55
|
+
(Reflect.get(pi, method) as (...a: unknown[]) => unknown).apply(pi, args);
|
|
56
|
+
|
|
57
|
+
const api = new Proxy({} as ExtensionAPI, {
|
|
58
|
+
get(_target, key) {
|
|
59
|
+
if (REGISTRATIONS.has(key)) {
|
|
60
|
+
return (...args: unknown[]) => {
|
|
61
|
+
registrations.push({ method: key, args });
|
|
62
|
+
return current ? call(current, key, args) : undefined;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (key === "sendMessage") {
|
|
66
|
+
return (...args: unknown[]) => {
|
|
67
|
+
if (current && live) return call(current, key, args);
|
|
68
|
+
queued.push(args);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (!current) throw new Error(`pi-famulus: pi.${String(key)} used while detached (between reloads)`);
|
|
72
|
+
const value = Reflect.get(current, key) as unknown;
|
|
73
|
+
return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(current) : value;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
api,
|
|
79
|
+
attach(pi) {
|
|
80
|
+
current = pi;
|
|
81
|
+
live = false;
|
|
82
|
+
for (const { method, args } of registrations) call(pi, method, args);
|
|
83
|
+
},
|
|
84
|
+
ready() {
|
|
85
|
+
if (!current) return;
|
|
86
|
+
live = true;
|
|
87
|
+
for (const args of queued.splice(0)) call(current, "sendMessage", args);
|
|
88
|
+
},
|
|
89
|
+
detach() {
|
|
90
|
+
current = null;
|
|
91
|
+
live = false;
|
|
92
|
+
},
|
|
93
|
+
discardQueued() {
|
|
94
|
+
queued.length = 0;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ParkedRuntime<R> {
|
|
100
|
+
/** Extension version that parked it; another version's runtime is not adopted. */
|
|
101
|
+
version: string;
|
|
102
|
+
runtime: R;
|
|
103
|
+
/** Full teardown (what a non-reload session_shutdown does). */
|
|
104
|
+
dispose(): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface Slot {
|
|
108
|
+
parked: ParkedRuntime<unknown>;
|
|
109
|
+
expiry: unknown;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Park a runtime for the next instance; tear it down if nobody takes it in time. */
|
|
113
|
+
export function parkRuntime<R>(parked: ParkedRuntime<R>, clock: Clock = realClock): void {
|
|
114
|
+
const store = globalThis as Record<symbol, Slot | undefined>;
|
|
115
|
+
const previous = store[HANDOFF_KEY];
|
|
116
|
+
if (previous) {
|
|
117
|
+
clock.clearTimeout(previous.expiry);
|
|
118
|
+
void previous.parked.dispose().catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
const expiry = clock.setTimeout(() => {
|
|
121
|
+
if (store[HANDOFF_KEY]?.parked !== parked) return;
|
|
122
|
+
store[HANDOFF_KEY] = undefined;
|
|
123
|
+
void parked.dispose().catch(() => {});
|
|
124
|
+
}, HANDOFF_EXPIRY_MS);
|
|
125
|
+
clock.unref?.(expiry);
|
|
126
|
+
store[HANDOFF_KEY] = { parked: parked as ParkedRuntime<unknown>, expiry };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Take the parked runtime, if any. The caller adopts or disposes it. */
|
|
130
|
+
export function takeParkedRuntime<R>(clock: Clock = realClock): ParkedRuntime<R> | null {
|
|
131
|
+
const store = globalThis as Record<symbol, Slot | undefined>;
|
|
132
|
+
const slot = store[HANDOFF_KEY];
|
|
133
|
+
if (!slot) return null;
|
|
134
|
+
store[HANDOFF_KEY] = undefined;
|
|
135
|
+
clock.clearTimeout(slot.expiry);
|
|
136
|
+
return slot.parked as ParkedRuntime<R>;
|
|
137
|
+
}
|
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.
|