@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +21 -3
  2. package/dist/types/modes/components/tool-execution.d.ts +1 -1
  3. package/dist/types/sdk/broker/lifecycle.d.ts +3 -0
  4. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  5. package/dist/types/session/blob-store.d.ts +9 -0
  6. package/dist/types/session/session-manager.d.ts +2 -0
  7. package/dist/types/slash-commands/helpers/usage-report.d.ts +10 -0
  8. package/dist/types/tools/ask-contract.d.ts +7 -7
  9. package/dist/types/tools/descriptor-validation.d.ts +2 -2
  10. package/package.json +7 -7
  11. package/src/config/model-profiles.ts +68 -0
  12. package/src/config/model-registry.ts +20 -2
  13. package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -8
  14. package/src/internal-urls/docs-index.generated.ts +3 -3
  15. package/src/modes/components/tool-execution.ts +24 -4
  16. package/src/modes/controllers/command-controller.ts +115 -13
  17. package/src/modes/controllers/input-controller.ts +51 -18
  18. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +10 -1
  19. package/src/sdk/broker/lifecycle.ts +21 -0
  20. package/src/sdk/broker/session-index.ts +13 -0
  21. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  22. package/src/sdk/bus/telegram-daemon.ts +29 -6
  23. package/src/sdk/router/session-router.ts +11 -4
  24. package/src/session/account-inventory.ts +25 -21
  25. package/src/session/agent-session.ts +9 -0
  26. package/src/session/blob-store.ts +73 -7
  27. package/src/session/session-manager.ts +104 -15
  28. package/src/slash-commands/builtin-registry.ts +11 -2
  29. package/src/slash-commands/helpers/usage-report.ts +56 -2
  30. package/src/tools/ask-contract.ts +1 -1
  31. package/src/tools/computer.enforcement.test.ts +9 -1
  32. package/src/tools/computer.redteam.test.ts +893 -0
  33. 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
- // locale so its `lstart` output can be represented as a stable epoch value.
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 parsed = Date.parse(new TextDecoder().decode(result.stdout).trim());
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 createdAt === undefined ? { pid, startTimeMs, nonce } : { pid, startTimeMs, nonce, createdAt };
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 a.pid === b.pid && a.startTimeMs === b.startTimeMs && a.nonce === b.nonce && a.createdAt === b.createdAt;
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, buffer)) {
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 = rehydrateColdSpillValue(
5580
- entry.message,
5581
- marker,
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
- const SESSION_LIST_TRAILING_PATCH_BYTES = 4096;
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(buffer.byteLength, SESSION_LIST_TRAILING_PATCH_BYTES);
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, buffer)) {
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 candidate = error instanceof ResidentCacheTrustError ? error.reason : "";
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
- logger.warn("Failed to dispose the managed sidecar resident cache; retained for retry", { reason });
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
- await adapted.output(await buildUsageReportText(adapted, { check: args === "check" }));
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)}: ${formatUsageAmount(limit)}`);
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.literal(0).describe("Round 0 topology confirmation"),
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
- resultMessage(permission).includes("Screen & System Audio Recording or Accessibility")
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
  });