@spzxg/cli-ui 0.6.2 → 0.6.5

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 (131) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +137 -11
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +73 -30
  26. package/src/chat/Composer.tsx +41 -41
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +8 -6
  35. package/src/core/conversation.ts +3 -3
  36. package/src/core/id.ts +15 -0
  37. package/src/core/identity.ts +33 -0
  38. package/src/core/provider.tsx +24 -14
  39. package/src/core/store.ts +83 -26
  40. package/src/core/types.ts +34 -0
  41. package/src/elements.tsx +135 -29
  42. package/src/embed.ts +6 -8
  43. package/src/index.ts +24 -17
  44. package/src/layout/CliSplit.tsx +1 -1
  45. package/src/msglist/CliMsgList.tsx +10 -10
  46. package/src/operator/branchFlow.ts +35 -0
  47. package/src/operator/index.ts +9 -0
  48. package/src/operator/liveStream.ts +1 -0
  49. package/src/operator/node/AgentConnect.tsx +624 -0
  50. package/src/operator/node/AppearancePanel.tsx +35 -0
  51. package/src/operator/node/BranchHistory.tsx +67 -0
  52. package/src/operator/node/BranchNavigator.tsx +36 -0
  53. package/src/operator/node/BrowserPane.tsx +128 -0
  54. package/src/operator/node/BrowserPopup.tsx +22 -0
  55. package/src/operator/node/Chat.tsx +517 -0
  56. package/src/operator/node/CommandPalette.tsx +213 -0
  57. package/src/operator/node/Composer.tsx +287 -0
  58. package/src/operator/node/InsightsView.tsx +831 -0
  59. package/src/operator/node/Layout.tsx +190 -0
  60. package/src/operator/node/Markdown.tsx +61 -0
  61. package/src/operator/node/Modal.tsx +179 -0
  62. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  63. package/src/operator/node/OnboardingWizard.tsx +250 -0
  64. package/src/operator/node/Orchestrator.tsx +634 -0
  65. package/src/operator/node/ProjectLanding.tsx +86 -0
  66. package/src/operator/node/ProjectsColumn.tsx +137 -0
  67. package/src/operator/node/ProviderAuth.tsx +561 -0
  68. package/src/operator/node/RunControls.tsx +2 -0
  69. package/src/operator/node/RunSwitch.tsx +113 -0
  70. package/src/operator/node/Services.tsx +245 -0
  71. package/src/operator/node/SessionRail.tsx +260 -0
  72. package/src/operator/node/SessionView.tsx +288 -0
  73. package/src/operator/node/SettingsView.tsx +690 -0
  74. package/src/operator/node/TaskBoard.tsx +352 -0
  75. package/src/operator/node/TaskChanges.tsx +530 -0
  76. package/src/operator/node/TaskHero.tsx +50 -0
  77. package/src/operator/node/TasksColumn.tsx +200 -0
  78. package/src/operator/node/Terminal.tsx +143 -0
  79. package/src/operator/node/Transcript.tsx +463 -0
  80. package/src/operator/node/ViewFile.tsx +79 -0
  81. package/src/operator/node/Welcome.tsx +101 -0
  82. package/src/operator/node/WorktreePrune.tsx +333 -0
  83. package/src/operator/node/agents.ts +58 -0
  84. package/src/operator/node/api.ts +40 -0
  85. package/src/operator/node/boardBackend.ts +80 -0
  86. package/src/operator/node/boardEvents.ts +22 -0
  87. package/src/operator/node/boardInteraction.ts +15 -0
  88. package/src/operator/node/boardState.ts +57 -0
  89. package/src/operator/node/boardWorkspace.ts +12 -0
  90. package/src/operator/node/branchFlow.ts +1 -0
  91. package/src/operator/node/composerDraft.ts +52 -0
  92. package/src/operator/node/fileLinks.ts +81 -0
  93. package/src/operator/node/format.ts +290 -0
  94. package/src/operator/node/github.tsx +234 -0
  95. package/src/operator/node/icons.tsx +88 -0
  96. package/src/operator/node/liveStream.ts +270 -0
  97. package/src/operator/node/modals.tsx +720 -0
  98. package/src/operator/node/navHistory.ts +70 -0
  99. package/src/operator/node/persist.ts +26 -0
  100. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  101. package/src/operator/node/protocol/agents/types.ts +394 -0
  102. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  103. package/src/operator/node/protocol/authFailure.ts +66 -0
  104. package/src/operator/node/protocol/customModels.ts +23 -0
  105. package/src/operator/node/protocol/events.ts +13 -0
  106. package/src/operator/node/protocol/features.ts +70 -0
  107. package/src/operator/node/protocol/internalUsage.ts +1 -0
  108. package/src/operator/node/protocol/promptLimits.ts +52 -0
  109. package/src/operator/node/protocol/storageRetention.ts +13 -0
  110. package/src/operator/node/protocol/types.ts +332 -0
  111. package/src/operator/node/protocol/usageLimit.ts +72 -0
  112. package/src/operator/node/shared.tsx +89 -0
  113. package/src/operator/node/transcriptModel.ts +129 -0
  114. package/src/operator/node/types.ts +296 -0
  115. package/src/operator/node/useGlobalEvents.ts +97 -0
  116. package/src/operator/node/useOrchestrator.ts +696 -0
  117. package/src/operator/node/usePrefs.ts +114 -0
  118. package/src/operator/node/useRecaps.ts +91 -0
  119. package/src/operator/node/useTaskStream.ts +279 -0
  120. package/src/operator.base.css +1798 -0
  121. package/src/operator.css +186 -16
  122. package/src/styles.css +20 -0
  123. package/src/ui/Markdown.tsx +7 -2
  124. package/src/ui/Popover.tsx +77 -0
  125. package/src/ui/TextBlock.tsx +3 -3
  126. package/src/ui/Transcript.tsx +10 -8
  127. package/src/ui/fileLinks.ts +6 -0
  128. package/src/ui/format.ts +1 -1
  129. package/src/ui/shared.tsx +1 -1
  130. package/src/ui/transcriptModel.ts +2 -2
  131. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -0,0 +1,70 @@
1
+ // Browser-history mechanics for the mobile projects → tasks → session panes,
2
+ // kept pure (no React, no globals) so the Back-button behaviour can be unit-tested.
3
+ //
4
+ // Why a "trap" instead of one-entry-per-level: the live task list re-selects the
5
+ // open task on every background refresh, so selTask churns null↔id constantly.
6
+ // Any scheme that pushes a history entry when the selection gets *deeper* turns
7
+ // that churn into a pile of duplicate session entries, and Back can never escape
8
+ // them (the original "back acts very strange / does nothing" bug on prod).
9
+ //
10
+ // Instead we keep at most ONE "trap" entry on top whenever a detail pane is open.
11
+ // Arming is keyed on the BOOLEAN "is a pane open" (isDeep), which selTask churn
12
+ // does not change — so churn only ever rewrites the current entry's URL, never
13
+ // adds entries. Back consumes the trap; the app then closes exactly one level
14
+ // (closeOneLevel) and re-arms, so Back walks session → tasks → projects → exit.
15
+ import type { View } from "./types";
16
+
17
+ export interface NavSel {
18
+ proj: string | null;
19
+ task: string | null;
20
+ view: View;
21
+ }
22
+
23
+ // Minimal slice of window.history this logic needs — lets tests pass a fake.
24
+ export interface HistoryLike {
25
+ readonly state: unknown;
26
+ pushState(state: unknown, unused: string, url: string): void;
27
+ replaceState(state: unknown, unused: string, url: string): void;
28
+ }
29
+
30
+ export function selectionUrl(sel: NavSel, pathname: string): string {
31
+ const q = new URLSearchParams();
32
+ if (sel.proj) q.set("project", sel.proj);
33
+ if (sel.task) q.set("task", sel.task);
34
+ if (sel.view === "settings" || sel.view === "insights") q.set("view", sel.view);
35
+ const s = q.toString();
36
+ return s ? `?${s}` : pathname;
37
+ }
38
+
39
+ // Deeper than the projects list? i.e. a detail pane (a project's task list, an
40
+ // open task, or settings) is showing and Back should close it rather than leave.
41
+ export const isDeep = (sel: NavSel): boolean => !!sel.proj || sel.view === "settings" || sel.view === "insights";
42
+
43
+ const trapArmed = (h: HistoryLike): boolean =>
44
+ !!(h.state && (h.state as { trap?: unknown }).trap);
45
+
46
+ // Keep the URL mirrored to the current selection (so a refresh lands back where
47
+ // you were) and, when `armTrap`, ensure exactly one trap entry sits on top while
48
+ // a pane is open. armTrap is the caller's "is mobile" gate — on desktop all
49
+ // columns are visible at once, so Back should not hijack to close a panel.
50
+ export function reconcileHistory(h: HistoryLike, pathname: string, sel: NavSel, armTrap: boolean): void {
51
+ const url = selectionUrl(sel, pathname);
52
+ const armed = trapArmed(h);
53
+ if (armTrap && isDeep(sel) && !armed) {
54
+ // Entering a detail pane from a non-trap entry: arm the single trap.
55
+ h.pushState({ trap: true }, "", url);
56
+ } else {
57
+ // Already armed (pane open — incl. selTask churn), or shallow/desktop: just
58
+ // mirror the URL onto the current entry, preserving the trap flag.
59
+ h.replaceState({ trap: armed }, "", url);
60
+ }
61
+ }
62
+
63
+ // One Back press closes the deepest open level. Returns the selection to apply;
64
+ // at the projects root it returns the input unchanged (Back then leaves the app).
65
+ export function closeOneLevel(sel: NavSel): NavSel {
66
+ if (sel.view === "settings" || sel.view === "insights") return { ...sel, view: "workspace" };
67
+ if (sel.task) return { ...sel, task: null };
68
+ if (sel.proj) return { ...sel, proj: null };
69
+ return sel;
70
+ }
@@ -0,0 +1,26 @@
1
+ // localStorage + URL persistence for the open project/task, layout and prefs.
2
+ import type { Appearance, Layout, Settings, TaskView } from "./types";
3
+
4
+ export const LS = "orchestrator_ui_v2";
5
+
6
+ type Persisted = { selProj?: string; selTask?: string; appearance?: Partial<Appearance>; layout?: Layout; settings?: Settings; taskView?: TaskView };
7
+
8
+ export function loadPersist(): Persisted {
9
+ if (typeof window === "undefined") return {};
10
+ try {
11
+ // `appearance` used to be called `tweaks` — read the legacy key so an existing
12
+ // install keeps its theme/density across the rename (dropped fields are ignored
13
+ // by the DEFAULT_APPEARANCE spread at the call site).
14
+ const raw = JSON.parse(localStorage.getItem(LS) || "{}") as Persisted & { tweaks?: Partial<Appearance> };
15
+ if (!raw.appearance && raw.tweaks) raw.appearance = raw.tweaks;
16
+ return raw;
17
+ } catch { return {}; }
18
+ }
19
+
20
+ // The open project/task live in the URL query (?project=…&task=…) so a refresh
21
+ // lands back where you were and the view is shareable. URL wins over localStorage.
22
+ export function readUrlSel(): { project?: string; task?: string; view?: string } {
23
+ if (typeof window === "undefined") return {};
24
+ const q = new URLSearchParams(window.location.search);
25
+ return { project: q.get("project") ?? undefined, task: q.get("task") ?? undefined, view: q.get("view") ?? undefined };
26
+ }
@@ -0,0 +1,9 @@
1
+ // Agents that run only when explicitly chosen (task / project default / app
2
+ // default). Connecting one never retargets existing work: implicit fallbacks
3
+ // (firstConnectedAgent, the new-task default, the "connect another agent"
4
+ // nudge) skip them. Dependency-free so server and client code can both import it.
5
+ export const OPT_IN_AGENTS: readonly string[] = ["grok", "pi"];
6
+
7
+ export function isOptInAgent(id: string | null | undefined): boolean {
8
+ return !!id && OPT_IN_AGENTS.includes(id);
9
+ }
@@ -0,0 +1,394 @@
1
+ // The agent-driver seam. The app never talks to a specific coding agent
2
+ // directly — every call site resolves an AgentDriver through
3
+ // lib/agents/registry.ts (keyed by tasks.agent / projects.default_agent) and
4
+ // speaks this interface. Adding an agent (Codex, Gemini, …) is a new driver
5
+ // module + a registry entry, with no edits to the runner, routes, or UI data
6
+ // flow — the same swappable-seam pattern as lib/billing/ and
7
+ // lib/control-plane/provisioner/.
8
+
9
+ import type { Project, Task, StreamEvent, TurnUsage } from "../types";
10
+
11
+ export type { StreamEvent };
12
+
13
+ // One selectable model in a driver's picker. `value` is what's persisted in
14
+ // tasks.model (null there = inherit the driver's default); `contextWindow` is
15
+ // the model's token window, driving the context-occupancy gauge. `group` is an
16
+ // optional section heading — drivers with long lists (Claude Code offers a
17
+ // dozen-plus pins) split them so the picker stays scannable; consecutive
18
+ // options sharing a group render under one header.
19
+ export interface AgentModelOption {
20
+ value: string;
21
+ label: string;
22
+ sub: string; // short picker subtitle, e.g. "most capable"
23
+ contextWindow: number;
24
+ group?: string;
25
+ }
26
+
27
+ // A reasoning preset / permission mode a driver supports. `value` is what's
28
+ // persisted in tasks.reasoning / tasks.permission_mode (null = driver default).
29
+ export interface AgentPickerOption {
30
+ value: string;
31
+ label: string;
32
+ sub: string;
33
+ }
34
+
35
+ /**
36
+ * What a driver can do, as data. Exposed to the client (GET /api/agents) so
37
+ * the UI renders model/reasoning/permission pickers and gates features
38
+ * (asks, cost display, resume) from the descriptor instead of hardcoding
39
+ * per-agent knowledge.
40
+ */
41
+ export interface AgentCapabilities {
42
+ models: AgentModelOption[];
43
+ reasoningOptions: AgentPickerOption[];
44
+ permissionModes: AgentPickerOption[];
45
+ /** Can surface interactive AskUserQuestion-style prompts mid-turn ("ask" events). */
46
+ supportsAsks: boolean;
47
+ /** Can mount the orchestrator's MCP tools (suggest_task / expose_service). */
48
+ supportsMcpTools: boolean;
49
+ /** Usage events carry a real dollar cost (not just token counts). */
50
+ reportsCostUsd: boolean;
51
+ /**
52
+ * The dollar cost in usage events is an ESTIMATE (token counts × published
53
+ * API prices) rather than a billed amount — set by drivers whose auth
54
+ * reports tokens only (Codex on a ChatGPT plan). The UI shows the figure
55
+ * with an ~ and labels it estimated. Mutually exclusive with reportsCostUsd.
56
+ */
57
+ costIsEstimated: boolean;
58
+ /** Turns can resume a prior session/thread id (tasks.session_id). */
59
+ supportsResume: boolean;
60
+ /**
61
+ * Absolute path to the CLI binary this driver spawns, when the app PINS one
62
+ * (Claude Code: CLAUDE_CLI_PATH, passed to the SDK as
63
+ * pathToClaudeCodeExecutable). A pinned path is also an installability test:
64
+ * the SDK does NOT fall back to PATH, so a missing file means every turn on
65
+ * this agent dies with "native binary not found" — see isAgentInstalled() in
66
+ * lib/agents/capabilities.ts, which is what keeps agent resolution from
67
+ * picking an agent that cannot possibly run. null/undefined = the driver
68
+ * resolves its own executable (bundled with its SDK, or found on PATH), so
69
+ * installation isn't knowable from here and the agent counts as installed.
70
+ */
71
+ cliPath?: string | null;
72
+ /** The driver accepts a user-supplied model id in addition to its catalog. */
73
+ supportsCustomModels?: boolean;
74
+ /** Claude Code can use Amazon Bedrock instead of Anthropic-hosted auth. */
75
+ supportsBedrock?: boolean;
76
+ /**
77
+ * Placeholder for the "I have an API key instead" field, e.g. "sk-ant-…".
78
+ * null = this agent has no per-token API-key path (subscription login only),
79
+ * so the client hides the api-key toggle.
80
+ */
81
+ apiKeyHint: string | null;
82
+ /**
83
+ * How the subscription login completes, so the generic connect UI knows what
84
+ * to render: "paste_code" — the user pastes an authorization code back into
85
+ * the app (Claude); "device_code" — the app shows a one-time code the user
86
+ * enters in the browser, then polls until it lands (Codex).
87
+ */
88
+ loginStyle: "paste_code" | "device_code";
89
+ /**
90
+ * Replaces the connect card's default "you'll get a login link" copy, for an
91
+ * agent whose sign-in happens outside Operator (Pi: /login in its own TUI).
92
+ * Unset = the default copy.
93
+ */
94
+ loginHint?: string;
95
+ /** The driver fronts many model providers and exposes `providers` (per-provider
96
+ * logins, API keys, custom endpoints) — the connect card renders that panel. */
97
+ supportsProviderAuth?: boolean;
98
+ }
99
+
100
+ // ---------- per-provider auth (an agent that fronts many model providers) ----------
101
+ // Pi talks to 40+ providers, each with its own OAuth and/or API-key login, plus
102
+ // user-defined endpoints. One "connect" button can't cover that, so such a
103
+ // driver exposes this optional surface and the UI lists providers to manage.
104
+ // Secrets only ever flow IN (prompt answers, endpoint keys); nothing here
105
+ // returns a credential value.
106
+
107
+ export interface AgentProviderEndpoint {
108
+ baseUrl: string;
109
+ api: string | null;
110
+ models: string[];
111
+ contextWindow: number | null;
112
+ reasoning: boolean;
113
+ /** The config file itself carries a key (set outside Operator). */
114
+ inlineKey: boolean;
115
+ }
116
+
117
+ export interface AgentProviderInfo {
118
+ id: string;
119
+ name: string;
120
+ /** Usable credentials from any source (stored login, env var, config file). */
121
+ configured: boolean;
122
+ /** Where the working credential comes from, as the agent reports it. */
123
+ source: string | null;
124
+ authType: "api_key" | "oauth" | null;
125
+ /** A credential in the agent's own store — what logout removes. */
126
+ credential: "api_key" | "oauth" | null;
127
+ /** Models usable right now through this provider. */
128
+ models: number;
129
+ apiKey: { name: string; canLogin: boolean } | null;
130
+ oauth: { name: string; label: string | null; subscription: boolean } | null;
131
+ /** Ships with the agent (vs. a user-defined endpoint). */
132
+ builtin: boolean;
133
+ /** A built-in provider pointed at another address (gateway / proxy), plus model ids added for it. */
134
+ override: { baseUrl: string | null; models: string[] } | null;
135
+ /** A user-defined endpoint; null for built-in providers. */
136
+ endpoint: AgentProviderEndpoint | null;
137
+ }
138
+
139
+ /** "Fill in an API key" for a provider, optionally through another address. */
140
+ export interface AgentProviderKeyInput {
141
+ /** Omit to keep the stored key (e.g. only changing the address). */
142
+ key?: string;
143
+ /** Empty = the provider's official address. */
144
+ baseUrl?: string;
145
+ /** Extra model ids the address serves (gateways often rename models). */
146
+ models?: string[];
147
+ }
148
+
149
+ export interface AgentProviderEndpointInput {
150
+ id: string;
151
+ name?: string;
152
+ baseUrl: string;
153
+ api: string;
154
+ models: string[];
155
+ contextWindow?: number;
156
+ reasoning?: boolean;
157
+ /** Optional; stored through the agent's own credential store, never in the config file. */
158
+ apiKey?: string;
159
+ }
160
+
161
+ /** Something the provider's login flow wants the user to see. */
162
+ export type AgentLoginEvent =
163
+ | { type: "info"; message: string; links?: { url: string; label?: string }[] }
164
+ | { type: "auth_url"; url: string; instructions?: string }
165
+ | { type: "device_code"; userCode: string; verificationUri: string; expiresInSeconds?: number }
166
+ | { type: "progress"; message: string };
167
+
168
+ /** Something the provider's login flow needs the user to answer. */
169
+ export type AgentLoginPrompt = { id: string } & (
170
+ | { type: "text" | "secret" | "manual_code"; message: string; placeholder?: string }
171
+ | { type: "select"; message: string; options: { id: string; label: string; description?: string }[] }
172
+ );
173
+
174
+ export interface AgentProviderLogin {
175
+ provider: string;
176
+ type: "oauth" | "api_key";
177
+ status: "running" | "success" | "error" | "cancelled";
178
+ events: AgentLoginEvent[];
179
+ /** The question the flow is waiting on, if any. */
180
+ prompt: AgentLoginPrompt | null;
181
+ error: string | null;
182
+ }
183
+
184
+ export interface AgentProviderAuth {
185
+ list(): Promise<{ providers: AgentProviderInfo[]; error: string | null }>;
186
+ startLogin(provider: string, type: "oauth" | "api_key"): Promise<AgentProviderLogin>;
187
+ getLogin(): AgentProviderLogin | null;
188
+ answer(promptId: string, value: string): Promise<AgentProviderLogin | null>;
189
+ cancelLogin(): void;
190
+ /** Save an API key (and optional address override). Returns the login flow when a key
191
+ * was given — it may still ask follow-up questions — or null when only the address changed. */
192
+ saveKey(provider: string, input: AgentProviderKeyInput): Promise<AgentProviderLogin | null>;
193
+ /** Remove the provider's stored credential. */
194
+ logout(provider: string): Promise<void>;
195
+ saveEndpoint(input: AgentProviderEndpointInput): Promise<{ backup: string | null }>;
196
+ removeEndpoint(id: string): Promise<{ backup: string | null }>;
197
+ }
198
+
199
+ // ---------- auth surface (shape after lib/claude-auth.ts) ----------
200
+
201
+ export interface AgentAuthStatus {
202
+ authenticated: boolean;
203
+ method: string | null; // how the account is signed in (raw provider text)
204
+ email: string | null;
205
+ plan: string | null;
206
+ error: string | null;
207
+ /** Underlying API provider when the CLI reports it (for example "bedrock"). */
208
+ provider?: string | null;
209
+ }
210
+
211
+ // A headless device-style login in progress (start → awaiting code → success).
212
+ export interface AgentLoginSession {
213
+ status: "starting" | "awaiting" | "submitting" | "success" | "error";
214
+ url: string | null; // the authorize URL for the user to open
215
+ code?: string | null; // device-code drivers: the one-time code to enter in the browser
216
+ email: string | null;
217
+ plan: string | null;
218
+ error: string | null;
219
+ log: string; // tail of the login terminal output, for the UI's pane
220
+ }
221
+
222
+ // ---------- account quota (the "how much have I got left" surface) ----------
223
+
224
+ /** A rate-limit window's duration, named where it's one of the common ones. */
225
+ export type AgentQuotaWindowKind = "hourly" | "5h" | "daily" | "weekly" | "monthly" | "custom";
226
+
227
+ /** One metered window on the account (a plan can meter several at once, e.g. a
228
+ * 5-hour burst cap alongside a weekly cap, and per model family). */
229
+ export interface AgentQuotaWindow {
230
+ /** The provider's bucket id, e.g. "codex" — stable, unlike the label. */
231
+ limitId: string | null;
232
+ /** Human name when the provider gives one, else the bucket id. */
233
+ label: string | null;
234
+ /** Which of the bucket's two windows this is, as the provider ordered them. */
235
+ scope: "primary" | "secondary";
236
+ kind: AgentQuotaWindowKind;
237
+ windowMinutes: number | null;
238
+ /** Pre-rendered duration ("1 周", "5 小时") so callers don't re-derive it. */
239
+ windowLabel: string | null;
240
+ usedPercent: number;
241
+ remainingPercent: number;
242
+ /** Epoch MS (the app's unit; providers often report seconds). */
243
+ resetsAt: number | null;
244
+ resetsInMs: number | null;
245
+ /** This particular window is spent. */
246
+ reached: boolean;
247
+ }
248
+
249
+ /** Who the quota belongs to, so a multi-account instance can tell them apart. */
250
+ export interface AgentQuotaAccount {
251
+ /** How the account authenticates, e.g. "chatgpt" | "apiKey" | "amazonBedrock". */
252
+ authType: string;
253
+ email: string | null;
254
+ plan: string | null;
255
+ }
256
+
257
+ /**
258
+ * An account's live quota state — the AUTHORITATIVE answer to "am I out?",
259
+ * as opposed to lib/usageLimit.ts, which can only pattern-match a provider's
260
+ * error text and so cannot distinguish a spent plan from a transient 429.
261
+ * A driver that can't read its provider's quota simply omits `quota()`.
262
+ */
263
+ export interface AgentQuota {
264
+ account: AgentQuotaAccount | null;
265
+ plan: string | null;
266
+ /** The provider says a limit is actually reached — not merely a 429. */
267
+ exhausted: boolean;
268
+ /** The provider's own reason code when it declared a limit reached. */
269
+ reachedType: string | null;
270
+ /** A spend control (not a rate limit) is what stopped the account. */
271
+ spendControlReached: boolean;
272
+ /** The most-consumed weekly window — the headline "周额度". */
273
+ weekly: AgentQuotaWindow | null;
274
+ /** Every metered window, including the one surfaced as `weekly`. */
275
+ limits: AgentQuotaWindow[];
276
+ credits: { hasCredits: boolean; unlimited: boolean; balance: string | null } | null;
277
+ /** One-off "reset my limit early" credits the account holds, when reported. */
278
+ resetCreditsAvailable: number | null;
279
+ totals: {
280
+ lifetimeTokens: number | null;
281
+ peakDailyTokens: number | null;
282
+ longestRunningTurnSec: number | null;
283
+ currentStreakDays: number | null;
284
+ longestStreakDays: number | null;
285
+ } | null;
286
+ daily: { date: string; tokens: number }[] | null;
287
+ fetchedAt: number;
288
+ /** Populated instead of the figures when the quota couldn't be read. */
289
+ error: string | null;
290
+ }
291
+
292
+ export interface AgentVerifyResult {
293
+ ok: boolean;
294
+ output: string;
295
+ error: string | null;
296
+ }
297
+
298
+ export interface OneShotResult {
299
+ text: string;
300
+ usage?: TurnUsage;
301
+ }
302
+
303
+ /**
304
+ * The "I have an API key instead" alternative to the subscription login, as a
305
+ * small surface a driver optionally provides (mirrors lib/anthropic-key.ts /
306
+ * lib/openai-key.ts). The key is persisted to a 0600 file on the volume and
307
+ * mirrored into process.env so the agent's children bill per-token against it.
308
+ * Drivers without a per-token path simply omit `apiKey`.
309
+ */
310
+ export interface AgentApiKeyAuth {
311
+ /** Placeholder for the input, e.g. "sk-ant-…". Also surfaced via capabilities.apiKeyHint. */
312
+ hint: string;
313
+ /** Loose shape check; the real validation is the verify turn working. */
314
+ looksValid(key: string): boolean;
315
+ /** Whether a key is currently persisted. */
316
+ has(): boolean;
317
+ /** Persist + apply the key. */
318
+ set(key: string): void;
319
+ /** Forget the key. */
320
+ clear(): void;
321
+ }
322
+
323
+ /**
324
+ * A pluggable coding-agent backend.
325
+ *
326
+ * `runTurn` is THE contract: one user turn in, a stream of normalized
327
+ * StreamEvents out (session/model/assistant/tool/tool_result/ask/ask_answered/
328
+ * suggested/usage/notice/error/done — see lib/types.ts). Drivers normalize
329
+ * their native event stream into it; everything downstream (lib/runner.ts
330
+ * persistence + publish, the SSE tail, the UI) is agent-agnostic.
331
+ *
332
+ * The session/thread id a driver reports via "session"/"done" events is opaque
333
+ * to the app — it's stored in tasks.session_id / sessions.claude_session_id
334
+ * and handed back verbatim on resume (a Codex thread id fits the same column).
335
+ */
336
+ export interface AgentDriver {
337
+ id: string; // persisted in tasks.agent / projects.default_agent
338
+ label: string; // human name, e.g. "Claude Code"
339
+ capabilities: AgentCapabilities;
340
+ /** Instance-wide provider selected outside Operator, when applicable. */
341
+ configuredProvider?(): string | null;
342
+ /** Whether the configured provider's credentials can be refreshed from the
343
+ * UI via the login surface (e.g. an AWS SSO device-code flow). Drives the
344
+ * refresh button in the connect card and gates POST /api/agents/[id]/login
345
+ * when a third-party provider is active. */
346
+ providerAuthRefreshable?(): boolean;
347
+
348
+ /**
349
+ * Run one user turn. Resumes task.session_id when set, otherwise starts a
350
+ * fresh session seeded with the project context. `abort` (the Stop button)
351
+ * must end the stream without emitting an error event.
352
+ */
353
+ runTurn(task: Task, project: Project, userText: string, abort?: AbortController): AsyncGenerator<StreamEvent>;
354
+
355
+ // ---------- one-shot helpers (no session, text in → text out) ----------
356
+ //
357
+ // All three are OPTIONAL: a driver can ship runTurn() alone and the app
358
+ // backstops the missing helper with the configured utility agent (see
359
+ // lib/agents/oneshots.ts). summarizeTranscript is task-scoped (runs on the
360
+ // task's own agent so the work bills the right login); draft/recap are
361
+ // project-scoped and run on the utility agent.
362
+
363
+ /** Condense a session transcript into a handoff note for the /clear flow. */
364
+ summarizeTranscript?(transcript: string, project: Project): Promise<OneShotResult>;
365
+ /** Draft a fresh "what we're building" context by exploring the repo (read-only). */
366
+ draftProjectContext?(project: Project, digest: string): Promise<OneShotResult>;
367
+ /** Short "where you left off" recap from a recent-activity digest. */
368
+ summarizeProjectRecap?(project: Project, digest: string): Promise<OneShotResult>;
369
+
370
+ // ---------- auth (the setup wizard's connect / verify flow) ----------
371
+
372
+ /** Whether the agent's CLI/SDK is signed in, and as whom. */
373
+ authStatus(): Promise<AgentAuthStatus>;
374
+ /** Start (or rejoin) a headless login; resolves once the authorize URL is known. */
375
+ startLogin(): Promise<AgentLoginSession>;
376
+ /** The in-progress login, if any (for the UI's poll loop). */
377
+ getLogin(): AgentLoginSession | null;
378
+ /** Hand the pasted authorization code to the waiting login. */
379
+ submitLoginCode(code: string): Promise<AgentLoginSession>;
380
+ /** Abandon an in-progress login. */
381
+ cancelLogin(): void;
382
+ /** Prove the connection works by running a real one-shot test turn. */
383
+ verify(): Promise<AgentVerifyResult>;
384
+ /**
385
+ * The account's live quota / usage, when the provider exposes it. Optional:
386
+ * a driver without it means the app falls back to error-text classification
387
+ * (lib/usageLimit.ts) alone.
388
+ */
389
+ quota?(): Promise<AgentQuota>;
390
+ /** The per-token API-key path, if this agent supports one (else undefined). */
391
+ apiKey?: AgentApiKeyAuth;
392
+ /** Per-provider auth management (see AgentProviderAuth); with capabilities.supportsProviderAuth. */
393
+ providers?: AgentProviderAuth;
394
+ }
@@ -0,0 +1,77 @@
1
+ // Detection + recovery constants for the "Codex's approval policy blocked the
2
+ // turn" failure mode.
3
+ //
4
+ // Operator runs Codex through `codex exec` (via @openai/codex-sdk), which is
5
+ // non-interactive: nobody can answer a command-approval prompt mid-turn. So the
6
+ // driver asks for `approval_policy=never` (the auto-run analog of Claude's
7
+ // bypassPermissions). Two configurations defeat that and produce the same
8
+ // baffling failure:
9
+ //
10
+ // 1. Enterprise-managed Codex requirements disallow `never`. The CLI warns
11
+ // ("Configured value for `approval_policy` is disallowed by requirements;
12
+ // falling back to required value UnlessTrusted") and silently downgrades.
13
+ // Under UnlessTrusted every non-allowlisted command needs an approval that
14
+ // exec mode cannot service — codex_core rejects each one ("approval
15
+ // request failed"), and the agent flails through its tools with no
16
+ // explanation.
17
+ // 2. The user's own ~/.codex/config.toml sets an approval-requiring policy
18
+ // and Operator runs with CODEX_APPROVAL_POLICY=inherit.
19
+ //
20
+ // The downgrade warning arrives as an error item on the very first affected
21
+ // turn (lib/agents/codex/events.ts maps it to a StreamEvent error), so the
22
+ // Codex driver matches it there and flips an instance-wide flag that makes it
23
+ // request the exec-compatible "on-request" policy from the next turn on
24
+ // (self-healing, no user action). This module classifies the failure for
25
+ // lib/runner.ts — same pattern as lib/promptLimits.ts / lib/authFailure.ts —
26
+ // which appends APPROVAL_BLOCKED_NOTICE so the UI can render a one-click Retry.
27
+ // Kept dependency-free so both server and client bundles can import it.
28
+
29
+ /** The CLI's managed-requirements downgrade warning — the signal that the
30
+ * approval policy Operator asked for was rejected and a stricter one applies.
31
+ * Matched by the Codex driver to trigger auto-negotiation. */
32
+ const APPROVAL_DOWNGRADE_RES = [
33
+ /approval_policy[^\n]{0,80}disallowed by requirements/i,
34
+ /disallowed by requirements[^\n]{0,120}approval_policy/i,
35
+ /invalid value for [`'"]?approval_policy[`'"]?[^\n]{0,80}not in the allowed set/i,
36
+ ];
37
+
38
+ /** True when an error is the CLI's "your approval_policy was rejected by
39
+ * managed requirements, falling back to a stricter value" warning. */
40
+ export function isApprovalDowngrade(msg: string | null | undefined): boolean {
41
+ return !!msg && APPROVAL_DOWNGRADE_RES.some((re) => re.test(msg));
42
+ }
43
+
44
+ // The rejections codex_core emits when an approval-requiring policy meets exec
45
+ // mode's inability to ask anyone:
46
+ // - "command execution approval is not supported in exec mode for thread …"
47
+ // - "exec_command failed …: Rejected(\"approval request failed\")"
48
+ // - "approval policy is UnlessTrusted; reject command — you cannot ask for
49
+ // escalated permissions if the approval policy is UnlessTrusted"
50
+ const APPROVAL_BLOCKED_RES = [
51
+ ...APPROVAL_DOWNGRADE_RES,
52
+ /approval request failed/i,
53
+ /approval policy is \w+; reject/i,
54
+ /(?:command execution )?approval is not supported in exec mode/i,
55
+ /cannot ask for escalated permissions/i,
56
+ ];
57
+
58
+ /** True when a turn's error text is an approval-policy rejection (the managed
59
+ * downgrade warning, or exec mode failing to service an approval) rather than
60
+ * a work failure. */
61
+ export function isApprovalBlocked(msg: string | null | undefined): boolean {
62
+ return !!msg && APPROVAL_BLOCKED_RES.some((re) => re.test(msg));
63
+ }
64
+
65
+ /** Appended to the persisted error line when a turn is blocked by the approval
66
+ * policy. The UI (app/orchestrator/Transcript.tsx) matches this exact string
67
+ * to render the "Retry" button — the driver has already switched future turns
68
+ * to the exec-compatible "on-request" policy by the time anyone reads this, so
69
+ * resending the same message is the recovery. Persisted message content is the
70
+ * durable channel — it survives SSE reconnects because the snapshot replays
71
+ * from SQLite. */
72
+ export const APPROVAL_BLOCKED_NOTICE =
73
+ "智能体的审批策略拦截了这一轮:它要求交互式命令审批,而 " +
74
+ "Operator 的无人值守会话无法提供。Operator 现已为其 Codex 会话改用兼容的 'on-request' " +
75
+ "策略,因此重试这条消息通常就能通过。(如果你使用了 " +
76
+ "CODEX_APPROVAL_POLICY=inherit,请在 ~/.codex/config.toml 中改为设置一个与 exec 兼容的 " +
77
+ "approval_policy,例如 \"on-request\"。)";
@@ -0,0 +1,66 @@
1
+ // Detection + recovery constants for the "your agent login stopped working"
2
+ // failure mode.
3
+ //
4
+ // An expired OAuth session is invisible until you send a turn, and then it looks
5
+ // like a code problem: the raw provider string ("Failed to authenticate: OAuth
6
+ // session expired and could not be refreshed") lands in ONE task's transcript,
7
+ // while every other task on the instance is equally dead — same CLI, same
8
+ // credentials on disk. Worse, each queued follow-up would dequeue and fail the
9
+ // same way, emptying the queue for nothing.
10
+ //
11
+ // So an auth failure is classified here (agent-agnostically — Codex phrases it
12
+ // differently and fails identically), which lets lib/runner.ts append
13
+ // AUTH_EXPIRED_NOTICE for a one-click reconnect, park the queue instead of
14
+ // burning it, and flag the agent instance-wide (lib/agents/connections.ts) so
15
+ // the app can say so in the titlebar rather than only inside the task that
16
+ // happened to run first. Kept dependency-free so both server and client bundles
17
+ // can import it (same rule as lib/promptLimits.ts).
18
+
19
+ // Each agent CLI/SDK words a dead credential differently, and the recovery
20
+ // ("reconnect this agent") is identical for all of them:
21
+ // - Claude Code: "Failed to authenticate: OAuth session expired and could
22
+ // not be refreshed"; API-key path: "invalid x-api-key" / 401.
23
+ // - Codex: "not logged in", "please run codex login", "401 Unauthorized".
24
+ // - Either provider's raw HTTP rejection: 401 + authentication_error.
25
+ // The 401/403 patterns require a nearby auth word so an unrelated error that
26
+ // merely contains a status code can't trip the banner.
27
+ const AUTH_FAILURE_RES = [
28
+ /failed to authenticate/i,
29
+ /authentication (?:failed|error|required)/i,
30
+ /could not be refreshed/i,
31
+ /(?:oauth|login|access|refresh) (?:session|token)[^.\n]{0,40}(?:expired|revoked|invalid|refresh)/i,
32
+ /(?:session|token|credentials?)[^.\n]{0,30}(?:has |have |is |are )?(?:expired|revoked)/i,
33
+ /invalid[_ ]grant/i,
34
+ /invalid[^.\n]{0,20}(?:api[- ]?key|x-api-key|credentials?|token)/i,
35
+ /(?:api[- ]?key|credentials?)[^.\n]{0,30}(?:invalid|expired|revoked|missing|not found)/i,
36
+ /\bnot (?:logged in|authenticated|signed in)\b/i,
37
+ /(?:re-?authenticate|re-?login|log ?in again|sign ?in again)/i,
38
+ /run [`'"]?(?:claude|codex)(?: auth)? login/i,
39
+ /\b40[13]\b[^\n]{0,60}(?:unauthorized|forbidden|authentication|invalid|expired|api[- ]?key)/i,
40
+ /(?:unauthorized|forbidden)[^\n]{0,40}\b40[13]\b/i,
41
+ /unable to locate (?:aws )?credentials/i,
42
+ /aws default-chain credential resolve (?:timed out|failed)/i,
43
+ /(?:ExpiredToken|InvalidClientTokenId|UnrecognizedClientException)/i,
44
+ /(?:aws|sso)[^.\n]{0,30}(?:credentials?|session|token)[^.\n]{0,30}(?:expired|invalid|missing)/i,
45
+ ];
46
+
47
+ /** True when a turn's error text is a dead-credential rejection (expired OAuth
48
+ * session, revoked token, bad/missing API key) rather than a work failure. */
49
+ export function isAuthFailure(msg: string | null | undefined): boolean {
50
+ return !!msg && AUTH_FAILURE_RES.some((re) => re.test(msg));
51
+ }
52
+
53
+ /** Appended to the persisted error line when a turn dies on authentication. The
54
+ * UI (app/orchestrator/Transcript.tsx) matches this exact string to render the
55
+ * "Reconnect" button, so it must stay agent-neutral — the client already knows
56
+ * the task's agent and labels the button with it. Persisted message content is
57
+ * the durable channel: it survives SSE reconnects because the snapshot replays
58
+ * from SQLite. */
59
+ export const AUTH_EXPIRED_NOTICE =
60
+ "该智能体的连接已失效 —— 登录凭据过期、被撤销或不可用," +
61
+ "在你重新连接或刷新之前无法运行任何轮次。没有任何内容丢失:本次会话及其 " +
62
+ "worktree 均未受影响,排队中的消息也仍在队列里。";
63
+
64
+ /** The one-line, instance-wide version of the same news, for the titlebar banner
65
+ * (app/orchestrator/AgentConnect.tsx → AgentAuthBanner). */
66
+ export const AUTH_BANNER_HINT = "在凭据刷新之前,任何会话都无法运行。";