@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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
|
-
//
|
|
643
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
|
1697
|
-
const
|
|
1698
|
-
const
|
|
1699
|
-
if (
|
|
1700
|
-
return `resets in ${
|
|
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 ${
|
|
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 =
|
|
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?:
|
|
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
|
-
|
|
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:
|
|
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.#
|
|
496
|
-
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
208
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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
|
}
|