@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 +6 -0
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/core/spawn.ts +4 -0
- package/src/core/usage-limit.ts +23 -0
- package/src/doctor.ts +114 -0
- package/src/external-command.ts +4 -5
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
|
|
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.
|
|
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
|
+
}
|
package/src/external-command.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|