@gajae-code/coding-agent 0.5.1 → 0.5.2

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 (98) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/dist/types/cli/setup-cli.d.ts +8 -1
  4. package/dist/types/commands/setup.d.ts +7 -0
  5. package/dist/types/config/file-lock.d.ts +24 -2
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/config/settings-schema.d.ts +62 -0
  9. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  10. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  12. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  13. package/dist/types/modes/interactive-mode.d.ts +1 -1
  14. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  15. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  16. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  17. package/dist/types/modes/theme/theme.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/session/history-storage.d.ts +2 -2
  20. package/dist/types/session/session-manager.d.ts +10 -1
  21. package/dist/types/setup/credential-import.d.ts +79 -0
  22. package/dist/types/task/executor.d.ts +1 -0
  23. package/dist/types/task/render.d.ts +1 -1
  24. package/dist/types/tools/subagent-render.d.ts +7 -1
  25. package/dist/types/tools/subagent.d.ts +21 -0
  26. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  27. package/dist/types/web/search/index.d.ts +4 -4
  28. package/dist/types/web/search/provider.d.ts +16 -20
  29. package/dist/types/web/search/providers/base.d.ts +2 -1
  30. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  31. package/dist/types/web/search/types.d.ts +14 -2
  32. package/package.json +7 -7
  33. package/scripts/build-binary.ts +7 -0
  34. package/src/cli/args.ts +2 -0
  35. package/src/cli/fast-help.ts +2 -0
  36. package/src/cli/setup-cli.ts +138 -3
  37. package/src/commands/setup.ts +5 -1
  38. package/src/commands/ultragoal.ts +3 -1
  39. package/src/config/file-lock-gc.ts +14 -2
  40. package/src/config/file-lock.ts +54 -12
  41. package/src/config/model-profile-activation.ts +15 -3
  42. package/src/config/model-profiles.ts +15 -15
  43. package/src/config/model-registry.ts +21 -1
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +62 -0
  46. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  47. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  48. package/src/gjc-runtime/launch-tmux.ts +3 -4
  49. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  50. package/src/gjc-runtime/state-runtime.ts +2 -1
  51. package/src/gjc-runtime/state-writer.ts +254 -7
  52. package/src/gjc-runtime/tmux-gc.ts +2 -1
  53. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  54. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  55. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  56. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  57. package/src/harness-control-plane/owner.ts +3 -2
  58. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  59. package/src/hooks/skill-state.ts +121 -2
  60. package/src/internal-urls/docs-index.generated.ts +13 -9
  61. package/src/lsp/defaults.json +1 -0
  62. package/src/main.ts +14 -4
  63. package/src/modes/acp/acp-agent.ts +4 -2
  64. package/src/modes/bridge/bridge-mode.ts +2 -1
  65. package/src/modes/components/history-search.ts +5 -2
  66. package/src/modes/components/model-selector.ts +26 -0
  67. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  68. package/src/modes/controllers/selector-controller.ts +80 -1
  69. package/src/modes/interactive-mode.ts +11 -1
  70. package/src/modes/rpc/rpc-mode.ts +132 -18
  71. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  72. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  73. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  74. package/src/modes/theme/defaults/claude-code.json +100 -0
  75. package/src/modes/theme/defaults/codex.json +100 -0
  76. package/src/modes/theme/defaults/index.ts +6 -0
  77. package/src/modes/theme/defaults/opencode.json +102 -0
  78. package/src/modes/theme/theme.ts +2 -2
  79. package/src/modes/types.ts +1 -1
  80. package/src/prompts/agents/executor.md +5 -2
  81. package/src/sdk.ts +12 -1
  82. package/src/session/agent-session.ts +22 -11
  83. package/src/session/history-storage.ts +32 -11
  84. package/src/session/session-manager.ts +70 -18
  85. package/src/setup/credential-import.ts +429 -0
  86. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  87. package/src/task/executor.ts +7 -1
  88. package/src/task/render.ts +18 -7
  89. package/src/tools/ask.ts +4 -2
  90. package/src/tools/cron.ts +1 -1
  91. package/src/tools/subagent-render.ts +119 -29
  92. package/src/tools/subagent.ts +147 -7
  93. package/src/tools/ultragoal-ask-guard.ts +39 -0
  94. package/src/web/search/index.ts +25 -25
  95. package/src/web/search/provider.ts +178 -87
  96. package/src/web/search/providers/base.ts +2 -1
  97. package/src/web/search/providers/openai-compatible.ts +151 -0
  98. package/src/web/search/types.ts +47 -22
@@ -40,6 +40,7 @@ import {
40
40
  isBlobRef,
41
41
  isImageDataUrl,
42
42
  MemoryBlobStore,
43
+ ResidentBlobMissingError,
43
44
  resolveImageData,
44
45
  resolveImageDataUrl,
45
46
  resolveResidentImageDataSync,
@@ -1201,6 +1202,12 @@ interface ResidentBlobStores {
1201
1202
  sessionFile?: string;
1202
1203
  }
1203
1204
 
1205
+ type ResidentBlobMissingPolicy = "throw" | "placeholder";
1206
+
1207
+ function residentBlobMissingPlaceholder(error: ResidentBlobMissingError): string {
1208
+ return `[Session resident ${error.kind} blob missing: sha256:${error.hash}; original content unavailable]`;
1209
+ }
1210
+
1204
1211
  function externalizeResidentValueSync(obj: unknown, stores: ResidentBlobStores, key?: string): unknown {
1205
1212
  if (obj === null || obj === undefined) return obj;
1206
1213
  if (typeof obj === "string") {
@@ -1256,6 +1263,7 @@ function materializeResidentValueSync(
1256
1263
  stores: ResidentBlobStores,
1257
1264
  key?: string,
1258
1265
  cache = new Map<string, string>(),
1266
+ missingPolicy: ResidentBlobMissingPolicy = "throw",
1259
1267
  ): unknown {
1260
1268
  if (obj === null || obj === undefined) return obj;
1261
1269
  if (typeof obj === "string") return obj;
@@ -1263,19 +1271,28 @@ function materializeResidentValueSync(
1263
1271
  const cacheKey = `${obj.kind}:${obj.ref}`;
1264
1272
  const cached = cache.get(cacheKey);
1265
1273
  if (cached !== undefined) return cached;
1266
- const resolved =
1267
- obj.kind === "imageUrl"
1268
- ? resolveResidentImageDataUrlSync(stores.imageStore, obj.ref, stores)
1269
- : obj.kind === "imageData"
1270
- ? resolveResidentImageDataSync(stores.imageStore, obj.ref, stores)
1271
- : resolveTextBlobSync(stores.textStore, obj.ref, stores);
1274
+ let resolved: string;
1275
+ try {
1276
+ resolved =
1277
+ obj.kind === "imageUrl"
1278
+ ? resolveResidentImageDataUrlSync(stores.imageStore, obj.ref, stores)
1279
+ : obj.kind === "imageData"
1280
+ ? resolveResidentImageDataSync(stores.imageStore, obj.ref, stores)
1281
+ : resolveTextBlobSync(stores.textStore, obj.ref, stores);
1282
+ } catch (err) {
1283
+ if (missingPolicy === "placeholder" && err instanceof ResidentBlobMissingError) {
1284
+ resolved = residentBlobMissingPlaceholder(err);
1285
+ } else {
1286
+ throw err;
1287
+ }
1288
+ }
1272
1289
  cache.set(cacheKey, resolved);
1273
1290
  return resolved;
1274
1291
  }
1275
1292
  if (Array.isArray(obj)) {
1276
1293
  let changed = false;
1277
1294
  const result = obj.map(item => {
1278
- const newItem = materializeResidentValueSync(item, stores, key, cache);
1295
+ const newItem = materializeResidentValueSync(item, stores, key, cache, missingPolicy);
1279
1296
  if (newItem !== item) changed = true;
1280
1297
  return newItem;
1281
1298
  });
@@ -1284,7 +1301,7 @@ function materializeResidentValueSync(
1284
1301
  if (typeof obj === "object") {
1285
1302
  let changed = false;
1286
1303
  const entries = Object.entries(obj).map(([childKey, value]) => {
1287
- const newValue = materializeResidentValueSync(value, stores, childKey, cache);
1304
+ const newValue = materializeResidentValueSync(value, stores, childKey, cache, missingPolicy);
1288
1305
  if (newValue !== value) changed = true;
1289
1306
  return [childKey, newValue] as const;
1290
1307
  });
@@ -1297,8 +1314,9 @@ function materializeResidentEntrySync<T extends FileEntry | SessionEntry>(
1297
1314
  entry: T,
1298
1315
  stores: ResidentBlobStores,
1299
1316
  cache: Map<string, string>,
1317
+ missingPolicy: ResidentBlobMissingPolicy = "throw",
1300
1318
  ): T {
1301
- return materializeResidentValueSync(entry, stores, undefined, cache) as T;
1319
+ return materializeResidentValueSync(entry, stores, undefined, cache, missingPolicy) as T;
1302
1320
  }
1303
1321
 
1304
1322
  function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(
@@ -1308,6 +1326,37 @@ function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(
1308
1326
  const cache = new Map<string, string>();
1309
1327
  return entries.map(entry => materializeResidentEntrySync(entry, stores, cache));
1310
1328
  }
1329
+
1330
+ function materializeResidentEntryForPersistenceSync<T extends FileEntry | SessionEntry>(
1331
+ entry: T,
1332
+ stores: ResidentBlobStores,
1333
+ cache: Map<string, string>,
1334
+ ): T {
1335
+ return materializeResidentEntrySync(entry, stores, cache, "placeholder");
1336
+ }
1337
+
1338
+ function materializeResidentEntriesForPersistenceSync<T extends FileEntry | SessionEntry>(
1339
+ entries: T[],
1340
+ stores: ResidentBlobStores,
1341
+ ): T[] {
1342
+ const cache = new Map<string, string>();
1343
+ return entries.map(entry => materializeResidentEntryForPersistenceSync(entry, stores, cache));
1344
+ }
1345
+
1346
+ export function residentBlobSentinelForTests(kind: ResidentBlobKind, ref: string): ResidentBlobSentinel {
1347
+ return residentBlobSentinel(kind, ref);
1348
+ }
1349
+
1350
+ export function materializeResidentEntriesForPersistenceForTests<T>(
1351
+ entries: T[],
1352
+ textStore: BlobStore,
1353
+ imageStore: BlobStore = textStore,
1354
+ ): T[] {
1355
+ return materializeResidentEntriesForPersistenceSync(entries as Array<T & FileEntry>, {
1356
+ textStore,
1357
+ imageStore,
1358
+ }) as T[];
1359
+ }
1311
1360
  function cloneJsonSemantic<T>(value: T): T {
1312
1361
  if (value === null || value === undefined || typeof value !== "object") return value;
1313
1362
  if (Array.isArray(value)) return value.map(item => cloneJsonSemantic(item)) as T;
@@ -2109,7 +2158,7 @@ export class SessionManager {
2109
2158
  #labelRevision = 0;
2110
2159
  #replayMetadataRevision = 0;
2111
2160
  #materializedEntriesRevision = -1;
2112
- #materializedEntriesCache: WeakRef<SessionEntry[]> | undefined;
2161
+ #materializedEntriesCache: SessionEntry[] | undefined;
2113
2162
  #sessionContextCache: WeakRef<SessionContext> | undefined;
2114
2163
  #sessionContextEntryRevision = -1;
2115
2164
  #sessionContextLeafRevision = -1;
@@ -2817,7 +2866,7 @@ export class SessionManager {
2817
2866
  await this.#queuePersistTask(async () => {
2818
2867
  await this.#closePersistWriterInternal();
2819
2868
  const entries = await Promise.all(
2820
- materializeResidentEntriesSync(this.#fileEntries, this.#residentBlobStores()).map(entry =>
2869
+ materializeResidentEntriesForPersistenceSync(this.#fileEntries, this.#residentBlobStores()).map(entry =>
2821
2870
  prepareEntryForPersistence(entry, this.#blobStore),
2822
2871
  ),
2823
2872
  );
@@ -2831,8 +2880,8 @@ export class SessionManager {
2831
2880
  #rewriteFileSync(): void {
2832
2881
  if (!this.persist || !this.#sessionFile) return;
2833
2882
  this.#closePersistWriterInternalSync();
2834
- const entries = materializeResidentEntriesSync(this.#fileEntries, this.#residentBlobStores()).map(entry =>
2835
- prepareEntryForPersistenceSync(entry, this.#blobStore),
2883
+ const entries = materializeResidentEntriesForPersistenceSync(this.#fileEntries, this.#residentBlobStores()).map(
2884
+ entry => prepareEntryForPersistenceSync(entry, this.#blobStore),
2836
2885
  );
2837
2886
  this.#writeEntriesAtomicallySync(entries);
2838
2887
  this.#needsFullRewriteOnNextPersist = false;
@@ -3139,7 +3188,11 @@ export class SessionManager {
3139
3188
  this.#rewriteFile().catch(() => {});
3140
3189
  return;
3141
3190
  }
3142
- const materializedEntry = materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map());
3191
+ const materializedEntry = materializeResidentEntryForPersistenceSync(
3192
+ entry,
3193
+ this.#residentBlobStores(),
3194
+ new Map(),
3195
+ );
3143
3196
  const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
3144
3197
  writer.writeSync(persistedEntry);
3145
3198
  } catch (err) {
@@ -3608,15 +3661,14 @@ export class SessionManager {
3608
3661
  * change the leaf pointer. Entries cannot be modified or deleted.
3609
3662
  */
3610
3663
  #getMaterializedEntriesInternal(): SessionEntry[] {
3611
- if (this.#materializedEntriesRevision === this.#entryRevision) {
3612
- const cached = this.#materializedEntriesCache?.deref();
3613
- if (cached) return cached;
3664
+ if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
3665
+ return this.#materializedEntriesCache;
3614
3666
  }
3615
3667
  const resolvedTextBlobCache = new Map<string, string>();
3616
3668
  const materializedEntries = this.#fileEntries
3617
3669
  .filter((e): e is SessionEntry => e.type !== "session")
3618
3670
  .map(entry => materializeResidentEntrySync(entry, this.#residentBlobStores(), resolvedTextBlobCache));
3619
- this.#materializedEntriesCache = new WeakRef(materializedEntries);
3671
+ this.#materializedEntriesCache = materializedEntries;
3620
3672
  this.#materializedEntriesRevision = this.#entryRevision;
3621
3673
  return materializedEntries;
3622
3674
  }
@@ -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> {
@@ -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,