@bermudi/pi-delegate 0.1.13 → 0.1.15

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/format.ts CHANGED
@@ -3,6 +3,8 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { renderOutputForLLM } from "./spill.ts";
5
5
  import { getOutputSpillThreshold, getOutputSpillTail } from "./config.ts";
6
+ import { toolExpandHint } from "./key-hints.ts";
7
+ import { sanitizeTerminalLine } from "./utils.ts";
6
8
  import type {
7
9
  ResolvedTask,
8
10
  TaskProgress,
@@ -136,9 +138,13 @@ export function applyLineBudget(lines: string[], expanded: boolean): string[] {
136
138
  const budget = Math.max(10, Math.min(18, Math.floor(rows * 0.4)));
137
139
  if (lines.length <= budget) return [...lines];
138
140
  const hidden = lines.length - budget + 1;
141
+ const expandHint = toolExpandHint("expands");
139
142
  return [
140
143
  ...lines.slice(0, budget - 1),
141
- truncLine(`… ${hidden} lines hidden · Ctrl+O expands`, getTermWidth()),
144
+ truncLine(
145
+ `… ${hidden} lines hidden${expandHint ? ` · ${expandHint}` : ""}`,
146
+ getTermWidth(),
147
+ ),
142
148
  ];
143
149
  }
144
150
 
@@ -189,7 +195,36 @@ export function trunc(s: string, n: number): string {
189
195
 
190
196
  /** Render an optional task `id` in a compact, visually distinct form. */
191
197
  export function formatTaskId(id: string | undefined): string {
192
- return id ? ` #${id}` : "";
198
+ if (!id) return "";
199
+ const safeId = sanitizeTerminalLine(id);
200
+ return safeId ? ` #${safeId}` : "";
201
+ }
202
+
203
+ /**
204
+ * Short identity tag for a resumed transcript, derived from its session file
205
+ * path (pi names sessions `<timestamp>_<uuid>.jsonl`, so the UUID prefix is
206
+ * the stable part). Best-effort display identity only — never parsed back.
207
+ */
208
+ export function formatResumeTag(resumeFrom: string): string {
209
+ const stem = sanitizeTerminalLine(resumeFrom).replace(/\.jsonl$/i, "");
210
+ const base = stem.split("/").pop() ?? stem;
211
+ const unique = base.includes("_") ? (base.split("_").pop() ?? base) : base;
212
+ return (unique.slice(0, 8) || "resumed").trim();
213
+ }
214
+
215
+ /**
216
+ * Plain-text revival marker for a progress row that continued an earlier
217
+ * transcript. Empty when the row is not a resume, or when the agent name
218
+ * already carries the resume identity (`resume:<tag>`, set at task resolution
219
+ * for omitted-agent resumes) — the marker must not duplicate it.
220
+ */
221
+ export function resumeMarker(p: {
222
+ agent: string;
223
+ resumedFrom?: string;
224
+ }): string {
225
+ return p.resumedFrom && p.agent !== `resume:${p.resumedFrom}`
226
+ ? ` ↻${p.resumedFrom}`
227
+ : "";
193
228
  }
194
229
 
195
230
  /**
@@ -206,7 +241,7 @@ export function previewOutputLine(output: string, maxWidth: number): string {
206
241
  const clean = output.trim();
207
242
  if (!clean || clean === "(no output)") return "";
208
243
  for (const raw of clean.split("\n")) {
209
- const line = raw.trim();
244
+ const line = sanitizeTerminalLine(raw);
210
245
  if (!line) continue;
211
246
  // Strip leading markdown noise so the preview reads as content, not markup.
212
247
  const stripped = line
@@ -241,20 +276,23 @@ function firstArg(
241
276
  return undefined;
242
277
  }
243
278
 
244
- /** Render a compact, human-readable summary of a tool call. */
279
+ /** Render a compact, terminal-safe, single-line summary of a tool call. */
245
280
  export function formatToolCallShort(
246
281
  name: string,
247
282
  args: Record<string, unknown>,
248
283
  ): string {
249
- if (!args || typeof args !== "object") return name;
284
+ const safeName = sanitizeTerminalLine(name);
285
+ if (!args || typeof args !== "object") return safeName;
250
286
  switch (name) {
251
287
  case "bash": {
252
- const cmd = firstArg(args, "command") ?? "...";
288
+ const cmd = sanitizeTerminalLine(firstArg(args, "command") ?? "...");
253
289
  const maxLen = 80;
254
290
  return `$ ${cmd.length > maxLen ? cmd.slice(0, maxLen) + "…" : cmd}`;
255
291
  }
256
292
  case "read": {
257
- const p = shortenPath(firstArg(args, "path", ["file_path"]) ?? "...");
293
+ const p = shortenPath(
294
+ sanitizeTerminalLine(firstArg(args, "path", ["file_path"]) ?? "..."),
295
+ );
258
296
  const offset = typeof args.offset === "number" ? args.offset : undefined;
259
297
  const limit = typeof args.limit === "number" ? args.limit : undefined;
260
298
  let line = `read ${p}`;
@@ -266,16 +304,20 @@ export function formatToolCallShort(
266
304
  return line;
267
305
  }
268
306
  case "write": {
269
- const p = shortenPath(firstArg(args, "path", ["file_path"]) ?? "...");
307
+ const p = shortenPath(
308
+ sanitizeTerminalLine(firstArg(args, "path", ["file_path"]) ?? "..."),
309
+ );
270
310
  const lines = String(args.content ?? "").split("\n").length;
271
311
  return `write ${p}${lines > 1 ? ` (${lines} lines)` : ""}`;
272
312
  }
273
313
  case "edit": {
274
- const p = shortenPath(firstArg(args, "path", ["file_path"]) ?? "...");
314
+ const p = shortenPath(
315
+ sanitizeTerminalLine(firstArg(args, "path", ["file_path"]) ?? "..."),
316
+ );
275
317
  return `edit ${p}`;
276
318
  }
277
319
  default: {
278
- // Try to pick a meaningful first arg before falling back to JSON
320
+ // Try to pick a meaningful first arg before falling back to JSON.
279
321
  for (const key of [
280
322
  "command",
281
323
  "path",
@@ -288,15 +330,20 @@ export function formatToolCallShort(
288
330
  ]) {
289
331
  const val = args[key];
290
332
  if (typeof val === "string" && val.trim()) {
291
- const preview = val.length > 50 ? val.slice(0, 50) + "…" : val;
292
- return `${name} ${preview}`;
333
+ const safeValue = sanitizeTerminalLine(val);
334
+ const preview =
335
+ safeValue.length > 50 ? safeValue.slice(0, 50) + "…" : safeValue;
336
+ return `${safeName} ${preview}`;
293
337
  }
294
338
  }
295
339
  try {
296
- const preview = JSON.stringify(args).slice(0, 50);
297
- return `${name} ${preview}${preview.length >= 50 ? "…" : ""}`;
340
+ const json = JSON.stringify(args);
341
+ if (json === undefined) return safeName;
342
+ const serialized = sanitizeTerminalLine(json);
343
+ const preview = serialized.slice(0, 50);
344
+ return `${safeName} ${preview}${serialized.length > 50 ? "…" : ""}`;
298
345
  } catch {
299
- return name;
346
+ return safeName;
300
347
  }
301
348
  }
302
349
  }
@@ -338,6 +385,10 @@ function isResumableSessionFile(sessionFile: string): boolean {
338
385
  return false;
339
386
  }
340
387
 
388
+ /** Shared LLM-facing warning for results returned before proven quiescence. */
389
+ export const INCOMPLETE_QUIESCENCE_WARNING =
390
+ "[INCOMPLETE: quiescence was abandoned; output, file evidence, token usage, and cost are lower bounds while the quarantined session settles.]";
391
+
341
392
  /**
342
393
  * Render a failed or aborted task's result lines for LLM consumption.
343
394
  *
@@ -364,7 +415,10 @@ export function formatFailedTask(
364
415
  config?: import("./config.ts").DelegateConfig,
365
416
  ): string[] {
366
417
  const parts: string[] = [];
367
- const isAbort = r.error === "Aborted";
418
+ if (r.incomplete === "quiescence_abandoned") {
419
+ parts.push(INCOMPLETE_QUIESCENCE_WARNING);
420
+ }
421
+ const isAbort = r.failureKind === "cancelled";
368
422
  // Empty string is falsy but not nullish — `||` covers both undefined and "".
369
423
  const failParts = [r.error || "unknown error"];
370
424
  if (r.sessionFile) failParts.push(`session: ${shortenPath(r.sessionFile)}`);
@@ -436,7 +490,7 @@ export function formatCompletedTask(
436
490
  // `|| task.sessionAction` covers action-only tasks (close/list/...) where prompt is
437
491
  // empty. Async prompt tasks always set prompt, so this is a no-op there.
438
492
  parts.push(
439
- `=== ${result.agent}${formatTaskId(result.id ?? task.id)}: ${trunc(task.prompt || task.sessionAction || "", 80)} ===`,
493
+ `=== ${result.agent}${resumeMarker(result)}${formatTaskId(result.id ?? task.id)}: ${trunc(task.prompt || task.sessionAction || "", 80)} ===`,
440
494
  );
441
495
  if (task.warnings?.length) {
442
496
  for (const w of task.warnings) parts.push(`[WARNING: ${w}]`);
@@ -444,8 +498,11 @@ export function formatCompletedTask(
444
498
  if (result.error) {
445
499
  parts.push(...formatFailedTask(result, task.cwd, config));
446
500
  } else {
501
+ if (result.incomplete === "quiescence_abandoned") {
502
+ parts.push(INCOMPLETE_QUIESCENCE_WARNING);
503
+ }
447
504
  const meta = [
448
- `OK | ${fmtDuration(result.durationMs)} | ${fmtTokens(result.tokens)} tokens`,
505
+ `OK | ${fmtDuration(result.durationMs)} | ${taskTokenLabel(result)}`,
449
506
  ];
450
507
  if (result.sessionFile) meta.push(shortenPath(result.sessionFile));
451
508
  const touched = relativeTouchedSummary(result.touchedFiles, task.cwd);
@@ -477,6 +534,23 @@ export function formatCompletedTask(
477
534
  for (const conflict of integration.conflicts ?? []) {
478
535
  parts.push(`conflict: ${conflict.path}: ${conflict.reason}`);
479
536
  }
537
+ for (const issue of "classificationIssues" in integration
538
+ ? (integration.classificationIssues ?? [])
539
+ : []) {
540
+ parts.push(
541
+ `attribution classification issue: ${issue.path}: ${issue.reason}`,
542
+ );
543
+ }
544
+ if (integration.cleanupIssue) {
545
+ parts.push(
546
+ `cleanup ${integration.cleanupIssue.status}: ${integration.cleanupIssue.reason}`,
547
+ );
548
+ if (integration.cleanupIssue.recoveryPath) {
549
+ parts.push(
550
+ `cleanup recovery path: ${integration.cleanupIssue.recoveryPath}`,
551
+ );
552
+ }
553
+ }
480
554
  if (integration.status === "applied_unverified") {
481
555
  parts.push(
482
556
  'Changes were applied but not verified. Suggested next call: delegate({ tasks: [{ agent: "reviewer", workspace: "scratch", prompt: "Review the applied isolated changes and run the relevant tests." }] })',
@@ -511,7 +585,9 @@ export function latestActivity(p: TaskProgress): ToolActivity | null {
511
585
  export function formatActivityLabel(p: TaskProgress): string {
512
586
  const activity = inFlightActivity(p) ?? latestActivity(p);
513
587
  if (!activity) return "thinking";
514
- const call = formatToolCallShort(activity.name, activity.args);
588
+ const call = sanitizeTerminalLine(
589
+ formatToolCallShort(activity.name, activity.args),
590
+ );
515
591
  if (!activity.result) return call;
516
592
  return `last: ${call}`;
517
593
  }
@@ -522,7 +598,9 @@ export function formatActivityLabel(p: TaskProgress): string {
522
598
  export function compactActivity(p: TaskProgress): string {
523
599
  const activity = inFlightActivity(p) ?? latestActivity(p);
524
600
  if (!activity) return "thinking…";
525
- const call = formatToolCallShort(activity.name, activity.args);
601
+ const call = sanitizeTerminalLine(
602
+ formatToolCallShort(activity.name, activity.args),
603
+ );
526
604
  if (!activity.result) {
527
605
  const toolAge = fmtDuration(Date.now() - activity.startTime);
528
606
  return `${call} | ${toolAge}`;
@@ -533,8 +611,16 @@ export function compactActivity(p: TaskProgress): string {
533
611
 
534
612
  /** Per-task stats core: [duration, tokens]. Callers append medium-specific
535
613
  * extras (touched files; themed join). */
614
+ export function taskTokenLabel(
615
+ result: Pick<TaskResult, "tokens" | "incomplete">,
616
+ ): string {
617
+ return result.incomplete === "quiescence_abandoned"
618
+ ? `≥${fmtTokens(result.tokens)} tokens (incomplete)`
619
+ : `${fmtTokens(result.tokens)} tokens`;
620
+ }
621
+
536
622
  export function taskMetaBase(r: TaskResult): string[] {
537
- return [fmtDuration(r.durationMs), `${fmtTokens(r.tokens)} tokens`];
623
+ return [fmtDuration(r.durationMs), taskTokenLabel(r)];
538
624
  }
539
625
 
540
626
  /** Pending-task waiting label: "queued (N running)" at the concurrency cap,