@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.
- package/CHANGELOG.md +72 -0
- package/dist/types/async/job-manager.d.ts +7 -6
- package/dist/types/config/autorouting-tier-map.d.ts +6 -0
- package/dist/types/config/file-lock.d.ts +1 -0
- package/dist/types/config/model-profile-activation.d.ts +18 -3
- package/dist/types/config/model-registry.d.ts +9 -0
- package/dist/types/coordinator-mcp/server.d.ts +3 -0
- package/dist/types/modes/acp/acp-agent.d.ts +40 -1
- package/dist/types/modes/components/thinking-selector.d.ts +2 -1
- package/dist/types/sdk/acp/adapter.d.ts +27 -0
- package/dist/types/sdk/acp/final-text.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +46 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
- package/dist/types/sdk/prompt-failure.d.ts +30 -1
- package/dist/types/sdk/prompt-status.d.ts +8 -0
- package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
- package/dist/types/thinking.d.ts +3 -1
- package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
- package/package.json +7 -7
- package/src/async/job-manager.ts +38 -8
- package/src/commands/sdk.ts +78 -4
- package/src/config/autorouting-tier-map.ts +2 -0
- package/src/config/file-lock.ts +52 -17
- package/src/config/model-profile-activation.ts +72 -10
- package/src/config/model-registry.ts +21 -0
- package/src/coordinator-mcp/server.ts +21 -6
- package/src/debug/report-bundle.ts +114 -24
- package/src/gjc-runtime/managed-owner-admission.ts +67 -50
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/modes/acp/acp-agent.ts +187 -14
- package/src/modes/acp/acp-event-mapper.ts +9 -0
- package/src/modes/components/model-selector.ts +31 -15
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/components/thinking-selector.ts +7 -3
- package/src/modes/controllers/selector-controller.ts +16 -3
- package/src/sdk/acp/adapter.ts +63 -1
- package/src/sdk/acp/final-text.ts +19 -2
- package/src/sdk/broker/ensure.ts +97 -19
- package/src/sdk/bus/index.ts +31 -4
- package/src/sdk/host/session-runtime.test.ts +498 -7
- package/src/sdk/host/session-runtime.ts +188 -21
- package/src/sdk/prompt-deadline-manager.ts +3 -3
- package/src/sdk/prompt-failure.ts +43 -1
- package/src/sdk/prompt-status.ts +8 -0
- package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
- package/src/sdk/router/session-router.ts +22 -3
- package/src/sdk/session.ts +12 -9
- package/src/sdk/transport/serve-cli.ts +105 -15
- package/src/session-import/codex.ts +12 -3
- package/src/thinking.ts +21 -2
- package/src/utils/mupdf-wasm-embedded.ts +3 -0
- 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 {
|
|
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
|
|
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
|
|
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
|
|
238
|
-
const todayPath = getLogPath();
|
|
322
|
+
const todayPath = await resolveTodayLogPath();
|
|
239
323
|
const todayName = path.basename(todayPath);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.
|
|
250
|
-
.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
return
|
|
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
|
|