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/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 = (await client.list(true).catch(() => [])).find((t) => t.task_id === taskId);
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, `Command exited with code ${exitCode}`));
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 === "text" || block.type === "thinking") return block.text ?? "";
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
- turns.push({ role: message.role ?? "message", text });
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 summary below the editor — counts only, no keyboard hooks.
2
+ * Passive fleet line below the editor — live work only, no keyboard hooks.
3
3
  *
4
- * Format: `2 workers · 1 subagent · 1 monitor`
5
- * workers = backgrounded shell tasks (not sync-waited)
6
- * subagents = in-process subagent children
7
- * monitors = active monitors
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 line = ` ${theme.fg("accent", "●")} ${parts.join(theme.fg("dim", " · "))} ${theme.fg("dim", "/tasks")}`;
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 };
@@ -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 10min) without
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 10 minutes. */
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;
@@ -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
- // total_size at the last read of each running task. A repeat read that finds
204
- // the same size is polling: the prompt forbids it, but models still loop.
205
- const lastRunningSize = new Map<string, number>();
206
- const guardPoll = (id: string, running: boolean, size: number) => {
207
- if (running && lastRunningSize.get(id) === size) {
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
- if (running) lastRunningSize.set(id, size);
211
- else lastRunningSize.delete(id);
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
- guardPoll(params.task_id, running, agentText.length);
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,