@jameslovespancakes/pi-plus 1.0.12 → 1.0.13
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/README.md +24 -3
- package/package.json +4 -2
- package/src/core/accounts/oauth-pool.ts +35 -0
- package/src/core/accounts/registry.ts +4 -0
- package/src/core/accounts/routing.ts +22 -0
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +227 -28
- package/src/core/anthropic/store.ts +4 -1
- package/src/core/codex/quota.ts +43 -11
- package/src/core/codex/store.ts +21 -11
- package/src/core/config.ts +12 -0
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/subscriptions/accounts-picker.ts +44 -4
- package/src/domains/subscriptions/accounts.ts +31 -8
- package/src/domains/subscriptions/index.ts +2 -2
- package/src/domains/subscriptions/provider.ts +46 -5
- package/src/domains/subscriptions/providers/anthropic.ts +37 -5
- package/src/domains/subscriptions/providers/codex.ts +120 -254
- package/src/domains/subscriptions/providers/hosted.ts +2 -2
- package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
- package/src/domains/workflows/index.ts +1 -1
- package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +24 -1
- package/src/domains/workflows/runtime/agent-runner.ts +42 -20
- package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
- package/src/domains/workflows/runtime/engine.ts +48 -12
- package/src/domains/workflows/runtime/progress.ts +2 -2
- package/src/domains/workflows/runtime/types.ts +4 -4
- package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
- package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
- package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
- package/src/domains/workflows/runtime/worktree.ts +35 -1
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
|
@@ -210,6 +210,16 @@ export async function runResolvedWorkflow(
|
|
|
210
210
|
);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
+
const workflowPause = workflowOutcome.ok
|
|
214
|
+
? undefined
|
|
215
|
+
: backgroundPauseError(workflowOutcome.error, ctx.signal, resolvedOptions.signal);
|
|
216
|
+
const willPauseWorkflow = workflowPause !== undefined
|
|
217
|
+
&& (!(workflowPause instanceof WorkflowProviderUsageLimitError) || resolvedOptions.background !== undefined);
|
|
218
|
+
const preserveFailedWorktrees = !workflowOutcome.ok
|
|
219
|
+
&& !willPauseWorkflow
|
|
220
|
+
&& !ctx.signal?.aborted
|
|
221
|
+
&& !resolvedOptions.signal?.aborted;
|
|
222
|
+
if (preserveFailedWorktrees) worktrees.preserveRecoverable();
|
|
213
223
|
const finalizationOutcome = await captureOutcome(() =>
|
|
214
224
|
finalizeWorkflowRun({
|
|
215
225
|
cwd: ctx.cwd,
|
|
@@ -219,9 +229,14 @@ export async function runResolvedWorkflow(
|
|
|
219
229
|
progress,
|
|
220
230
|
runStore,
|
|
221
231
|
worktrees,
|
|
232
|
+
preserveWorktrees: preserveFailedWorktrees,
|
|
222
233
|
unlinkSignals: [unlinkContextAbortSignal, unlinkOptionAbortSignal],
|
|
223
234
|
}),
|
|
224
235
|
);
|
|
236
|
+
const retainedPaths = preserveFailedWorktrees ? worktrees.preservedPaths : [];
|
|
237
|
+
const terminalWorkflowError = workflowOutcome.ok
|
|
238
|
+
? undefined
|
|
239
|
+
: withRetainedWorktrees(workflowOutcome.error, retainedPaths);
|
|
225
240
|
|
|
226
241
|
if (workflowOutcome.ok && finalizationOutcome.ok) {
|
|
227
242
|
durableRun.transition({
|
|
@@ -232,16 +247,16 @@ export async function runResolvedWorkflow(
|
|
|
232
247
|
});
|
|
233
248
|
} else if (!workflowOutcome.ok) {
|
|
234
249
|
const error = finalizationOutcome.ok
|
|
235
|
-
?
|
|
236
|
-
: combinedWorkflowError(
|
|
237
|
-
persistTerminalWorkflowError(error);
|
|
250
|
+
? terminalWorkflowError!
|
|
251
|
+
: combinedWorkflowError(terminalWorkflowError!, finalizationOutcome.error);
|
|
252
|
+
persistTerminalWorkflowError(error, workflowOutcome.error);
|
|
238
253
|
} else if (!finalizationOutcome.ok) {
|
|
239
254
|
persistTerminalWorkflowError(finalizationOutcome.error);
|
|
240
255
|
}
|
|
241
256
|
await durableRun.flush().catch(() => undefined);
|
|
242
257
|
|
|
243
|
-
function persistTerminalWorkflowError(error: unknown): void {
|
|
244
|
-
const pauseError = backgroundPauseError(
|
|
258
|
+
function persistTerminalWorkflowError(error: unknown, pauseCandidate: unknown = error): void {
|
|
259
|
+
const pauseError = backgroundPauseError(pauseCandidate, ctx.signal, resolvedOptions.signal);
|
|
245
260
|
if (pauseError) {
|
|
246
261
|
if (pauseError instanceof WorkflowProviderUsageLimitError) {
|
|
247
262
|
if (resolvedOptions.background === undefined) {
|
|
@@ -284,8 +299,8 @@ export async function runResolvedWorkflow(
|
|
|
284
299
|
if (finalizationOutcome.ok) return workflowOutcome.value;
|
|
285
300
|
throw finalizationOutcome.error;
|
|
286
301
|
}
|
|
287
|
-
if (finalizationOutcome.ok) throw
|
|
288
|
-
throw combinedWorkflowError(
|
|
302
|
+
if (finalizationOutcome.ok) throw terminalWorkflowError;
|
|
303
|
+
throw combinedWorkflowError(terminalWorkflowError, finalizationOutcome.error);
|
|
289
304
|
}
|
|
290
305
|
|
|
291
306
|
function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
|
|
@@ -314,6 +329,7 @@ interface WorkflowFinalizationInput {
|
|
|
314
329
|
readonly progress: ProgressTracker;
|
|
315
330
|
readonly runStore: WorkflowRunStore;
|
|
316
331
|
readonly worktrees: WorktreeRegistry;
|
|
332
|
+
readonly preserveWorktrees: boolean;
|
|
317
333
|
readonly unlinkSignals: readonly (() => void)[];
|
|
318
334
|
}
|
|
319
335
|
|
|
@@ -340,11 +356,22 @@ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<vo
|
|
|
340
356
|
criticality: "required" as const,
|
|
341
357
|
run,
|
|
342
358
|
})),
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
359
|
+
input.preserveWorktrees
|
|
360
|
+
? {
|
|
361
|
+
name: "worktree cleanup and retention",
|
|
362
|
+
criticality: "required",
|
|
363
|
+
run: async () => {
|
|
364
|
+
await input.worktrees.removeUnpreserved();
|
|
365
|
+
for (const path of input.worktrees.preservedPaths) {
|
|
366
|
+
input.progress.log(`retained failed workflow worktree: ${path}`);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
: {
|
|
371
|
+
name: "worktree cleanup",
|
|
372
|
+
criticality: "required",
|
|
373
|
+
run: () => input.worktrees.removeAll(),
|
|
374
|
+
},
|
|
348
375
|
{ name: "journal pruning", criticality: "best-effort", run: () => pruneWorkflowJournals(input.cwd) },
|
|
349
376
|
{ name: "run record pruning", criticality: "best-effort", run: () => input.runStore.prune() },
|
|
350
377
|
{ name: "progress completion", criticality: "best-effort", run: () => input.progress.done() },
|
|
@@ -371,6 +398,15 @@ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<vo
|
|
|
371
398
|
);
|
|
372
399
|
}
|
|
373
400
|
|
|
401
|
+
function withRetainedWorktrees(error: unknown, paths: readonly string[]): unknown {
|
|
402
|
+
if (paths.length === 0) return error;
|
|
403
|
+
const locations = paths.map((path) => `- ${path}`).join("\n");
|
|
404
|
+
return new Error(
|
|
405
|
+
`${unknownErrorMessage(error)}\n\nFailed workflow worktree${paths.length === 1 ? "" : "s"} retained for recovery:\n${locations}`,
|
|
406
|
+
{ cause: error },
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
374
410
|
function combinedWorkflowError(workflowError: unknown, finalizationError: unknown): AggregateError {
|
|
375
411
|
return new AggregateError(
|
|
376
412
|
[workflowError, finalizationError],
|
|
@@ -4,7 +4,7 @@ import type { AgentChatMessage, AgentChatRole, AgentRowStatus, WorkflowLaneItemS
|
|
|
4
4
|
import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./usage.ts";
|
|
5
5
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
6
6
|
import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
|
|
7
|
-
import { toDisplayLine } from "./ui/display-text.ts";
|
|
7
|
+
import { toDisplayLine, toDisplayText } from "./ui/display-text.ts";
|
|
8
8
|
import { renderWorkflowWidgetLines } from "./ui/workflow-widget.ts";
|
|
9
9
|
|
|
10
10
|
export type {
|
|
@@ -304,7 +304,7 @@ export class ProgressTracker {
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
private appendAgentChat(id: number, role: AgentChatRole, text: string): void {
|
|
307
|
-
const value =
|
|
307
|
+
const value = toDisplayText(text, AGENT_CHAT_TEXT_LIMIT);
|
|
308
308
|
if (!value) return;
|
|
309
309
|
const messages = this.agentChats.get(id) ?? [];
|
|
310
310
|
messages.push({ role, text: value, createdAt: Date.now() });
|
|
@@ -143,7 +143,7 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
143
143
|
/**
|
|
144
144
|
* Resume policy for this call. Shared-workspace agents run live unless they
|
|
145
145
|
* explicitly declare themselves read-only. Those calls bind replay to the full
|
|
146
|
-
* Git-visible workspace; isolated agents bind to their
|
|
146
|
+
* Git-visible workspace; isolated agents bind to their isolated baseline.
|
|
147
147
|
* Use "off" to disable both journal reads and writes.
|
|
148
148
|
*/
|
|
149
149
|
resume?: AgentResumePolicy;
|
|
@@ -154,7 +154,7 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
154
154
|
* `resume: "off"` when their bounded contents cannot be captured.
|
|
155
155
|
*/
|
|
156
156
|
resumeInputs?: readonly string[];
|
|
157
|
-
/** Run
|
|
157
|
+
/** Run in an isolated git worktree. Successful runs clean it up; failed runs retain it for recovery. */
|
|
158
158
|
isolation?: "worktree";
|
|
159
159
|
/** Apply a captured candidate to a fresh isolated baseline before evaluation. */
|
|
160
160
|
candidatePatch?: { readonly baselineOid: string; readonly patch: string };
|
|
@@ -187,12 +187,12 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
187
187
|
* exports `meta` plus a default `async (api: WorkflowApi) => result`.
|
|
188
188
|
*/
|
|
189
189
|
export interface WorkflowApi {
|
|
190
|
-
/** Run a schema subagent in
|
|
190
|
+
/** Run a schema subagent in an isolated worktree and return its structured result plus patch. */
|
|
191
191
|
agent<S extends TSchema>(
|
|
192
192
|
prompt: string,
|
|
193
193
|
opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
|
|
194
194
|
): Promise<IsolatedAgentResult<Static<S>>>;
|
|
195
|
-
/** Run a text subagent in
|
|
195
|
+
/** Run a text subagent in an isolated worktree and return its final text plus patch. */
|
|
196
196
|
agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
|
|
197
197
|
/** Run a subagent and return validated structured output; rejects with a recoverable typed error on repair exhaustion. */
|
|
198
198
|
agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const ANSI_PATTERN = /\u001B\[[0-9;?]*[ -/]*[@-~]/g;
|
|
2
2
|
const CONTROL_PATTERN = /[\u0000-\u001F\u007F]/g;
|
|
3
|
+
const MULTILINE_CONTROL_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
3
4
|
|
|
4
5
|
/** Flattens arbitrary text into one stable, printable row. */
|
|
5
6
|
export function toDisplayLine(text: string, maxLength = 120): string {
|
|
@@ -11,3 +12,17 @@ export function toDisplayLine(text: string, maxLength = 120): string {
|
|
|
11
12
|
if (maxLength <= 0 || flat.length <= maxLength) return flat;
|
|
12
13
|
return `${flat.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
|
|
13
14
|
}
|
|
15
|
+
|
|
16
|
+
/** Sanitizes chat content while preserving the line breaks and Markdown shown by Pi. */
|
|
17
|
+
export function toDisplayText(text: string, maxLength = 2_000): string {
|
|
18
|
+
const value = text
|
|
19
|
+
.replace(ANSI_PATTERN, "")
|
|
20
|
+
.replace(/\r\n?/g, "\n")
|
|
21
|
+
.replace(MULTILINE_CONTROL_PATTERN, " ")
|
|
22
|
+
.split("\n")
|
|
23
|
+
.map((line) => line.replace(/\t/g, " ").trimEnd())
|
|
24
|
+
.join("\n")
|
|
25
|
+
.trim();
|
|
26
|
+
if (maxLength <= 0 || value.length <= maxLength) return value;
|
|
27
|
+
return `${value.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
|
|
28
|
+
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
|
+
Box,
|
|
3
4
|
Input,
|
|
5
|
+
Markdown,
|
|
4
6
|
matchesKey,
|
|
7
|
+
Text,
|
|
5
8
|
type Focusable,
|
|
9
|
+
type MarkdownTheme,
|
|
6
10
|
type TUI,
|
|
7
11
|
type TuiMouseEvent,
|
|
8
12
|
type TuiMouseEventResult,
|
|
@@ -71,7 +75,7 @@ export class WorkflowInspector implements Focusable {
|
|
|
71
75
|
this.outcome = outcome;
|
|
72
76
|
this.live = live;
|
|
73
77
|
this.input = new Input({
|
|
74
|
-
prompt: "› ",
|
|
78
|
+
prompt: this.theme.fg("accent", "› "),
|
|
75
79
|
placeholder: "Send a follow-up to this agent…",
|
|
76
80
|
placeholderStyle: (text) => this.theme.fg("dim", text),
|
|
77
81
|
});
|
|
@@ -321,11 +325,52 @@ export class WorkflowInspector implements Focusable {
|
|
|
321
325
|
}
|
|
322
326
|
|
|
323
327
|
private chatMessageRows(message: AgentChatMessage, width: number): string[] {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
328
|
+
if (message.role === "task" || message.role === "user") {
|
|
329
|
+
const box = new Box(1, 1, (text) => this.theme.bg("userMessageBg", text));
|
|
330
|
+
box.addChild(new Markdown(
|
|
331
|
+
message.text,
|
|
332
|
+
0,
|
|
333
|
+
0,
|
|
334
|
+
this.markdownTheme(),
|
|
335
|
+
{ color: (text) => this.theme.fg("userMessageText", text) },
|
|
336
|
+
{ preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
|
|
337
|
+
));
|
|
338
|
+
return box.render(width);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (message.role === "assistant") {
|
|
342
|
+
return ["", ...new Markdown(message.text, 1, 0, this.markdownTheme()).render(width)];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (message.role === "tool") {
|
|
346
|
+
const box = new Box(1, 0, (text) => this.theme.bg("toolPendingBg", text));
|
|
347
|
+
box.addChild(new Text(this.theme.fg("toolTitle", this.theme.bold(message.text)), 0, 0));
|
|
348
|
+
return box.render(width);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return [
|
|
352
|
+
"",
|
|
353
|
+
...new Text(this.theme.fg("error", message.text), 1, 0).render(width),
|
|
354
|
+
];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private markdownTheme(): MarkdownTheme {
|
|
358
|
+
return {
|
|
359
|
+
heading: (text) => this.theme.fg("mdHeading", text),
|
|
360
|
+
link: (text) => this.theme.fg("mdLink", text),
|
|
361
|
+
linkUrl: (text) => this.theme.fg("mdLinkUrl", text),
|
|
362
|
+
code: (text) => this.theme.fg("mdCode", text),
|
|
363
|
+
codeBlock: (text) => this.theme.fg("mdCodeBlock", text),
|
|
364
|
+
codeBlockBorder: (text) => this.theme.fg("mdCodeBlockBorder", text),
|
|
365
|
+
quote: (text) => this.theme.fg("mdQuote", text),
|
|
366
|
+
quoteBorder: (text) => this.theme.fg("mdQuoteBorder", text),
|
|
367
|
+
hr: (text) => this.theme.fg("mdHr", text),
|
|
368
|
+
listBullet: (text) => this.theme.fg("mdListBullet", text),
|
|
369
|
+
bold: (text) => this.theme.bold(text),
|
|
370
|
+
italic: (text) => this.theme.italic(text),
|
|
371
|
+
strikethrough: (text) => this.theme.strikethrough(text),
|
|
372
|
+
underline: (text) => this.theme.underline(text),
|
|
373
|
+
};
|
|
329
374
|
}
|
|
330
375
|
|
|
331
376
|
private agentRow(entry: BoardAgent, index: number, width: number): string {
|
|
@@ -375,14 +420,6 @@ export class WorkflowInspector implements Focusable {
|
|
|
375
420
|
}
|
|
376
421
|
}
|
|
377
422
|
|
|
378
|
-
function chatLabel(message: AgentChatMessage): string {
|
|
379
|
-
if (message.role === "task") return "Task";
|
|
380
|
-
if (message.role === "user") return "You";
|
|
381
|
-
if (message.role === "assistant") return "Agent";
|
|
382
|
-
if (message.role === "tool") return "Tool";
|
|
383
|
-
return "Status";
|
|
384
|
-
}
|
|
385
|
-
|
|
386
423
|
function shortModel(model: string): string {
|
|
387
424
|
return model.replace(/^openai-codex\//, "codex/").replace(/^anthropic\//, "");
|
|
388
425
|
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
|
|
3
|
-
const VIEWPORT_HEIGHT_RATIO = 0.
|
|
4
|
-
const VIEWPORT_MARGIN_ROWS =
|
|
3
|
+
const VIEWPORT_HEIGHT_RATIO = 0.7;
|
|
4
|
+
const VIEWPORT_MARGIN_ROWS = 4;
|
|
5
5
|
|
|
6
6
|
export const WORKFLOW_VIEWER_OVERLAY_OPTIONS = {
|
|
7
7
|
overlay: true,
|
|
8
8
|
overlayOptions: {
|
|
9
9
|
anchor: "center",
|
|
10
|
-
width: "
|
|
11
|
-
minWidth:
|
|
12
|
-
maxHeight: "
|
|
13
|
-
margin:
|
|
10
|
+
width: "70%",
|
|
11
|
+
minWidth: 48,
|
|
12
|
+
maxHeight: "70%",
|
|
13
|
+
margin: 2,
|
|
14
14
|
},
|
|
15
15
|
} as const;
|
|
16
16
|
|
|
@@ -100,7 +100,9 @@ export function formatWorkflowRunDetails(
|
|
|
100
100
|
now = Date.now(),
|
|
101
101
|
): string {
|
|
102
102
|
const agents = record.progress.phases.flatMap((phase) =>
|
|
103
|
-
phase.agents.map((agent) =>
|
|
103
|
+
phase.agents.map((agent) =>
|
|
104
|
+
`${phase.title} / ${agent.label}: ${agent.status}${agent.error ? ` — ${agent.error}` : ""}`
|
|
105
|
+
)
|
|
104
106
|
);
|
|
105
107
|
const shownAgents = agents.slice(0, WORKFLOW_RUN_DETAIL_AGENT_LIMIT);
|
|
106
108
|
const usage = formatWorkflowUsageLine(record.usage);
|
|
@@ -279,6 +279,7 @@ function compactWorkflowProgress(snapshot: WorkflowProgressSnapshot): WorkflowPr
|
|
|
279
279
|
doneAt: agent.doneAt,
|
|
280
280
|
toolUses: agent.toolUses,
|
|
281
281
|
lastTool: agent.lastTool === undefined ? undefined : boundedText(agent.lastTool),
|
|
282
|
+
error: agent.error === undefined ? undefined : boundedText(agent.error),
|
|
282
283
|
}));
|
|
283
284
|
remainingAgents -= agents.length;
|
|
284
285
|
return { title: boundedText(phase.title), agents };
|
|
@@ -106,6 +106,8 @@ let pathCounter = 0;
|
|
|
106
106
|
export class WorktreeRegistry {
|
|
107
107
|
private readonly paths = new Set<string>();
|
|
108
108
|
private readonly snapshots = new Set<string>();
|
|
109
|
+
private readonly recoverable = new Set<string>();
|
|
110
|
+
private readonly preserved = new Set<string>();
|
|
109
111
|
private readonly removals = new Map<string, Promise<WorktreeGitCommandResult>>();
|
|
110
112
|
private readonly repoCwd: string;
|
|
111
113
|
private readonly runner: WorktreeGitRunner;
|
|
@@ -123,10 +125,31 @@ export class WorktreeRegistry {
|
|
|
123
125
|
return this.paths.size;
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/** Failed worktrees retained for manual recovery. */
|
|
129
|
+
get preservedPaths(): readonly string[] {
|
|
130
|
+
return [...this.preserved].filter((path) => this.paths.has(path)).sort();
|
|
131
|
+
}
|
|
132
|
+
|
|
126
133
|
register(path: string): void {
|
|
127
134
|
this.paths.add(path);
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
/** Marks a worktree where a live agent could have produced useful edits. */
|
|
138
|
+
markRecoverable(path: string): void {
|
|
139
|
+
if (this.paths.has(path)) this.recoverable.add(path);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
preserve(path: string): void {
|
|
143
|
+
if (this.paths.has(path)) this.preserved.add(path);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Retain only worktrees that reached a live agent, not empty setup/replay worktrees. */
|
|
147
|
+
preserveRecoverable(): void {
|
|
148
|
+
for (const path of this.recoverable) {
|
|
149
|
+
if (this.paths.has(path)) this.preserved.add(path);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
130
153
|
async probe(signal?: AbortSignal): Promise<GitWorktreeProbe> {
|
|
131
154
|
return await isGitWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
132
155
|
}
|
|
@@ -183,8 +206,17 @@ export class WorktreeRegistry {
|
|
|
183
206
|
}
|
|
184
207
|
|
|
185
208
|
async removeAll(): Promise<readonly WorktreeRemovalOutcome[]> {
|
|
209
|
+
return await this.removePaths([...this.paths]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Remove disposable worktrees while leaving failed ones available for recovery. */
|
|
213
|
+
async removeUnpreserved(): Promise<readonly WorktreeRemovalOutcome[]> {
|
|
214
|
+
return await this.removePaths([...this.paths].filter((path) => !this.preserved.has(path)));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async removePaths(paths: readonly string[]): Promise<readonly WorktreeRemovalOutcome[]> {
|
|
186
218
|
const outcomes = await Promise.all(
|
|
187
|
-
|
|
219
|
+
paths.map(async (path) => ({
|
|
188
220
|
path,
|
|
189
221
|
...(await this.remove(path)),
|
|
190
222
|
})),
|
|
@@ -209,6 +241,8 @@ export class WorktreeRegistry {
|
|
|
209
241
|
if (result.ok) {
|
|
210
242
|
this.paths.delete(path);
|
|
211
243
|
this.snapshots.delete(path);
|
|
244
|
+
this.recoverable.delete(path);
|
|
245
|
+
this.preserved.delete(path);
|
|
212
246
|
}
|
|
213
247
|
return result;
|
|
214
248
|
}
|
package/src/ui/usage-bars.ts
CHANGED
|
@@ -120,8 +120,7 @@ export function renderUsageLines(state: UsageState, theme: any, width: number, m
|
|
|
120
120
|
const status = availability.ready
|
|
121
121
|
? `${availability.ready}/${availability.total} ready`
|
|
122
122
|
: availability.unknown ? "unknown/stale" : "exhausted";
|
|
123
|
-
const
|
|
124
|
-
const claudeTitle = `Claude Σ${state.accounts} · ${status}${partial ? " · partial" : ""}`;
|
|
123
|
+
const claudeTitle = `Claude Σ${state.accounts} · ${status}`;
|
|
125
124
|
const codexTitle = state.codexPlan ? `Codex · ${state.codexPlan}` : "Codex";
|
|
126
125
|
const lines = [` ${theme.fg("accent", claudeTitle.padEnd(cellWidth))}${" ".repeat(gap)}${theme.fg("accent", codexTitle)}`];
|
|
127
126
|
|
package/src/core/codex/oauth.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { generatePkce, generateState, parseCallback } from "../oauth/pkce.ts";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Codex OAuth (ChatGPT account login).
|
|
5
|
-
*
|
|
6
|
-
* The client id is the public one the Codex CLI itself uses; it is not a
|
|
7
|
-
* secret and the flow is PKCE precisely so that no secret is required. The
|
|
8
|
-
* redirect is a loopback URL, which is what lets a CLI complete the flow.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
12
|
-
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
13
|
-
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
14
|
-
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
15
|
-
const SCOPE = "openid profile email offline_access";
|
|
16
|
-
|
|
17
|
-
export interface CodexAuthStart {
|
|
18
|
-
url: string;
|
|
19
|
-
verifier: string;
|
|
20
|
-
state: string;
|
|
21
|
-
redirectUri: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface CodexTokens {
|
|
25
|
-
access: string;
|
|
26
|
-
refresh: string;
|
|
27
|
-
expires: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Builds the authorization URL and the PKCE material needed to finish. */
|
|
31
|
-
export async function authorizeCodex(): Promise<CodexAuthStart> {
|
|
32
|
-
const pkce = await generatePkce();
|
|
33
|
-
const state = generateState();
|
|
34
|
-
|
|
35
|
-
const url = new URL(AUTHORIZE_URL);
|
|
36
|
-
url.searchParams.set("response_type", "code");
|
|
37
|
-
url.searchParams.set("client_id", CLIENT_ID);
|
|
38
|
-
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
39
|
-
url.searchParams.set("scope", SCOPE);
|
|
40
|
-
url.searchParams.set("code_challenge", pkce.challenge);
|
|
41
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
42
|
-
url.searchParams.set("state", state);
|
|
43
|
-
// Forces the account chooser, which is the entire point when adding a
|
|
44
|
-
// SECOND account: without it the browser silently reuses the existing
|
|
45
|
-
// session and you get a duplicate of the account you already have.
|
|
46
|
-
url.searchParams.set("prompt", "login");
|
|
47
|
-
|
|
48
|
-
return { url: url.toString(), verifier: pkce.verifier, state, redirectUri: REDIRECT_URI };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Exchanges the callback URL (or a bare code) for tokens. */
|
|
52
|
-
export async function exchangeCodex(
|
|
53
|
-
callback: string,
|
|
54
|
-
verifier: string,
|
|
55
|
-
redirectUri: string,
|
|
56
|
-
expectedState?: string,
|
|
57
|
-
): Promise<CodexTokens> {
|
|
58
|
-
const parsed = parseCallback(callback);
|
|
59
|
-
const code = parsed?.code ?? callback.trim();
|
|
60
|
-
if (!code) throw new Error("No authorization code found in the callback.");
|
|
61
|
-
if (expectedState && parsed?.state && parsed.state !== expectedState) {
|
|
62
|
-
throw new Error("OAuth state mismatch; the callback does not belong to this login attempt.");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const response = await fetch(TOKEN_URL, {
|
|
66
|
-
method: "POST",
|
|
67
|
-
headers: { "content-type": "application/json" },
|
|
68
|
-
body: JSON.stringify({
|
|
69
|
-
grant_type: "authorization_code",
|
|
70
|
-
client_id: CLIENT_ID,
|
|
71
|
-
code,
|
|
72
|
-
redirect_uri: redirectUri,
|
|
73
|
-
code_verifier: verifier,
|
|
74
|
-
}),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
if (!response.ok) {
|
|
78
|
-
throw new Error(`Codex token exchange failed (${response.status}): ${(await response.text()).slice(0, 200)}`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const body: any = await response.json();
|
|
82
|
-
if (!body?.access_token) throw new Error("Codex token exchange returned no access token.");
|
|
83
|
-
return {
|
|
84
|
-
access: body.access_token,
|
|
85
|
-
refresh: body.refresh_token,
|
|
86
|
-
expires: Date.now() + Number(body.expires_in ?? 3600) * 1000,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Refreshes an expiring token. */
|
|
91
|
-
export async function refreshCodexToken(refresh: string): Promise<CodexTokens> {
|
|
92
|
-
const response = await fetch(TOKEN_URL, {
|
|
93
|
-
method: "POST",
|
|
94
|
-
headers: { "content-type": "application/json" },
|
|
95
|
-
body: JSON.stringify({
|
|
96
|
-
grant_type: "refresh_token",
|
|
97
|
-
client_id: CLIENT_ID,
|
|
98
|
-
refresh_token: refresh,
|
|
99
|
-
scope: SCOPE,
|
|
100
|
-
}),
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
if (!response.ok) {
|
|
104
|
-
throw new Error(`Codex token refresh failed (${response.status}).`);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const body: any = await response.json();
|
|
108
|
-
if (!body?.access_token) throw new Error("Codex token refresh returned no access token.");
|
|
109
|
-
return {
|
|
110
|
-
// OpenAI may omit a rotated refresh token; keeping the old one is correct.
|
|
111
|
-
access: body.access_token,
|
|
112
|
-
refresh: body.refresh_token ?? refresh,
|
|
113
|
-
expires: Date.now() + Number(body.expires_in ?? 3600) * 1000,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** A token good for at least a minute, refreshing and persisting if needed. */
|
|
118
|
-
export async function ensureCodexToken(
|
|
119
|
-
account: { access?: string; refresh?: string; expires?: number },
|
|
120
|
-
persist: (tokens: CodexTokens) => void,
|
|
121
|
-
): Promise<string | undefined> {
|
|
122
|
-
if (account.access && typeof account.expires === "number" && Date.now() + 60_000 < account.expires) {
|
|
123
|
-
return account.access;
|
|
124
|
-
}
|
|
125
|
-
if (!account.refresh) return account.access;
|
|
126
|
-
const tokens = await refreshCodexToken(account.refresh);
|
|
127
|
-
persist(tokens);
|
|
128
|
-
return tokens.access;
|
|
129
|
-
}
|