@gajae-code/coding-agent 0.17.1 → 0.17.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 (53) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/async/job-manager.d.ts +7 -6
  3. package/dist/types/config/autorouting-tier-map.d.ts +6 -0
  4. package/dist/types/config/file-lock.d.ts +1 -0
  5. package/dist/types/config/model-profile-activation.d.ts +18 -3
  6. package/dist/types/config/model-registry.d.ts +9 -0
  7. package/dist/types/coordinator-mcp/server.d.ts +3 -0
  8. package/dist/types/modes/acp/acp-agent.d.ts +40 -1
  9. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +27 -0
  11. package/dist/types/sdk/acp/final-text.d.ts +8 -0
  12. package/dist/types/sdk/broker/ensure.d.ts +46 -1
  13. package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
  14. package/dist/types/sdk/prompt-failure.d.ts +30 -1
  15. package/dist/types/sdk/prompt-status.d.ts +8 -0
  16. package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
  17. package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
  18. package/dist/types/thinking.d.ts +3 -1
  19. package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
  20. package/package.json +7 -7
  21. package/src/async/job-manager.ts +38 -8
  22. package/src/commands/sdk.ts +78 -4
  23. package/src/config/autorouting-tier-map.ts +2 -0
  24. package/src/config/file-lock.ts +52 -17
  25. package/src/config/model-profile-activation.ts +72 -10
  26. package/src/config/model-registry.ts +21 -0
  27. package/src/coordinator-mcp/server.ts +21 -6
  28. package/src/debug/report-bundle.ts +114 -24
  29. package/src/gjc-runtime/managed-owner-admission.ts +67 -50
  30. package/src/internal-urls/docs-index.generated.ts +7 -6
  31. package/src/modes/acp/acp-agent.ts +187 -14
  32. package/src/modes/acp/acp-event-mapper.ts +9 -0
  33. package/src/modes/components/model-selector.ts +31 -15
  34. package/src/modes/components/status-line/segments.ts +6 -1
  35. package/src/modes/components/thinking-selector.ts +7 -3
  36. package/src/modes/controllers/selector-controller.ts +16 -3
  37. package/src/sdk/acp/adapter.ts +63 -1
  38. package/src/sdk/acp/final-text.ts +19 -2
  39. package/src/sdk/broker/ensure.ts +97 -19
  40. package/src/sdk/bus/index.ts +31 -4
  41. package/src/sdk/host/session-runtime.test.ts +498 -7
  42. package/src/sdk/host/session-runtime.ts +188 -21
  43. package/src/sdk/prompt-deadline-manager.ts +3 -3
  44. package/src/sdk/prompt-failure.ts +43 -1
  45. package/src/sdk/prompt-status.ts +8 -0
  46. package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
  47. package/src/sdk/router/session-router.ts +22 -3
  48. package/src/sdk/session.ts +12 -9
  49. package/src/sdk/transport/serve-cli.ts +105 -15
  50. package/src/session-import/codex.ts +12 -3
  51. package/src/thinking.ts +21 -2
  52. package/src/utils/mupdf-wasm-embedded.ts +3 -0
  53. package/src/utils/mupdf-wasm.ts +24 -2
@@ -6,7 +6,16 @@
6
6
  import * as fs from "node:fs/promises";
7
7
  import * as path from "node:path";
8
8
  import type { WorkProfile } from "@gajae-code/natives";
9
- import { APP_NAME, getLogPath, getLogsDir, getReportsDir, isEnoent } from "@gajae-code/utils";
9
+ import {
10
+ APP_NAME,
11
+ getEffectiveLogPath,
12
+ getEffectiveLogsDir,
13
+ getLogPath,
14
+ getLogsDir,
15
+ getReportsDir,
16
+ isEnoent,
17
+ resolveEquivalentPath,
18
+ } from "@gajae-code/utils";
10
19
  import type { CpuProfile, HeapSnapshot } from "./profiler";
11
20
  import { collectSystemInfo, sanitizeEnv } from "./system-info";
12
21
 
@@ -34,6 +43,70 @@ async function readLastLines(filePath: string, n: number, maxBytes = MAX_LOG_BYT
34
43
  }
35
44
  }
36
45
 
46
+ /**
47
+ * Both log directories can legitimately hold dated `gjc.<date>.log` files, and a
48
+ * reader that consults only one of them is wrong in one direction or the other.
49
+ *
50
+ * The *effective* directory is where this process writes — a trusted
51
+ * `GJC_LOG_DIR` pin, which the test preload sets to a per-process temp sink
52
+ * (issue #5618). The *canonical* directory is the config root, which carries
53
+ * history: previous runs, other processes, and anything written through
54
+ * `getLogsDir()` before this process pinned anything.
55
+ *
56
+ * Deduplicated by equivalent path, effective first. In production — and in any
57
+ * process that never pinned `GJC_LOG_DIR` — the two resolve to the same
58
+ * directory and this collapses to the single entry these readers have always
59
+ * used, so nothing is listed twice and no ordering changes.
60
+ */
61
+ function logSearchDirs(): string[] {
62
+ const dirs: string[] = [];
63
+ for (const resolve of [getEffectiveLogsDir, getLogsDir]) {
64
+ const resolved = attemptPath(resolve);
65
+ if (resolved !== undefined && !dirs.includes(resolved)) dirs.push(resolved);
66
+ }
67
+ return dirs;
68
+ }
69
+
70
+ /**
71
+ * Resolve a log path, or `undefined` when it is unavailable.
72
+ *
73
+ * `getLogsDir()`/`getLogPath()` throw when there is no trustworthy home. That
74
+ * must not deny the caller the *other* directory, which a pinned `GJC_LOG_DIR`
75
+ * can still supply.
76
+ */
77
+ function attemptPath(resolve: () => string): string | undefined {
78
+ try {
79
+ return resolveEquivalentPath(resolve());
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Today's log text, oldest content first.
87
+ *
88
+ * Concatenated canonical-then-effective so the tail stays chronological, and the
89
+ * line cap is applied to the COMBINED result: capping per file would let a large
90
+ * history file push this process's own entries out of a bundle that still looks
91
+ * complete. When the two paths are the same file — production, and any process
92
+ * without a `GJC_LOG_DIR` pin — this is a single read of exactly the bytes the
93
+ * previous single-path implementation returned.
94
+ */
95
+ async function readTodayLogText(maxLines: number): Promise<string> {
96
+ const parts: string[] = [];
97
+ const seen = new Set<string>();
98
+ for (const resolve of [getLogPath, getEffectiveLogPath]) {
99
+ const candidate = attemptPath(resolve);
100
+ if (candidate === undefined || seen.has(candidate)) continue;
101
+ seen.add(candidate);
102
+ const text = await readLastLines(candidate, maxLines);
103
+ if (text.length > 0) parts.push(text);
104
+ }
105
+ const combined = parts.join("\n");
106
+ const lines = combined.split("\n");
107
+ return lines.length > maxLines ? lines.slice(-maxLines).join("\n") : combined;
108
+ }
109
+
37
110
  export interface ReportBundleOptions {
38
111
  /** Session file path */
39
112
  sessionFile: string | undefined;
@@ -101,9 +174,8 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
101
174
  files.push("config.json");
102
175
  }
103
176
 
104
- // Recent logs (last 1000 lines)
105
- const logPath = getLogPath();
106
- const logs = await readLastLines(logPath, 1000);
177
+ // Recent logs (last 1000 lines, across both log directories)
178
+ const logs = await readTodayLogText(1000);
107
179
  if (logs) {
108
180
  data["logs.txt"] = logs;
109
181
  files.push("logs.txt");
@@ -228,31 +300,50 @@ async function addSubagentSessions(
228
300
 
229
301
  /** Get recent log entries for display (tail-limited to avoid OOM on large files). */
230
302
  export async function getLogText(): Promise<string> {
231
- return readLastLines(getLogPath(), MAX_LOG_LINES);
303
+ return readTodayLogText(MAX_LOG_LINES);
232
304
  }
233
305
 
234
306
  const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.log$`);
235
307
 
308
+ /**
309
+ * Today's log file: the one this process is writing when it exists, else the
310
+ * canonical one. Both spellings share a basename, so the "is this today's file"
311
+ * filter below is unaffected by which is chosen.
312
+ */
313
+ async function resolveTodayLogPath(): Promise<string> {
314
+ const effective = getEffectiveLogPath();
315
+ if (await Bun.file(effective).exists()) return effective;
316
+ const canonical = attemptPath(getLogPath);
317
+ if (canonical !== undefined && (await Bun.file(canonical).exists())) return canonical;
318
+ return effective;
319
+ }
320
+
236
321
  export async function createDebugLogSource(): Promise<DebugLogSource> {
237
- const logsDir = getLogsDir();
238
- const todayPath = getLogPath();
322
+ const todayPath = await resolveTodayLogPath();
239
323
  const todayName = path.basename(todayPath);
240
- let olderFiles: string[] = [];
241
- try {
242
- const entries = await fs.readdir(logsDir, { withFileTypes: true });
243
- const datedFiles = entries
244
- .filter(entry => entry.isFile())
245
- .map(entry => {
246
- const match = LOG_FILE_PATTERN.exec(entry.name);
247
- return match ? { name: entry.name, date: match[1] } : undefined;
248
- })
249
- .filter((entry): entry is { name: string; date: string } => entry !== undefined)
250
- .filter(entry => entry.name !== todayName)
251
- .sort((a, b) => b.date.localeCompare(a.date));
252
- olderFiles = datedFiles.map(entry => entry.name);
253
- } catch {
254
- olderFiles = [];
324
+ // Absolute paths, not bare names: entries can come from either log directory
325
+ // (see {@link logSearchDirs}), so the name alone no longer locates the file.
326
+ const olderFiles: string[] = [];
327
+ const dated: { path: string; date: string }[] = [];
328
+ const seen = new Set<string>();
329
+ for (const dir of logSearchDirs()) {
330
+ // A missing directory contributes nothing; the other one may still exist.
331
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
332
+ for (const entry of entries) {
333
+ if (!entry.isFile()) continue;
334
+ const match = LOG_FILE_PATTERN.exec(entry.name);
335
+ if (!match || entry.name === todayName) continue;
336
+ const filePath = path.join(dir, entry.name);
337
+ if (seen.has(filePath)) continue;
338
+ seen.add(filePath);
339
+ dated.push({ path: filePath, date: match[1]! });
340
+ }
255
341
  }
342
+ // Newest first. `sort` is stable, so same-date files keep the directory order
343
+ // above (effective before canonical) and the single-directory case is ordered
344
+ // exactly as it was before the second directory was added.
345
+ dated.sort((a, b) => b.date.localeCompare(a.date));
346
+ olderFiles.push(...dated.map(entry => entry.path));
256
347
 
257
348
  let cursor = 0;
258
349
 
@@ -270,8 +361,7 @@ export async function createDebugLogSource(): Promise<DebugLogSource> {
270
361
  const slice = olderFiles.slice(cursor, cursor + count);
271
362
  cursor += slice.length;
272
363
  const chunks: string[] = [];
273
- for (const filename of slice.reverse()) {
274
- const filePath = path.join(logsDir, filename);
364
+ for (const filePath of slice.reverse()) {
275
365
  try {
276
366
  const content = await readLastLines(filePath, MAX_LOG_LINES);
277
367
  if (content.length > 0) {
@@ -128,8 +128,15 @@ function safeChildToken(value: string): boolean {
128
128
  }
129
129
  }
130
130
 
131
- async function readExactJsons(root: string, files: readonly string[]): Promise<unknown[] | null> {
132
- if (process.platform !== "linux") return null;
131
+ /**
132
+ * The exact reader needs the Linux-only recovery-fs authority, so an unavailable
133
+ * read has two very different causes. A blocked operator cannot act on either
134
+ * unless the outcome says which one it was, so the unavailable arm names it.
135
+ */
136
+ type ExactJsonRead = { values: unknown[] } | { reader: "unsupported_platform" | "read_failed" };
137
+
138
+ async function readExactJsons(root: string, files: readonly string[]): Promise<ExactJsonRead> {
139
+ if (process.platform !== "linux") return { reader: "unsupported_platform" };
133
140
  try {
134
141
  const { openRecoveryFsRoot } = require("@gajae-code/natives") as Pick<
135
142
  typeof import("@gajae-code/natives"),
@@ -140,17 +147,37 @@ async function readExactJsons(root: string, files: readonly string[]): Promise<u
140
147
  const values: unknown[] = [];
141
148
  for (const file of files) {
142
149
  const result = authority.read(file, 64 * 1024);
143
- if (!result.ok || !result.data) return null;
150
+ if (!result.ok || !result.data) return { reader: "read_failed" };
144
151
  const content = Buffer.from(result.data).toString("utf8");
145
- if (!content.endsWith("\n") || content.indexOf("\n") !== content.length - 1) return null;
152
+ if (!content.endsWith("\n") || content.indexOf("\n") !== content.length - 1)
153
+ return { reader: "read_failed" };
146
154
  values.push(JSON.parse(content));
147
155
  }
148
- return values;
156
+ return { values };
149
157
  } finally {
150
158
  authority.close();
151
159
  }
152
160
  } catch {
153
- return null;
161
+ return { reader: "read_failed" };
162
+ }
163
+ }
164
+
165
+ function exactJsonValues(read: ExactJsonRead): unknown[] {
166
+ return "values" in read ? read.values : [];
167
+ }
168
+
169
+ function exactReaderDetails(read: ExactJsonRead): Record<string, unknown> {
170
+ return "values" in read ? {} : { platform: process.platform, evidence_reader: read.reader };
171
+ }
172
+
173
+ function readerExplanation(details: Record<string, unknown>): string {
174
+ switch (details.evidence_reader) {
175
+ case "unsupported_platform":
176
+ return ` (platform ${process.platform}: exact binding reader unsupported)`;
177
+ case "read_failed":
178
+ return ` (platform ${process.platform}: exact binding evidence unreadable)`;
179
+ default:
180
+ return "";
154
181
  }
155
182
  }
156
183
 
@@ -187,6 +214,22 @@ async function durableHandoff(
187
214
  }
188
215
  }
189
216
 
217
+ /**
218
+ * Fails closed and says so. The durable handoff is the record of authority, but
219
+ * nothing reads it during an incident, so the same reason also goes to stderr:
220
+ * otherwise a blocked child exits 75 having printed nothing at all.
221
+ */
222
+ async function blockAdmission(
223
+ owner: { root: string; generation: string; sessionId: string },
224
+ reason: string,
225
+ details: Record<string, unknown> = {},
226
+ ): Promise<{ kind: "blocked" }> {
227
+ await durableHandoff(owner.root, owner.generation, owner.sessionId, reason, details);
228
+ process.stderr.write(`child admission blocked: ${reason}${readerExplanation(details)}\n`);
229
+ process.exitCode = 75;
230
+ return { kind: "blocked" };
231
+ }
232
+
190
233
  /**
191
234
  * This is the pre-CLI barrier. A replacement is identified only by the exact
192
235
  * predecessor token supplied by its launch binding; directory enumeration is
@@ -198,12 +241,9 @@ export async function admitManagedOwnerBeforeCli(): Promise<ManagedOwnerAdmissio
198
241
  const childToken = process.env[MANAGED_OWNER_CHILD_TOKEN_ENV]?.trim();
199
242
  const predecessorToken = process.env[MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]?.trim();
200
243
  if (childToken && !predecessorToken) {
201
- if (!safeChildToken(childToken)) {
202
- await durableHandoff(owner.root, owner.generation, owner.sessionId, "exact_child_binding_unavailable");
203
- process.exitCode = 75;
204
- return { kind: "blocked" };
205
- }
206
- const [binding] = (await readExactJsons(owner.root, [`child-${childToken}.binding.json`])) ?? [];
244
+ if (!safeChildToken(childToken)) return blockAdmission(owner, "exact_child_binding_unavailable");
245
+ const read = await readExactJsons(owner.root, [`child-${childToken}.binding.json`]);
246
+ const [binding] = exactJsonValues(read);
207
247
  if (
208
248
  isBinding(binding, {
209
249
  generation: owner.generation,
@@ -214,33 +254,20 @@ export async function admitManagedOwnerBeforeCli(): Promise<ManagedOwnerAdmissio
214
254
  })
215
255
  )
216
256
  return { kind: "supervised" };
217
- await durableHandoff(owner.root, owner.generation, owner.sessionId, "exact_child_binding_unavailable");
218
- process.exitCode = 75;
219
- return { kind: "blocked" };
257
+ return blockAdmission(owner, "exact_child_binding_unavailable", exactReaderDetails(read));
220
258
  }
221
259
  const predecessorGeneration = process.env[MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]?.trim();
222
260
  const predecessorRunId = process.env[MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]?.trim();
223
261
  const predecessorIncarnation = process.env[MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]?.trim();
224
- if (!predecessorGeneration || !predecessorRunId || !predecessorIncarnation) {
225
- await durableHandoff(owner.root, owner.generation, owner.sessionId, "replacement_predecessor_identity_missing");
226
- process.exitCode = 75;
227
- return { kind: "blocked" };
228
- }
229
- if (!predecessorToken) {
230
- await durableHandoff(owner.root, owner.generation, owner.sessionId, "replacement_predecessor_binding_missing");
231
- process.exitCode = 75;
232
- return { kind: "blocked" };
233
- }
234
- if (!safeChildToken(predecessorToken)) {
235
- await durableHandoff(owner.root, owner.generation, owner.sessionId, "replacement_predecessor_binding_untrusted");
236
- process.exitCode = 75;
237
- return { kind: "blocked" };
238
- }
239
- const [binding, receipt] =
240
- (await readExactJsons(owner.root, [
241
- `child-${predecessorToken}.binding.json`,
242
- `sigabrt-${predecessorToken}.receipt.json`,
243
- ])) ?? [];
262
+ if (!predecessorGeneration || !predecessorRunId || !predecessorIncarnation)
263
+ return blockAdmission(owner, "replacement_predecessor_identity_missing");
264
+ if (!predecessorToken) return blockAdmission(owner, "replacement_predecessor_binding_missing");
265
+ if (!safeChildToken(predecessorToken)) return blockAdmission(owner, "replacement_predecessor_binding_untrusted");
266
+ const read = await readExactJsons(owner.root, [
267
+ `child-${predecessorToken}.binding.json`,
268
+ `sigabrt-${predecessorToken}.receipt.json`,
269
+ ]);
270
+ const [binding, receipt] = exactJsonValues(read);
244
271
  if (
245
272
  !isBinding(binding, {
246
273
  generation: predecessorGeneration,
@@ -249,16 +276,10 @@ export async function admitManagedOwnerBeforeCli(): Promise<ManagedOwnerAdmissio
249
276
  incarnation: predecessorIncarnation,
250
277
  token: predecessorToken,
251
278
  })
252
- ) {
253
- await durableHandoff(owner.root, owner.generation, owner.sessionId, "replacement_predecessor_binding_untrusted");
254
- process.exitCode = 75;
255
- return { kind: "blocked" };
256
- }
257
- if (!isReceipt(receipt, binding)) {
258
- await durableHandoff(owner.root, owner.generation, owner.sessionId, "exact_sigabrt_receipt_untrusted");
259
- process.exitCode = 75;
260
- return { kind: "blocked" };
261
- }
279
+ )
280
+ return blockAdmission(owner, "replacement_predecessor_binding_untrusted", exactReaderDetails(read));
281
+ if (!isReceipt(receipt, binding))
282
+ return blockAdmission(owner, "exact_sigabrt_receipt_untrusted", exactReaderDetails(read));
262
283
  const admission = {
263
284
  session_id: owner.sessionId,
264
285
  endpoint_incarnation: predecessorIncarnation,
@@ -287,11 +308,7 @@ export async function admitManagedOwnerBeforeCli(): Promise<ManagedOwnerAdmissio
287
308
  },
288
309
  decision,
289
310
  });
290
- if (decision.disposition !== "resume") {
291
- await durableHandoff(owner.root, owner.generation, owner.sessionId, decision.reason);
292
- process.exitCode = 75;
293
- return { kind: "blocked" };
294
- }
311
+ if (decision.disposition !== "resume") return blockAdmission(owner, decision.reason);
295
312
  return { kind: "recovery", context: { root: owner.root, binding, receipt, admission, decision } };
296
313
  }
297
314