pi-famulus 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-famulus",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Subagents, monitoring, and auto-backgrounding bash for pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,10 +15,10 @@
15
15
  "files": ["src", "bin", "README.md"],
16
16
  "bin": { "pi-famulus": "./bin/pi-famulus.js" },
17
17
  "optionalDependencies": {
18
- "pi-famulus-linux-x64": "0.1.0",
19
- "pi-famulus-linux-arm64": "0.1.0",
20
- "pi-famulus-darwin-x64": "0.1.0",
21
- "pi-famulus-darwin-arm64": "0.1.0"
18
+ "pi-famulus-linux-x64": "0.1.2",
19
+ "pi-famulus-linux-arm64": "0.1.2",
20
+ "pi-famulus-darwin-x64": "0.1.2",
21
+ "pi-famulus-darwin-arm64": "0.1.2"
22
22
  },
23
23
  "pi": {
24
24
  "extensions": ["./src/index.ts"]
@@ -27,18 +27,17 @@
27
27
  "test": "vitest run",
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
- "dependencies": {
31
- "typebox": "^1.3.7"
32
- },
33
30
  "devDependencies": {
34
- "@earendil-works/pi-coding-agent": "0.87.1",
35
- "@earendil-works/pi-tui": "^0.87.1",
31
+ "@earendil-works/pi-coding-agent": "1.0.0",
32
+ "@earendil-works/pi-tui": "^1.0.0",
36
33
  "@types/node": "^24.0.0",
34
+ "typebox": "1.3.27",
37
35
  "typescript": "7.0.2",
38
36
  "vitest": "^3.0.0"
39
37
  },
40
38
  "peerDependencies": {
41
39
  "@earendil-works/pi-coding-agent": "*",
40
+ "typebox": "*",
42
41
  "@earendil-works/pi-tui": "*"
43
42
  },
44
43
  "peerDependenciesMeta": {
@@ -17,7 +17,7 @@ import { randomUUID } from "node:crypto";
17
17
  import { writeFileSync } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
- import { Type } from "typebox";
20
+ import { Type, type Static } from "typebox";
21
21
  import type {
22
22
  AgentToolResult,
23
23
  BashToolDetails,
@@ -62,6 +62,46 @@ const bashParameters = Type.Object({
62
62
 
63
63
  type BashParams = { command: string; timeout?: number; run_in_background?: boolean };
64
64
 
65
+ /**
66
+ * Mirrors the built-in bash outputSchema (pi ≥0.99) so codemode scripts calling
67
+ * `bash()` resolve to the same structured shape the built-in tool returns.
68
+ * pi does not export `bashOutputSchema`, so keep this in sync with BashToolOutput.
69
+ */
70
+ const bashOutputSchema = Type.Object({
71
+ output: Type.String(),
72
+ truncated: Type.Boolean(),
73
+ full_output_path: Type.Optional(Type.String()),
74
+ exit_code: Type.Number(),
75
+ wall_time_seconds: Type.Number(),
76
+ });
77
+ type BashToolOutput = Static<typeof bashOutputSchema>;
78
+
79
+ /** Same cap as the built-in structured output (1 MiB). */
80
+ const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024;
81
+
82
+ /** Full-output slice for structuredContent, keeping the tail like the built-in. */
83
+ function structuredOutput(fullText: string): { output: string; truncated: boolean } {
84
+ if (Buffer.byteLength(fullText, "utf8") <= STRUCTURED_OUTPUT_MAX_BYTES) {
85
+ return { output: fullText, truncated: false };
86
+ }
87
+ // Keep the largest tail that fits the cap without materializing the whole
88
+ // output as a second buffer: search a UTF-16 index near the end (each code
89
+ // unit encodes to at least one byte, so the tail starts within `cap` units
90
+ // of the end) and only encode that slice.
91
+ let lo = Math.max(0, fullText.length - STRUCTURED_OUTPUT_MAX_BYTES);
92
+ let hi = fullText.length;
93
+ while (lo < hi) {
94
+ const mid = (lo + hi) >> 1;
95
+ if (Buffer.byteLength(fullText.slice(mid), "utf8") <= STRUCTURED_OUTPUT_MAX_BYTES) hi = mid;
96
+ else lo = mid + 1;
97
+ }
98
+ return { output: fullText.slice(lo), truncated: true };
99
+ }
100
+
101
+ function wallSeconds(startedAtMs: number, endedAtMs: number): number {
102
+ return Math.round((endedAtMs - startedAtMs) / 100) / 10;
103
+ }
104
+
65
105
  /** Details shape returned by this override; superset of BashToolDetails. */
66
106
  export interface FamulusBashDetails extends BashToolDetails {
67
107
  backgrounded?: boolean;
@@ -130,6 +170,7 @@ async function executeLocal(
130
170
  clock: Clock,
131
171
  ): Promise<AgentToolResult<FamulusBashDetails | undefined>> {
132
172
  const timeoutMs = resolveTimeoutMs(params.timeout);
173
+ const startedAtMs = clock.now();
133
174
  const shell = process.env.SHELL && process.env.SHELL.length > 0 ? process.env.SHELL : "/bin/bash";
134
175
 
135
176
  const output = await new Promise<{ text: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
@@ -216,11 +257,26 @@ async function executeLocal(
216
257
  if (output.timedOut && params.timeout !== undefined) {
217
258
  throw new Error(appendStatus(text, timedOutStatus(params.timeout)));
218
259
  }
219
- if (output.exitCode !== 0 && output.exitCode !== null) {
220
- throw new Error(appendStatus(text, `Command exited with code ${output.exitCode}`));
221
- }
222
260
  if (output.exitCode === null) throw new Error(appendStatus(text, killedBy(output.signal)));
223
- return { content: [{ type: "text", text }], details };
261
+ const { output: structuredText, truncated: structuredTruncated } = structuredOutput(output.text);
262
+ const structuredContent: BashToolOutput = {
263
+ output: structuredText,
264
+ truncated: structuredTruncated,
265
+ ...(details?.fullOutputPath ? { full_output_path: details.fullOutputPath } : {}),
266
+ exit_code: output.exitCode,
267
+ wall_time_seconds: wallSeconds(startedAtMs, clock.now()),
268
+ };
269
+ if (output.exitCode !== 0) {
270
+ // Mirror the built-in bash tool: non-zero exits resolve to an isError
271
+ // result carrying structuredContent instead of rejecting.
272
+ return {
273
+ content: [{ type: "text", text: appendStatus(text, `Command exited with code ${output.exitCode}`) }],
274
+ details,
275
+ structuredContent,
276
+ isError: true,
277
+ };
278
+ }
279
+ return { content: [{ type: "text", text }], details, structuredContent };
224
280
  }
225
281
 
226
282
  // ---------------------------------------------------------------------------
@@ -245,6 +301,7 @@ export function createBashOverride(
245
301
  "Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them. End your turn (a reply with no tool call) and resume from the task wake when it arrives.",
246
302
  ],
247
303
  parameters: bashParameters,
304
+ outputSchema: bashOutputSchema,
248
305
  renderResult(result, { expanded }, theme, context) {
249
306
  const details = result.details as FamulusBashDetails | undefined;
250
307
  if (details?.backgrounded && details.task_id) {
@@ -291,6 +348,7 @@ export function createBashOverride(
291
348
  }
292
349
 
293
350
  const timeoutMs = resolveTimeoutMs(input.timeout);
351
+ const startedAtMs = (deps.clock ?? realClock).now();
294
352
  let start;
295
353
  try {
296
354
  start = await client.start({
@@ -358,15 +416,37 @@ export function createBashOverride(
358
416
  const collected = await collectOutput(client, start.task_id);
359
417
  const { text, details } = formatFinishedOutput(collected, outputPath);
360
418
  const exitCode = waitResult.exit_code ?? null;
361
- if (exitCode !== 0 && exitCode !== null) {
362
- throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
363
- }
364
419
  // No exit code: killed (timeout, stop, crash), unless the manager
365
420
  // finished it as completed with its runner status unobservable.
366
421
  if (exitCode === null && collected.status !== "completed") {
367
422
  throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
368
423
  }
369
- return { content: [{ type: "text", text }], details };
424
+ if (exitCode === null) {
425
+ // Completed but the exit code was unobservable: no honest
426
+ // structured shape (exit_code is required, and a sentinel like -1
427
+ // would misclassify success as failure), so omit structuredContent
428
+ // and let codemode scripts fall back to the text content.
429
+ return { content: [{ type: "text", text }], details };
430
+ }
431
+ const { output: structuredText, truncated: structuredTruncated } = structuredOutput(collected.text);
432
+ const structuredContent: BashToolOutput = {
433
+ output: structuredText,
434
+ truncated: structuredTruncated || collected.windowed,
435
+ full_output_path: outputPath,
436
+ exit_code: exitCode,
437
+ wall_time_seconds: wallSeconds(startedAtMs, (deps.clock ?? realClock).now()),
438
+ };
439
+ if (exitCode !== 0) {
440
+ // Mirror the built-in bash tool: non-zero exits resolve to an isError
441
+ // result carrying structuredContent instead of rejecting.
442
+ return {
443
+ content: [{ type: "text", text: appendStatus(text, `Command exited with code ${exitCode}`) }],
444
+ details,
445
+ structuredContent,
446
+ isError: true,
447
+ };
448
+ }
449
+ return { content: [{ type: "text", text }], details, structuredContent };
370
450
  },
371
451
  };
372
452
  }
@@ -16,7 +16,7 @@ import { famulusPaths } from "./config";
16
16
  import { realClock, type Clock, type ClockTimer } from "./clock";
17
17
 
18
18
  const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
19
- const EXTENSION_VERSION = "0.1.0";
19
+ const EXTENSION_VERSION = "0.1.2";
20
20
  // 3: speaks in-place upgrade (resends on reconnect, start keys).
21
21
  const PROTOCOL = 3;
22
22
  const HELLO_TIMEOUT_MS = 5000;
@@ -9,7 +9,7 @@
9
9
  * Driven by WorkIndex changes. Does not poll the manager.
10
10
  */
11
11
  import { realClock, type Clock, type ClockTimer } from "../clock";
12
- import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
12
+ import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
13
13
  import { truncateToWidth } from "../tui/pi-tui-load";
14
14
 
15
15
  export const FLEET_WIDGET_KEY = "pi-famulus-fleet";
@@ -72,6 +72,7 @@ export class FleetWidget {
72
72
  private tui: FleetTui | null = null;
73
73
  private unsubscribe: (() => void) | null = null;
74
74
  private ageTimer: ClockTimer | null = null;
75
+ private ageTimerIsTimeout = false;
75
76
  private readonly clock: Clock;
76
77
 
77
78
  constructor(deps: FleetStatusDeps) {
@@ -93,10 +94,11 @@ export class FleetWidget {
93
94
  const counts = this.deps.index.counts();
94
95
  const activeAgents = items.filter((item) => item.kind === "agent" && isActive(item));
95
96
  const total = counts.workers + counts.subagents + counts.monitors;
96
- if (activeAgents.length > 0 && !this.ageTimer) {
97
- this.ageTimer = this.clock.setInterval(() => this.tui?.requestRender(), 5000);
98
- this.clock.unref?.(this.ageTimer);
99
- } else if (activeAgents.length === 0) {
97
+ // Always reschedule: an item added while a timeout is pending may have
98
+ // an earlier boundary, and a removed item must not tick for stale state.
99
+ if (activeAgents.length > 0) {
100
+ this.startAgeTicker();
101
+ } else {
100
102
  this.clearAgeTimer();
101
103
  }
102
104
  if (total === 0) {
@@ -109,6 +111,11 @@ export class FleetWidget {
109
111
  (tui, theme) => {
110
112
  this.tui = tui;
111
113
  this.widgetRegistered = true;
114
+ // The ticker refuses to schedule while no component is mounted
115
+ // (refresh may have run before the host invoked the factory).
116
+ if (!this.ageTimer && this.deps.index.list(this.clock.now()).some((item) => item.kind === "agent" && isActive(item))) {
117
+ this.scheduleNextAgeTick();
118
+ }
112
119
  return {
113
120
  render: (width) => this.renderLine(width, theme),
114
121
  invalidate: () => {},
@@ -136,10 +143,43 @@ export class FleetWidget {
136
143
  if (ui) this.clearWidget(ui);
137
144
  }
138
145
 
146
+ /**
147
+ * Tick exactly when a displayed age changes. formatAge rounds
148
+ * (now - startedAt) to the nearest second, so each live item's visible
149
+ * value flips on half-second boundaries relative to its own startedAt —
150
+ * a global phase would be early/late for items started off the second.
151
+ * Schedule the earliest pending flip and re-evaluate after every tick.
152
+ */
153
+ private startAgeTicker(): void {
154
+ this.clearAgeTimer();
155
+ this.scheduleNextAgeTick();
156
+ }
157
+
158
+ private scheduleNextAgeTick(): void {
159
+ const now = this.clock.now();
160
+ const items = this.deps.index.list(now).filter((item) => item.kind === "agent" && isActive(item));
161
+ if (items.length === 0 || !this.tui) return;
162
+ let next = Infinity;
163
+ for (const item of items) {
164
+ next = Math.min(next, nextAgeBoundary(now, item.startedAt));
165
+ }
166
+ this.ageTimerIsTimeout = true;
167
+ this.ageTimer = this.clock.setTimeout(() => {
168
+ this.ageTimer = null;
169
+ this.ageTimerIsTimeout = false;
170
+ this.tui?.requestRender();
171
+ // Phases and the active set may have changed; schedule the next flip.
172
+ this.scheduleNextAgeTick();
173
+ }, next - now);
174
+ this.clock.unref?.(this.ageTimer);
175
+ }
176
+
139
177
  private clearAgeTimer(): void {
140
178
  if (!this.ageTimer) return;
141
- this.clock.clearInterval(this.ageTimer);
179
+ if (this.ageTimerIsTimeout) this.clock.clearTimeout(this.ageTimer);
180
+ else this.clock.clearInterval(this.ageTimer);
142
181
  this.ageTimer = null;
182
+ this.ageTimerIsTimeout = false;
143
183
  }
144
184
 
145
185
  private clearWidget(ui: FleetUi): void {
@@ -161,9 +161,11 @@ function wrapSession(
161
161
  return {
162
162
  ...(extras.warning !== undefined ? { warning: extras.warning } : {}),
163
163
  ...(extras.resolvedModel !== undefined ? { resolvedModel: extras.resolvedModel } : {}),
164
- prompt: (text) => session.prompt(text),
165
- steer: (text) => session.steer(text),
166
- followUp: (text) => session.followUp(text),
164
+ // pi ≥0.99 returns QueuedInputDisposition ("handled" | "queued") from
165
+ // input methods; the adapter contract stays Promise<void>.
166
+ prompt: async (text) => { await session.prompt(text); },
167
+ steer: async (text) => { await session.steer(text); },
168
+ followUp: async (text) => { await session.followUp(text); },
167
169
  abort: () => session.abort(),
168
170
  waitForIdle: () => session.waitForIdle(),
169
171
  getLastAssistantText: () => session.getLastAssistantText(),
@@ -10,13 +10,12 @@ import type { ManagerClient, TaskRecord } from "../manager-client";
10
10
  import { taskOutputPath } from "../config";
11
11
  import { formatConversation } from "../subagent/conversation";
12
12
  import type { SubagentRegistry } from "../subagent/registry";
13
- import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
13
+ import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
14
14
  import { notifyPlainFallback, showScrollDetail } from "./scroll-detail-view";
15
15
  import { readTaskFileTailCached, stderrPathFor } from "./task-output-paths";
16
16
  import { fitLines, loadPiTui, truncateToWidth } from "./pi-tui-load";
17
17
  import { statusGlyph } from "./tool-component";
18
18
 
19
- const AGE_TICK_MS = 1000;
20
19
 
21
20
  function isLive(item: WorkItem): boolean {
22
21
  return item.status === "pending" || item.status === "running";
@@ -261,9 +260,36 @@ async function showTaskList(
261
260
  let loadingAll = false;
262
261
  let confirmId: string | undefined;
263
262
  let hint = "";
264
- const unsub = subscribe(() => tui.requestRender());
265
- const ageTimer = clock.setInterval(() => tui.requestRender(), AGE_TICK_MS);
266
- clock.unref?.(ageTimer);
263
+ // formatAge rounds (now - startedAt) to the nearest second, so each
264
+ // live row's visible age flips on half-second boundaries relative to
265
+ // its own startedAt. Schedule the earliest pending flip and
266
+ // re-evaluate after every tick and every index change.
267
+ let ageTimer: unknown = null;
268
+ const clearAgeTimer = () => {
269
+ if (ageTimer === null) return;
270
+ clock.clearTimeout(ageTimer);
271
+ ageTimer = null;
272
+ };
273
+ const scheduleAgeTick = () => {
274
+ clearAgeTimer();
275
+ const now = clock.now();
276
+ let next = Infinity;
277
+ for (const item of visibleItems()) {
278
+ if (!isLive(item)) continue;
279
+ next = Math.min(next, nextAgeBoundary(now, item.startedAt));
280
+ }
281
+ if (!Number.isFinite(next)) return;
282
+ ageTimer = clock.setTimeout(() => {
283
+ ageTimer = null;
284
+ tui.requestRender();
285
+ scheduleAgeTick();
286
+ }, next - now);
287
+ clock.unref?.(ageTimer);
288
+ };
289
+ const unsub = subscribe(() => {
290
+ tui.requestRender();
291
+ scheduleAgeTick();
292
+ });
267
293
  const border = new DynamicBorder((text) => theme.fg("border", text));
268
294
  const pageSize = () => Math.max(1, (tui.terminal?.rows ?? 24) - 8);
269
295
  const matches = (data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel") => {
@@ -273,6 +299,7 @@ async function showTaskList(
273
299
  scope === "all" ? mergeItems(getItems(), allItems) : getItems(),
274
300
  filter,
275
301
  );
302
+ scheduleAgeTick();
276
303
  const choose = (id: string | undefined) => {
277
304
  selectedId = id;
278
305
  hint = "";
@@ -394,7 +421,10 @@ async function showTaskList(
394
421
  tui.requestRender();
395
422
  }
396
423
  },
397
- dispose() { clock.clearInterval(ageTimer); unsub(); },
424
+ dispose() {
425
+ clearAgeTimer();
426
+ unsub();
427
+ },
398
428
  };
399
429
  },
400
430
  // Bottom sheet over the editor, where the user typed /tasks; the list is
package/src/work-index.ts CHANGED
@@ -180,3 +180,19 @@ export function formatAge(startedAt: number, endedAt: number | undefined, now: n
180
180
  if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
181
181
  return `${Math.floor(ms / 3_600_000)}h${Math.floor((ms % 3_600_000) / 60_000)}m`;
182
182
  }
183
+
184
+ /**
185
+ * Next time the age displayed by {@link formatAge} for an item started at
186
+ * `startedAt` changes, given at most one render per second.
187
+ *
188
+ * Below 1s formatAge shows raw milliseconds, so a sub-second item's first
189
+ * scheduled update is the transition to "1s" at the 1s mark. At 1s and above
190
+ * the value rounds to the nearest second and flips on half-second boundaries
191
+ * relative to `startedAt`.
192
+ */
193
+ export function nextAgeBoundary(now: number, startedAt: number): number {
194
+ const age = now - startedAt;
195
+ if (age < 1000) return now + (1000 - age);
196
+ const offset = age % 1000;
197
+ return now + (offset < 500 ? 500 - offset : 1500 - offset);
198
+ }