pi-crew 0.9.36 → 0.9.38

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-crew",
3
- "version": "0.9.36",
3
+ "version": "0.9.38",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -694,7 +694,7 @@ function parseIsolationPolicy(value: unknown): CrewRuntimeConfig["isolationPolic
694
694
 
695
695
  function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
696
696
  const obj = asRecord(value);
697
- if (!obj) return undefined;
697
+ if (!obj) return { inheritContext: true } as CrewRuntimeConfig;
698
698
  const runtime: CrewRuntimeConfig = {
699
699
  mode: parseWithSchema(
700
700
  Type.Union([Type.Literal("auto"), Type.Literal("scaffold"), Type.Literal("child-process"), Type.Literal("live-session")]),
@@ -704,7 +704,7 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
704
704
  allowChildProcessFallback: parseWithSchema(Type.Boolean(), obj.allowChildProcessFallback),
705
705
  maxTurns: parsePositiveInteger(obj.maxTurns, LIMIT_CEILINGS.runtimeMaxTurns),
706
706
  graceTurns: parsePositiveInteger(obj.graceTurns, LIMIT_CEILINGS.runtimeGraceTurns),
707
- inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext),
707
+ inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext) ?? true,
708
708
  promptMode: parseWithSchema(Type.Union([Type.Literal("replace"), Type.Literal("append")]), obj.promptMode),
709
709
  groupJoin: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")]), obj.groupJoin),
710
710
  groupJoinAckTimeoutMs: parsePositiveInteger(obj.groupJoinAckTimeoutMs, 86_400_000),
@@ -1,5 +1,5 @@
1
1
  import type { SpeedConfig } from "./config.ts";
2
- import { isWebTerminal } from "./font-detect.ts";
2
+ import { hasCrewFontFile, isWebTerminal } from "./font-detect.ts";
3
3
 
4
4
  /**
5
5
  * Crew figures replacing the original cat glyphs from pi-speeed / pi-chonk.
@@ -57,12 +57,19 @@ const PUA_CREW_FRAMES: readonly string[] = [
57
57
  /**
58
58
  * Return the indicator frames for the given style.
59
59
  *
60
- * - `"braille"` (default): standard Unicode braille spinner, works everywhere
61
- * - `"pua"`: runner-pose glyphs from crew-vibes.ttf, requires font install
60
+ * Default is `"braille"` — standard Unicode braille spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏)
61
+ * that renders correctly on every terminal, every platform, with zero
62
+ * font dependency. PUA runner-glyphs require crew-vibes.ttf installed AND
63
+ * a terminal that does font fallback for Private Use Area codepoints —
64
+ * Windows Terminal does NOT do this reliably, showing garbage chars even
65
+ * with the font installed.
66
+ *
67
+ * To opt into the PUA runner look, pass `style: "pua"` explicitly.
62
68
  */
63
- export function crewFrames(style: "braille" | "pua" = "pua"): readonly string[] {
64
- // Web terminals (gotty, wetty) cannot render PUA glyphs — fall back to braille
65
- if (style === "pua" && isWebTerminal()) return BRAILLE_FRAMES;
69
+ export function crewFrames(style: "braille" | "pua" = "braille"): readonly string[] {
70
+ // Even when explicitly requested, fall back to braille if the font is
71
+ // missing or we're on a web terminal (which can't render PUA at all).
72
+ if (style === "pua" && (isWebTerminal() || !hasCrewFontFile())) return BRAILLE_FRAMES;
66
73
  return style === "pua" ? PUA_CREW_FRAMES : BRAILLE_FRAMES;
67
74
  }
68
75
 
@@ -1,5 +1,24 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { requestRender, setFooter } from "../../ui/pi-ui-compat.ts";
3
+ import { logInternalError } from "../../utils/internal-error.ts";
4
+
5
+ /**
6
+ * Wrap a pi UI call in try/catch. If the call throws (e.g. terminal doesn't
7
+ * support PUA glyphs, theme helper returns undefined, or pi's API signature
8
+ * changed), log it and silently fall back to pi's default behavior — which
9
+ * is simply "do nothing", letting pi show its own built-in indicator.
10
+ *
11
+ * Crew-vibes must NEVER break the user's session. A broken spinner is not
12
+ * worth a crashed pi.
13
+ */
14
+ function safeUiCall(scope: string, fn: () => void): void {
15
+ try {
16
+ fn();
17
+ } catch (error) {
18
+ logInternalError(`crew-vibes.${scope}`, error, undefined, "warn");
19
+ }
20
+ }
21
+
3
22
  import { type CrewVibesConfig, loadConfig, PROVIDER_STATUS_ID, saveConfig } from "./config.ts";
4
23
  import { intervalForSpeed } from "./figures.ts";
5
24
  import { type CrewVibesFooterSource, createCrewVibesFooter } from "./footer.ts";
@@ -72,44 +91,48 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
72
91
  function installFooter(ctx: ExtensionContext): void {
73
92
  if (!ctx?.hasUI) return;
74
93
  if (!metersActive()) {
75
- setFooter(ctx, undefined);
94
+ safeUiCall("clear-footer", () => setFooter(ctx, undefined));
76
95
  return;
77
96
  }
78
- setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
79
- requestRender(ctx);
97
+ safeUiCall("install-footer", () => {
98
+ setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
99
+ requestRender(ctx);
100
+ });
80
101
  }
81
102
 
82
103
  /** Trigger a footer repaint; the footer recomputes capacity/quota on render. */
83
104
  function refreshFooter(ctx: ExtensionContext): void {
84
- if (ctx?.hasUI) requestRender(ctx);
105
+ if (ctx?.hasUI) safeUiCall("refresh-footer", () => requestRender(ctx));
85
106
  }
86
107
 
87
108
  function publishSpeedFooter(ctx: ExtensionContext, speed = footerAnimator.value()): void {
88
109
  if (!config.enabled || !config.speed.enabled || !config.speed.footer) {
89
- setSpeedStatus(ctx, config, undefined);
110
+ safeUiCall("clear-speed-status", () => setSpeedStatus(ctx, config, undefined));
90
111
  return;
91
112
  }
92
- setSpeedStatus(ctx, config, renderSpeedFooter(themeOf(ctx), config.speed, speed));
113
+ safeUiCall("publish-speed-status", () => setSpeedStatus(ctx, config, renderSpeedFooter(themeOf(ctx), config.speed, speed)));
93
114
  }
94
115
 
95
116
  function applyIndicator(ctx: ExtensionContext, speed: number | null, force = false): void {
96
117
  if (!ctx.hasUI || !ctx.ui.setWorkingIndicator) return;
97
118
  if (!config.enabled || !config.speed.enabled || !config.speed.indicator) {
98
- ctx.ui.setWorkingIndicator();
119
+ safeUiCall("reset-indicator", () => ctx.ui.setWorkingIndicator?.());
99
120
  return;
100
121
  }
101
122
  const next = intervalForSpeed(config.speed, speed);
102
123
  if (!force && Math.abs(next - currentIntervalMs) < 10) return;
103
- ctx.ui.setWorkingIndicator({
104
- frames: crewIndicatorFrames(themeOf(ctx)),
105
- intervalMs: next,
106
- });
124
+ safeUiCall("set-indicator", () =>
125
+ ctx.ui.setWorkingIndicator?.({
126
+ frames: crewIndicatorFrames(themeOf(ctx)),
127
+ intervalMs: next,
128
+ }),
129
+ );
107
130
  currentIntervalMs = next;
108
131
  }
109
132
 
110
133
  function renderWorking(ctx: ExtensionContext, speed: number | null): void {
111
134
  if (!config.enabled || !config.speed.enabled || !ctx.hasUI) return;
112
- ctx.ui.setWorkingMessage(renderWorkingMessage(themeOf(ctx), config.speed, speed));
135
+ safeUiCall("set-working-message", () => ctx.ui.setWorkingMessage?.(renderWorkingMessage(themeOf(ctx), config.speed, speed)));
113
136
  }
114
137
 
115
138
  function stopLiveTimer(): void {
@@ -315,7 +338,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
315
338
  stopLiveTimer();
316
339
  if (ctx && config.enabled && ctx.hasUI) {
317
340
  applyIndicator(ctx, speedTracker.lastTokS);
318
- ctx.ui.setWorkingMessage();
341
+ safeUiCall("clear-working-message", () => ctx.ui.setWorkingMessage?.());
319
342
  }
320
343
  });
321
344
 
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
2
3
  import type { PiTeamsConfig } from "../../config/config.ts";
3
4
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
4
5
  import type { RunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
@@ -47,6 +48,40 @@ function extractTextContent(content: unknown): string {
47
48
  .join("\n");
48
49
  }
49
50
 
51
+ /**
52
+ * Maximum character budget for inherited parent context (~3K tokens).
53
+ * When exceeded, oldest messages are dropped first (most-recent-first retention).
54
+ */
55
+ export const MAX_PARENT_CONTEXT_CHARS = 12_000;
56
+
57
+ /** Maximum chars for a single assistant message before truncation. */
58
+ const MAX_ASSISTANT_MSG_CHARS = 500;
59
+
60
+ /** Truncated length for oversized assistant messages. */
61
+ const TRUNCATED_ASSISTANT_CHARS = 200;
62
+
63
+ /** Messages starting with these patterns and exceeding this size are likely
64
+ * file dumps / bash output — skip them to keep context relevant and compact. */
65
+ const NOISY_THRESHOLD = 1_000;
66
+ const NOISY_PREFIXES = ["```", "total ", "drwx", "-rw", "import ", "export "];
67
+
68
+ /**
69
+ * Check if a message looks like noisy file/tool output (code dumps, ls output,
70
+ * import lists) that bloats context without helping the subagent.
71
+ */
72
+ function isNoisyContent(text: string): boolean {
73
+ if (text.length < NOISY_THRESHOLD) return false;
74
+ return NOISY_PREFIXES.some((prefix) => text.startsWith(prefix));
75
+ }
76
+
77
+ /**
78
+ * Build a compact parent conversation context for subagent inheritance.
79
+ *
80
+ * Extracts recent user messages, assistant reasoning, and compaction summaries
81
+ * from the parent session. Applies a character budget (most-recent-first
82
+ * retention) and filters noisy content (file dumps, long bash output) to keep
83
+ * the inherited context relevant without bloating the subagent's token budget.
84
+ */
50
85
  export function buildParentContext(ctx: TeamContext): string | undefined {
51
86
  const branch = ctx.sessionManager?.getBranch?.();
52
87
  if (!Array.isArray(branch) || branch.length === 0) return undefined;
@@ -58,21 +93,43 @@ export function buildParentContext(ctx: TeamContext): string | undefined {
58
93
  message?: unknown;
59
94
  summary?: unknown;
60
95
  };
61
- if (record.type === "compaction" && typeof record.summary === "string") parts.push(`[Summary]: ${record.summary}`);
96
+ // Compaction summaries are always valuable — keep in full.
97
+ if (record.type === "compaction" && typeof record.summary === "string") {
98
+ parts.push(`[Summary]: ${record.summary}`);
99
+ continue;
100
+ }
62
101
  const message =
63
102
  record.message && typeof record.message === "object" && !Array.isArray(record.message)
64
103
  ? (record.message as { role?: unknown; content?: unknown })
65
104
  : undefined;
66
105
  if (!message || (message.role !== "user" && message.role !== "assistant")) continue;
67
- const text = extractTextContent(message.content).trim();
68
- if (text) parts.push(`[${message.role === "user" ? "User" : "Assistant"}]: ${text}`);
106
+ let text = extractTextContent(message.content).trim();
107
+ if (!text) continue;
108
+ // Filter: skip noisy content (file dumps, long bash output).
109
+ if (isNoisyContent(text)) continue;
110
+ // Truncate: long assistant messages get shortened to key points.
111
+ if (message.role === "assistant" && text.length > MAX_ASSISTANT_MSG_CHARS) {
112
+ text = `${text.slice(0, TRUNCATED_ASSISTANT_CHARS)}…`;
113
+ }
114
+ parts.push(`[${message.role === "user" ? "User" : "Assistant"}]: ${text}`);
69
115
  }
70
116
  if (!parts.length) return undefined;
117
+
118
+ // Budget: keep most-recent messages first, drop oldest when over budget.
119
+ let totalChars = 0;
120
+ const budgeted: string[] = [];
121
+ for (const part of [...parts].reverse()) {
122
+ if (totalChars + part.length > MAX_PARENT_CONTEXT_CHARS) break;
123
+ budgeted.unshift(part);
124
+ totalChars += part.length;
125
+ }
126
+ if (!budgeted.length) return undefined;
127
+
71
128
  return [
72
129
  `# Parent Conversation Context`,
73
130
  "The following context was inherited from the parent Pi session. Treat it as reference-only.",
74
131
  "",
75
- parts.join("\n\n"),
132
+ budgeted.join("\n\n"),
76
133
  ].join("\n");
77
134
  }
78
135
 
@@ -14,7 +14,7 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
14
14
  "runtime.mode": "auto",
15
15
  "runtime.maxTurns": 10000,
16
16
  "runtime.graceTurns": 5,
17
- "runtime.inheritContext": false,
17
+ "runtime.inheritContext": true,
18
18
  "runtime.promptMode": "replace",
19
19
  "runtime.completionMutationGuard": "warn",
20
20
  "runtime.isolationPolicy": undefined,
@@ -832,6 +832,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
832
832
  runtime,
833
833
  runtimeConfig: executedConfig.runtime,
834
834
  parentContext: buildParentContext(ctx),
835
+
835
836
  parentModel: ctx.model,
836
837
  modelRegistry: ctx.modelRegistry,
837
838
  modelOverride: params.model,
@@ -327,7 +327,7 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
327
327
  "runtime.mode": "auto",
328
328
  "runtime.maxTurns": 10000,
329
329
  "runtime.graceTurns": 5,
330
- "runtime.inheritContext": false,
330
+ "runtime.inheritContext": true,
331
331
  "runtime.promptMode": "replace",
332
332
  "runtime.completionMutationGuard": "warn",
333
333
  "runtime.isolationPolicy": undefined,
@@ -9,6 +9,7 @@
9
9
  import { type Container, Text, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
11
11
  import { truncateToWidth } from "../../utils/visual.ts";
12
+ import { spinnerFrame } from "../spinner.ts";
12
13
  import type { CrewTheme } from "../theme-adapter.ts";
13
14
  import { formatDuration, formatTokens, truncLine } from "../tool-render.ts";
14
15
  import { briefToolResult, isBrief } from "./brief-mode.ts";
@@ -373,9 +374,25 @@ function renderAgentResult(result: Record<string, unknown>, options: unknown, th
373
374
  // isPartial = agent still running
374
375
  const isPartial = (options as Record<string, unknown>)?.isPartial === true;
375
376
  if (isPartial && !ctx.expanded) {
376
- const spinner = theme.fg("warning", "◉");
377
- const label = theme.fg("muted", "agent working...");
378
- contentLines.push(padVisual(` ${spinner} ${label}`, innerW));
377
+ const spinner = theme.fg("accent", spinnerFrame(d.agentId as string));
378
+ const parts: string[] = [theme.fg("muted", "working")];
379
+
380
+ // Extract progress from onUpdate content (formatCompactToolProgress output)
381
+ const progressText = extractContentText(result?.content);
382
+ if (progressText) {
383
+ const elapsedMatch = progressText.match(/elapsed=(\d+)s/);
384
+ const tokenMatch = progressText.match(/tokens=(\d+)/);
385
+ const toolMatch = progressText.match(/tool:\s+(\S+)/);
386
+ const elapsedSec = elapsedMatch ? parseInt(elapsedMatch[1]!, 10) : 0;
387
+ const tokens = tokenMatch ? parseInt(tokenMatch[1]!, 10) : 0;
388
+ const tps = elapsedSec > 0 ? Math.round(tokens / elapsedSec) : 0;
389
+
390
+ if (elapsedSec > 0) parts.push(theme.fg("dim", formatDuration(elapsedSec * 1000)));
391
+ if (tps > 0) parts.push(theme.fg("dim", `${formatTokens(tps)} tok/s`));
392
+ if (toolMatch) parts.push(theme.fg("dim", `· ${toolMatch[1]}`));
393
+ }
394
+
395
+ contentLines.push(padVisual(` ${spinner} ${parts.join(" ")}`, innerW));
379
396
  return buildFrameBottom(contentLines, w, theme, "borderAccent", "borderAccent");
380
397
  }
381
398
 
@@ -18,6 +18,7 @@ import { computeLiveDurationMs } from "../live-duration.ts";
18
18
  // verbatim (no truncation/crash) — these are rare one-off states, not jitter.
19
19
  const TOOLS_METRIC_WIDTH = 8; // "127 tools"
20
20
  const TOKENS_METRIC_WIDTH = 10; // "1.2k tok", "12.3M tok"
21
+ const TPS_METRIC_WIDTH = 9; // "411 tok/s"
21
22
  const CTX_METRIC_WIDTH = 7; // "100% ctx"
22
23
  const DURATION_METRIC_WIDTH = 6; // "120.0s"
23
24
 
@@ -139,10 +140,19 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
139
140
  /* ignore */
140
141
  }
141
142
  const ms = computeLiveDurationMs(act);
143
+ if (total > 0 && ms > 1000) {
144
+ const tps = Math.round(total / (ms / 1000));
145
+ if (tps > 0) parts.push(alignMetric(`${formatTokensCompact(tps)}/s`, TPS_METRIC_WIDTH));
146
+ }
142
147
  parts.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
143
148
  } else {
144
149
  if (agent.toolUses) parts.push(alignMetric(`${agent.toolUses} tools`, TOOLS_METRIC_WIDTH));
145
150
  if (agent.progress?.tokens) parts.push(alignMetric(formatTokensCompact(agent.progress.tokens), TOKENS_METRIC_WIDTH));
151
+ const ageMs = agent.startedAt ? Math.max(0, Date.now() - new Date(agent.startedAt).getTime()) : 0;
152
+ if (agent.progress?.tokens && ageMs > 1000) {
153
+ const tps = Math.round(agent.progress.tokens / (ageMs / 1000));
154
+ if (tps > 0) parts.push(alignMetric(`${formatTokensCompact(tps)}/s`, TPS_METRIC_WIDTH));
155
+ }
146
156
  const age = elapsed(agent.completedAt ?? agent.startedAt);
147
157
  if (age) parts.push(alignMetric(age, DURATION_METRIC_WIDTH));
148
158
  }