@gajae-code/coding-agent 0.5.1 → 0.5.3

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 (165) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +6 -0
  4. package/dist/types/cli/setup-cli.d.ts +8 -1
  5. package/dist/types/commands/setup.d.ts +7 -0
  6. package/dist/types/config/file-lock.d.ts +24 -2
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +62 -0
  10. package/dist/types/dap/client.d.ts +2 -1
  11. package/dist/types/edit/read-file.d.ts +6 -0
  12. package/dist/types/eval/js/context-manager.d.ts +3 -0
  13. package/dist/types/eval/js/executor.d.ts +1 -0
  14. package/dist/types/exec/bash-executor.d.ts +2 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  19. package/dist/types/lsp/types.d.ts +2 -0
  20. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  23. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  24. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -1
  27. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  28. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  29. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  30. package/dist/types/modes/theme/theme.d.ts +1 -0
  31. package/dist/types/modes/types.d.ts +1 -1
  32. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  33. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  34. package/dist/types/runtime-mcp/types.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +17 -1
  36. package/dist/types/session/artifacts.d.ts +4 -1
  37. package/dist/types/session/history-storage.d.ts +2 -2
  38. package/dist/types/session/session-manager.d.ts +10 -1
  39. package/dist/types/session/streaming-output.d.ts +5 -0
  40. package/dist/types/setup/credential-import.d.ts +79 -0
  41. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  42. package/dist/types/task/executor.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +1 -1
  44. package/dist/types/tools/bash.d.ts +1 -0
  45. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  46. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  47. package/dist/types/tools/subagent-render.d.ts +7 -1
  48. package/dist/types/tools/subagent.d.ts +21 -0
  49. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  50. package/dist/types/web/search/index.d.ts +4 -4
  51. package/dist/types/web/search/provider.d.ts +16 -20
  52. package/dist/types/web/search/providers/base.d.ts +2 -1
  53. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  54. package/dist/types/web/search/types.d.ts +14 -2
  55. package/package.json +7 -7
  56. package/scripts/build-binary.ts +7 -0
  57. package/src/async/job-manager.ts +153 -39
  58. package/src/cli/args.ts +2 -0
  59. package/src/cli/fast-help.ts +2 -0
  60. package/src/cli/setup-cli.ts +138 -3
  61. package/src/commands/setup.ts +5 -1
  62. package/src/commands/ultragoal.ts +3 -1
  63. package/src/config/file-lock-gc.ts +14 -2
  64. package/src/config/file-lock.ts +63 -13
  65. package/src/config/model-profile-activation.ts +15 -3
  66. package/src/config/model-profiles.ts +15 -15
  67. package/src/config/model-registry.ts +21 -1
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +62 -0
  70. package/src/dap/client.ts +105 -64
  71. package/src/dap/session.ts +44 -7
  72. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  73. package/src/edit/read-file.ts +19 -1
  74. package/src/eval/js/context-manager.ts +228 -65
  75. package/src/eval/js/executor.ts +2 -0
  76. package/src/eval/js/index.ts +1 -0
  77. package/src/eval/js/worker-core.ts +10 -6
  78. package/src/eval/py/executor.ts +68 -19
  79. package/src/eval/py/kernel.ts +46 -22
  80. package/src/eval/py/runner.py +68 -14
  81. package/src/exec/bash-executor.ts +49 -13
  82. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  83. package/src/gjc-runtime/launch-tmux.ts +3 -4
  84. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  85. package/src/gjc-runtime/state-runtime.ts +2 -1
  86. package/src/gjc-runtime/state-writer.ts +254 -7
  87. package/src/gjc-runtime/tmux-gc.ts +88 -38
  88. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  89. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  90. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  91. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  92. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  93. package/src/harness-control-plane/owner.ts +3 -2
  94. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  95. package/src/hooks/skill-state.ts +121 -2
  96. package/src/internal-urls/artifact-protocol.ts +10 -1
  97. package/src/internal-urls/docs-index.generated.ts +14 -10
  98. package/src/lsp/client.ts +64 -26
  99. package/src/lsp/defaults.json +1 -0
  100. package/src/lsp/index.ts +2 -1
  101. package/src/lsp/lspmux.ts +33 -9
  102. package/src/lsp/types.ts +2 -0
  103. package/src/main.ts +14 -4
  104. package/src/modes/acp/acp-agent.ts +4 -2
  105. package/src/modes/bridge/bridge-mode.ts +23 -1
  106. package/src/modes/components/assistant-message.ts +10 -2
  107. package/src/modes/components/bash-execution.ts +5 -1
  108. package/src/modes/components/eval-execution.ts +5 -1
  109. package/src/modes/components/history-search.ts +5 -2
  110. package/src/modes/components/model-selector.ts +60 -2
  111. package/src/modes/components/oauth-selector.ts +5 -0
  112. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  113. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  114. package/src/modes/components/skill-message.ts +24 -16
  115. package/src/modes/components/tool-execution.ts +6 -0
  116. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  117. package/src/modes/controllers/input-controller.ts +5 -0
  118. package/src/modes/controllers/selector-controller.ts +86 -2
  119. package/src/modes/interactive-mode.ts +11 -1
  120. package/src/modes/rpc/rpc-mode.ts +132 -18
  121. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  122. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  123. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  124. package/src/modes/theme/defaults/claude-code.json +100 -0
  125. package/src/modes/theme/defaults/codex.json +100 -0
  126. package/src/modes/theme/defaults/index.ts +6 -0
  127. package/src/modes/theme/defaults/opencode.json +102 -0
  128. package/src/modes/theme/theme.ts +2 -2
  129. package/src/modes/types.ts +1 -1
  130. package/src/modes/utils/ui-helpers.ts +5 -2
  131. package/src/prompts/agents/executor.md +5 -2
  132. package/src/runtime/process-lifecycle.ts +400 -0
  133. package/src/runtime-mcp/manager.ts +164 -50
  134. package/src/runtime-mcp/transports/http.ts +12 -11
  135. package/src/runtime-mcp/transports/stdio.ts +64 -38
  136. package/src/runtime-mcp/types.ts +3 -0
  137. package/src/sdk.ts +39 -1
  138. package/src/session/agent-session.ts +190 -33
  139. package/src/session/artifacts.ts +17 -2
  140. package/src/session/blob-store.ts +36 -2
  141. package/src/session/history-storage.ts +32 -11
  142. package/src/session/session-manager.ts +99 -31
  143. package/src/session/streaming-output.ts +54 -3
  144. package/src/setup/credential-import.ts +429 -0
  145. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  146. package/src/slash-commands/builtin-registry.ts +30 -3
  147. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  148. package/src/task/executor.ts +7 -1
  149. package/src/task/render.ts +18 -7
  150. package/src/tools/archive-reader.ts +10 -1
  151. package/src/tools/ask.ts +4 -2
  152. package/src/tools/bash.ts +11 -4
  153. package/src/tools/browser/tab-supervisor.ts +22 -0
  154. package/src/tools/browser.ts +38 -4
  155. package/src/tools/cron.ts +1 -1
  156. package/src/tools/read.ts +11 -12
  157. package/src/tools/sqlite-reader.ts +19 -5
  158. package/src/tools/subagent-render.ts +119 -29
  159. package/src/tools/subagent.ts +147 -7
  160. package/src/tools/ultragoal-ask-guard.ts +39 -0
  161. package/src/web/search/index.ts +25 -25
  162. package/src/web/search/provider.ts +178 -87
  163. package/src/web/search/providers/base.ts +2 -1
  164. package/src/web/search/providers/openai-compatible.ts +151 -0
  165. package/src/web/search/types.ts +47 -22
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Discover and import existing Claude Code / Codex CLI credentials.
3
+ *
4
+ * This is the testable core behind `gjc setup credentials` (CLI, primary entry)
5
+ * and the TUI provider-onboarding "import existing credentials" action. It never
6
+ * prints or returns raw tokens: callers receive redacted summaries plus opaque
7
+ * {@link AuthCredential} payloads that go straight into the store.
8
+ *
9
+ * Sources:
10
+ * - Claude Code: `~/.claude/.credentials.json` (Linux/WSL/Windows native),
11
+ * the macOS Keychain (`Claude Code-credentials`), and env vars.
12
+ * - Codex CLI: `~/.codex/auth.json` (OAuth `tokens` block or stored
13
+ * `OPENAI_API_KEY`), and env vars.
14
+ */
15
+ import * as fs from "node:fs/promises";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+ import type { AuthCredential, OAuthCredential } from "@gajae-code/ai";
19
+ import { isEnoent } from "@gajae-code/utils";
20
+ import { redactSecret } from "./provider-onboarding";
21
+
22
+ /** gjc provider ids that external credentials map onto. */
23
+ export type ExternalProvider = "anthropic" | "openai-codex";
24
+
25
+ /** Where a discovered credential came from. */
26
+ export type CredentialOrigin = "claude-code-file" | "claude-code-keychain" | "codex-file";
27
+
28
+ /** Human labels for providers, used in redacted summaries. */
29
+ export const EXTERNAL_PROVIDER_LABELS: Record<ExternalProvider, string> = {
30
+ anthropic: "Claude (Anthropic)",
31
+ "openai-codex": "Codex (ChatGPT)",
32
+ };
33
+
34
+ /** A credential that can be safely imported into gjc's store. */
35
+ export interface ImportableCredential {
36
+ provider: ExternalProvider;
37
+ origin: CredentialOrigin;
38
+ /** Redacted, human-readable description of where this came from. */
39
+ source: string;
40
+ kind: AuthCredential["type"];
41
+ identity?: { email?: string; accountId?: string };
42
+ /** Epoch-ms expiry for OAuth credentials, when known. */
43
+ expiresAt?: number;
44
+ /** Redacted access token / API key — safe to display. */
45
+ redactedToken: string;
46
+ /** Opaque credential payload. Never include this in any summary output. */
47
+ credential: AuthCredential;
48
+ }
49
+
50
+ /** A source that was found but could not be imported. */
51
+ export interface SkippedCredential {
52
+ origin: CredentialOrigin;
53
+ source: string;
54
+ reason: string;
55
+ }
56
+
57
+ /** Ambient environment-backed auth that is already usable without import. */
58
+ export interface EnvironmentCredentialHint {
59
+ provider: ExternalProvider;
60
+ variable: string;
61
+ redactedValue: string;
62
+ }
63
+
64
+ export interface CredentialDiscoveryResult {
65
+ importable: ImportableCredential[];
66
+ skipped: SkippedCredential[];
67
+ environment: EnvironmentCredentialHint[];
68
+ }
69
+
70
+ export interface DiscoveryOptions {
71
+ /** Override the home directory (defaults to `os.homedir()`). */
72
+ homeDir?: string;
73
+ /** Override the environment (defaults to `process.env`). */
74
+ env?: Record<string, string | undefined>;
75
+ /** Override the platform (defaults to `process.platform`). */
76
+ platform?: NodeJS.Platform;
77
+ /**
78
+ * Reader for the macOS Keychain `Claude Code-credentials` entry. Defaults to
79
+ * shelling out to `security`; injected in tests. Returns the raw JSON string,
80
+ * or `null` when no entry exists.
81
+ */
82
+ readClaudeKeychain?: () => Promise<string | null>;
83
+ }
84
+
85
+ export type CredentialUpserter = (provider: string, credential: AuthCredential) => unknown | Promise<unknown>;
86
+
87
+ export interface ImportSummary {
88
+ imported: ImportableCredential[];
89
+ failed: Array<{ credential: ImportableCredential; error: string }>;
90
+ }
91
+
92
+ // ─── Source-file shapes ──────────────────────────────────────────────────────
93
+
94
+ interface ClaudeCredentialsFile {
95
+ claudeAiOauth?: {
96
+ accessToken?: unknown;
97
+ refreshToken?: unknown;
98
+ expiresAt?: unknown;
99
+ scopes?: unknown;
100
+ };
101
+ }
102
+
103
+ interface CodexAuthFile {
104
+ OPENAI_API_KEY?: unknown;
105
+ tokens?: {
106
+ id_token?: unknown;
107
+ access_token?: unknown;
108
+ refresh_token?: unknown;
109
+ account_id?: unknown;
110
+ };
111
+ last_refresh?: unknown;
112
+ }
113
+
114
+ const ANTHROPIC_ENV_KEYS = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] as const;
115
+ const OPENAI_ENV_KEYS = ["OPENAI_API_KEY"] as const;
116
+
117
+ // ─── JWT helpers (best-effort identity/expiry extraction) ────────────────────
118
+
119
+ const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
120
+ const OPENAI_PROFILE_CLAIM = "https://api.openai.com/profile";
121
+
122
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
123
+ try {
124
+ const parts = token.split(".");
125
+ if (parts.length !== 3) return null;
126
+ const payload = parts[1] ?? "";
127
+ const decoded = Buffer.from(payload, "base64url").toString("utf-8");
128
+ const parsed = JSON.parse(decoded) as unknown;
129
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ function nonEmptyString(value: unknown): string | undefined {
136
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
137
+ }
138
+
139
+ /**
140
+ * Build a user-safe `skipped.reason` for a failed credential source.
141
+ *
142
+ * NEVER include the raw exception message: JSON parse errors and filesystem
143
+ * errors can echo back file contents (including bare token-like substrings) or
144
+ * other sensitive material, which then surfaces through CLI text/JSON and TUI
145
+ * discovery summaries. We expose only a generic phrase plus a non-sensitive
146
+ * error class (e.g. `SyntaxError`) or a standard Node syscall code (e.g.
147
+ * `EACCES`), both of which come from fixed, non-secret vocabularies.
148
+ */
149
+ function sanitizedFailureReason(
150
+ base: "malformed credential file" | "unreadable credential file",
151
+ err: unknown,
152
+ ): string {
153
+ if (!(err instanceof Error)) return base;
154
+ const code = (err as NodeJS.ErrnoException).code;
155
+ const detail = typeof code === "string" && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : err.constructor.name;
156
+ return detail ? `${base} (${detail})` : base;
157
+ }
158
+
159
+ // ─── Claude Code discovery ───────────────────────────────────────────────────
160
+
161
+ function parseClaudeCredentials(
162
+ raw: string,
163
+ origin: CredentialOrigin,
164
+ source: string,
165
+ ): ImportableCredential | SkippedCredential {
166
+ let parsed: unknown;
167
+ try {
168
+ parsed = JSON.parse(raw) as ClaudeCredentialsFile;
169
+ } catch (err) {
170
+ return { origin, source, reason: sanitizedFailureReason("malformed credential file", err) };
171
+ }
172
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
173
+ return { origin, source, reason: "unsupported shape (root is not an object)" };
174
+ }
175
+ const credentials = parsed as ClaudeCredentialsFile;
176
+ const oauth = credentials.claudeAiOauth;
177
+ if (typeof oauth !== "object" || oauth === null) {
178
+ return { origin, source, reason: "missing claudeAiOauth block (unsupported shape)" };
179
+ }
180
+ const access = nonEmptyString(oauth.accessToken);
181
+ const refresh = nonEmptyString(oauth.refreshToken);
182
+ if (!access || !refresh) {
183
+ return { origin, source, reason: "missing accessToken or refreshToken" };
184
+ }
185
+ const expiresAt =
186
+ typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : undefined;
187
+ const credential: OAuthCredential = {
188
+ type: "oauth",
189
+ access,
190
+ refresh,
191
+ expires: expiresAt ?? Date.now(),
192
+ };
193
+ return {
194
+ provider: "anthropic",
195
+ origin,
196
+ source,
197
+ kind: "oauth",
198
+ expiresAt,
199
+ redactedToken: redactSecret(access),
200
+ credential,
201
+ };
202
+ }
203
+
204
+ async function discoverClaudeCode(
205
+ opts: Required<Pick<DiscoveryOptions, "homeDir" | "platform">> & Pick<DiscoveryOptions, "readClaudeKeychain">,
206
+ result: CredentialDiscoveryResult,
207
+ ): Promise<void> {
208
+ const filePath = path.join(opts.homeDir, ".claude", ".credentials.json");
209
+ const displayPath = `~/.claude/.credentials.json`;
210
+ let fileRaw: string | null = null;
211
+ try {
212
+ fileRaw = await fs.readFile(filePath, "utf-8");
213
+ } catch (err) {
214
+ if (!isEnoent(err)) {
215
+ result.skipped.push({
216
+ origin: "claude-code-file",
217
+ source: `Claude Code (${displayPath})`,
218
+ reason: sanitizedFailureReason("unreadable credential file", err),
219
+ });
220
+ }
221
+ }
222
+ if (fileRaw !== null) {
223
+ const outcome = parseClaudeCredentials(fileRaw, "claude-code-file", `Claude Code (${displayPath})`);
224
+ pushOutcome(result, outcome);
225
+ } else if (opts.platform === "darwin") {
226
+ const reader = opts.readClaudeKeychain ?? defaultClaudeKeychainReader;
227
+ let keychainRaw: string | null = null;
228
+ try {
229
+ keychainRaw = await reader();
230
+ } catch (err) {
231
+ result.skipped.push({
232
+ origin: "claude-code-keychain",
233
+ source: "Claude Code (macOS Keychain)",
234
+ reason: sanitizedFailureReason("unreadable credential file", err),
235
+ });
236
+ }
237
+ if (keychainRaw !== null && keychainRaw.trim().length > 0) {
238
+ const outcome = parseClaudeCredentials(keychainRaw, "claude-code-keychain", "Claude Code (macOS Keychain)");
239
+ pushOutcome(result, outcome);
240
+ }
241
+ }
242
+ }
243
+
244
+ async function defaultClaudeKeychainReader(): Promise<string | null> {
245
+ const { $ } = await import("bun");
246
+ const proc = await $`security find-generic-password -s ${"Claude Code-credentials"} -w`.quiet().nothrow();
247
+ if (proc.exitCode !== 0) return null;
248
+ const out = proc.stdout.toString().trim();
249
+ return out.length > 0 ? out : null;
250
+ }
251
+
252
+ // ─── Codex discovery ─────────────────────────────────────────────────────────
253
+
254
+ function parseCodexAuth(raw: string, source: string): ImportableCredential | SkippedCredential {
255
+ let parsed: unknown;
256
+ try {
257
+ parsed = JSON.parse(raw) as CodexAuthFile;
258
+ } catch (err) {
259
+ return { origin: "codex-file", source, reason: sanitizedFailureReason("malformed credential file", err) };
260
+ }
261
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
262
+ return { origin: "codex-file", source, reason: "unsupported shape (root is not an object)" };
263
+ }
264
+ const auth = parsed as CodexAuthFile;
265
+ const tokens = auth.tokens;
266
+ const access = nonEmptyString(tokens?.access_token);
267
+ const refresh = nonEmptyString(tokens?.refresh_token);
268
+ if (access && refresh) {
269
+ const accessPayload = decodeJwtPayload(access);
270
+ const idPayload = nonEmptyString(tokens?.id_token) ? decodeJwtPayload(tokens?.id_token as string) : null;
271
+ const accountId =
272
+ nonEmptyString(tokens?.account_id) ??
273
+ nonEmptyString((accessPayload?.[OPENAI_AUTH_CLAIM] as { chatgpt_account_id?: unknown })?.chatgpt_account_id) ??
274
+ nonEmptyString((idPayload?.[OPENAI_AUTH_CLAIM] as { chatgpt_account_id?: unknown })?.chatgpt_account_id);
275
+ const email =
276
+ nonEmptyString((accessPayload?.[OPENAI_PROFILE_CLAIM] as { email?: unknown })?.email) ??
277
+ nonEmptyString(idPayload?.email)?.toLowerCase();
278
+ const expSeconds = typeof accessPayload?.exp === "number" ? accessPayload.exp : undefined;
279
+ const expiresAt = expSeconds !== undefined ? expSeconds * 1000 : undefined;
280
+ const credential: OAuthCredential = {
281
+ type: "oauth",
282
+ access,
283
+ refresh,
284
+ expires: expiresAt ?? Date.now(),
285
+ ...(accountId ? { accountId } : {}),
286
+ ...(email ? { email } : {}),
287
+ };
288
+ const identity =
289
+ accountId || email ? { ...(email ? { email } : {}), ...(accountId ? { accountId } : {}) } : undefined;
290
+ return {
291
+ provider: "openai-codex",
292
+ origin: "codex-file",
293
+ source,
294
+ kind: "oauth",
295
+ ...(identity ? { identity } : {}),
296
+ expiresAt,
297
+ redactedToken: redactSecret(access),
298
+ credential,
299
+ };
300
+ }
301
+ if (tokens && (tokens.access_token !== undefined || tokens.refresh_token !== undefined)) {
302
+ return {
303
+ origin: "codex-file",
304
+ source,
305
+ reason: "incomplete OAuth tokens (missing access_token or refresh_token)",
306
+ };
307
+ }
308
+ const apiKey = nonEmptyString(auth.OPENAI_API_KEY);
309
+ if (apiKey) {
310
+ return {
311
+ provider: "openai-codex",
312
+ origin: "codex-file",
313
+ source,
314
+ kind: "api_key",
315
+ redactedToken: redactSecret(apiKey),
316
+ credential: { type: "api_key", key: apiKey },
317
+ };
318
+ }
319
+ return { origin: "codex-file", source, reason: "no OAuth tokens or OPENAI_API_KEY present (unsupported shape)" };
320
+ }
321
+
322
+ async function discoverCodex(homeDir: string, result: CredentialDiscoveryResult): Promise<void> {
323
+ const filePath = path.join(homeDir, ".codex", "auth.json");
324
+ const displayPath = "~/.codex/auth.json";
325
+ let raw: string | null = null;
326
+ try {
327
+ raw = await fs.readFile(filePath, "utf-8");
328
+ } catch (err) {
329
+ if (!isEnoent(err)) {
330
+ result.skipped.push({
331
+ origin: "codex-file",
332
+ source: `Codex CLI (${displayPath})`,
333
+ reason: sanitizedFailureReason("unreadable credential file", err),
334
+ });
335
+ }
336
+ return;
337
+ }
338
+ pushOutcome(result, parseCodexAuth(raw, `Codex CLI (${displayPath})`));
339
+ }
340
+
341
+ // ─── Environment hints ───────────────────────────────────────────────────────
342
+
343
+ function discoverEnvironment(env: Record<string, string | undefined>, result: CredentialDiscoveryResult): void {
344
+ for (const variable of ANTHROPIC_ENV_KEYS) {
345
+ const value = nonEmptyString(env[variable]);
346
+ if (value) {
347
+ result.environment.push({ provider: "anthropic", variable, redactedValue: redactSecret(value) });
348
+ }
349
+ }
350
+ for (const variable of OPENAI_ENV_KEYS) {
351
+ const value = nonEmptyString(env[variable]);
352
+ if (value) {
353
+ result.environment.push({ provider: "openai-codex", variable, redactedValue: redactSecret(value) });
354
+ }
355
+ }
356
+ }
357
+
358
+ // ─── Public API ──────────────────────────────────────────────────────────────
359
+
360
+ function pushOutcome(result: CredentialDiscoveryResult, outcome: ImportableCredential | SkippedCredential): void {
361
+ if ("reason" in outcome) result.skipped.push(outcome);
362
+ else result.importable.push(outcome);
363
+ }
364
+
365
+ /**
366
+ * Discover Claude Code and Codex CLI credentials across files, the macOS
367
+ * Keychain, and environment variables. Never throws for individual unreadable or
368
+ * malformed sources — those land in {@link CredentialDiscoveryResult.skipped}.
369
+ */
370
+ export async function discoverExternalCredentials(options: DiscoveryOptions = {}): Promise<CredentialDiscoveryResult> {
371
+ const homeDir = options.homeDir ?? os.homedir();
372
+ const env = options.env ?? process.env;
373
+ const platform = options.platform ?? process.platform;
374
+ const result: CredentialDiscoveryResult = { importable: [], skipped: [], environment: [] };
375
+ await discoverClaudeCode({ homeDir, platform, readClaudeKeychain: options.readClaudeKeychain }, result);
376
+ await discoverCodex(homeDir, result);
377
+ discoverEnvironment(env, result);
378
+ return result;
379
+ }
380
+
381
+ /** Redacted one-line summary of an importable credential. Never includes secrets. */
382
+ export function formatCredentialSummary(credential: ImportableCredential): string {
383
+ const provider = EXTERNAL_PROVIDER_LABELS[credential.provider];
384
+ const kind = credential.kind === "oauth" ? "OAuth" : "API key";
385
+ const identity = credential.identity?.email ?? credential.identity?.accountId;
386
+ const identityPart = identity ? ` ${identity}` : "";
387
+ let expiry = "";
388
+ if (credential.kind === "oauth" && credential.expiresAt !== undefined) {
389
+ expiry = credential.expiresAt < Date.now() ? " [expired]" : "";
390
+ }
391
+ return `${provider} · ${kind}${identityPart} · token ${credential.redactedToken}${expiry} (from ${credential.source})`;
392
+ }
393
+
394
+ /** Redacted summary lines for an entire discovery result. Never includes secrets. */
395
+ export function formatDiscoverySummary(result: CredentialDiscoveryResult): string[] {
396
+ const lines: string[] = [];
397
+ for (const credential of result.importable) {
398
+ lines.push(`import ${formatCredentialSummary(credential)}`);
399
+ }
400
+ for (const skip of result.skipped) {
401
+ lines.push(`skip ${skip.source}: ${skip.reason}`);
402
+ }
403
+ for (const hint of result.environment) {
404
+ lines.push(
405
+ `env ${EXTERNAL_PROVIDER_LABELS[hint.provider]} · ${hint.variable}=${hint.redactedValue} (already active via environment)`,
406
+ );
407
+ }
408
+ return lines;
409
+ }
410
+
411
+ /**
412
+ * Persist discovered credentials via `upsert`. Each credential is imported
413
+ * independently; a failure on one is recorded without aborting the rest.
414
+ */
415
+ export async function importCredentials(
416
+ credentials: readonly ImportableCredential[],
417
+ upsert: CredentialUpserter,
418
+ ): Promise<ImportSummary> {
419
+ const summary: ImportSummary = { imported: [], failed: [] };
420
+ for (const credential of credentials) {
421
+ try {
422
+ await upsert(credential.provider, credential.credential);
423
+ summary.imported.push(credential);
424
+ } catch (err) {
425
+ summary.failed.push({ credential, error: err instanceof Error ? err.message : String(err) });
426
+ }
427
+ }
428
+ return summary;
429
+ }
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import type { AgentTool } from "@gajae-code/agent-core";
3
+ import { logger } from "@gajae-code/utils";
3
4
  import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
4
5
  import { ModeStateSchema } from "../gjc-runtime/state-schema";
5
6
  import { LocalProtocolHandler, resolveLocalUrlToPath } from "../internal-urls/local-protocol";
@@ -78,7 +79,7 @@ function modeStatePath(cwd: string, skill: string, sessionId?: string): string {
78
79
  }
79
80
 
80
81
  function warnInvalidModeState(filePath: string, error: string): void {
81
- console.warn(`gjc skill-state: invalid mode-state at ${filePath}: ${error}`);
82
+ logger.warn(`gjc skill-state: invalid mode-state at ${filePath}: ${error}`);
82
83
  }
83
84
 
84
85
  async function readValidatedModeState(filePath: string): Promise<ModeState | null> {
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import type { ThinkingLevel } from "@gajae-code/agent-core";
4
4
  import { type Model, modelsAreEqual } from "@gajae-code/ai";
5
5
  import { getOAuthProviders } from "@gajae-code/ai/utils/oauth";
6
+ import { Spacer, Text } from "@gajae-code/tui";
6
7
  import { setProjectDir } from "@gajae-code/utils";
7
8
  import { jobElapsedMs } from "../async";
8
9
  import {
@@ -13,6 +14,8 @@ import {
13
14
  import { extractExplicitThinkingSelector, formatModelSelectorValue, parseModelPattern } from "../config/model-resolver";
14
15
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers.js";
15
16
  import { resolveMemoryBackend } from "../memory-backend";
17
+ import { DynamicBorder } from "../modes/components/dynamic-border";
18
+ import { theme } from "../modes/theme/theme";
16
19
  import type { InteractiveModeContext } from "../modes/types";
17
20
  import { formatModelOnboardingGuidance } from "../setup/model-onboarding-guidance";
18
21
  import {
@@ -23,6 +26,7 @@ import {
23
26
  } from "../setup/provider-onboarding";
24
27
  import { parseThinkingLevel } from "../thinking";
25
28
  import { buildContextReportText } from "./helpers/context-report";
29
+ import { buildFastStatusReport } from "./helpers/fast-status-report";
26
30
  import { formatDuration } from "./helpers/format";
27
31
  import { commandConsumed, errorMessage, parseSlashCommand, usage } from "./helpers/parse";
28
32
  import { handleSshAcp } from "./helpers/ssh";
@@ -41,6 +45,14 @@ export type { BuiltinSlashCommand, SubcommandDef } from "./types";
41
45
  /** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */
42
46
  export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
43
47
 
48
+ function fastStatusRoleTargets(): Array<{ id: GjcModelAssignmentTargetId; label: string; isSubagentRole: boolean }> {
49
+ return GJC_MODEL_ASSIGNMENT_TARGET_IDS.map(id => ({
50
+ id,
51
+ label: GJC_MODEL_ASSIGNMENT_TARGETS[id].tag ?? id.toUpperCase(),
52
+ isSubagentRole: GJC_MODEL_ASSIGNMENT_TARGETS[id].settingsPath === "task.agentModelOverrides",
53
+ }));
54
+ }
55
+
44
56
  function parseProviderSetupSlashArgs(args: string): {
45
57
  preset?: string;
46
58
  compat?: string;
@@ -357,7 +369,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
357
369
  return commandConsumed();
358
370
  }
359
371
  if (arg === "status") {
360
- await runtime.output(`Fast mode is ${runtime.session.isFastModeEnabled() ? "on" : "off"}.`);
372
+ await runtime.output(
373
+ buildFastStatusReport({
374
+ session: runtime.session,
375
+ roleTargets: fastStatusRoleTargets(),
376
+ iconFast: theme.icon.fast,
377
+ }),
378
+ );
361
379
  return commandConsumed();
362
380
  }
363
381
  return usage("Usage: /fast [on|off|status]", runtime);
@@ -386,8 +404,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
386
404
  return;
387
405
  }
388
406
  if (arg === "status") {
389
- const enabled = runtime.ctx.session.isFastModeEnabled();
390
- runtime.ctx.showStatus(`Fast mode is ${enabled ? "on" : "off"}.`);
407
+ const report = buildFastStatusReport({
408
+ session: runtime.ctx.session,
409
+ roleTargets: fastStatusRoleTargets(),
410
+ iconFast: theme.icon.fast,
411
+ formatInactive: text => theme.fg("dim", text),
412
+ });
413
+ runtime.ctx.chatContainer.addChild(new Spacer(1));
414
+ runtime.ctx.chatContainer.addChild(new DynamicBorder());
415
+ runtime.ctx.chatContainer.addChild(new Text(report, 1, 0));
416
+ runtime.ctx.chatContainer.addChild(new DynamicBorder());
417
+ runtime.ctx.ui.requestRender();
391
418
  runtime.ctx.editor.setText("");
392
419
  return;
393
420
  }
@@ -0,0 +1,111 @@
1
+ import type { Model } from "@gajae-code/ai";
2
+
3
+ /**
4
+ * A single line in the `/fast status` report: a labelled model and whether fast
5
+ * mode is effective for it. The `fast` flag is resolved by the caller
6
+ * (`buildFastStatusReport`) so each row can use the correct service tier — the
7
+ * main session tier for the current model / `modelRoles` roles, or the subagent
8
+ * tier (`task.serviceTier`) for `task.agentModelOverrides` roles.
9
+ */
10
+ export interface FastStatusRow {
11
+ /** Display label, e.g. "현재 모델", "DEFAULT", "EXECUTOR". */
12
+ label: string;
13
+ /** Resolved model for this row, if any. */
14
+ model?: Model;
15
+ /** Whether fast mode is effective for this row's model. */
16
+ fast: boolean;
17
+ }
18
+
19
+ export interface FormatFastStatusReportArgs {
20
+ rows: FastStatusRow[];
21
+ /** The active theme's fast icon token (`theme.icon.fast`). */
22
+ iconFast: string;
23
+ /** Optional decorator for inactive ("off") text, e.g. theme dim in the TUI. */
24
+ formatInactive?: (text: string) => string;
25
+ }
26
+
27
+ /** Title line of the `/fast status` report. */
28
+ export const FAST_STATUS_TITLE = "Fast 모드 상태";
29
+
30
+ /** The inactive marker shown for rows where fast mode does not apply. */
31
+ export const FAST_STATUS_OFF = "off";
32
+
33
+ /**
34
+ * Format a multiline `/fast status` report. Pure and shared by the CLI
35
+ * (`handle`) and TUI (`handleTui`) command branches so the two never drift.
36
+ * Each row's fast/off state is decided by the caller (see
37
+ * {@link buildFastStatusReport}) so per-row service-tier differences are honored.
38
+ */
39
+ export function formatFastStatusReport(args: FormatFastStatusReportArgs): string {
40
+ const { rows, iconFast } = args;
41
+ const formatInactive = args.formatInactive ?? ((text: string) => text);
42
+ const lines: string[] = [FAST_STATUS_TITLE];
43
+ for (const row of rows) {
44
+ if (!row.model) {
45
+ lines.push(`${row.label}: ${formatInactive(FAST_STATUS_OFF)}`);
46
+ continue;
47
+ }
48
+ const ref = `${row.model.provider}/${row.model.id}`;
49
+ lines.push(`${row.label}: ${ref} ${row.fast ? iconFast : formatInactive(FAST_STATUS_OFF)}`);
50
+ }
51
+ return lines.join("\n");
52
+ }
53
+
54
+ /** Minimal session surface needed to build the `/fast status` report. */
55
+ export interface FastStatusSessionLike {
56
+ readonly model?: Model;
57
+ /** Fast predicate against the main session tier (current model + `modelRoles`). */
58
+ isFastForProvider(provider?: string): boolean;
59
+ /** Fast predicate against the effective subagent tier (`task.agentModelOverrides` roles). */
60
+ isFastForSubagentProvider(provider?: string): boolean;
61
+ resolveRoleModelWithThinking(role: string): { model?: Model };
62
+ }
63
+
64
+ /** A role to enumerate in the report, with the tier source its subagent runs under. */
65
+ export interface FastStatusRoleTarget {
66
+ id: string;
67
+ label: string;
68
+ /**
69
+ * True for `task.agentModelOverrides` roles (executor/architect/planner/critic)
70
+ * that run under `task.serviceTier`; false for `modelRoles` roles (default)
71
+ * that run under the main session tier.
72
+ */
73
+ isSubagentRole: boolean;
74
+ }
75
+
76
+ export interface BuildFastStatusReportArgs {
77
+ session: FastStatusSessionLike;
78
+ /** Role targets to enumerate, in display order. */
79
+ roleTargets: ReadonlyArray<FastStatusRoleTarget>;
80
+ /** The active theme's fast icon token (`theme.icon.fast`). */
81
+ iconFast: string;
82
+ /** Optional decorator for inactive ("off") text, e.g. theme dim in the TUI. */
83
+ formatInactive?: (text: string) => string;
84
+ }
85
+
86
+ /**
87
+ * Build the `/fast status` report from a live session: the active/current model
88
+ * followed by each assigned role (subagent) model. Unassigned roles are skipped
89
+ * so the report mirrors the `/model` selector, which only badges assigned roles.
90
+ *
91
+ * Subagent roles (`task.agentModelOverrides`) are evaluated against the
92
+ * effective subagent tier (`task.serviceTier`), while the current model and
93
+ * `modelRoles` roles use the main session tier — matching where each model
94
+ * actually runs.
95
+ */
96
+ export function buildFastStatusReport(args: BuildFastStatusReportArgs): string {
97
+ const { session, roleTargets, iconFast, formatInactive } = args;
98
+ const rows: FastStatusRow[] = [
99
+ { label: "현재 모델", model: session.model, fast: session.isFastForProvider(session.model?.provider) },
100
+ ];
101
+ for (const target of roleTargets) {
102
+ const resolved = session.resolveRoleModelWithThinking(target.id);
103
+ if (resolved.model) {
104
+ const fast = target.isSubagentRole
105
+ ? session.isFastForSubagentProvider(resolved.model.provider)
106
+ : session.isFastForProvider(resolved.model.provider);
107
+ rows.push({ label: target.label, model: resolved.model, fast });
108
+ }
109
+ }
110
+ return formatFastStatusReport({ rows, iconFast, formatInactive });
111
+ }
@@ -482,11 +482,17 @@ function getUsageTokens(usage: unknown): number {
482
482
  return firstNumberField(record, ["totalTokens", "total_tokens"]) ?? 0;
483
483
  }
484
484
 
485
- function createSubagentSettings(baseSettings: Settings): Settings {
485
+ export function createSubagentSettings(baseSettings: Settings): Settings {
486
486
  const snapshot: Partial<Record<SettingPath, unknown>> = {};
487
487
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
488
488
  snapshot[key] = baseSettings.get(key);
489
489
  }
490
+ // Subagent-scoped service-tier override: "inherit" keeps the snapshotted main
491
+ // session tier; any explicit value applies only to subagent sessions.
492
+ const taskServiceTier = baseSettings.get("task.serviceTier");
493
+ if (taskServiceTier !== "inherit") {
494
+ snapshot.serviceTier = taskServiceTier;
495
+ }
490
496
  return Settings.isolated({
491
497
  ...snapshot,
492
498
  "async.enabled": false,