@gajae-code/coding-agent 0.10.0 → 0.10.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 (47) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/settings.d.ts +2 -0
  4. package/dist/types/coordinator/contract.d.ts +1 -1
  5. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +17 -5
  7. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  9. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  10. package/dist/types/modes/components/status-line.d.ts +0 -10
  11. package/dist/types/modes/print-mode.d.ts +3 -4
  12. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  13. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  14. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  15. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  16. package/dist/types/session/agent-session.d.ts +14 -1
  17. package/dist/types/session/context-estimation.d.ts +31 -0
  18. package/package.json +7 -7
  19. package/src/cli.ts +1 -0
  20. package/src/config/settings.ts +96 -45
  21. package/src/coordinator/contract.ts +1 -0
  22. package/src/coordinator-mcp/policy.ts +30 -0
  23. package/src/coordinator-mcp/server.ts +294 -66
  24. package/src/coordinator-mcp/session-reaper.ts +129 -0
  25. package/src/extensibility/extensions/runner.ts +1 -1
  26. package/src/extensibility/extensions/types.ts +3 -1
  27. package/src/internal-urls/docs-index.generated.ts +4 -4
  28. package/src/main.ts +3 -5
  29. package/src/modes/acp/acp-agent.ts +2 -2
  30. package/src/modes/components/footer.ts +3 -3
  31. package/src/modes/components/status-line/segments.ts +13 -6
  32. package/src/modes/components/status-line/types.ts +1 -1
  33. package/src/modes/components/status-line.ts +4 -171
  34. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  35. package/src/modes/interactive-mode.ts +16 -0
  36. package/src/modes/print-mode.ts +228 -92
  37. package/src/modes/rpc/rpc-client.ts +58 -1
  38. package/src/modes/rpc/rpc-mode.ts +286 -85
  39. package/src/modes/rpc/rpc-types.ts +15 -1
  40. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  41. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  42. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  43. package/src/modes/utils/context-usage.ts +56 -92
  44. package/src/session/agent-session.ts +287 -85
  45. package/src/session/context-estimation.ts +91 -0
  46. package/src/slash-commands/builtin-registry.ts +12 -2
  47. package/src/slash-commands/helpers/context-report.ts +26 -4
package/src/main.ts CHANGED
@@ -1398,12 +1398,10 @@ export async function runRootCommand(
1398
1398
  if ($env.PI_TIMING) {
1399
1399
  logger.printTimings();
1400
1400
  }
1401
+ stopThemeWatcher();
1401
1402
  if (!deps.suppressProcessExit) {
1402
- await session.dispose();
1403
- stopThemeWatcher();
1404
- await postmortem.quit(0);
1405
- } else {
1406
- stopThemeWatcher();
1403
+ const exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
1404
+ await postmortem.quit(exitCode);
1407
1405
  }
1408
1406
  }
1409
1407
  }
@@ -2217,14 +2217,14 @@ export class AcpAgent implements Agent {
2217
2217
  const sessionId = record.session.sessionId;
2218
2218
 
2219
2219
  const contextUsage = record.session.getContextUsage();
2220
- if (contextUsage) {
2220
+ if (contextUsage?.tokens != null) {
2221
2221
  const usageStats = record.session.sessionManager.getUsageStatistics();
2222
2222
  await this.#connection.sessionUpdate({
2223
2223
  sessionId,
2224
2224
  update: {
2225
2225
  sessionUpdate: "usage_update",
2226
2226
  size: contextUsage.contextWindow,
2227
- used: contextUsage.tokens ?? 0,
2227
+ used: contextUsage.tokens,
2228
2228
  cost: usageStats.cost > 0 ? { amount: usageStats.cost, currency: "USD" } : undefined,
2229
2229
  },
2230
2230
  });
@@ -139,8 +139,8 @@ export class FooterComponent implements Component {
139
139
  // After compaction, tokens are unknown until the next LLM response.
140
140
  const contextUsage = this.session.getContextUsage();
141
141
  const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
142
- const contextPercentValue = contextUsage?.percent ?? 0;
143
- const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
142
+ const contextPercentValue = contextUsage?.percent;
143
+ const contextPercent = typeof contextPercentValue === "number" ? contextPercentValue.toFixed(1) : "?";
144
144
 
145
145
  // Replace home directory with ~
146
146
  let pwd = shortenPath(getProjectDir());
@@ -188,7 +188,7 @@ export class FooterComponent implements Component {
188
188
  contextPercent === "?"
189
189
  ? `?/${formatNumber(contextWindow)}${autoIndicator}`
190
190
  : `${contextPercent}%/${formatNumber(contextWindow)}${autoIndicator}`;
191
- if (contextUsage?.percent !== null && contextUsage?.percent !== undefined) {
191
+ if (typeof contextPercentValue === "number") {
192
192
  const color = getContextUsageThemeColor(getContextUsageLevel(contextPercentValue, contextWindow));
193
193
  contextPercentStr =
194
194
  color === "statusLineContext" ? contextPercentDisplay : theme.fg(color, contextPercentDisplay);
@@ -114,9 +114,13 @@ const modelSegment: StatusLineSegment = {
114
114
  if (opts.showContextPercent !== false && ctx.contextPctSegmentActive !== true) {
115
115
  const pct = ctx.contextPercent;
116
116
  const window = ctx.contextWindow;
117
- if (window > 0 && Number.isFinite(pct)) {
118
- const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
119
- content += `${theme.sep.dot}${theme.fg(color, `${pct.toFixed(1)}%`)}`;
117
+ if (window > 0) {
118
+ if (typeof pct === "number" && Number.isFinite(pct)) {
119
+ const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
120
+ content += `${theme.sep.dot}${theme.fg(color, `${pct.toFixed(1)}%`)}`;
121
+ } else {
122
+ content += `${theme.sep.dot}${theme.fg("statusLineContext", "?")}`;
123
+ }
120
124
  }
121
125
  }
122
126
 
@@ -382,11 +386,14 @@ const contextPctSegment: StatusLineSegment = {
382
386
  render(ctx) {
383
387
  const pct = ctx.contextPercent;
384
388
  const window = ctx.contextWindow;
389
+ const knownPct = typeof pct === "number" && Number.isFinite(pct) ? pct : undefined;
385
390
 
386
391
  const autoIcon = ctx.autoCompactEnabled && theme.icon.auto ? ` ${theme.icon.auto}` : "";
387
- const text = `${pct.toFixed(1)}%/${formatNumber(window)}${autoIcon}`;
388
-
389
- const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
392
+ const text = `${knownPct === undefined ? "?" : `${knownPct.toFixed(1)}%`}/${formatNumber(window)}${autoIcon}`;
393
+ const color =
394
+ knownPct === undefined
395
+ ? "statusLineContext"
396
+ : getContextUsageThemeColor(getContextUsageLevel(knownPct, window));
390
397
  const content = withIcon(theme.icon.context, theme.fg(color, text));
391
398
 
392
399
  return { content, visible: true };
@@ -39,7 +39,7 @@ export interface SegmentContext {
39
39
  cost: number;
40
40
  tokensPerSecond: number | null;
41
41
  };
42
- contextPercent: number;
42
+ contextPercent: number | null;
43
43
  contextWindow: number;
44
44
  /**
45
45
  * True when a standalone `context_pct` segment is also part of the active
@@ -1,6 +1,5 @@
1
1
  import * as fs from "node:fs";
2
- import type { AgentMessage } from "@gajae-code/agent-core";
3
- import { estimateMessageTokensHeuristic } from "@gajae-code/agent-core/compaction";
2
+
4
3
  import { type Component, truncateToWidth, visibleWidth } from "@gajae-code/tui";
5
4
  import { formatCount, getProjectDir } from "@gajae-code/utils";
6
5
  import { $ } from "bun";
@@ -13,7 +12,6 @@ import * as git from "../../utils/git";
13
12
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color";
14
13
  import { EMPTY_JOBS_SNAPSHOT, type JobsSnapshot } from "../jobs-observer";
15
14
  import { sanitizeStatusText } from "../shared";
16
- import { computeNonMessageTokens } from "../utils/context-usage";
17
15
  import { renderSkillHudBar } from "./skill-hud/render";
18
16
  import {
19
17
  canReuseCachedPr,
@@ -70,104 +68,6 @@ interface CollectedStatusSegments {
70
68
  rightCapWidth: number;
71
69
  }
72
70
 
73
- // ═══════════════════════════════════════════════════════════════════════════
74
- // Per-message token cache
75
- // ═══════════════════════════════════════════════════════════════════════════
76
-
77
- /**
78
- * Symbol-keyed sidecar tagged onto each `AgentMessage` to memoize its
79
- * `estimateMessageTokensHeuristic` result. Keyed by message identity (the object itself);
80
- * a cheap content fingerprint detects in-place mutations (post-hoc error
81
- * attachment, retry-truncated branch rebuild, etc.) and forces recompute.
82
- *
83
- * Cache lives on the message — multiple `StatusLineComponent` instances
84
- * share it for free, and entries collect with the message itself when the
85
- * conversation is replaced or compacted.
86
- */
87
- const kTokenCache = Symbol("statusLine.tokenCache");
88
- interface TaggedMessage {
89
- [kTokenCache]?: { fingerprint: string; tokens: number };
90
- }
91
-
92
- /**
93
- * Cheap structural fingerprint mirroring `estimateMessageTokensHeuristic`'s content walk.
94
- * O(blocks) — only reads string `.length` and primitives, never copies or
95
- * serializes content. Any in-place mutation that alters total tokenized
96
- * content also alters one of the byte-length sums or block counts captured
97
- * here, forcing the cached heuristic token value to be recomputed.
98
- */
99
- function messageFingerprint(msg: AgentMessage): string {
100
- const role = (msg as { role?: string }).role ?? "";
101
- const ts = (msg as { timestamp?: number }).timestamp ?? 0;
102
- let textLen = 0;
103
- let blocks = 0;
104
- let images = 0;
105
- if (role === "bashExecution") {
106
- const b = msg as { command?: unknown; output?: unknown };
107
- if (typeof b.command === "string") textLen += b.command.length;
108
- if (typeof b.output === "string") textLen += b.output.length;
109
- } else if (role === "user") {
110
- const content = (msg as { content?: unknown }).content;
111
- if (typeof content === "string") {
112
- textLen += content.length;
113
- } else if (Array.isArray(content)) {
114
- blocks = content.length;
115
- for (const block of content) {
116
- if (block?.type === "text" && typeof block.text === "string") textLen += block.text.length;
117
- }
118
- }
119
- } else if (role === "assistant") {
120
- const content = (msg as { content?: unknown }).content;
121
- if (Array.isArray(content)) {
122
- blocks = content.length;
123
- for (const block of content) {
124
- if (!block || typeof block !== "object") continue;
125
- const b = block as { type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown };
126
- if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
127
- else if (b.type === "thinking" && typeof b.thinking === "string") textLen += b.thinking.length;
128
- else if (b.type === "toolCall") {
129
- if (typeof b.name === "string") textLen += b.name.length;
130
- // Argument bytes vary; a length proxy is enough to detect in-place edits.
131
- textLen += b.arguments === undefined ? 0 : JSON.stringify(b.arguments).length;
132
- }
133
- }
134
- }
135
- } else if (role === "toolResult" || role === "hookMessage") {
136
- const content = (msg as { content?: unknown }).content;
137
- if (typeof content === "string") {
138
- textLen += content.length;
139
- } else if (Array.isArray(content)) {
140
- blocks = content.length;
141
- for (const block of content) {
142
- if (!block || typeof block !== "object") continue;
143
- const b = block as { type?: string; text?: string };
144
- if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
145
- else if (b.type === "image") images++;
146
- }
147
- }
148
- } else if (role === "branchSummary" || role === "compactionSummary") {
149
- const s = (msg as { summary?: unknown }).summary;
150
- if (typeof s === "string") textLen += s.length;
151
- }
152
- return `${role}:${ts}:${textLen}:${blocks}:${images}`;
153
- }
154
-
155
- /**
156
- * Token count for a single message, using the per-message sidecar cache.
157
- * The caller MUST skip caching for the last message during streaming —
158
- * it may still be growing and its tokens belong recomputed each refresh.
159
- */
160
- function tokensForMessage(msg: AgentMessage): number {
161
- const fp = messageFingerprint(msg);
162
- const tagged = msg as TaggedMessage;
163
- const cached = tagged[kTokenCache];
164
- if (cached && cached.fingerprint === fp) return cached.tokens;
165
- const tokens = estimateMessageTokensHeuristic(msg);
166
- tagged[kTokenCache] = { fingerprint: fp, tokens };
167
- return tokens;
168
- }
169
-
170
- // ═══════════════════════════════════════════════════════════════════════════
171
71
  // StatusLineComponent
172
72
  // ═══════════════════════════════════════════════════════════════════════════
173
73
 
@@ -217,20 +117,6 @@ export class StatusLineComponent implements Component {
217
117
  #cachedUsage: SegmentContext["usage"] = null;
218
118
  #usageFetchedAt = 0;
219
119
  #usageInFlight = false;
220
- // Context breakdown — incremental cache. Replaces the previous 2-second
221
- // TTL design (which re-walked every message on each refresh and produced
222
- // ~1.1 s sync freezes on 2,000+ message sessions because `updateEditorTopBorder`
223
- // is called on every agent event in event-controller). The new scheme
224
- // caches by message-object identity (a Symbol-keyed sidecar on each
225
- // message) plus a cheap content fingerprint, so in-place mutations of
226
- // an existing message (post-hoc error attachment, retry-truncated
227
- // branch rebuild, replaceMessages with the same length) are detected
228
- // and recomputed.
229
- // Cached non-message total (system prompt + tools + skills). Invalidated
230
- // when the inputs-identity fingerprint changes (model swap, skill toggle,
231
- // tool registration).
232
- #nonMessageTokensCache: number | undefined;
233
- #nonMessageInputsKey: string | undefined;
234
120
 
235
121
  constructor(
236
122
  private readonly session: AgentSession,
@@ -618,57 +504,6 @@ export class StatusLineComponent implements Component {
618
504
  return windows.length > 0 ? { windows } : null;
619
505
  }
620
506
 
621
- /**
622
- * Compute the (cached) used-tokens / context-window totals for the
623
- * status-line context% segment. Exposed (non-private) so unit tests can
624
- * verify the incremental-cache invariants; not part of any external
625
- * API.
626
- */
627
- getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
628
- const messages = this.session.messages ?? [];
629
- const contextWindow = this.session.model?.contextWindow ?? 0;
630
-
631
- // 1) Non-message tokens (system prompt + tools + skills). Refresh only
632
- // when the inputs identity fingerprint changes — usually never
633
- // during a streaming turn. ~10-30 ms when it does refresh.
634
- const inputsKey = this.#computeNonMessageInputsKey();
635
- if (this.#nonMessageTokensCache === undefined || this.#nonMessageInputsKey !== inputsKey) {
636
- this.#nonMessageTokensCache = computeNonMessageTokens(this.session);
637
- this.#nonMessageInputsKey = inputsKey;
638
- }
639
-
640
- // 2) Message tokens — incremental. The sidecar cache lives on the
641
- // message object itself (Symbol-keyed), keyed by identity and
642
- // validated by a cheap content fingerprint. Mutations that
643
- // replace messages (replaceMessages, branch rebuild, compaction)
644
- // yield fresh objects → cache miss → recompute. In-place
645
- // mutations on the same object are caught by fingerprint
646
- // mismatch. The LAST message is always recomputed because it
647
- // may still be growing during streaming.
648
- let messagesTokens = 0;
649
- const lastIdx = messages.length - 1;
650
- for (let i = 0; i < messages.length; i++) {
651
- messagesTokens += i === lastIdx ? estimateMessageTokensHeuristic(messages[i]) : tokensForMessage(messages[i]);
652
- }
653
-
654
- const usedTokens = this.#nonMessageTokensCache + messagesTokens;
655
- return { usedTokens, contextWindow };
656
- }
657
-
658
- /**
659
- * Build an identity fingerprint for the non-message inputs (system prompt,
660
- * tools, skills). When this changes, the non-message token cache must be
661
- * recomputed. Cheap: just lengths + first-string-length. Doesn't need to
662
- * be cryptographically unique — only stable for the same inputs.
663
- */
664
- #computeNonMessageInputsKey(): string {
665
- const sp = this.session.systemPrompt ?? [];
666
- const tools = this.session.agent?.state?.tools ?? [];
667
- const skills = this.session.skills ?? [];
668
- const modelId = this.session.model?.id ?? "";
669
- return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
670
- }
671
-
672
507
  #buildSegmentContext(
673
508
  width: number,
674
509
  effectiveSettings: Required<
@@ -695,11 +530,9 @@ export class StatusLineComponent implements Component {
695
530
  tokensPerSecond: this.#getTokensPerSecond(),
696
531
  };
697
532
 
698
- // Context usage — aligned with /context command so both surfaces report the same value
699
- const breakdown = this.getCachedContextBreakdown();
700
- const contextTokens = breakdown.usedTokens;
701
- const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
702
- const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
533
+ const contextUsage = this.session.getContextUsage?.();
534
+ const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
535
+ const contextPercent = contextUsage?.percent ?? null;
703
536
  // Suppress the inline model percentage when a standalone context_pct
704
537
  // segment is also rendered, so the value is not shown twice.
705
538
  const contextPctSegmentActive =
@@ -578,7 +578,7 @@ export class ExtensionUiController {
578
578
  shutdown: () => {
579
579
  // Signal shutdown request
580
580
  },
581
- getSystemPrompt: () => this.ctx.session.systemPrompt,
581
+ getSystemPrompt: () => [...this.ctx.session.systemPrompt],
582
582
  });
583
583
  } catch (err) {
584
584
  this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
@@ -21,6 +21,7 @@ import {
21
21
  getRenderCacheRetainedBytes,
22
22
  Loader,
23
23
  Markdown,
24
+ onImageProtocolChanged,
24
25
  ProcessTerminal,
25
26
  Spacer,
26
27
  Text,
@@ -387,6 +388,7 @@ export class InteractiveMode implements InteractiveModeContext {
387
388
  #baseReservedSlashCommandNames: Set<string> = new Set();
388
389
  #cleanupUnsubscribe?: () => void;
389
390
  #subprocessTeardownUnsubscribe?: () => void;
391
+ #petProtocolUnsubscribe?: () => void;
390
392
  readonly #version: string;
391
393
  readonly #changelogMarkdown: string | undefined;
392
394
  #planModePreviousTools: string[] | undefined;
@@ -617,6 +619,18 @@ export class InteractiveMode implements InteractiveModeContext {
617
619
  this.ui.setFocus(this.editor);
618
620
  this.petWidget = this.#createPetWidget(this.editor);
619
621
  this.petWidget.setMode(settings.get("pet.mode"));
622
+ // The async sixel capability probe can enable graphics after the saved
623
+ // pet mode was applied and dropped (no protocol yet at startup).
624
+ // Re-apply the configured mode when capability arrives so the pet
625
+ // appears without the user re-running /pet.
626
+ this.#petProtocolUnsubscribe?.();
627
+ this.#petProtocolUnsubscribe = onImageProtocolChanged(protocol => {
628
+ if (!protocol) return;
629
+ const saved = settings.get("pet.mode");
630
+ if (saved !== "off" && this.petWidget && this.petWidget.mode === "off") {
631
+ this.petWidget.setMode(saved);
632
+ }
633
+ });
620
634
 
621
635
  this.#inputController.setupKeyHandlers();
622
636
  this.#inputController.setupEditorSubmitHandler();
@@ -2074,6 +2088,8 @@ export class InteractiveMode implements InteractiveModeContext {
2074
2088
  }
2075
2089
 
2076
2090
  stop(): void {
2091
+ this.#petProtocolUnsubscribe?.();
2092
+ this.#petProtocolUnsubscribe = undefined;
2077
2093
  this.petWidget?.dispose();
2078
2094
  this.petWidget = undefined;
2079
2095
  if (this.loadingAnimation) {