@gajae-code/coding-agent 0.14.0 → 0.14.1
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 +21 -3
- package/dist/types/modes/components/tool-execution.d.ts +1 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +9 -0
- package/dist/types/session/session-manager.d.ts +2 -0
- package/dist/types/slash-commands/helpers/usage-report.d.ts +10 -0
- package/dist/types/tools/ask-contract.d.ts +7 -7
- package/dist/types/tools/descriptor-validation.d.ts +2 -2
- package/package.json +7 -7
- package/src/config/model-profiles.ts +68 -0
- package/src/config/model-registry.ts +20 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -8
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/modes/components/tool-execution.ts +24 -4
- package/src/modes/controllers/command-controller.ts +115 -13
- package/src/modes/controllers/input-controller.ts +51 -18
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +10 -1
- package/src/sdk/broker/lifecycle.ts +21 -0
- package/src/sdk/broker/session-index.ts +13 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +29 -6
- package/src/sdk/router/session-router.ts +11 -4
- package/src/session/account-inventory.ts +25 -21
- package/src/session/agent-session.ts +9 -0
- package/src/session/blob-store.ts +73 -7
- package/src/session/session-manager.ts +104 -15
- package/src/slash-commands/builtin-registry.ts +11 -2
- package/src/slash-commands/helpers/usage-report.ts +56 -2
- package/src/tools/ask-contract.ts +1 -1
- package/src/tools/computer.enforcement.test.ts +9 -1
- package/src/tools/computer.redteam.test.ts +893 -0
- package/src/utils/herdr-pane.ts +17 -2
|
@@ -7562,6 +7562,15 @@ export class AgentSession {
|
|
|
7562
7562
|
disposeVmContextsByOwner(kernelOwnerId).catch((error: unknown) =>
|
|
7563
7563
|
logger.warn("signal teardown: disposeVmContextsByOwner failed", { error }),
|
|
7564
7564
|
),
|
|
7565
|
+
// Tool-owned resources (e.g. an autoresearch mission's persistent Python
|
|
7566
|
+
// kernel) register their own disposer and are keyed by an owner id that is
|
|
7567
|
+
// NOT #evalKernelOwnerId, so the two disposals above never reach them. Drain
|
|
7568
|
+
// them here inside the same budget: without this a signal exit orphans the
|
|
7569
|
+
// subprocess. #runToolSessionCleanups already clears the set, so the graceful
|
|
7570
|
+
// dispose path running first makes this a no-op rather than a double free.
|
|
7571
|
+
this.#runToolSessionCleanups().catch((error: unknown) =>
|
|
7572
|
+
logger.warn("signal teardown: tool session cleanups failed", { error }),
|
|
7573
|
+
),
|
|
7565
7574
|
]);
|
|
7566
7575
|
await Promise.race([work, Bun.sleep(timeoutMs)]);
|
|
7567
7576
|
}
|
|
@@ -43,9 +43,18 @@ const ownedResidentCacheInstanceDirs = new Set<string>();
|
|
|
43
43
|
const activeResidentCacheRootSweeps = new Set<string>();
|
|
44
44
|
let ownResidentCacheProcessStartTimeMs: number | null | undefined;
|
|
45
45
|
|
|
46
|
+
/** Marks `startTimeMs` as an absolute instant parsed from a UTC-pinned `ps` render. */
|
|
47
|
+
const RESIDENT_CACHE_START_TIME_BASIS = "utc";
|
|
48
|
+
|
|
46
49
|
interface ResidentCacheOwnerToken {
|
|
47
50
|
readonly pid: number;
|
|
48
51
|
readonly startTimeMs: number | null;
|
|
52
|
+
/**
|
|
53
|
+
* Absent on tokens written before the start time was pinned to UTC. Such a
|
|
54
|
+
* value is a wall clock in the writer's zone, so comparing it against an
|
|
55
|
+
* absolute one proves nothing about PID reuse and must not imply staleness.
|
|
56
|
+
*/
|
|
57
|
+
readonly startTimeBasis?: string;
|
|
49
58
|
readonly nonce: string;
|
|
50
59
|
readonly createdAt?: number;
|
|
51
60
|
}
|
|
@@ -87,6 +96,16 @@ export class BlobCorruptError extends Error {
|
|
|
87
96
|
* memory before retrying the triggering write.
|
|
88
97
|
*/
|
|
89
98
|
export class ResidentCacheTrustError extends Error {
|
|
99
|
+
/**
|
|
100
|
+
* errno of the OS failure this rejection wrapped (`ENOENT`, `EMFILE`, `EACCES`, …),
|
|
101
|
+
* or `undefined` when the rejection was a pure policy decision with no cause.
|
|
102
|
+
* `reason` alone names the *step* that failed; only this names *why*, so a
|
|
103
|
+
* demotion is diagnosable from a log line instead of requiring a debugger.
|
|
104
|
+
*/
|
|
105
|
+
readonly causeCode: string | undefined;
|
|
106
|
+
/** Bounded one-line rendering of the wrapped cause for logs that must not serialize an arbitrary thrown value. */
|
|
107
|
+
readonly causeSummary: string | undefined;
|
|
108
|
+
|
|
90
109
|
constructor(
|
|
91
110
|
readonly reason: string,
|
|
92
111
|
readonly path: string,
|
|
@@ -94,7 +113,27 @@ export class ResidentCacheTrustError extends Error {
|
|
|
94
113
|
) {
|
|
95
114
|
super(`Resident cache trust validation failed (${reason}): ${path}`, options);
|
|
96
115
|
this.name = "ResidentCacheTrustError";
|
|
116
|
+
this.causeCode = errorCode(options?.cause);
|
|
117
|
+
this.causeSummary = summarizeResidentCacheTrustCause(options?.cause);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const RESIDENT_CACHE_CAUSE_SUMMARY_MAX_CHARS = 200;
|
|
122
|
+
|
|
123
|
+
function summarizeResidentCacheTrustCause(cause: unknown): string | undefined {
|
|
124
|
+
if (cause === undefined) return undefined;
|
|
125
|
+
let text: string;
|
|
126
|
+
try {
|
|
127
|
+
text = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
|
|
128
|
+
} catch {
|
|
129
|
+
// A cause whose own stringification throws must not turn a demotion into a crash.
|
|
130
|
+
return "<uninspectable cause>";
|
|
97
131
|
}
|
|
132
|
+
text = text.replace(/\s+/g, " ").trim();
|
|
133
|
+
if (!text) return undefined;
|
|
134
|
+
return text.length > RESIDENT_CACHE_CAUSE_SUMMARY_MAX_CHARS
|
|
135
|
+
? `${text.slice(0, RESIDENT_CACHE_CAUSE_SUMMARY_MAX_CHARS - 1)}…`
|
|
136
|
+
: text;
|
|
98
137
|
}
|
|
99
138
|
|
|
100
139
|
function residentCacheTrustError(reason: string, pathname: string, cause?: unknown): ResidentCacheTrustError {
|
|
@@ -201,17 +240,25 @@ function residentCacheProcessStartTimeMs(pid: number): number | null {
|
|
|
201
240
|
return ownResidentCacheProcessStartTimeMs;
|
|
202
241
|
}
|
|
203
242
|
|
|
204
|
-
// Mirror file-lock's `ps`-based process incarnation probe, but pin the
|
|
205
|
-
//
|
|
243
|
+
// Mirror file-lock's `ps`-based process incarnation probe, but pin the locale
|
|
244
|
+
// AND the zone. `lstart` is a zoneless local wall clock rendered by `ps` from
|
|
245
|
+
// /etc/localtime, while `Date.parse` of a zoneless string binds it to the JS
|
|
246
|
+
// runtime's own zone. Those are two independent resolutions, so a reader whose
|
|
247
|
+
// runtime resolves UTC while `ps` renders UTC+9 derives an epoch nine hours off
|
|
248
|
+
// for the very same live process — and this value's only job is to detect PID
|
|
249
|
+
// reuse, so a disagreeing reader reports every live owner as reused and the GC
|
|
250
|
+
// reaps a running session's cache. Pinning TZ on the child fixes the rendering
|
|
251
|
+
// side; the explicit GMT suffix fixes the parsing side.
|
|
206
252
|
let startTimeMs: number | null = null;
|
|
207
253
|
try {
|
|
208
254
|
const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
|
|
209
255
|
stdout: "pipe",
|
|
210
256
|
stderr: "ignore",
|
|
211
|
-
env: { ...process.env, LC_ALL: "C", LANG: "C" },
|
|
257
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" },
|
|
212
258
|
});
|
|
213
259
|
if (result.exitCode === 0) {
|
|
214
|
-
const
|
|
260
|
+
const rendered = new TextDecoder().decode(result.stdout).trim();
|
|
261
|
+
const parsed = rendered ? Date.parse(`${rendered} GMT`) : Number.NaN;
|
|
215
262
|
if (Number.isFinite(parsed)) startTimeMs = parsed;
|
|
216
263
|
}
|
|
217
264
|
} catch {
|
|
@@ -255,9 +302,10 @@ function parseResidentCacheOwnerToken(text: string): ResidentCacheOwnerToken | n
|
|
|
255
302
|
return null;
|
|
256
303
|
}
|
|
257
304
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
258
|
-
const { pid, startTimeMs, nonce, createdAt } = parsed as {
|
|
305
|
+
const { pid, startTimeMs, startTimeBasis, nonce, createdAt } = parsed as {
|
|
259
306
|
pid?: unknown;
|
|
260
307
|
startTimeMs?: unknown;
|
|
308
|
+
startTimeBasis?: unknown;
|
|
261
309
|
nonce?: unknown;
|
|
262
310
|
createdAt?: unknown;
|
|
263
311
|
};
|
|
@@ -266,17 +314,30 @@ function parseResidentCacheOwnerToken(text: string): ResidentCacheOwnerToken | n
|
|
|
266
314
|
!Number.isInteger(pid) ||
|
|
267
315
|
pid <= 0 ||
|
|
268
316
|
(startTimeMs !== null && (typeof startTimeMs !== "number" || !Number.isFinite(startTimeMs))) ||
|
|
317
|
+
(startTimeBasis !== undefined && typeof startTimeBasis !== "string") ||
|
|
269
318
|
typeof nonce !== "string" ||
|
|
270
319
|
nonce.length === 0 ||
|
|
271
320
|
(createdAt !== undefined && (typeof createdAt !== "number" || !Number.isFinite(createdAt)))
|
|
272
321
|
) {
|
|
273
322
|
return null;
|
|
274
323
|
}
|
|
275
|
-
return
|
|
324
|
+
return {
|
|
325
|
+
pid,
|
|
326
|
+
startTimeMs,
|
|
327
|
+
...(startTimeBasis === undefined ? {} : { startTimeBasis }),
|
|
328
|
+
nonce,
|
|
329
|
+
...(createdAt === undefined ? {} : { createdAt }),
|
|
330
|
+
};
|
|
276
331
|
}
|
|
277
332
|
|
|
278
333
|
function sameResidentCacheOwnerToken(a: ResidentCacheOwnerToken, b: ResidentCacheOwnerToken): boolean {
|
|
279
|
-
return
|
|
334
|
+
return (
|
|
335
|
+
a.pid === b.pid &&
|
|
336
|
+
a.startTimeMs === b.startTimeMs &&
|
|
337
|
+
a.startTimeBasis === b.startTimeBasis &&
|
|
338
|
+
a.nonce === b.nonce &&
|
|
339
|
+
a.createdAt === b.createdAt
|
|
340
|
+
);
|
|
280
341
|
}
|
|
281
342
|
|
|
282
343
|
function readResidentCacheOwnerSnapshot(instanceDir: string, uid: number): ResidentCacheOwnerSnapshot | null {
|
|
@@ -313,6 +374,7 @@ function writeResidentCacheOwnerToken(instanceDir: string, uid: number): void {
|
|
|
313
374
|
const owner: ResidentCacheOwnerToken = {
|
|
314
375
|
pid: process.pid,
|
|
315
376
|
startTimeMs: residentCacheProcessStartTimeMs(process.pid),
|
|
377
|
+
startTimeBasis: RESIDENT_CACHE_START_TIME_BASIS,
|
|
316
378
|
nonce,
|
|
317
379
|
createdAt: Date.now(),
|
|
318
380
|
};
|
|
@@ -526,6 +588,10 @@ function residentCacheOwnerIsStale(
|
|
|
526
588
|
const liveness = residentCacheOwnerLiveness(owner.pid);
|
|
527
589
|
if (liveness === "dead") return true;
|
|
528
590
|
if (liveness !== "alive" || owner.startTimeMs === null) return false;
|
|
591
|
+
// A token from before the UTC pin carries a zone-dependent wall clock. Comparing
|
|
592
|
+
// it against an absolute instant would read an ordinary timezone offset as PID
|
|
593
|
+
// reuse and reap a live owner, so an unlabelled basis leaves reuse unproven.
|
|
594
|
+
if (owner.startTimeBasis !== RESIDENT_CACHE_START_TIME_BASIS) return false;
|
|
529
595
|
const currentStartTimeMs = cachedResidentCacheProcessStartTimeMs(owner.pid, startTimeCache);
|
|
530
596
|
return currentStartTimeMs !== null && currentStartTimeMs !== owner.startTimeMs;
|
|
531
597
|
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
getTerminalSessionsDir,
|
|
34
34
|
hasFsCode,
|
|
35
35
|
isEnoent,
|
|
36
|
+
isFsError,
|
|
36
37
|
logger,
|
|
37
38
|
parseJsonlLenient,
|
|
38
39
|
pathIsWithin,
|
|
@@ -288,6 +289,7 @@ type ResidentTransitionSource =
|
|
|
288
289
|
textStore: BlobStore | null;
|
|
289
290
|
imageStore: BlobStore;
|
|
290
291
|
textFallback?: (hash: string) => Buffer | null;
|
|
292
|
+
onResidentBlobMissing?: (kind: ResidentBlobKind, hash: string) => void;
|
|
291
293
|
};
|
|
292
294
|
missingPolicy?: ResidentBlobMissingPolicy;
|
|
293
295
|
}
|
|
@@ -371,7 +373,7 @@ interface FreshSessionState {
|
|
|
371
373
|
readonly adoptsLifecycleId: boolean;
|
|
372
374
|
}
|
|
373
375
|
|
|
374
|
-
type ResidentCacheDegradedStore = BlobStore & { degradedReason?: string };
|
|
376
|
+
type ResidentCacheDegradedStore = BlobStore & { degradedReason?: string; degradedCauseCode?: string };
|
|
375
377
|
|
|
376
378
|
const RESIDENT_CACHE_WRITE_FAILURE_REASONS = new Set([
|
|
377
379
|
"root_create_failed",
|
|
@@ -452,6 +454,8 @@ export interface SessionManagerObservabilityStats {
|
|
|
452
454
|
residentCacheTrustRejectCount: number;
|
|
453
455
|
residentCacheWin32FallbackCount: number;
|
|
454
456
|
residentCacheDegradedReason?: string;
|
|
457
|
+
residentCacheDegradedCauseCode?: string;
|
|
458
|
+
residentBlobPlaceholderCount: number;
|
|
455
459
|
publicMaterializerCallCount: number;
|
|
456
460
|
getEntryMaterializerCallCount: number;
|
|
457
461
|
getBranchMaterializerCallCount: number;
|
|
@@ -4528,7 +4532,7 @@ async function getSortedSessions(
|
|
|
4528
4532
|
)
|
|
4529
4533
|
return undefined;
|
|
4530
4534
|
if (typeof header.version === "number" && header.version >= 4) {
|
|
4531
|
-
for (const patch of await readSessionListTrailingPatches(candidate.path, storage
|
|
4535
|
+
for (const patch of await readSessionListTrailingPatches(candidate.path, storage)) {
|
|
4532
4536
|
applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
|
|
4533
4537
|
}
|
|
4534
4538
|
}
|
|
@@ -4964,6 +4968,8 @@ interface ResidentBlobStores {
|
|
|
4964
4968
|
sessionId?: string;
|
|
4965
4969
|
sessionFile?: string;
|
|
4966
4970
|
onResidentBlobRead?: (kind: ResidentBlobKind) => void;
|
|
4971
|
+
/** Fired when `missingPolicy: "placeholder"` substitutes for content that is gone for good. */
|
|
4972
|
+
onResidentBlobMissing?: (kind: ResidentBlobKind, hash: string) => void;
|
|
4967
4973
|
}
|
|
4968
4974
|
|
|
4969
4975
|
function residentBlobMissingPlaceholder(error: ResidentBlobMissingError): string {
|
|
@@ -5048,6 +5054,7 @@ function materializeResidentValueSync(
|
|
|
5048
5054
|
} catch (err) {
|
|
5049
5055
|
if (missingPolicy === "placeholder" && err instanceof ResidentBlobMissingError) {
|
|
5050
5056
|
resolved = residentBlobMissingPlaceholder(err);
|
|
5057
|
+
stores.onResidentBlobMissing?.(err.kind, err.hash);
|
|
5051
5058
|
} else {
|
|
5052
5059
|
throw err;
|
|
5053
5060
|
}
|
|
@@ -5569,6 +5576,48 @@ function rehydrateColdSpillValue(
|
|
|
5569
5576
|
return Object.fromEntries(entries);
|
|
5570
5577
|
}
|
|
5571
5578
|
|
|
5579
|
+
/**
|
|
5580
|
+
* Enforce the provider-facing invariant that a tool call's `arguments` is an
|
|
5581
|
+
* object, after cold-spill rehydration has had its say.
|
|
5582
|
+
*
|
|
5583
|
+
* `rehydrateColdSpillRef` reports an unrecoverable payload by returning the
|
|
5584
|
+
* human-readable `coldSpillUnavailable(...)` sentence, and a blob holding a
|
|
5585
|
+
* non-object JSON value rehydrates as that value. Either outcome lands a
|
|
5586
|
+
* non-object on `toolCall.arguments`, which every provider then forwards
|
|
5587
|
+
* verbatim — Anthropic serializes it straight into `tool_use.input` and the
|
|
5588
|
+
* request fails with `tool_use.input: Input should be a valid dictionary`.
|
|
5589
|
+
* That rejection is fatal for the whole transcript, so one missing blob makes
|
|
5590
|
+
* a session permanently unresumable with no actionable diagnostic.
|
|
5591
|
+
*
|
|
5592
|
+
* Degrade to the existing malformed-arguments contract instead: the recovered
|
|
5593
|
+
* text is preserved under `recoveryNotice` for the reader, and the agent loop
|
|
5594
|
+
* rejects just that call with reason-specific, retryable guidance rather than
|
|
5595
|
+
* letting the provider reject the entire request.
|
|
5596
|
+
*/
|
|
5597
|
+
function enforceToolCallArgumentObjects(message: AgentMessage): AgentMessage {
|
|
5598
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
|
|
5599
|
+
let changed = false;
|
|
5600
|
+
const content = message.content.map(block => {
|
|
5601
|
+
if (block.type !== "toolCall" || isRecord(block.arguments)) return block;
|
|
5602
|
+
changed = true;
|
|
5603
|
+
const recovered = block.arguments;
|
|
5604
|
+
return {
|
|
5605
|
+
...block,
|
|
5606
|
+
arguments: {
|
|
5607
|
+
recoveryNotice:
|
|
5608
|
+
typeof recovered === "string"
|
|
5609
|
+
? recovered
|
|
5610
|
+
: `[Cold-spill payload for this tool call rehydrated as ${
|
|
5611
|
+
recovered === null ? "null" : typeof recovered
|
|
5612
|
+
}, not an object]`,
|
|
5613
|
+
},
|
|
5614
|
+
incompleteArguments: true,
|
|
5615
|
+
incompleteArgumentsReason: "malformed" as const,
|
|
5616
|
+
};
|
|
5617
|
+
});
|
|
5618
|
+
return changed ? ({ ...message, content } as AgentMessage) : message;
|
|
5619
|
+
}
|
|
5620
|
+
|
|
5572
5621
|
function rehydrateColdSpillEntry(
|
|
5573
5622
|
entry: SessionEntry,
|
|
5574
5623
|
blobStore: BlobStore,
|
|
@@ -5576,13 +5625,9 @@ function rehydrateColdSpillEntry(
|
|
|
5576
5625
|
): SessionEntry {
|
|
5577
5626
|
if (entry.type === "message") {
|
|
5578
5627
|
const marker = entry.evictedContent;
|
|
5579
|
-
const message =
|
|
5580
|
-
entry.message,
|
|
5581
|
-
|
|
5582
|
-
blobStore,
|
|
5583
|
-
"message",
|
|
5584
|
-
residentStores,
|
|
5585
|
-
) as AgentMessage;
|
|
5628
|
+
const message = enforceToolCallArgumentObjects(
|
|
5629
|
+
rehydrateColdSpillValue(entry.message, marker, blobStore, "message", residentStores) as AgentMessage,
|
|
5630
|
+
);
|
|
5586
5631
|
return { ...entry, message };
|
|
5587
5632
|
}
|
|
5588
5633
|
if (entry.type === "custom_message") {
|
|
@@ -6276,7 +6321,13 @@ function extractTextFromContent(content: Message["content"]): string {
|
|
|
6276
6321
|
}
|
|
6277
6322
|
|
|
6278
6323
|
const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
6279
|
-
|
|
6324
|
+
// Reverse-scan chunk for trailing header patches. Sized independently of the
|
|
6325
|
+
// 4 KiB prefix buffer: the scan walks back to BOF whenever a field stays
|
|
6326
|
+
// unresolved (#3633), which is the common case because most transcripts carry
|
|
6327
|
+
// no header_patch at all. A 4 KiB chunk turned that walk into one read syscall
|
|
6328
|
+
// per 4 KiB of transcript and defeated OS readahead, so listing cost scaled
|
|
6329
|
+
// with total transcript bytes on every resume.
|
|
6330
|
+
const SESSION_LIST_TRAILING_PATCH_BYTES = 64 * 1024;
|
|
6280
6331
|
const SESSION_NAME_MAX_CHARS = 1_000;
|
|
6281
6332
|
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
6282
6333
|
const SESSION_LIST_MAX_WORKERS = 16;
|
|
@@ -6299,7 +6350,6 @@ async function readSessionListPrefix(file: string, storage: SessionStorage, buff
|
|
|
6299
6350
|
async function readSessionListTrailingPatches(
|
|
6300
6351
|
file: string,
|
|
6301
6352
|
storage: SessionStorage,
|
|
6302
|
-
buffer: Buffer,
|
|
6303
6353
|
): Promise<HeaderPatchRecord["patch"][]> {
|
|
6304
6354
|
if (!(storage instanceof FileSessionStorage)) {
|
|
6305
6355
|
const content = await storage.readText(file);
|
|
@@ -6317,8 +6367,15 @@ async function readSessionListTrailingPatches(
|
|
|
6317
6367
|
// window when a field is still missing so a buried but canonically valid
|
|
6318
6368
|
// header_patch remains listable without a full sequential JSONL parse (#3633).
|
|
6319
6369
|
// Chunks that cannot contain a header_patch marker skip JSON parsing.
|
|
6370
|
+
//
|
|
6371
|
+
// The scan owns its buffer instead of borrowing the caller's 4 KiB prefix
|
|
6372
|
+
// buffer. A transcript with no header_patch at all — the common case, since
|
|
6373
|
+
// only /rename and workspace moves emit one — cannot be recognized without
|
|
6374
|
+
// reaching BOF, so the chunk size sets how many read syscalls a resume costs
|
|
6375
|
+
// per transcript byte.
|
|
6320
6376
|
let trailingFragment = Buffer.alloc(0);
|
|
6321
|
-
const chunkSize = Math.min(
|
|
6377
|
+
const chunkSize = Math.min(size, SESSION_LIST_TRAILING_PATCH_BYTES);
|
|
6378
|
+
const buffer = Buffer.allocUnsafe(chunkSize);
|
|
6322
6379
|
const headerPatchMarker = Buffer.from("header_patch");
|
|
6323
6380
|
const handle = await fs.promises.open(file, "r");
|
|
6324
6381
|
try {
|
|
@@ -6518,7 +6575,7 @@ async function collectSessionFromFile(
|
|
|
6518
6575
|
const header = parseSessionListHeader(content, entries);
|
|
6519
6576
|
if (!header) return undefined;
|
|
6520
6577
|
if (typeof header.version === "number" && header.version >= 4 && header.version <= CURRENT_SESSION_VERSION) {
|
|
6521
|
-
for (const patch of await readSessionListTrailingPatches(file, storage
|
|
6578
|
+
for (const patch of await readSessionListTrailingPatches(file, storage)) {
|
|
6522
6579
|
applySessionListHeaderPatch(header, patch);
|
|
6523
6580
|
}
|
|
6524
6581
|
}
|
|
@@ -7089,6 +7146,7 @@ export class SessionManager {
|
|
|
7089
7146
|
#residentImageReadCount = 0;
|
|
7090
7147
|
#residentCacheAdoptFallbackCount = 0;
|
|
7091
7148
|
#residentCacheTrustRejectCount = 0;
|
|
7149
|
+
#residentBlobPlaceholderCount = 0;
|
|
7092
7150
|
#residentCacheWin32FallbackCount = 0;
|
|
7093
7151
|
#publicMaterializerCallCount = 0;
|
|
7094
7152
|
#getEntryMaterializerCallCount = 0;
|
|
@@ -7337,6 +7395,7 @@ export class SessionManager {
|
|
|
7337
7395
|
textStore: sourceTextStore,
|
|
7338
7396
|
imageStore: source.sourceStores.imageStore,
|
|
7339
7397
|
textFallback: source.sourceStores.textFallback,
|
|
7398
|
+
onResidentBlobMissing: source.sourceStores.onResidentBlobMissing,
|
|
7340
7399
|
},
|
|
7341
7400
|
|
|
7342
7401
|
source.missingPolicy,
|
|
@@ -7595,11 +7654,14 @@ export class SessionManager {
|
|
|
7595
7654
|
#demoteResidentTextStoreAfterTrustReject(error: ResidentCacheTrustError): void {
|
|
7596
7655
|
const predecessor = this.#residentTextBlobStore;
|
|
7597
7656
|
(predecessor as ResidentCacheDegradedStore).degradedReason = error.reason;
|
|
7657
|
+
(predecessor as ResidentCacheDegradedStore).degradedCauseCode = error.causeCode;
|
|
7598
7658
|
this.#residentCacheTrustRejectCount++;
|
|
7599
7659
|
logger.warn("Resident cache trust rejection; demoting resident text store", {
|
|
7600
7660
|
sessionId: this.#sessionId,
|
|
7601
7661
|
sessionFile: this.#sessionFile,
|
|
7602
7662
|
reason: error.reason,
|
|
7663
|
+
causeCode: error.causeCode,
|
|
7664
|
+
cause: error.causeSummary,
|
|
7603
7665
|
});
|
|
7604
7666
|
const persistedTextFallback =
|
|
7605
7667
|
predecessor instanceof EphemeralBlobStore ? this.#createPersistedResidentTextFallback() : undefined;
|
|
@@ -7618,12 +7680,30 @@ export class SessionManager {
|
|
|
7618
7680
|
predecessor instanceof EphemeralBlobStore
|
|
7619
7681
|
? hash => predecessor.getBufferedSync(hash) ?? persistedTextFallback?.(hash) ?? null
|
|
7620
7682
|
: undefined,
|
|
7683
|
+
onResidentBlobMissing: (kind, hash) => {
|
|
7684
|
+
this.#residentBlobPlaceholderCount++;
|
|
7685
|
+
logger.warn("Resident blob unrecoverable; substituted a placeholder", {
|
|
7686
|
+
sessionId: this.#sessionId,
|
|
7687
|
+
kind,
|
|
7688
|
+
hash,
|
|
7689
|
+
reason: error.reason,
|
|
7690
|
+
causeCode: error.causeCode,
|
|
7691
|
+
});
|
|
7692
|
+
},
|
|
7621
7693
|
},
|
|
7694
|
+
// This transition IS the salvage after the cache already failed, so it runs
|
|
7695
|
+
// against a store that is by definition missing blobs. Inheriting the default
|
|
7696
|
+
// fail-closed policy makes the recovery throw on the very entry it exists to
|
|
7697
|
+
// rescue, which leaves the demotion uncommitted and repeats it every turn.
|
|
7698
|
+
// The placeholder never emits a `blob:sha256:` ref, so the fail-closed
|
|
7699
|
+
// invariant that policy protects is preserved.
|
|
7700
|
+
missingPolicy: "placeholder",
|
|
7622
7701
|
},
|
|
7623
7702
|
},
|
|
7624
7703
|
"memory-only",
|
|
7625
7704
|
);
|
|
7626
7705
|
(prepared.store as ResidentCacheDegradedStore).degradedReason = error.reason;
|
|
7706
|
+
(prepared.store as ResidentCacheDegradedStore).degradedCauseCode = error.causeCode;
|
|
7627
7707
|
this.#commitResidentTextStoreTransition(prepared);
|
|
7628
7708
|
}
|
|
7629
7709
|
|
|
@@ -11065,9 +11145,16 @@ export class SessionManager {
|
|
|
11065
11145
|
pending.dispose();
|
|
11066
11146
|
this.#managedSidecarCleanupStores.delete(pending);
|
|
11067
11147
|
} catch (error) {
|
|
11068
|
-
const
|
|
11148
|
+
const trustError = error instanceof ResidentCacheTrustError ? error : undefined;
|
|
11149
|
+
const candidate = trustError?.reason ?? "";
|
|
11069
11150
|
const reason = /^[a-z0-9_]{1,64}$/.test(candidate) ? candidate : "cleanup_failed";
|
|
11070
|
-
|
|
11151
|
+
// An errno is path-free, so it can join `reason` here; the cause *summary*
|
|
11152
|
+
// cannot, because fs messages embed the sidecar cache path this record withholds.
|
|
11153
|
+
const causeCode = trustError?.causeCode ?? (isFsError(error) ? error.code : undefined);
|
|
11154
|
+
logger.warn("Failed to dispose the managed sidecar resident cache; retained for retry", {
|
|
11155
|
+
reason,
|
|
11156
|
+
...(causeCode === undefined ? {} : { causeCode }),
|
|
11157
|
+
});
|
|
11071
11158
|
}
|
|
11072
11159
|
}
|
|
11073
11160
|
}
|
|
@@ -17121,6 +17208,8 @@ export class SessionManager {
|
|
|
17121
17208
|
residentCacheTrustRejectCount: this.#residentCacheTrustRejectCount,
|
|
17122
17209
|
residentCacheWin32FallbackCount: this.#residentCacheWin32FallbackCount,
|
|
17123
17210
|
residentCacheDegradedReason: (this.#residentTextBlobStore as ResidentCacheDegradedStore).degradedReason,
|
|
17211
|
+
residentCacheDegradedCauseCode: (this.#residentTextBlobStore as ResidentCacheDegradedStore).degradedCauseCode,
|
|
17212
|
+
residentBlobPlaceholderCount: this.#residentBlobPlaceholderCount,
|
|
17124
17213
|
publicMaterializerCallCount: this.#publicMaterializerCallCount,
|
|
17125
17214
|
getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
|
|
17126
17215
|
getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
|
|
@@ -50,7 +50,7 @@ import { buildFastStatusReport } from "./helpers/fast-status-report";
|
|
|
50
50
|
import { formatDuration } from "./helpers/format";
|
|
51
51
|
import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
|
|
52
52
|
import { handleSshAcp } from "./helpers/ssh";
|
|
53
|
-
import { buildUsageReportText } from "./helpers/usage-report";
|
|
53
|
+
import { buildUsageReportText, collectCachedUsageReports } from "./helpers/usage-report";
|
|
54
54
|
import type {
|
|
55
55
|
BuiltinSlashCommand,
|
|
56
56
|
ParsedSlashCommand,
|
|
@@ -1348,7 +1348,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1348
1348
|
runtime.ctx.showError("Usage: /usage [check]");
|
|
1349
1349
|
} else {
|
|
1350
1350
|
const adapted = toSlashCommandRuntime(runtime);
|
|
1351
|
-
|
|
1351
|
+
// Plain `/usage` renders the graphical panel from the cache-only
|
|
1352
|
+
// inventory snapshot — same data the text view reads, no fetch or
|
|
1353
|
+
// probe. `/usage check` stays on the text path because its value is
|
|
1354
|
+
// the per-credential health verdict, not the bars.
|
|
1355
|
+
const cached = args === "" ? collectCachedUsageReports(adapted) : [];
|
|
1356
|
+
if (cached.length > 0) {
|
|
1357
|
+
await runtime.ctx.handleUsageCommand(cached);
|
|
1358
|
+
} else {
|
|
1359
|
+
await adapted.output(await buildUsageReportText(adapted, { check: args === "check" }));
|
|
1360
|
+
}
|
|
1352
1361
|
}
|
|
1353
1362
|
runtime.ctx.editor.setText("");
|
|
1354
1363
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { UsageLimit } from "@gajae-code/ai/core";
|
|
1
|
+
import type { UsageLimit, UsageReport } from "@gajae-code/ai/core";
|
|
2
2
|
import { sanitizeText } from "@gajae-code/utils";
|
|
3
3
|
import {
|
|
4
4
|
type AccountInventoryRow,
|
|
@@ -34,6 +34,60 @@ function healthLabel(row: AccountInventoryRow): string {
|
|
|
34
34
|
return "unknown";
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Reset detail for one limit. The account rows dropped every reset signal when
|
|
39
|
+
* the panel was replaced, which left `/usage` unable to answer the question it
|
|
40
|
+
* exists for: how long until the quota comes back. Two-unit precision, because
|
|
41
|
+
* a single rounded unit reads `7d` at both 6.6 and 7.4 days remaining.
|
|
42
|
+
*/
|
|
43
|
+
function formatLimitReset(limit: UsageLimit, nowMs: number): string {
|
|
44
|
+
const resetsAt = limit.window?.resetsAt;
|
|
45
|
+
if (resetsAt === undefined || !Number.isFinite(resetsAt) || resetsAt <= nowMs) return "";
|
|
46
|
+
const totalMinutes = Math.floor((resetsAt - nowMs) / 60_000);
|
|
47
|
+
const totalHours = Math.floor(totalMinutes / 60);
|
|
48
|
+
let countdown: string;
|
|
49
|
+
if (totalMinutes < 1) countdown = "<1m";
|
|
50
|
+
else if (totalMinutes < 60) countdown = `${totalMinutes}m`;
|
|
51
|
+
else if (totalHours < 48) {
|
|
52
|
+
const minutes = totalMinutes % 60;
|
|
53
|
+
countdown = minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`;
|
|
54
|
+
} else {
|
|
55
|
+
const days = Math.floor(totalHours / 24);
|
|
56
|
+
const hours = totalHours % 24;
|
|
57
|
+
countdown = hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
58
|
+
}
|
|
59
|
+
const withinADay = resetsAt - nowMs < 24 * 3_600_000;
|
|
60
|
+
const at = new Date(resetsAt).toLocaleString(undefined, {
|
|
61
|
+
month: withinADay ? undefined : "short",
|
|
62
|
+
day: withinADay ? undefined : "numeric",
|
|
63
|
+
hour: "2-digit",
|
|
64
|
+
minute: "2-digit",
|
|
65
|
+
});
|
|
66
|
+
return `, resets in ${countdown} (${at})`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One limit line: how much is left, and when it comes back. */
|
|
70
|
+
export function formatLimitDetail(limit: UsageLimit, nowMs: number): string {
|
|
71
|
+
return `${formatUsageAmount(limit)}${formatLimitReset(limit, nowMs)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Cache-only usage reports for the interactive panel. Mirrors the plain
|
|
76
|
+
* `/usage` contract exactly — reads the account inventory snapshot, never
|
|
77
|
+
* fetches or probes — so the graphical view can be restored without
|
|
78
|
+
* reintroducing the network call that motivated replacing it.
|
|
79
|
+
*/
|
|
80
|
+
export function collectCachedUsageReports(runtime: SlashCommandRuntime): UsageReport[] {
|
|
81
|
+
const session = runtime.session;
|
|
82
|
+
const modelRegistry = session.modelRegistry;
|
|
83
|
+
const snapshot = buildAccountInventorySnapshot({
|
|
84
|
+
authStorage: modelRegistry.authStorage,
|
|
85
|
+
modelRegistry,
|
|
86
|
+
sessionId: session.credentialSessionId ?? session.sessionId,
|
|
87
|
+
});
|
|
88
|
+
return snapshot.rows.flatMap(row => (row.usage ? [row.usage.report] : []));
|
|
89
|
+
}
|
|
90
|
+
|
|
37
91
|
function renderAccountRows(rows: AccountInventoryRow[], nowMs: number, checked: boolean): string {
|
|
38
92
|
const lines = [`Accounts${checked ? " (checked)" : " (cache only)"}`];
|
|
39
93
|
if (rows.length === 0) {
|
|
@@ -52,7 +106,7 @@ function renderAccountRows(rows: AccountInventoryRow[], nowMs: number, checked:
|
|
|
52
106
|
);
|
|
53
107
|
if (row.usage?.report.limits.length) {
|
|
54
108
|
for (const limit of row.usage.report.limits.slice(0, 8)) {
|
|
55
|
-
lines.push(` ${sanitizeText(limit.label)}: ${
|
|
109
|
+
lines.push(` ${sanitizeText(limit.label)}: ${formatLimitDetail(limit, nowMs)}`);
|
|
56
110
|
}
|
|
57
111
|
}
|
|
58
112
|
}
|
|
@@ -99,7 +99,7 @@ const DeepInterviewMetadata = z.object({
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
const DeepInterviewTopologyMeta = DeepInterviewMetadata.extend({
|
|
102
|
-
round: z.
|
|
102
|
+
round: z.number().int().min(0).max(0).describe("Round 0 topology confirmation"),
|
|
103
103
|
component: z.literal("review-topology"),
|
|
104
104
|
dimension: z.literal("topology"),
|
|
105
105
|
intent_contract: DeepInterviewIntentContract.describe("required Round 0 locked-intent contract"),
|
|
@@ -153,7 +153,15 @@ describe("computer enforcement red-team probes", () => {
|
|
|
153
153
|
observed: `code=${resultCode(permission)} message=${resultMessage(permission)} nativeScreenshotCalls=${permissionCalls}`,
|
|
154
154
|
verdict:
|
|
155
155
|
resultCode(permission) === "COMPUTER_PERMISSION_REQUIRED" &&
|
|
156
|
-
|
|
156
|
+
// Assert the substance of the guidance contract -- the hint must name
|
|
157
|
+
// both required macOS permissions -- rather than one exact sentence.
|
|
158
|
+
// A literal pin is what rotted here before: the previous literal
|
|
159
|
+
// ("screen-recording or accessibility permission") went stale when the
|
|
160
|
+
// hint was reworded, silently failing this probe. Matching on the two
|
|
161
|
+
// permission names still fails if either is dropped, without
|
|
162
|
+
// re-arming that trap on the next rewording.
|
|
163
|
+
/recording/i.test(resultMessage(permission)) &&
|
|
164
|
+
/accessibility/i.test(resultMessage(permission))
|
|
157
165
|
? "passed"
|
|
158
166
|
: "failed",
|
|
159
167
|
});
|