@tranhoangnguyen0310/pi-flow-external 2.7.0-external.0 → 2.8.0-external.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to pi-flow external are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [2.8.0-external.0] - 2026-09-24
8
+
9
+ ### Added
10
+ - Expand `/external doctor` with CLI readiness, supported login-status checks, and secret-safe inherited authentication-setting warnings.
11
+ - Report project-scoped historical usage-limit evidence from retained summaries, including reset times and later successful runs. Capture new Claude structured rejections and Antigravity terminal quota errors without live quota probes.
12
+
7
13
  ## [2.7.0-external.0] - 2026-09-24
8
14
 
9
15
  ### Added
package/README.md CHANGED
@@ -189,7 +189,9 @@ All user commands use the `/external` namespace:
189
189
  | `/external runs --prune` | Prune eligible completed run records |
190
190
  | `/external help` | Show the command reference |
191
191
 
192
- `/external doctor` reports CLI availability separately from provider authentication, and separately from whether the catalog itself is valid.
192
+ `/external doctor` reports CLI availability separately from provider authentication and catalog validity. Claude and Codex use their non-interactive login-status commands; other CLIs report login as unverified. Credential presence does not prove a request will succeed. Known inherited authentication/routing environment variables are listed by name only, never value. Native status commands may access credential storage or perform network activity; no model prompts or quota requests are sent, and no credentials are changed.
193
+
194
+ Doctor also reads retained run summaries for this project and shows the latest recorded usage-limit observation per harness, its source run, any reset time, and whether a later run succeeded. These are historical observations, not current account status. New failed Claude runs can preserve structured rate-limit rejections; Antigravity runs can preserve terminal individual-quota errors. Other backends and older records may have no evidence. Missing, malformed, or oversized summaries are skipped and counted; backend event logs and model answers are never searched. Remaining allowance stays unavailable.
193
195
 
194
196
  A typical overview:
195
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "2.7.0-external.0",
3
+ "version": "2.8.0-external.0",
4
4
  "description": "External Claude Code, Codex CLI, Antigravity, Grok Build CLI, and Muse Code delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/src/core/spawn.ts CHANGED
@@ -344,10 +344,12 @@ export function createPiChildResourceLoader(options: {
344
344
  appendSystemPromptOverride: (base) => [...base, ...extra],
345
345
  });
346
346
  }
347
+ import { usageLimitFromEvent, type UsageLimit } from "./usage-limit.ts";
347
348
 
348
349
  export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentToolResult> {
349
350
  const startedAt = Date.now();
350
351
  let backendEventCount = 0;
352
+ let usageLimit: UsageLimit | undefined;
351
353
  let nestedActivitySeen = false;
352
354
  const effectiveTier = resolveEffectivePermissionTier(params.permission, params.profile, params.defaultPermission ?? "danger");
353
355
  const permission = resolvePermission(effectiveTier, params.profile.backend);
@@ -410,6 +412,7 @@ export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentT
410
412
  const timeout = createTimeoutSignal(params.signal, params.timeoutMs, params.description);
411
413
  const onBackendEvent = (event: unknown) => {
412
414
  backendEventCount++;
415
+ usageLimit = usageLimitFromEvent(params.profile.backend, event) ?? usageLimit;
413
416
  const hadNestedActivity = nestedActivitySeen;
414
417
  try {
415
418
  nestedActivitySeen ||= hasNestedAgentActivity(event, params.profile.backend);
@@ -490,6 +493,7 @@ export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentT
490
493
  firstActivityAt: details.progress?.firstActivityAt,
491
494
  lastActivityAt: details.progress?.lastActivityAt,
492
495
  error: details.error,
496
+ ...(details.status === "error" && usageLimit ? { usageLimit } : {}),
493
497
  usage: details.usage,
494
498
  durationMs: Date.now() - startedAt,
495
499
  backendEventCount,
@@ -0,0 +1,23 @@
1
+ export interface UsageLimit {
2
+ source: "claude.rate_limit_event" | "agy.result.error";
3
+ observedAt: string;
4
+ resetsAt?: string;
5
+ }
6
+
7
+ export function usageLimitFromEvent(backend: string, value: unknown, now = Date.now()): UsageLimit | undefined {
8
+ if (!value || typeof value !== "object") return;
9
+ const event = value as Record<string, unknown>;
10
+ let source: UsageLimit["source"];
11
+ let reset: number | undefined;
12
+ if (backend === "claude" && event.type === "rate_limit_event") {
13
+ const info = event.rate_limit_info as Record<string, unknown> | undefined;
14
+ if (!info || info.status !== "rejected") return;
15
+ source = "claude.rate_limit_event";
16
+ if (typeof info.resetsAt === "number") reset = info.resetsAt * 1000;
17
+ } else if (backend === "agy" && event.type === "result" && event.status === "ERROR" && typeof event.error === "string" && /\bIndividual quota reached\b/i.test(event.error)) {
18
+ source = "agy.result.error";
19
+ const duration = /Resets in (?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?\./i.exec(event.error);
20
+ if (duration && duration.slice(1).some(Boolean)) reset = now + ((Number(duration[1] ?? 0) * 3600) + (Number(duration[2] ?? 0) * 60) + Number(duration[3] ?? 0)) * 1000;
21
+ } else return;
22
+ return { source, observedAt: new Date(now).toISOString(), ...(reset !== undefined && Number.isFinite(reset) && Math.abs(reset) <= 8.64e15 ? { resetsAt: new Date(reset).toISOString() } : {}) };
23
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { open, readdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+
5
+ // Summary-only, bounded reads: never scan potentially huge backend event logs.
6
+ export async function usageLimitHistory(directory: string, project: string, now = Date.now()): Promise<string> {
7
+ const latest = new Map<string, { time: number; runId: string; reset?: number }>();
8
+ const successes = new Map<string, number>();
9
+ let skipped = 0;
10
+ let entries;
11
+ try { entries = await readdir(directory, { withFileTypes: true }); }
12
+ catch { return "Usage-limit history: unavailable (run records could not be read)."; }
13
+ for (const entry of entries) {
14
+ if (!entry.isDirectory() || !/^run_[A-Za-z0-9_-]{1,128}$/.test(entry.name)) continue;
15
+ let file;
16
+ try {
17
+ file = await open(join(directory, entry.name, "summary.json"), "r");
18
+ const buffer = Buffer.alloc(256 * 1024 + 1);
19
+ let length = 0;
20
+ while (length < buffer.length) {
21
+ const { bytesRead } = await file.read(buffer, length, buffer.length - length, null);
22
+ if (!bytesRead) break;
23
+ length += bytesRead;
24
+ }
25
+ if (length === buffer.length) { skipped++; continue; }
26
+ const doc = JSON.parse(buffer.subarray(0, length).toString("utf8"));
27
+ if (doc?.runId !== entry.name) { skipped++; continue; }
28
+ const cwd = doc.metadata?.project ?? doc.metadata?.cwd;
29
+ if (typeof cwd !== "string" || resolve(cwd) !== resolve(project)) continue;
30
+ const summary = doc.summary;
31
+ const harness = doc.metadata?.harness ?? summary?.backend;
32
+ if (typeof harness !== "string" || !/^(claude|codex|agy|grok|muse|pi-[a-z0-9-]+)$/.test(harness)) continue;
33
+ const finished = Date.parse(doc.finishedAt);
34
+ if (summary?.status === "done" && Number.isFinite(finished)) successes.set(harness, Math.max(successes.get(harness) ?? 0, finished));
35
+ const limit = summary?.usageLimit;
36
+ if (summary?.status !== "error" || !limit || !["claude.rate_limit_event", "agy.result.error"].includes(limit.source)) continue;
37
+ if (limit.source !== `${summary.backend}.${summary.backend === "claude" ? "rate_limit_event" : "result.error"}`) continue;
38
+ const time = Date.parse(limit.observedAt);
39
+ if (!Number.isFinite(time) || time > now) continue;
40
+ const reset = typeof limit.resetsAt === "string" ? Date.parse(limit.resetsAt) : NaN;
41
+ if (!latest.has(harness) || latest.get(harness)!.time < time) latest.set(harness, { time, runId: entry.name, ...(Number.isFinite(reset) ? { reset } : {}) });
42
+ } catch { skipped++; }
43
+ finally { await file?.close(); }
44
+ }
45
+ const lines = ["Usage-limit history (this project only; not a live account check):"];
46
+ for (const [harness, limit] of latest) {
47
+ const success = successes.get(harness);
48
+ lines.push(` ${harness}: limit reported ${new Date(limit.time).toISOString()} · ${limit.runId}${limit.reset === undefined ? "" : ` · reset ${new Date(limit.reset).toISOString()}${limit.reset <= now ? " (time passed)" : ""}`}${success !== undefined && success > limit.time ? " · a later run succeeded" : ""}. Current availability unverified.`);
49
+ }
50
+ if (!latest.size) lines.push(" No recorded limit evidence found. This does not mean allowance is available.");
51
+ lines.push(" New Claude/Antigravity records can report limits; other backends and older records may not. Retained records only.");
52
+ if (skipped) lines.push(` ${skipped} missing, oversized, or unreadable summaries skipped.`);
53
+ return lines.join("\n");
54
+ }
55
+
56
+ // Names only: values and raw CLI diagnostics can contain credentials.
57
+ const ENV_WARNINGS: Record<string, string[]> = {
58
+ claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"],
59
+ codex: ["CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_HOME"],
60
+ agy: [],
61
+ grok: ["XAI_API_KEY"],
62
+ muse: ["META_API_KEY"],
63
+ };
64
+
65
+ export async function diagnoseCli(exec: ExtensionAPI["exec"], backend: string, env: NodeJS.ProcessEnv = process.env): Promise<string> {
66
+ const lines = [`${backend}:`];
67
+ let available = false;
68
+ try {
69
+ const result = await exec(backend, ["--version"], { timeout: 10_000 });
70
+ available = result.code === 0 && !result.killed;
71
+ // Only display a version number, never echo arbitrary subprocess output.
72
+ const version = (result.stdout || result.stderr).match(/\b\d+\.\d+\.\d+\b/)?.[0];
73
+ lines.push(available
74
+ ? ` CLI: available${version ? ` (${backend} ${version})` : ""}`
75
+ : result.killed ? " CLI: version check timed out" : " CLI: could not start; check the installation with --version");
76
+ } catch (error) {
77
+ lines.push(error && typeof error === "object" && "code" in error && error.code === "ENOENT"
78
+ ? " CLI: not found; install it or check PATH"
79
+ : " CLI: check failed; check the installation with --version");
80
+ }
81
+
82
+ let login = "not verified (no supported status check)";
83
+ if (!available) login = "not checked (CLI unavailable)";
84
+ else if (backend === "claude" || backend === "codex") {
85
+ try {
86
+ const result = await exec(backend, backend === "claude" ? ["auth", "status", "--json"] : ["login", "status"], { timeout: 10_000 });
87
+ login = result.killed ? "not verified (status check timed out)" : "not verified (status check failed)";
88
+ if (!result.killed) {
89
+ if (backend === "claude") {
90
+ let status: unknown;
91
+ try { status = JSON.parse(result.stdout); } catch { /* Unknown output stays unverified. */ }
92
+ const loggedIn = status && typeof status === "object" && "loggedIn" in status ? status.loggedIn : undefined;
93
+ if (loggedIn === false) login = "no login reported; use claude auth login if account login is intended";
94
+ else if (result.code === 0) login = loggedIn === true ? "login reported by CLI; requests not tested" : "not verified (unrecognized status)";
95
+ } else {
96
+ const text = (result.stderr || result.stdout).trim();
97
+ if (text === "Not logged in") login = "no login reported; use codex login if account login is intended";
98
+ else if (result.code === 0) login = text === "Logged in using ChatGPT"
99
+ ? "ChatGPT login reported by CLI; requests not tested"
100
+ : /^Logged in using an API key(?: - [^\r\n]*)?$/.test(text)
101
+ ? "API key login reported by CLI; requests not tested"
102
+ : "not verified (unrecognized status)";
103
+ }
104
+ }
105
+ } catch {
106
+ login = "not verified (status check failed)";
107
+ }
108
+ }
109
+ lines.push(` Login: ${login}`);
110
+ const set = (ENV_WARNINGS[backend] ?? []).filter(name => Boolean(env[name]));
111
+ if (set.length) lines.push(` Settings: ${set.join(", ")} set; inherited by child and may affect authentication or routing. Values hidden.`);
112
+ lines.push(" Remaining allowance: unavailable (no quota request made)");
113
+ return lines.join("\n");
114
+ }
@@ -24,6 +24,7 @@ import { pruneRunRecords, runRecordsDirectory } from "./core/retention.ts";
24
24
  import { createExternalRunsTool, type ExternalRunsParams } from "./external-runs.ts";
25
25
  import { formatDurationMs, formatRunRow } from "./core/run-render.ts";
26
26
  import { listSavedWorkflows } from "./workflow/registry.ts";
27
+ import { diagnoseCli, usageLimitHistory } from "./doctor.ts";
27
28
 
28
29
  const COMMANDS = [
29
30
  { value: "doctor", description: "Validate config/catalog and report runtime readiness" },
@@ -542,11 +543,7 @@ async function doctorText(pi: ExtensionAPI, options: ExternalCommandOptions, ctx
542
543
  profiles.size ? `✓ Roles: ${profiles.size} execution identities` : "✗ Roles: none configured",
543
544
  ];
544
545
  for (const backend of backends) {
545
- const result = await pi.exec(backend, ["--version"], { timeout: 10_000 });
546
- const version = (result.stdout || result.stderr).trim().split(/\r?\n/, 1)[0];
547
- lines.push(result.code === 0 && !result.killed
548
- ? `✓ ${backend}: ${version || "available"} (authentication unverified)`
549
- : `✗ ${backend}: unavailable`);
546
+ lines.push(await diagnoseCli(pi.exec.bind(pi), backend));
550
547
  }
551
548
  // Pi harnesses run in-process, not as a CLI: there is no subprocess to exec.
552
549
  // Confirm the registered model resolves and report configured auth instead.
@@ -561,7 +558,9 @@ async function doctorText(pi: ExtensionAPI, options: ExternalCommandOptions, ctx
561
558
  lines.push(ctx.modelRegistry.hasConfiguredAuth(model)
562
559
  ? `✓ ${name}: ${config.model} · ${preset} (auth configured)`
563
560
  : `⚠ ${name}: ${config.model} · ${preset} (no credentials configured)`);
561
+ lines.push(" Requests not tested; provider authentication/routing follows Pi configuration.", " Remaining allowance: unavailable (no quota request made)");
564
562
  }
563
+ lines.push(await usageLimitHistory(runRecordsDirectory(), ctx.cwd));
565
564
  return lines.join("\n");
566
565
  }
567
566