pi-crew 0.9.27 → 0.9.28

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.27",
3
+ "version": "0.9.28",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -83,6 +83,8 @@ export interface AgentConfig {
83
83
  contextMode?: "fresh" | "fork";
84
84
  /** Maximum turns for this agent. Overrides runtime config if set. */
85
85
  maxTurns?: number;
86
+ /** Cap on output tokens per API call. Set via PI_CREW_MAX_OUTPUT_TOKENS env in child. */
87
+ maxTokens?: number;
86
88
  /** Effort level for this agent. Controls how much work the agent puts in. */
87
89
  effort?: "low" | "medium" | "high";
88
90
  /** Tools to explicitly forbid for this agent. Takes precedence over allowedTools. */
@@ -431,6 +431,10 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
431
431
  const n = Number.parseInt(frontmatter.maxTurns, 10);
432
432
  return Number.isFinite(n) && n > 0 ? n : undefined;
433
433
  })(),
434
+ maxTokens: (() => {
435
+ const n = Number.parseInt(frontmatter.maxTokens, 10);
436
+ return Number.isFinite(n) && n > 0 ? n : undefined;
437
+ })(),
434
438
  effort:
435
439
  frontmatter.effort === "low" || frontmatter.effort === "medium" || frontmatter.effort === "high"
436
440
  ? frontmatter.effort
@@ -61,7 +61,7 @@ const PUA_CREW_FRAMES: readonly string[] = [
61
61
  * - `"pua"`: runner-pose glyphs from crew-vibes.ttf, requires font install
62
62
  */
63
63
  export function crewFrames(style: "braille" | "pua" = "pua"): readonly string[] {
64
- // Web terminals (gotty, wetty) cannot render PUA glyphs — use braille.
64
+ // Web terminals (gotty, wetty) cannot render PUA glyphs — fall back to braille
65
65
  if (style === "pua" && isWebTerminal()) return BRAILLE_FRAMES;
66
66
  return style === "pua" ? PUA_CREW_FRAMES : BRAILLE_FRAMES;
67
67
  }
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type CrewVibesConfig, loadConfig, PROVIDER_STATUS_ID, saveConfig } from "./config.ts";
3
- import { isWebTerminal } from "./font-detect.ts";
4
- import { fetchProviderUsage, clearProviderUsageCache } from "./provider-usage.ts";
3
+ import { fetchProviderUsage, clearProviderUsageCache, providerSupportsQuota } from "./provider-usage.ts";
5
4
  import { intervalForSpeed } from "./figures.ts";
6
5
  import {
7
6
  asCrewTheme,
@@ -18,7 +17,6 @@ import {
18
17
  setSpeedStatus,
19
18
  } from "./render.ts";
20
19
  import { SpeedAnimator, SpeedTracker } from "./speed.ts";
21
- import { CAT_FRAMES } from "./cat-frames.ts";
22
20
 
23
21
  export const CREW_VIBES_STATUS_KEY = "pi-crew-vibes";
24
22
 
@@ -52,7 +50,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
52
50
  let capacityTimer: ReturnType<typeof setInterval> | undefined;
53
51
  let providerTimer: ReturnType<typeof setInterval> | undefined;
54
52
  let lastProviderText: string | undefined;
55
- let catFrameIndex = 0;
53
+ let currentProvider: string | undefined;
56
54
 
57
55
  /** Strip ANSI codes to measure visible width. */
58
56
  function visibleLen(text: string): number {
@@ -146,11 +144,6 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
146
144
  const speed = speedTracker.liveTokS();
147
145
  applyIndicator(ctx, speed);
148
146
  renderWorking(ctx, speed);
149
- // Update cat animation frame in web terminals
150
- if (isWebTerminal()) {
151
- catFrameIndex = (catFrameIndex + 1) % CAT_FRAMES.length;
152
- ctx.ui.setWidget("crew-vibes-cat", [...CAT_FRAMES[catFrameIndex]], { placement: "aboveEditor" });
153
- }
154
147
  }, config.speed.renderIntervalMs);
155
148
  liveTimer.unref?.();
156
149
  }
@@ -186,7 +179,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
186
179
  return;
187
180
  }
188
181
  try {
189
- const usage = await fetchProviderUsage(config.capacity.providerRefreshMs);
182
+ const usage = await fetchProviderUsage(config.capacity.providerRefreshMs, currentProvider);
190
183
  lastProviderText = renderProviderUsage(themeOf(ctx), usage);
191
184
  // Re-render combined capacity + provider line
192
185
  publishCapacity(ctx);
@@ -235,6 +228,8 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
235
228
  speedTracker.resetSession();
236
229
  footerAnimator.reset(null);
237
230
  clearProviderUsageCache();
231
+ // Initialize provider from current model — model_select only fires on manual switch
232
+ currentProvider = (ctx.model as { provider?: string } | undefined)?.provider;
238
233
  if (!config.enabled) {
239
234
  clearVibesStatus(ctx);
240
235
  return;
@@ -266,10 +261,6 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
266
261
  footerAnimator.reset(speedTracker.lastTokS);
267
262
  startLiveTimer(ctx);
268
263
  lastRenderedAt = 0;
269
- // Show cat widget in web terminals
270
- if (isWebTerminal() && ctx.hasUI) {
271
- ctx.ui.setWidget("crew-vibes-cat", [...CAT_FRAMES[0]], { placement: "aboveEditor" });
272
- }
273
264
  });
274
265
 
275
266
  pi.on("message_update", (event, ctx) => {
@@ -304,10 +295,6 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
304
295
  publishSpeedFooter(ctx);
305
296
  startFooterTimer(ctx);
306
297
  applyIndicator(ctx, speedTracker.lastTokS);
307
- // Remove cat widget on message end
308
- if (isWebTerminal() && ctx.hasUI) {
309
- ctx.ui.setWidget("crew-vibes-cat", undefined);
310
- }
311
298
  });
312
299
 
313
300
  pi.on("turn_end", () => {
@@ -321,11 +308,14 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
321
308
  if (ctx && config.enabled && ctx.hasUI) {
322
309
  applyIndicator(ctx, speedTracker.lastTokS);
323
310
  ctx.ui.setWorkingMessage();
324
- if (isWebTerminal()) ctx.ui.setWidget("crew-vibes-cat", undefined);
325
311
  }
326
312
  });
327
313
 
328
- pi.on("model_select", (_event, ctx) => publishCapacity(ctx));
314
+ pi.on("model_select", (event, ctx) => {
315
+ currentProvider = (event as { model?: { provider?: string } }).model?.provider;
316
+ clearProviderUsageCache();
317
+ publishCapacity(ctx);
318
+ });
329
319
  pi.on("session_compact", (_event, ctx) => publishCapacity(ctx));
330
320
  pi.on("session_tree", (_event, ctx) => publishCapacity(ctx));
331
321
 
@@ -335,7 +325,6 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
335
325
  stopCapacityTimer();
336
326
  stopProviderTimer();
337
327
  clearVibesStatus(ctx);
338
- if (ctx.hasUI) ctx.ui.setWidget("crew-vibes-cat", undefined);
339
328
  });
340
329
 
341
330
  async function handleCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
@@ -68,6 +68,21 @@ export function loadZaiToken(): string | undefined {
68
68
  }
69
69
  }
70
70
 
71
+ /** Load the Minimax API key from env or auth.json. */
72
+ export function loadMinimaxToken(): string | undefined {
73
+ const envKey = process.env.MINIMAX_API_KEY?.trim();
74
+ if (envKey) return envKey;
75
+ try {
76
+ const data = JSON.parse(readFileSync(piAuthPath(), "utf8")) as {
77
+ minimax?: { key?: string };
78
+ };
79
+ const key = data.minimax?.key;
80
+ return typeof key === "string" && key.length > 0 ? key : undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
71
86
  /** Copilot host entry keys used by the legacy GitHub Copilot CLI. */
72
87
  type CopilotHostEntry = {
73
88
  oauth_token?: string;
@@ -262,69 +277,120 @@ export function clearProviderUsageCache(): void {
262
277
  cachedAt = 0;
263
278
  }
264
279
 
280
+ /** Minimax token plan remains response shape. */
281
+ type MinimaxModelRemain = {
282
+ model_name?: string;
283
+ current_interval_remaining_percent?: number;
284
+ current_weekly_remaining_percent?: number;
285
+ end_time?: number;
286
+ weekly_end_time?: number;
287
+ };
288
+ type MinimaxUsageResponse = {
289
+ model_remains?: MinimaxModelRemain[];
290
+ base_resp?: { status_code?: number; status_msg?: string };
291
+ };
292
+
293
+ async function fetchMinimaxUsage(token: string): Promise<ProviderUsage> {
294
+ const data = await withTimeout(10000, async (signal) => {
295
+ const res = await fetch("https://www.minimax.io/v1/token_plan/remains", {
296
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
297
+ signal,
298
+ });
299
+ if (!res.ok) throw new Error(`minimax usage HTTP ${res.status}`);
300
+ return (await res.json()) as MinimaxUsageResponse;
301
+ });
302
+ if (data.base_resp?.status_code !== 0) throw new Error(data.base_resp?.status_msg || "minimax API error");
303
+
304
+ // Find the "general" model (text/chat). Fall back to first model.
305
+ const models = data.model_remains ?? [];
306
+ const general = models.find((m) => m.model_name === "general") ?? models[0];
307
+ if (!general) throw new Error("minimax: no model data");
308
+
309
+ // remaining_percent → used percent
310
+ const intervalUsed = 100 - (general.current_interval_remaining_percent ?? 100);
311
+ const weeklyUsed = 100 - (general.current_weekly_remaining_percent ?? 100);
312
+
313
+ const intervalReset = typeof general.end_time === "number" ? new Date(general.end_time).toISOString() : null;
314
+ const weeklyReset = typeof general.weekly_end_time === "number" ? new Date(general.weekly_end_time).toISOString() : null;
315
+
316
+ return {
317
+ providerName: "Minimax",
318
+ fiveHourPercent: intervalUsed,
319
+ fiveHourResetAt: intervalReset,
320
+ weeklyPercent: weeklyUsed,
321
+ weeklyResetAt: weeklyReset,
322
+ };
323
+ }
324
+
325
+ /** Providers that have a quota API. */
326
+ const QUOTA_PROVIDERS = new Set(["anthropic", "minimax", "minimax-cn", "zai", "github-copilot"]);
327
+
328
+ /** Check if a provider supports quota checking. */
329
+ export function providerSupportsQuota(provider: string): boolean {
330
+ return QUOTA_PROVIDERS.has(provider);
331
+ }
332
+
333
+ /** Fetch usage for a SPECIFIC provider. Returns null if not supported. */
334
+ async function fetchForProvider(provider: string): Promise<ProviderUsage | null> {
335
+ switch (provider) {
336
+ case "anthropic": {
337
+ const token = loadAnthropicToken();
338
+ if (!token) return null;
339
+ const base = await fetchAnthropicUsage(token);
340
+ return { providerName: "Claude", ...base };
341
+ }
342
+ case "minimax":
343
+ case "minimax-cn": {
344
+ const token = loadMinimaxToken();
345
+ if (!token) return null;
346
+ return await fetchMinimaxUsage(token);
347
+ }
348
+ case "zai": {
349
+ const token = loadZaiToken();
350
+ if (!token) return null;
351
+ const usage = await fetchZaiUsage(token);
352
+ usage.providerName = "z.ai";
353
+ return usage;
354
+ }
355
+ case "github-copilot": {
356
+ const token = loadCopilotToken();
357
+ if (!token) return null;
358
+ const pct = await fetchCopilotMonthlyPercent(token);
359
+ if (pct === undefined) return null;
360
+ return {
361
+ providerName: "Copilot",
362
+ fiveHourPercent: 0,
363
+ fiveHourResetAt: null,
364
+ weeklyPercent: pct,
365
+ weeklyResetAt: null,
366
+ copilotMonthlyPercent: pct,
367
+ };
368
+ }
369
+ default:
370
+ return null;
371
+ }
372
+ }
373
+
265
374
  /**
266
- * Fetch provider rate-limit usage, caching the result for `maxAgeMs`.
267
- *
268
- * Tries providers in order: Anthropic → z.ai → Copilot.
269
- * Returns the first one that has credentials + responds successfully.
270
- * Returns `null` when no credentials exist or all fetches fail — never throws.
375
+ * Fetch provider rate-limit usage for the current model's provider.
376
+ * Returns `null` when provider has no quota API or no credentials.
271
377
  */
272
- export async function fetchProviderUsage(maxAgeMs = 300000): Promise<ProviderUsage | null> {
273
- // Serve fresh-enough cache without hitting the network.
378
+ export async function fetchProviderUsage(maxAgeMs = 300000, provider?: string): Promise<ProviderUsage | null> {
379
+ if (!provider || !QUOTA_PROVIDERS.has(provider)) {
380
+ cachedUsage = null;
381
+ return null;
382
+ }
274
383
  if (cachedUsage !== null && Date.now() - cachedAt < maxAgeMs) {
275
384
  return cachedUsage;
276
385
  }
277
-
278
386
  try {
279
- // Try Anthropic first
280
- const anthropicToken = loadAnthropicToken();
281
- if (anthropicToken) {
282
- const base = await fetchAnthropicUsage(anthropicToken);
283
- const usage: ProviderUsage = {
284
- providerName: "Claude",
285
- fiveHourPercent: base.fiveHourPercent,
286
- fiveHourResetAt: base.fiveHourResetAt,
287
- weeklyPercent: base.weeklyPercent,
288
- weeklyResetAt: base.weeklyResetAt,
289
- };
387
+ const usage = await fetchForProvider(provider);
388
+ if (usage) {
290
389
  cachedUsage = usage;
291
390
  cachedAt = Date.now();
292
- return usage;
293
391
  }
294
-
295
- // Try z.ai
296
- const zaiToken = loadZaiToken();
297
- if (zaiToken) {
298
- const usage = await fetchZaiUsage(zaiToken);
299
- usage.providerName = "z.ai";
300
- cachedUsage = usage;
301
- cachedAt = Date.now();
302
- return usage;
303
- }
304
-
305
- // Try Copilot
306
- const copilotToken = loadCopilotToken();
307
- if (copilotToken) {
308
- const monthlyPercent = await fetchCopilotMonthlyPercent(copilotToken);
309
- if (monthlyPercent !== undefined) {
310
- const usage: ProviderUsage = {
311
- providerName: "Copilot",
312
- fiveHourPercent: 0,
313
- fiveHourResetAt: null,
314
- weeklyPercent: monthlyPercent,
315
- weeklyResetAt: null,
316
- copilotMonthlyPercent: monthlyPercent,
317
- };
318
- cachedUsage = usage;
319
- cachedAt = Date.now();
320
- return usage;
321
- }
322
- }
323
-
324
- // No credentials for any provider
325
- return null;
392
+ return usage;
326
393
  } catch {
327
- // Network error / timeout / parse error — fail gracefully.
328
394
  return null;
329
395
  }
330
396
  }
@@ -498,6 +498,17 @@ export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTe
498
498
  }
499
499
  task.pendingSteers.push(message);
500
500
  saveRunTasks(loaded.manifest, loaded.tasks);
501
+ // Real-time steer delivery: write to steering file so child can read immediately
502
+ try {
503
+ const steeringDir = `${loaded.manifest.artifactsRoot}/steering`;
504
+ fs.mkdirSync(steeringDir, { recursive: true });
505
+ fs.appendFileSync(
506
+ `${steeringDir}/${taskId}.jsonl`,
507
+ JSON.stringify({ type: "steer", message, ts: new Date().toISOString() }) + "\n",
508
+ );
509
+ } catch {
510
+ // Best-effort: file write failure doesn't block the steer from pending array
511
+ }
501
512
  appendEvent(loaded.manifest.eventsPath, {
502
513
  type: "task.steer_queued",
503
514
  runId,
@@ -1,9 +1,12 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import * as fs from "node:fs";
2
3
 
3
4
  export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
4
5
  export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
5
6
  export const PI_CREW_INHERIT_PROJECT_CONTEXT_ENV = "PI_CREW_INHERIT_PROJECT_CONTEXT";
6
7
  export const PI_CREW_INHERIT_SKILLS_ENV = "PI_CREW_INHERIT_SKILLS";
8
+ const PI_CREW_MAX_OUTPUT_ENV = "PI_CREW_MAX_OUTPUT";
9
+ const PI_CREW_STEERING_FILE_ENV = "PI_CREW_STEERING_FILE";
7
10
 
8
11
  const PROJECT_CONTEXT_HEADER = "\n\n# Project Context\n\nProject-specific instructions and guidelines:\n\n";
9
12
  const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions for specific tasks.";
@@ -58,6 +61,68 @@ export function rewriteTeamWorkerPrompt(prompt: string, options: { inheritProjec
58
61
  }
59
62
 
60
63
  export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
64
+ // ── Feature 1: maxTokens cap ──────────────────────────────────────────
65
+ // Cap output tokens per API call for background workers. Reads
66
+ // PI_CREW_MAX_OUTPUT_TOKENS env (set by pi-args.ts from agent.maxTokens).
67
+ const maxTokensEnv = process.env[PI_CREW_MAX_OUTPUT_ENV];
68
+ const maxTokensCap = maxTokensEnv ? Number.parseInt(maxTokensEnv, 10) : undefined;
69
+ if (maxTokensCap && maxTokensCap > 0) {
70
+ pi.on("before_provider_request", (event) => {
71
+ const payload = event.payload as Record<string, unknown> | undefined;
72
+ if (!payload || typeof payload !== "object") return;
73
+ // Cap both OpenAI-style max_tokens and Anthropic-style max_tokens
74
+ if (typeof payload.max_tokens === "number" && payload.max_tokens > maxTokensCap) {
75
+ payload.max_tokens = maxTokensCap;
76
+ }
77
+ });
78
+ }
79
+
80
+ // ── Feature 2: real-time steering ──────────────────────────────────────
81
+ // Poll the steering JSONL file for new steer messages. The parent (team
82
+ // tool) writes steers here in real-time; this reader injects them into
83
+ // the active session via pi.sendMessage with deliverAs:"steer".
84
+ const steeringFile = process.env[PI_CREW_STEERING_FILE_ENV];
85
+ if (steeringFile) {
86
+ let lastOffset = 0;
87
+ const pollSteering = (): void => {
88
+ try {
89
+ const stat = fs.statSync(steeringFile, { throwIfNoEntry: false });
90
+ if (!stat || stat.size <= lastOffset) return;
91
+ const fd = fs.openSync(steeringFile, "r");
92
+ try {
93
+ const buf = Buffer.alloc(stat.size - lastOffset);
94
+ fs.readSync(fd, buf, 0, buf.length, lastOffset);
95
+ lastOffset = stat.size;
96
+ const lines = buf.toString("utf8").split("\n").filter(Boolean);
97
+ for (const line of lines) {
98
+ try {
99
+ const entry = JSON.parse(line) as { type?: string; message?: string };
100
+ if (entry.type === "steer" && entry.message) {
101
+ pi.sendMessage(
102
+ { customType: "crew-steer", content: entry.message, display: false },
103
+ { deliverAs: "steer" },
104
+ );
105
+ }
106
+ } catch {
107
+ // Malformed line — skip
108
+ }
109
+ }
110
+ } finally {
111
+ try {
112
+ fs.closeSync(fd);
113
+ } catch {
114
+ /* already closed */
115
+ }
116
+ }
117
+ } catch {
118
+ // File doesn't exist yet or read error — will retry next tick
119
+ }
120
+ };
121
+ const timer = setInterval(pollSteering, 500);
122
+ timer.unref?.();
123
+ }
124
+
125
+ // ── Prompt rewriting (existing) ────────────────────────────────────────
61
126
  pi.on("before_agent_start", (event) => {
62
127
  const inheritProjectContext = readBooleanEnvAny(PI_CREW_INHERIT_PROJECT_CONTEXT_ENV, PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV);
63
128
  const inheritSkills = readBooleanEnvAny(PI_CREW_INHERIT_SKILLS_ENV, PI_TEAMS_INHERIT_SKILLS_ENV);
@@ -238,6 +238,8 @@ export interface ChildPiRunInput {
238
238
  excludeContextBash?: boolean;
239
239
  /** pi session ID for session naming (aligns with pi-crew run ID) */
240
240
  sessionId?: string;
241
+ /** Path to steering JSONL file for real-time steer injection. */
242
+ steeringFile?: string;
241
243
  /** Run ID for cleanup tracking */
242
244
  runId?: string;
243
245
  /** Agent ID for cleanup tracking */
@@ -317,6 +319,8 @@ const BASE_ALLOWLIST: string[] = [
317
319
  "PI_TEAMS_PI_BIN",
318
320
  "PI_TEAMS_MOCK_CHILD_PI",
319
321
  "PI_CREW_ALLOW_MOCK",
322
+ "PI_CREW_MAX_OUTPUT",
323
+ "PI_CREW_STEERING_FILE",
320
324
  ];
321
325
 
322
326
  export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
@@ -888,6 +892,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
888
892
  skillPaths: input.skillPaths,
889
893
  role: input.role,
890
894
  });
895
+ // Pass steering file path to child for real-time steer injection
896
+ if (input.steeringFile) built.env.PI_CREW_STEERING_FILE = input.steeringFile;
891
897
  const spawnSpec = getPiSpawnCommand(built.args);
892
898
  try {
893
899
  return await new Promise<ChildPiRunResult>((resolve) => {
@@ -360,6 +360,8 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
360
360
  PI_TEAMS_DEPTH: String(parentDepth + 1),
361
361
  PI_TEAMS_MAX_DEPTH: String(maxDepth),
362
362
  PI_TEAMS_ROLE: input.agent.name,
363
+ // maxTokens cap for background workers — prompt-runtime reads this to cap API output
364
+ ...(input.agent.maxTokens ? { PI_CREW_MAX_OUTPUT: String(input.agent.maxTokens) } : {}),
363
365
  },
364
366
  tempDir,
365
367
  };
@@ -449,6 +449,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
449
449
  runId: manifest.runId,
450
450
  agentId: task.id,
451
451
  artifactsRoot: manifest.artifactsRoot,
452
+ steeringFile: `${manifest.artifactsRoot}/steering/${task.id}.jsonl`,
452
453
  onSpawn: (pid) => {
453
454
  try {
454
455
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));