@jameslovespancakes/pi-plus 1.0.12 → 1.0.14

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.
Files changed (42) hide show
  1. package/README.md +7 -3
  2. package/package.json +7 -6
  3. package/src/core/accounts/oauth-pool.ts +35 -0
  4. package/src/core/accounts/registry.ts +4 -0
  5. package/src/core/accounts/routing.ts +22 -0
  6. package/src/core/anthropic/identity.ts +54 -0
  7. package/src/core/anthropic/oauth.ts +3 -0
  8. package/src/core/anthropic/quota.ts +227 -28
  9. package/src/core/anthropic/store.ts +4 -1
  10. package/src/core/codex/quota.ts +43 -11
  11. package/src/core/codex/store.ts +21 -11
  12. package/src/core/config.ts +8 -10
  13. package/src/domains/agents/format.ts +39 -0
  14. package/src/domains/agents/index.ts +39 -50
  15. package/src/domains/remote/config-path.ts +0 -2
  16. package/src/domains/remote/index.ts +4 -41
  17. package/src/domains/subscriptions/accounts-picker.ts +44 -4
  18. package/src/domains/subscriptions/accounts.ts +31 -8
  19. package/src/domains/subscriptions/index.ts +2 -2
  20. package/src/domains/subscriptions/provider.ts +46 -5
  21. package/src/domains/subscriptions/providers/anthropic.ts +37 -5
  22. package/src/domains/subscriptions/providers/codex.ts +120 -254
  23. package/src/domains/subscriptions/providers/hosted.ts +2 -2
  24. package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
  25. package/src/domains/workflows/index.ts +1 -1
  26. package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
  27. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  28. package/src/domains/workflows/runtime/agent-limits.ts +24 -1
  29. package/src/domains/workflows/runtime/agent-retry.ts +11 -6
  30. package/src/domains/workflows/runtime/agent-runner.ts +42 -20
  31. package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
  32. package/src/domains/workflows/runtime/engine.ts +48 -12
  33. package/src/domains/workflows/runtime/progress.ts +2 -2
  34. package/src/domains/workflows/runtime/types.ts +4 -4
  35. package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
  36. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
  37. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
  38. package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
  39. package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
  40. package/src/domains/workflows/runtime/worktree.ts +35 -1
  41. package/src/ui/usage-bars.ts +1 -2
  42. package/src/core/codex/oauth.ts +0 -129
@@ -1,11 +1,9 @@
1
1
  import type { AgentExecutionOptions, AgentProgress } from "./agent-runner-types.ts";
2
- import { unknownErrorMessage } from "./unknown-error.ts";
3
2
  import type { WorktreeRegistry } from "./worktree.ts";
4
3
 
5
4
  interface AgentWorkspaceBase {
6
5
  readonly cwd: string;
7
6
  wrapResult(result: unknown): Promise<unknown>;
8
- dispose(): Promise<void>;
9
7
  }
10
8
 
11
9
  export interface SharedAgentWorkspace extends AgentWorkspaceBase {
@@ -39,7 +37,6 @@ export async function createAgentWorkspace(
39
37
  async wrapResult(result) {
40
38
  return result;
41
39
  },
42
- async dispose() {},
43
40
  };
44
41
  }
45
42
 
@@ -78,26 +75,5 @@ export async function createAgentWorkspace(
78
75
  if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
79
76
  return { result, patch: patch.patch, changed: patch.changed, baselineOid: added.baselineOid };
80
77
  },
81
- async dispose() {
82
- const removed = await rc.worktrees.remove(worktreePath);
83
- if (!removed.ok) {
84
- rc.progress.log(
85
- `${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`,
86
- );
87
- }
88
- },
89
78
  };
90
79
  }
91
-
92
- export async function disposeAgentWorkspace(
93
- rc: Pick<AgentWorkspaceContext, "progress">,
94
- label: string,
95
- workspace: AgentWorkspace | undefined,
96
- ): Promise<void> {
97
- if (!workspace) return;
98
- try {
99
- await workspace.dispose();
100
- } catch (error) {
101
- rc.progress.log(`${label}: failed to dispose isolated workspace (${unknownErrorMessage(error)})`);
102
- }
103
- }
@@ -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
- ? workflowOutcome.error
236
- : combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
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(error, ctx.signal, resolvedOptions.signal);
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 workflowOutcome.error;
288
- throw combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
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
- name: "worktree cleanup",
345
- criticality: "required",
346
- run: () => input.worktrees.removeAll(),
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 = toDisplayLine(text, AGENT_CHAT_TEXT_LIMIT);
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 disposable baseline.
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 this agent in a disposable git worktree and return its patch with the result. */
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 a disposable worktree and return its structured result plus patch. */
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 a disposable worktree and return its final text plus patch. */
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
- const label = chatLabel(message);
325
- const timestamp = new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
326
- const color = message.role === "status" ? "error" : message.role === "user" ? "accent" : message.role === "tool" ? "muted" : "text";
327
- const prefix = `${this.theme.fg("dim", timestamp)} ${this.theme.fg(color, label.padEnd(6))} `;
328
- return wrapTextWithAnsi(`${prefix}${message.text}`, Math.max(1, width - 1)).map((line) => ` ${line}`);
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.8;
4
- const VIEWPORT_MARGIN_ROWS = 2;
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: "80%",
11
- minWidth: 40,
12
- maxHeight: "80%",
13
- margin: 1,
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) => `${phase.title} / ${agent.label}: ${agent.status}`)
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
- [...this.paths].map(async (path) => ({
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
  }
@@ -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 partial = claude.some((cell) => cell.partial);
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
 
@@ -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
- }