@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
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@gajae-code/agent-core";
1
+ import { type AgentTool, isToolFailureEnvelope } from "@gajae-code/agent-core";
2
2
  import {
3
3
  type AnimationRegistration,
4
4
  Box,
@@ -201,6 +201,7 @@ export interface ToolExecutionHandle {
201
201
  export class ToolExecutionComponent extends Container {
202
202
  #contentBox: Box; // Used for custom tools and bash visual truncation
203
203
  #contentText: Text; // For built-in tools (with its own padding/bg)
204
+ #usesContentBox: boolean; // Which of the two the constructor put in the tree
204
205
  #multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
205
206
  #imageComponents: Image[] = [];
206
207
  #imageSpacers: Spacer[] = [];
@@ -299,7 +300,8 @@ export class ToolExecutionComponent extends Container {
299
300
  // Use Box for custom tools or built-in tools that have renderers
300
301
  const hasRenderer = toolName in toolRenderers;
301
302
  const hasCustomRenderer = !!(tool?.renderCall || tool?.renderResult);
302
- if (hasCustomRenderer || hasRenderer) {
303
+ this.#usesContentBox = hasCustomRenderer || hasRenderer;
304
+ if (this.#usesContentBox) {
303
305
  this.addChild(this.#contentBox);
304
306
  } else {
305
307
  this.addChild(this.#contentText);
@@ -639,8 +641,26 @@ export class ToolExecutionComponent extends Container {
639
641
  this.#renderState.isPartial = this.#isPartial;
640
642
  this.#renderState.spinnerFrame = this.#spinnerFrame;
641
643
 
642
- // Check for custom tool rendering
643
- if (this.#tool && (this.#tool.renderCall || this.#tool.renderResult)) {
644
+ // A call the loop rejected carries the loop's failure envelope in place of the
645
+ // tool's own details. A renderer owns only its own detail shape, so handing it
646
+ // the envelope either throws — leaving the TUI's bare `[render error: Box]`
647
+ // fallback line — or paints the tool's own card while the rejection reason is
648
+ // dropped. The reason is the only content left, so render it the way a tool
649
+ // without a renderer already reports failure.
650
+ if (this.#usesContentBox && this.#result?.isError && isToolFailureEnvelope(this.#result.details)) {
651
+ for (const box of this.#multiFileBoxes) {
652
+ this.removeChild(box);
653
+ }
654
+ this.#multiFileBoxes = [];
655
+ this.#contentBox.setBgFn(bgFn);
656
+ this.#contentBox.clear();
657
+ this.#contentBox.addChild(new Text(renderStatusLine({ icon: "error", title: this.#toolLabel }, theme), 0, 0));
658
+ const reason = this.#getTextOutput().trimEnd();
659
+ if (reason) {
660
+ this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(reason)), 0, 0));
661
+ }
662
+ } else if (this.#tool && (this.#tool.renderCall || this.#tool.renderResult)) {
663
+ // Custom tool rendering
644
664
  const tool = this.#tool;
645
665
  const mergeCallAndResult = Boolean((tool as { mergeCallAndResult?: boolean }).mergeCallAndResult);
646
666
  // Custom tools use Box for flexible component rendering
@@ -398,7 +398,7 @@ export class CommandController {
398
398
  const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
399
399
  for (let j = toolCalls.length - 1; j >= 0; j--) {
400
400
  const tc = toolCalls[j];
401
- if (tc.name === "bash" && typeof tc.arguments.command === "string") {
401
+ if (tc.name === "bash" && typeof tc.arguments?.command === "string") {
402
402
  this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard");
403
403
  return;
404
404
  }
@@ -629,7 +629,9 @@ export class CommandController {
629
629
  if (!usageReports) {
630
630
  const provider = this.ctx.session as { fetchUsageReports?: () => Promise<UsageReport[] | null> };
631
631
  if (!provider.fetchUsageReports) {
632
- this.ctx.showWarning("Usage reporting is not configured for this session.");
632
+ this.ctx.showWarning(
633
+ "Usage reporting is not configured for this session: no provider exposes usage limits here.",
634
+ );
633
635
  return;
634
636
  }
635
637
  try {
@@ -641,7 +643,9 @@ export class CommandController {
641
643
  }
642
644
 
643
645
  if (!usageReports || usageReports.length === 0) {
644
- this.ctx.showWarning("No usage data available.");
646
+ this.ctx.showWarning(
647
+ "No usage data available: the configured providers reported no limits. Subscription/OAuth credentials report limits; plain API keys usually do not.",
648
+ );
645
649
  return;
646
650
  }
647
651
 
@@ -1604,6 +1608,39 @@ function formatUnlimitedReportLabel(report: UsageReport, reportIndex: number): s
1604
1608
  return `account ${reportIndex + 1}`;
1605
1609
  }
1606
1610
 
1611
+ /**
1612
+ * Two-unit countdown for usage windows. `formatDuration` collapses everything
1613
+ * past 48h to a single rounded unit, so a weekly window reads `7d` whether 6.6
1614
+ * or 7.4 days remain — useless when the question is "how long until I get my
1615
+ * quota back". Kept local to the usage panel so job/elapsed rendering keeps the
1616
+ * coarse label.
1617
+ */
1618
+ function formatResetCountdown(ms: number): string {
1619
+ const totalMinutes = Math.max(0, Math.floor(ms / 60_000));
1620
+ if (totalMinutes < 1) return "<1m";
1621
+ if (totalMinutes < 60) return `${totalMinutes}m`;
1622
+ const totalHours = Math.floor(totalMinutes / 60);
1623
+ if (totalHours < 48) {
1624
+ const minutes = totalMinutes % 60;
1625
+ return minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`;
1626
+ }
1627
+ const days = Math.floor(totalHours / 24);
1628
+ const hours = totalHours % 24;
1629
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
1630
+ }
1631
+
1632
+ /** Absolute local reset time, so a long countdown maps onto a real calendar day. */
1633
+ function formatResetAt(resetsAt: number, nowMs: number): string {
1634
+ const date = new Date(resetsAt);
1635
+ const withinADay = resetsAt - nowMs < 24 * 3_600_000;
1636
+ return date.toLocaleString(undefined, {
1637
+ month: withinADay ? undefined : "short",
1638
+ day: withinADay ? undefined : "numeric",
1639
+ hour: "2-digit",
1640
+ minute: "2-digit",
1641
+ });
1642
+ }
1643
+
1607
1644
  function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined {
1608
1645
  if (limit.window?.resetsAt !== undefined) {
1609
1646
  return formatDuration(limit.window.resetsAt - nowMs);
@@ -1611,6 +1648,61 @@ function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined
1611
1648
  return undefined;
1612
1649
  }
1613
1650
 
1651
+ /** Squeeze a label from the middle, keeping both ends. */
1652
+ function squeezeMiddle(label: string, maxWidth: number): string {
1653
+ if (visibleWidth(label) <= maxWidth) return label;
1654
+ if (maxWidth <= 3) return truncateJobLabel(label, maxWidth);
1655
+ const chars = [...label];
1656
+ const headBudget = Math.ceil((maxWidth - 1) / 2);
1657
+ const tailBudget = maxWidth - 1 - headBudget;
1658
+ let head = "";
1659
+ for (const char of chars) {
1660
+ if (visibleWidth(head + char) > headBudget) break;
1661
+ head += char;
1662
+ }
1663
+ let tail = "";
1664
+ for (let index = chars.length - 1; index >= 0; index--) {
1665
+ const next = chars[index]! + tail;
1666
+ if (visibleWidth(next) > tailBudget) break;
1667
+ tail = next;
1668
+ }
1669
+ return `${head}…${tail}`;
1670
+ }
1671
+
1672
+ function accountLocalPart(label: string): string {
1673
+ const at = label.lastIndexOf("@");
1674
+ return at > 0 ? label.slice(0, at) : label;
1675
+ }
1676
+
1677
+ /**
1678
+ * Fit account labels into `maxWidth` while keeping them mutually
1679
+ * distinguishable. Credentials pooled on one provider share a domain and often
1680
+ * a prefix, so truncating each label in isolation collapses every column into
1681
+ * the same stub — which defeats the only reason the panel shows accounts at
1682
+ * all. Try progressively cheaper representations and keep the first one that is
1683
+ * still unique; if nothing is, tag the columns with an index so the rows can at
1684
+ * least be told apart.
1685
+ */
1686
+ function truncateAccountLabels(labels: string[], maxWidth: number): string[] {
1687
+ const distinctInput = new Set(labels).size;
1688
+ const strategies: ((label: string) => string)[] = [
1689
+ label => label,
1690
+ label => accountLocalPart(label),
1691
+ label => truncateJobLabel(accountLocalPart(label), maxWidth),
1692
+ label => squeezeMiddle(accountLocalPart(label), maxWidth),
1693
+ ];
1694
+ for (const strategy of strategies) {
1695
+ const candidate = labels.map(strategy);
1696
+ const fits = candidate.every(entry => visibleWidth(entry) <= maxWidth);
1697
+ if (fits && new Set(candidate).size === distinctInput) return candidate;
1698
+ }
1699
+ return labels.map((label, index) => {
1700
+ const tag = `#${index + 1}`;
1701
+ const budget = Math.max(1, maxWidth - visibleWidth(tag));
1702
+ return `${truncateJobLabel(accountLocalPart(label), budget)}${tag}`;
1703
+ });
1704
+ }
1705
+
1614
1706
  function formatAccountHeaderRow(
1615
1707
  limits: UsageLimit[],
1616
1708
  reports: UsageReport[],
@@ -1638,9 +1730,13 @@ function formatAccountHeaderRow(
1638
1730
  });
1639
1731
  }
1640
1732
 
1641
- return parts.map(p => {
1642
- const prefix = truncateJobLabel(p.label, prefixBudget);
1643
- const prefixCell = prefix + " ".repeat(prefixBudget - visibleWidth(prefix));
1733
+ const prefixes = truncateAccountLabels(
1734
+ parts.map(p => p.label),
1735
+ prefixBudget,
1736
+ );
1737
+ return parts.map((p, index) => {
1738
+ const prefix = prefixes[index]!;
1739
+ const prefixCell = prefix + " ".repeat(Math.max(0, prefixBudget - visibleWidth(prefix)));
1644
1740
  if (!p.suffix) return prefixCell + " ".repeat(maxSuffixWidth + gap);
1645
1741
  const suffixPad = " ".repeat(maxSuffixWidth - visibleWidth(p.suffix));
1646
1742
  return `${prefixCell} ${suffixPad}${uiTheme.fg("dim", p.suffix)}`;
@@ -1688,18 +1784,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
1688
1784
  return `${limits.length} accts`;
1689
1785
  }
1690
1786
 
1787
+ /**
1788
+ * Reset line for one window. Renders a range when the accounts in the window
1789
+ * reset at materially different times, and pins the earliest reset to an
1790
+ * absolute local time so "when do I get my quota back" is answerable without
1791
+ * doing date arithmetic in your head.
1792
+ */
1691
1793
  function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
1692
1794
  const absolute = limits
1693
1795
  .map(limit => limit.window?.resetsAt)
1694
1796
  .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs);
1695
1797
  if (absolute.length === 0) return null;
1696
- const offsets = absolute.map(value => value - nowMs);
1697
- const minReset = Math.min(...offsets);
1698
- const maxReset = Math.max(...offsets);
1699
- if (maxReset - minReset > 60_000) {
1700
- return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
1798
+ const earliest = Math.min(...absolute);
1799
+ const latest = Math.max(...absolute);
1800
+ const at = formatResetAt(earliest, nowMs);
1801
+ if (latest - earliest > 60_000) {
1802
+ return `resets in ${formatResetCountdown(earliest - nowMs)}–${formatResetCountdown(latest - nowMs)} (first ${at})`;
1701
1803
  }
1702
- return `resets in ${formatDuration(minReset)}`;
1804
+ return `resets in ${formatResetCountdown(earliest - nowMs)} (${at})`;
1703
1805
  }
1704
1806
 
1705
1807
  function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string {
@@ -1868,7 +1970,7 @@ export function renderUsageReports(
1868
1970
  padColumn(renderUsageBar(limit, uiTheme, sectionColumnWidth), sectionColumnWidth),
1869
1971
  );
1870
1972
  lines.push(` ${bars.join(" ")} ${amountText}`.trimEnd());
1871
- const resetText = sortedLimits.length <= 1 ? resolveResetRange(sortedLimits, nowMs) : null;
1973
+ const resetText = resolveResetRange(sortedLimits, nowMs);
1872
1974
  if (resetText) {
1873
1975
  lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd());
1874
1976
  }
@@ -315,6 +315,10 @@ export class InputController {
315
315
  return this.ctx.keybindings.getKeys("app.interrupt").some(key => matchesKey(data, key));
316
316
  }
317
317
 
318
+ #matchesClearKey(data: string): boolean {
319
+ return this.ctx.keybindings.getKeys("app.clear").some(key => matchesKey(data, key));
320
+ }
321
+
318
322
  #hasHookDialog(): boolean {
319
323
  return Boolean(this.ctx.hookSelector || this.ctx.hookInput || this.ctx.hookEditor);
320
324
  }
@@ -327,12 +331,25 @@ export class InputController {
327
331
  );
328
332
  }
329
333
 
334
+ #handlePendingSteerInterrupt(): boolean {
335
+ if (!this.#steerConsumePending) return false;
336
+ this.#resetEscapeGestures();
337
+ if (!this.ctx.session.hasQueuedSteering) {
338
+ this.#steerConsumePending = false;
339
+ return false;
340
+ }
341
+
342
+ this.#steerConsumePending = false;
343
+ this.restoreQueuedMessagesToEditor({ abort: true });
344
+ return true;
345
+ }
346
+
330
347
  #handleCancellableWorkEscape(options: {
331
348
  loading?: boolean;
332
349
  processes?: boolean;
333
350
  modes?: boolean;
334
351
  maintenance?: boolean;
335
- retry?: boolean;
352
+ retry?: "escape" | "clear";
336
353
  streaming?: boolean;
337
354
  }): boolean {
338
355
  if (options.loading && this.ctx.loadingAnimation) {
@@ -376,7 +393,7 @@ export class InputController {
376
393
  }
377
394
  if (options.retry) {
378
395
  if (this.#isRetryBackoffActive()) {
379
- if (this.ctx.retryEscapePrimed) {
396
+ if (options.retry === "clear" || this.ctx.retryEscapePrimed) {
380
397
  this.ctx.session.abortRetry();
381
398
  } else {
382
399
  this.ctx.retryEscapePrimed = true;
@@ -407,9 +424,36 @@ export class InputController {
407
424
  }
408
425
  this.#globalInterruptUnsubscribe?.();
409
426
  this.#globalInterruptUnsubscribe = this.ctx.ui.addInputListener(data => {
410
- if (!this.#matchesInterruptKey(data)) {
427
+ const isInterruptKey = this.#matchesInterruptKey(data);
428
+ const isClearKey = this.#matchesClearKey(data);
429
+ if (!isInterruptKey && !isClearKey) {
430
+ return undefined;
431
+ }
432
+ if (isClearKey && !isInterruptKey && this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
433
+ this.#resetEscapeGestures();
434
+ return { consume: true };
435
+ }
436
+ if (isClearKey && !isInterruptKey && this.ctx.hookSelector?.hasActiveInlineInput?.() === true) {
437
+ this.#resetEscapeGestures();
411
438
  return undefined;
412
439
  }
440
+ if (isClearKey && !isInterruptKey) {
441
+ if (this.#handlePendingSteerInterrupt()) return { consume: true };
442
+ if (
443
+ !this.#handleCancellableWorkEscape({
444
+ loading: true,
445
+ processes: true,
446
+ modes: true,
447
+ maintenance: true,
448
+ retry: "clear",
449
+ streaming: true,
450
+ })
451
+ ) {
452
+ return undefined;
453
+ }
454
+ this.#resetEscapeGestures();
455
+ return { consume: true };
456
+ }
413
457
  if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
414
458
  this.#resetEscapeGestures();
415
459
  return { consume: true };
@@ -428,7 +472,7 @@ export class InputController {
428
472
  processes: hookDialogActive,
429
473
  modes: false,
430
474
  maintenance: true,
431
- retry: true,
475
+ retry: "escape",
432
476
  streaming: hookDialogActive,
433
477
  })
434
478
  ) {
@@ -492,19 +536,8 @@ export class InputController {
492
536
  this.#resetEscapeGestures();
493
537
  return;
494
538
  }
495
- if (this.#steerConsumePending) {
496
- this.#resetEscapeGestures();
497
- if (this.ctx.session.hasQueuedSteering) {
498
- // Second Esc before the scheduled steer continuation drains the
499
- // queue: restore/drop the queued steer and perform a real abort,
500
- // even if abort cleanup already made the session look idle.
501
- this.#steerConsumePending = false;
502
- this.restoreQueuedMessagesToEditor({ abort: true });
503
- return;
504
- }
505
- this.#steerConsumePending = false;
506
- }
507
- if (this.#handleCancellableWorkEscape({ maintenance: true, retry: true })) {
539
+ if (this.#handlePendingSteerInterrupt()) return;
540
+ if (this.#handleCancellableWorkEscape({ maintenance: true, retry: "escape" })) {
508
541
  this.#resetEscapeGestures();
509
542
  return;
510
543
  }
@@ -514,7 +547,7 @@ export class InputController {
514
547
  processes: true,
515
548
  modes: true,
516
549
  maintenance: true,
517
- retry: true,
550
+ retry: "escape",
518
551
  streaming: true,
519
552
  })
520
553
  ) {
@@ -1041,10 +1041,10 @@ export class FileGateStore implements GateStore {
1041
1041
  }
1042
1042
  }
1043
1043
  private flushState(next: FileState): void {
1044
- mkdirSync(path.dirname(this.filePath), { recursive: true });
1045
1044
  const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
1046
1045
  let renamed = false;
1047
1046
  try {
1047
+ mkdirSync(path.dirname(this.filePath), { recursive: true });
1048
1048
  const fd = openSync(tmp, "w");
1049
1049
  try {
1050
1050
  writeFileSync(fd, JSON.stringify(next, null, 2));
@@ -1125,6 +1125,15 @@ export class FileGateStore implements GateStore {
1125
1125
  }
1126
1126
  }
1127
1127
  next.runtimeInstanceId = instanceId;
1128
+ // Nothing to persist yet: an empty store carries no gates or counters a
1129
+ // later process could recover or quarantine, so stamping the runtime
1130
+ // instance id now would mkdir under the cwd at construction time and
1131
+ // defeat the lazy first-write contract (#4568). Adopt it in memory so it
1132
+ // rides along with the first real mutation instead.
1133
+ if (Object.keys(next.gates).length === 0 && Object.keys(next.counters).length === 0) {
1134
+ this.state = next;
1135
+ return;
1136
+ }
1128
1137
  this.commit(next);
1129
1138
  }
1130
1139
  list(): PersistedGate[] {
@@ -827,6 +827,18 @@ function observeProcess(
827
827
  expectedIncarnation: string | undefined,
828
828
  readIncarnation: (pid: number) => string | undefined = processIncarnation,
829
829
  ): ProcessObservation {
830
+ if (process.platform === "win32") {
831
+ try {
832
+ const reference = nativeLifecycle().Process.fromPid(pid);
833
+ if (reference?.status() !== "running") return "exited";
834
+ } catch {
835
+ return "uncertain";
836
+ }
837
+ if (!expectedIncarnation) return "uncertain";
838
+ const actualIncarnation = readIncarnation(pid);
839
+ if (!actualIncarnation) return "uncertain";
840
+ return actualIncarnation === expectedIncarnation ? "alive" : "exited";
841
+ }
830
842
  try {
831
843
  process.kill(pid, 0);
832
844
  } catch (error) {
@@ -843,6 +855,15 @@ function observeProcess(
843
855
  return actualIncarnation === expectedIncarnation ? "alive" : "exited";
844
856
  }
845
857
 
858
+ /** Test seam for the lifecycle-owned process observation boundary. */
859
+ export function observeProcessForTest(
860
+ pid: number,
861
+ expectedIncarnation: string | undefined,
862
+ readIncarnation: (pid: number) => string | undefined = processIncarnation,
863
+ ): ProcessObservation {
864
+ return observeProcess(pid, expectedIncarnation, readIncarnation);
865
+ }
866
+
846
867
  function hasObservedProcessExit(pid: number): boolean {
847
868
  try {
848
869
  process.kill(pid, 0);
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { logger, resolveEquivalentPath } from "@gajae-code/utils";
5
+ import { nativeProcessBindings } from "@gajae-code/utils/native-process";
5
6
  import { withFileLock } from "../../config/file-lock";
6
7
  import { processIncarnation } from "./process-incarnation";
7
8
  import {
@@ -754,6 +755,18 @@ async function replaceAtomically(file: string, contents: Buffer | string): Promi
754
755
  }
755
756
 
756
757
  function alive(pid: number): boolean {
758
+ if (process.platform === "win32") {
759
+ try {
760
+ // Bun's signal-0 probe is not a reliable Windows liveness authority for a
761
+ // detached child: it can report a non-live process even while the child
762
+ // has already published its endpoint. Use the native process handle, which
763
+ // is also the source of the process-incarnation fence, and fail closed when
764
+ // the handle cannot be opened.
765
+ return nativeProcessBindings().Process.fromPid(pid)?.status() === "running";
766
+ } catch {
767
+ return false;
768
+ }
769
+ }
757
770
  try {
758
771
  process.kill(pid, 0);
759
772
  return true;
@@ -274,7 +274,7 @@ export const SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION = 1;
274
274
  * Generation 169 delivers every ring-positioned session event live through the
275
275
  * bounded, capability-gated directed subscriber leg used by replay.
276
276
  */
277
- export const DAEMON_GENERATION = 169;
277
+ export const DAEMON_GENERATION = 170;
278
278
 
279
279
  /**
280
280
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 7
@@ -6841,7 +6841,11 @@ export class TelegramNotificationDaemon {
6841
6841
  if (timer !== undefined) (this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
6842
6842
  this.#rejectedTopicCleanupTimers.delete(sessionId);
6843
6843
  }
6844
- #leaseAllows(session: AttachmentSession, logicalSessionId = this.#logicalSessionId(session)): boolean {
6844
+ #leaseAllows(
6845
+ session: AttachmentSession,
6846
+ logicalSessionId = this.#logicalSessionId(session),
6847
+ options?: { allowLeaseRearm?: boolean },
6848
+ ): boolean {
6845
6849
  if (this.#droppedSessions.has(session)) return false;
6846
6850
  if (!this.#topicAdmissionAllows(session)) return false;
6847
6851
  const closedBinding = this.closedEndpointKeys.get(logicalSessionId);
@@ -6853,13 +6857,22 @@ export class TelegramNotificationDaemon {
6853
6857
  if (lease?.state !== "authorized" || lease.logicalSessionId !== logicalSessionId) return false;
6854
6858
  if (this.#logicalSessionOwners.get(logicalSessionId) !== session) return false;
6855
6859
  const record = this.topics.get(logicalSessionId);
6860
+ // Lease re-arm mirrors `acquireLease` admission: an expired-but-still-owned
6861
+ // active lease, or a same-owner grace-window resume. Every other authority
6862
+ // state (archive/inactive/quarantine/malformed) stays fenced.
6863
+ const authorityResumable =
6864
+ options?.allowLeaseRearm !== true
6865
+ ? record?.authorityState === "active"
6866
+ : record?.authorityState === "active" ||
6867
+ (record?.authorityState === "disconnect_grace" &&
6868
+ (record.disconnectGraceExpiresAt ?? 0) > this.runtime.now());
6856
6869
  return (
6857
6870
  lease.binding.logicalSessionId === logicalSessionId &&
6858
6871
  (!record ||
6859
- (record.authorityState === "active" &&
6872
+ (authorityResumable &&
6860
6873
  !record.bindingMalformed &&
6861
6874
  record.leaseOwner === this.installationHostId &&
6862
- (record.leaseExpiresAt ?? 0) > this.runtime.now() &&
6875
+ (options?.allowLeaseRearm === true || (record.leaseExpiresAt ?? 0) > this.runtime.now()) &&
6863
6876
  record.chatId === lease.binding.chatId))
6864
6877
  );
6865
6878
  }
@@ -9807,7 +9820,10 @@ export class TelegramNotificationDaemon {
9807
9820
  )
9808
9821
  continue;
9809
9822
  const sessionId = this.#logicalSessionId(session);
9810
- if (renewed.has(sessionId) || !this.#leaseAllows(session, sessionId)) continue;
9823
+ // A live trusted attachment may re-arm its own expired host lease: the
9824
+ // authority above (owner, binding, state) is what makes renewal safe, and
9825
+ // gating renewal on an unexpired lease made expiry permanent (#4647).
9826
+ if (renewed.has(sessionId) || !this.#leaseAllows(session, sessionId, { allowLeaseRearm: true })) continue;
9811
9827
  renewed.add(sessionId);
9812
9828
  if (!(await this.#renewTopicLease(sessionId, session)))
9813
9829
  logger.warn(`notifications: Telegram topic lease renewal was not admitted for session ${sessionId}`);
@@ -10280,8 +10296,15 @@ export class TelegramNotificationDaemon {
10280
10296
  if (msg?.type === "event_replay_result") return;
10281
10297
  if (!this.#topicAdmissionAllows(session)) return;
10282
10298
  if (msg && typeof msg === "object") await this.#updateLogicalSessionForThreadedFrame(session, msg);
10283
- if (session.logicalSessionIdTrusted && !this.#leaseAllows(session))
10284
- await this.#failPublicationPreSend(publicationId, "trusted attachment lease is stale");
10299
+ if (session.logicalSessionIdTrusted && !this.#leaseAllows(session)) {
10300
+ // A still-owned live attachment re-arms its own expired host lease before
10301
+ // the gate so slow turns do not permanently strand the topic (#4647);
10302
+ // every other authority failure still fails closed below.
10303
+ if (this.#leaseAllows(session, undefined, { allowLeaseRearm: true }))
10304
+ await this.#renewTopicLease(this.#logicalSessionId(session), session);
10305
+ if (!this.#leaseAllows(session))
10306
+ await this.#failPublicationPreSend(publicationId, "trusted attachment lease is stale");
10307
+ }
10285
10308
  session.activePublicationId = publicationId;
10286
10309
  try {
10287
10310
  if (await this.#frameRouter.dispatch(session, msg as Record<string, unknown>)) return;
@@ -1,7 +1,7 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { logger } from "@gajae-code/utils";
4
+ import { logger, resolveEquivalentPath } from "@gajae-code/utils";
5
5
  import {
6
6
  SessionIndex as DefaultSessionIndex,
7
7
  type IndexedSession,
@@ -837,11 +837,18 @@ export class SessionRouter {
837
837
  if (!isSessionAuthorityEligible(indexed)) return null;
838
838
  const repo = path.resolve(indexed.locator.repo);
839
839
  const defaultStateRoot = path.join(repo, ".gjc", "state");
840
- const indexedStateRoot = path.resolve(indexed.locator.stateRoot);
840
+ // The session index stores the lifecycle caller's lexical cwd in `locator.repo`
841
+ // (reconcileReadyScope re-scopes only that field) while `locator.stateRoot` is
842
+ // the host process's physical path, because process.cwd() resolves symlinks.
843
+ // The scope test therefore compares path identity, not spelling: a lexical
844
+ // match fails for every symlinked cwd (macOS /var -> /private/var,
845
+ // /home/jun/desk -> /data/Lina-Desk), the adopted attachment is retired on
846
+ // reconcile, and session/new surfaces "lost exact Router authority".
847
+ const indexedStateRoot = resolveEquivalentPath(indexed.locator.stateRoot);
841
848
  const scope =
842
- indexedStateRoot === defaultStateRoot
849
+ indexedStateRoot === resolveEquivalentPath(defaultStateRoot)
843
850
  ? "default"
844
- : indexedStateRoot === path.join(defaultStateRoot, "chat")
851
+ : indexedStateRoot === resolveEquivalentPath(path.join(defaultStateRoot, "chat"))
845
852
  ? "chat"
846
853
  : undefined;
847
854
  if (!scope || indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return null;
@@ -204,8 +204,12 @@ function storedHealth(authStorage: AuthStorage, row: CredentialInventoryRecord):
204
204
  };
205
205
  }
206
206
 
207
- function storedUsage(authStorage: AuthStorage, row: CredentialInventoryRecord): AccountUsageCache | undefined {
208
- return authStorage.getCachedUsageReport(row.provider as Provider, row.id);
207
+ function storedUsage(
208
+ authStorage: AuthStorage,
209
+ row: CredentialInventoryRecord,
210
+ baseUrl?: string,
211
+ ): AccountUsageCache | undefined {
212
+ return authStorage.getCachedUsageReport(row.provider as Provider, row.id, baseUrl);
209
213
  }
210
214
 
211
215
  function safeUsageUnit(value: unknown): UsageUnit {
@@ -308,6 +312,17 @@ function redactUsageCache(usage: CachedUsageReport | undefined): AccountUsageCac
308
312
  };
309
313
  }
310
314
 
315
+ function freshUsageCache(report: CachedUsageReport["report"]): AccountUsageCache {
316
+ const now = Date.now();
317
+ return {
318
+ report: redactUsageReport(report),
319
+ fetchedAt: finiteNumber(report.fetchedAt) ?? now,
320
+ freshUntil: now + 15 * 60_000,
321
+ retainUntil: now + 24 * 60 * 60_000,
322
+ freshness: "fresh",
323
+ };
324
+ }
325
+
311
326
  function canPinStoredOAuth(authStorage: AuthStorage, provider: string): boolean {
312
327
  if (authStorage.hasRuntimeApiKey(provider) || authStorage.hasConfigApiKey(provider)) return false;
313
328
  return !getEnvApiKey(provider);
@@ -352,6 +367,7 @@ function addStoredRows(
352
367
  authStorage: AuthStorage,
353
368
  inventory: CredentialInventoryRecord[],
354
369
  sessionId: string | undefined,
370
+ baseUrlResolver?: (provider: string) => string | undefined,
355
371
  ): void {
356
372
  const removalTargetIds = new Set(
357
373
  (typeof authStorage.listCredentialRemovalTargets === "function"
@@ -361,7 +377,7 @@ function addStoredRows(
361
377
  );
362
378
  for (const record of inventory) {
363
379
  const identityLabel = asSafeLabel(record.identityLabel);
364
- const safeUsage = redactUsageCache(storedUsage(authStorage, record));
380
+ const safeUsage = redactUsageCache(storedUsage(authStorage, record, baseUrlResolver?.(record.provider)));
365
381
  const active =
366
382
  typeof record.provider === "string" &&
367
383
  authStorage.getSessionCredentialRowId(record.provider, sessionId) === record.id;
@@ -453,7 +469,9 @@ export function buildAccountInventorySnapshot(input: AccountInventoryInput): Acc
453
469
  const nowMs = input.nowMs ?? Date.now();
454
470
  const inventory = input.authStorage.listCredentialInventory();
455
471
  const rows: AccountInventoryRow[] = [];
456
- addStoredRows(rows, input.authStorage, inventory, input.sessionId);
472
+ addStoredRows(rows, input.authStorage, inventory, input.sessionId, provider =>
473
+ input.modelRegistry?.getProviderBaseUrl?.(provider),
474
+ );
457
475
  addSyntheticRows(rows, input.authStorage, providerSet(input, inventory), input.sessionId);
458
476
  rows.sort((left, right) => left.id.localeCompare(right.id));
459
477
  return { generatedAt: nowMs, generation: input.authStorage.getGeneration(), rows };
@@ -464,7 +482,6 @@ export const buildAccountInventory = buildAccountInventorySnapshot;
464
482
 
465
483
  function applyStoredCheck(
466
484
  rows: AccountInventoryRow[],
467
- authStorage: AuthStorage,
468
485
  results: CredentialHealthResult[],
469
486
  ): AccountInventoryCheckResult[] {
470
487
  const byId = new Map(rows.filter(row => row.credentialId !== undefined).map(row => [row.credentialId!, row]));
@@ -477,14 +494,8 @@ function applyStoredCheck(
477
494
  reason: asSafeLabel(result.reason),
478
495
  };
479
496
  if (result.report) {
480
- const cached =
481
- row.credentialId === undefined
482
- ? undefined
483
- : authStorage.getCachedUsageReport(row.provider as Provider, row.credentialId);
484
- if (cached) {
485
- row.usage = redactUsageCache(cached);
486
- row.capabilities.hasCachedUsage = true;
487
- }
497
+ row.usage = freshUsageCache(result.report);
498
+ row.capabilities.hasCachedUsage = true;
488
499
  }
489
500
  checked.push({
490
501
  rowId: row.id,
@@ -509,7 +520,6 @@ export async function checkAccountInventory(input: AccountInventoryInput): Promi
509
520
  const authStorage = input.authStorage;
510
521
  applyStoredCheck(
511
522
  rows,
512
- authStorage,
513
523
  await authStorage.checkCredentials({
514
524
  provider: input.provider,
515
525
  baseUrlResolver: provider => input.modelRegistry?.getProviderBaseUrl?.(provider),
@@ -539,13 +549,7 @@ export async function checkAccountInventory(input: AccountInventoryInput): Promi
539
549
  };
540
550
  recordSourceHealth(authStorage, row.provider, row.source as SyntheticAccountSource, result.ok, result.reason);
541
551
  if (result.report) {
542
- row.usage = {
543
- report: redactUsageReport(result.report),
544
- fetchedAt: finiteNumber(result.report.fetchedAt) ?? 0,
545
- freshUntil: Date.now() + 15 * 60_000,
546
- retainUntil: Date.now() + 24 * 60 * 60_000,
547
- freshness: "fresh",
548
- };
552
+ row.usage = freshUsageCache(result.report);
549
553
  row.capabilities.hasCachedUsage = true;
550
554
  }
551
555
  }