@oh-my-pi/pi-coding-agent 15.10.11 → 15.10.12

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 (121) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli.js +5349 -5328
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli-commands.d.ts +12 -0
  5. package/dist/types/commands/launch.d.ts +4 -0
  6. package/dist/types/config/api-key-resolver.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +18 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  12. package/dist/types/eval/py/executor.d.ts +5 -0
  13. package/dist/types/eval/py/kernel.d.ts +6 -1
  14. package/dist/types/eval/py/runtime.d.ts +9 -0
  15. package/dist/types/exec/bash-executor.d.ts +2 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/memory-backend/index.d.ts +1 -0
  19. package/dist/types/memory-backend/runtime.d.ts +4 -0
  20. package/dist/types/memory-backend/types.d.ts +66 -1
  21. package/dist/types/modes/index.d.ts +3 -3
  22. package/dist/types/modes/interactive-mode.d.ts +7 -2
  23. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  24. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  26. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  27. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  28. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  29. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  30. package/dist/types/modes/types.d.ts +2 -0
  31. package/dist/types/secrets/index.d.ts +1 -1
  32. package/dist/types/secrets/obfuscator.d.ts +8 -2
  33. package/dist/types/session/agent-session.d.ts +14 -2
  34. package/dist/types/session/streaming-output.d.ts +23 -0
  35. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  36. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  37. package/dist/types/slash-commands/types.d.ts +1 -1
  38. package/dist/types/system-prompt.d.ts +2 -0
  39. package/dist/types/task/executor.d.ts +1 -0
  40. package/dist/types/task/index.d.ts +2 -2
  41. package/dist/types/task/types.d.ts +8 -0
  42. package/dist/types/thinking.d.ts +4 -0
  43. package/dist/types/tiny/title-client.d.ts +11 -0
  44. package/dist/types/tiny/title-protocol.d.ts +1 -0
  45. package/dist/types/tools/index.d.ts +6 -0
  46. package/dist/types/utils/git.d.ts +15 -2
  47. package/dist/types/utils/title-generator.d.ts +3 -2
  48. package/package.json +10 -10
  49. package/src/auto-thinking/classifier.ts +1 -0
  50. package/src/cli/args.ts +3 -0
  51. package/src/cli-commands.ts +29 -0
  52. package/src/cli.ts +8 -9
  53. package/src/commands/launch.ts +4 -0
  54. package/src/commit/model-selection.ts +3 -2
  55. package/src/config/api-key-resolver.ts +8 -6
  56. package/src/config/model-registry.ts +97 -30
  57. package/src/config/model-resolver.ts +60 -0
  58. package/src/config/settings-schema.ts +43 -15
  59. package/src/config/settings.ts +61 -3
  60. package/src/edit/hashline/execute.ts +39 -2
  61. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  62. package/src/eval/completion-bridge.ts +1 -0
  63. package/src/eval/py/executor.ts +29 -7
  64. package/src/eval/py/index.ts +6 -1
  65. package/src/eval/py/kernel.ts +31 -11
  66. package/src/eval/py/runtime.ts +37 -0
  67. package/src/exec/bash-executor.ts +82 -3
  68. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  69. package/src/extensibility/extensions/runner.ts +6 -1
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/hindsight/bank.ts +17 -2
  72. package/src/internal-urls/docs-index.generated.ts +3 -3
  73. package/src/main.ts +18 -6
  74. package/src/memories/index.ts +2 -0
  75. package/src/memory-backend/index.ts +1 -0
  76. package/src/memory-backend/local-backend.ts +9 -0
  77. package/src/memory-backend/off-backend.ts +9 -0
  78. package/src/memory-backend/runtime.ts +66 -0
  79. package/src/memory-backend/types.ts +81 -1
  80. package/src/mnemopi/backend.ts +151 -4
  81. package/src/modes/acp/acp-agent.ts +119 -11
  82. package/src/modes/components/assistant-message.ts +19 -21
  83. package/src/modes/components/footer.ts +3 -1
  84. package/src/modes/components/status-line/component.ts +118 -34
  85. package/src/modes/controllers/command-controller.ts +1 -1
  86. package/src/modes/controllers/input-controller.ts +1 -0
  87. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  88. package/src/modes/index.ts +3 -21
  89. package/src/modes/interactive-mode.ts +39 -9
  90. package/src/modes/oauth-manual-input.ts +30 -3
  91. package/src/modes/rpc/rpc-client.ts +154 -3
  92. package/src/modes/rpc/rpc-mode.ts +97 -12
  93. package/src/modes/rpc/rpc-subagents.ts +265 -0
  94. package/src/modes/rpc/rpc-types.ts +81 -1
  95. package/src/modes/setup-wizard/index.ts +12 -2
  96. package/src/modes/setup-wizard/lazy.ts +16 -0
  97. package/src/modes/types.ts +2 -0
  98. package/src/sdk.ts +8 -1
  99. package/src/secrets/index.ts +8 -1
  100. package/src/secrets/obfuscator.ts +39 -18
  101. package/src/session/agent-session.ts +179 -54
  102. package/src/session/streaming-output.ts +166 -10
  103. package/src/slash-commands/acp-builtins.ts +24 -0
  104. package/src/slash-commands/builtin-registry.ts +20 -0
  105. package/src/slash-commands/types.ts +1 -1
  106. package/src/system-prompt.ts +14 -0
  107. package/src/task/executor.ts +13 -12
  108. package/src/task/index.ts +9 -8
  109. package/src/task/render.ts +18 -3
  110. package/src/task/types.ts +9 -0
  111. package/src/thinking.ts +7 -0
  112. package/src/tiny/title-client.ts +34 -5
  113. package/src/tiny/title-protocol.ts +1 -1
  114. package/src/tiny/worker.ts +6 -4
  115. package/src/tools/bash.ts +46 -5
  116. package/src/tools/image-gen.ts +11 -4
  117. package/src/tools/index.ts +13 -1
  118. package/src/tools/inspect-image.ts +1 -0
  119. package/src/utils/commit-message-generator.ts +1 -0
  120. package/src/utils/git.ts +267 -13
  121. package/src/utils/title-generator.ts +24 -5
@@ -71,7 +71,12 @@ import {
71
71
  type SessionInfo as StoredSessionInfo,
72
72
  type UsageStatistics,
73
73
  } from "../../session/session-manager";
74
- import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
74
+ import {
75
+ ACP_BUILTIN_RESERVED_NAMES,
76
+ ACP_BUILTIN_SLASH_COMMANDS,
77
+ executeAcpBuiltinSlashCommand,
78
+ isAcpBuiltinShadowedName,
79
+ } from "../../slash-commands/acp-builtins";
75
80
  import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
76
81
  import { normalizeLocalScheme } from "../../tools/path-utils";
77
82
  import { runResolveInvocation } from "../../tools/resolve";
@@ -117,6 +122,7 @@ type PromptQueueState = {
117
122
  promise: Promise<void>;
118
123
  release: (() => void) | undefined;
119
124
  };
125
+ type PromptLifecycleError = Error & { readonly code: "ACP_SESSION_CLOSED" };
120
126
 
121
127
  type PromptTurnState = {
122
128
  userMessageId: string;
@@ -158,6 +164,9 @@ type ManagedSessionRecord = {
158
164
  // Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released
159
165
  // in `#disposeSessionRecord`. Lives independent of any prompt turn.
160
166
  lifetimeUnsubscribe: (() => void) | undefined;
167
+ closedError: PromptLifecycleError | undefined;
168
+ promptEventHandlers: Set<Promise<void>>;
169
+ extensionUserMessageTasks: Set<Promise<void>>;
161
170
  };
162
171
 
163
172
  type ReplayableMessage = {
@@ -594,7 +603,23 @@ export class AcpAgent implements Agent {
594
603
  const record = this.#getSessionRecord(params.sessionId);
595
604
  const activeTurn = record.promptTurn;
596
605
  if (activeTurn && !activeTurn.settled && record.session.isStreaming) {
597
- throw new Error("ACP prompt already in progress for this session");
606
+ // New prompt arrived while the previous turn is still in-flight (e.g. the
607
+ // client sent a message immediately after pressing stop, before or without
608
+ // a preceding session/cancel notification). Implicitly cancel the running
609
+ // turn so the new prompt can queue behind the abort cleanup — identical to
610
+ // what cancel() does when called explicitly. #beginCancelCleanup is
611
+ // idempotent, so a concurrent session/cancel notification is harmless.
612
+ // Mirror cancel()'s timeout handling: if abort() hangs past the cleanup
613
+ // timeout, close the managed session instead of leaving it registered
614
+ // with a still-streaming AgentSession. The queued prompt below observes
615
+ // the same cleanup rejection and fails accordingly.
616
+ this.#beginCancelCleanup(record, activeTurn).catch(async (error: unknown) => {
617
+ logger.warn("ACP cancel cleanup timed out; closing session", {
618
+ sessionId: record.session.sessionId,
619
+ error,
620
+ });
621
+ await this.#closeManagedSession(params.sessionId, record);
622
+ });
598
623
  }
599
624
  return await this.#queuePrompt(record, async () => {
600
625
  const previousTurn = record.promptTurn;
@@ -607,6 +632,7 @@ export class AcpAgent implements Agent {
607
632
  await previousTurn.promise.catch(() => undefined);
608
633
  await previousTurn.cleanup;
609
634
  }
635
+ this.#throwIfRecordClosed(record);
610
636
 
611
637
  const converted = this.#convertPromptBlocks(params.prompt);
612
638
  const pendingPrompt = Promise.withResolvers<PromptResponse>();
@@ -623,7 +649,7 @@ export class AcpAgent implements Agent {
623
649
  };
624
650
 
625
651
  record.promptTurn.unsubscribe = record.session.subscribe(event => {
626
- void this.#handlePromptEvent(record, event);
652
+ this.#trackPromptEvent(record, event);
627
653
  });
628
654
 
629
655
  this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => {
@@ -643,6 +669,7 @@ export class AcpAgent implements Agent {
643
669
  release: releaseQueue,
644
670
  };
645
671
  await previousQueue.promise;
672
+ this.#throwIfRecordClosed(record);
646
673
  try {
647
674
  return await run();
648
675
  } finally {
@@ -653,6 +680,55 @@ export class AcpAgent implements Agent {
653
680
  }
654
681
  }
655
682
 
683
+ #throwIfRecordClosed(record: ManagedSessionRecord): void {
684
+ if (record.closedError) {
685
+ throw record.closedError;
686
+ }
687
+ }
688
+
689
+ #createPromptLifecycleError(message: string): PromptLifecycleError {
690
+ return Object.assign(new Error(message), { code: "ACP_SESSION_CLOSED" as const });
691
+ }
692
+
693
+ #trackPromptEvent(record: ManagedSessionRecord, event: AgentSessionEvent): void {
694
+ const handling = this.#handlePromptEvent(record, event).catch((error: unknown) => {
695
+ logger.warn("ACP prompt event handler failed", { error });
696
+ });
697
+ record.promptEventHandlers.add(handling);
698
+ void handling.finally(() => {
699
+ record.promptEventHandlers.delete(handling);
700
+ });
701
+ }
702
+
703
+ async #waitForPromptEventHandlers(record: ManagedSessionRecord): Promise<void> {
704
+ while (record.promptEventHandlers.size > 0) {
705
+ await Promise.allSettled(Array.from(record.promptEventHandlers));
706
+ }
707
+ }
708
+
709
+ #trackExtensionUserMessage(record: ManagedSessionRecord, task: Promise<void>): void {
710
+ const tracked = task.catch((error: unknown) => {
711
+ logger.warn("ACP extension sendUserMessage failed", { error });
712
+ });
713
+ record.extensionUserMessageTasks.add(tracked);
714
+ void tracked.finally(() => {
715
+ record.extensionUserMessageTasks.delete(tracked);
716
+ });
717
+ }
718
+
719
+ async #waitForExtensionUserMessages(
720
+ record: ManagedSessionRecord,
721
+ baseline: ReadonlySet<Promise<void>>,
722
+ ): Promise<void> {
723
+ while (true) {
724
+ const pending = Array.from(record.extensionUserMessageTasks).filter(task => !baseline.has(task));
725
+ if (pending.length === 0) {
726
+ return;
727
+ }
728
+ await Promise.allSettled(pending);
729
+ }
730
+ }
731
+
656
732
  async #runPromptOrCommand(record: ManagedSessionRecord, text: string, images: AgentImageContent[]): Promise<void> {
657
733
  const skillResult = await this.#tryRunSkillCommand(record, text);
658
734
  if (skillResult) {
@@ -699,7 +775,18 @@ export class AcpAgent implements Agent {
699
775
  return;
700
776
  }
701
777
 
702
- await record.session.prompt(text, { images });
778
+ const extensionPromptBaseline = new Set(record.extensionUserMessageTasks);
779
+ const agentInvoked = await record.session.prompt(text, { images });
780
+ // Extension and custom-TS commands are handled locally inside session.prompt().
781
+ // An ACP extension command can still call pi.sendUserMessage(), which starts
782
+ // an async nested prompt through the extension runtime. Keep the ACP turn
783
+ // subscribed until those scheduled prompts and their event handlers drain;
784
+ // only then is `false` proof that the slash command was purely local.
785
+ if (!agentInvoked) {
786
+ await this.#waitForExtensionUserMessages(record, extensionPromptBaseline);
787
+ await this.#waitForPromptEventHandlers(record);
788
+ this.#finishPrompt(record, { stopReason: "end_turn" });
789
+ }
703
790
  }
704
791
 
705
792
  async #tryRunSkillCommand(record: ManagedSessionRecord, text: string): Promise<boolean> {
@@ -991,6 +1078,9 @@ export class AcpAgent implements Agent {
991
1078
  liveMessageProgress: undefined,
992
1079
  toolArgsById: new Map(),
993
1080
  extensionsConfigured: false,
1081
+ closedError: undefined,
1082
+ promptEventHandlers: new Set(),
1083
+ extensionUserMessageTasks: new Set(),
994
1084
  lifetimeUnsubscribe: undefined,
995
1085
  };
996
1086
  }
@@ -1582,10 +1672,12 @@ export class AcpAgent implements Agent {
1582
1672
  commands.push(command);
1583
1673
  };
1584
1674
 
1585
- // Advertise in the order dispatch resolves them: ACP builtins first
1586
- // (so core commands like `/model`, `/mcp`, `/todo` cannot be shadowed),
1587
- // then skills, then custom/user commands, then file-based slash
1588
- // commands. `appendCommand` dedupes by name so earlier entries win.
1675
+ // Advertise in the order dispatch resolves them (mirrors AgentSession
1676
+ // dispatch: builtins skills extensions custom TS → file-based).
1677
+ // `appendCommand` dedupes by name so earlier entries win; extension
1678
+ // commands therefore correctly shadow custom TS commands of the same
1679
+ // name, matching the runtime behaviour of #tryExecuteExtensionCommand
1680
+ // running before #tryExecuteCustomCommand.
1589
1681
  for (const command of ACP_BUILTIN_SLASH_COMMANDS) {
1590
1682
  appendCommand(command);
1591
1683
  }
@@ -1600,6 +1692,20 @@ export class AcpAgent implements Agent {
1600
1692
  }
1601
1693
  }
1602
1694
 
1695
+ for (const command of session.extensionRunner?.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES) ?? []) {
1696
+ // Reserved-set filtering in getRegisteredCommands only covers exact
1697
+ // names; colon-namespaced names whose prefix is a builtin (e.g.
1698
+ // `model:foo`) would still dispatch to the builtin in ACP.
1699
+ if (isAcpBuiltinShadowedName(command.name)) {
1700
+ continue;
1701
+ }
1702
+ appendCommand({
1703
+ name: command.name,
1704
+ description: command.description ?? "(extension command)",
1705
+ input: { hint: "arguments" },
1706
+ });
1707
+ }
1708
+
1603
1709
  for (const command of session.customCommands) {
1604
1710
  appendCommand({
1605
1711
  name: command.command.name,
@@ -2069,9 +2175,7 @@ export class AcpAgent implements Agent {
2069
2175
  });
2070
2176
  },
2071
2177
  sendUserMessage: (content, options) => {
2072
- record.session.sendUserMessage(content, options).catch((error: unknown) => {
2073
- logger.warn("ACP extension sendUserMessage failed", { error });
2074
- });
2178
+ this.#trackExtensionUserMessage(record, record.session.sendUserMessage(content, options));
2075
2179
  },
2076
2180
  appendEntry: (customType, data) => {
2077
2181
  record.session.sessionManager.appendCustomEntry(customType, data);
@@ -2224,6 +2328,7 @@ export class AcpAgent implements Agent {
2224
2328
  }
2225
2329
 
2226
2330
  async #closeManagedSession(sessionId: string, record: ManagedSessionRecord): Promise<void> {
2331
+ record.closedError ??= this.#createPromptLifecycleError("ACP session closed before queued prompt could run");
2227
2332
  this.#sessions.delete(sessionId);
2228
2333
  await this.#cancelPromptForClose(record);
2229
2334
  await this.#disposeSessionRecord(record);
@@ -2279,6 +2384,9 @@ export class AcpAgent implements Agent {
2279
2384
  await Promise.all(
2280
2385
  records.map(async ([sessionId, record]) => {
2281
2386
  try {
2387
+ record.closedError ??= this.#createPromptLifecycleError(
2388
+ "ACP agent disposed before queued prompt could run",
2389
+ );
2282
2390
  await this.#cancelPromptForClose(record);
2283
2391
  await this.#disposeSessionRecord(record);
2284
2392
  } catch (error) {
@@ -222,7 +222,9 @@ export class AssistantMessageComponent extends Container {
222
222
  this.#contentContainer.clear();
223
223
 
224
224
  const hasVisibleContent = message.content.some(
225
- c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
225
+ c =>
226
+ (c.type === "text" && c.text.trim()) ||
227
+ (!this.hideThinkingBlock && c.type === "thinking" && c.thinking.trim()),
226
228
  );
227
229
 
228
230
  // Render content in order
@@ -236,32 +238,28 @@ export class AssistantMessageComponent extends Container {
236
238
  markdown.transientRenderCache = this.#lastUpdateTransient;
237
239
  this.#contentContainer.addChild(markdown);
238
240
  } else if (content.type === "thinking" && content.thinking.trim()) {
241
+ if (this.hideThinkingBlock) {
242
+ thinkingIndex += 1;
243
+ continue;
244
+ }
239
245
  // Add spacing only when another visible assistant content block follows.
240
246
  // This avoids a superfluous blank line before separately-rendered tool execution blocks.
241
247
  const hasVisibleContentAfter = message.content
242
248
  .slice(i + 1)
243
249
  .some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
244
250
 
245
- if (this.hideThinkingBlock) {
246
- // Show static "Thinking..." label when hidden
247
- this.#contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
248
- if (hasVisibleContentAfter) {
249
- this.#contentContainer.addChild(new Spacer(1));
250
- }
251
- } else {
252
- const thinkingText = content.thinking.trim();
253
- // Thinking traces in thinkingText color, italic
254
- const thinkingMarkdown = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
255
- color: (text: string) => theme.fg("thinkingText", text),
256
- italic: true,
257
- });
258
- thinkingMarkdown.transientRenderCache = this.#lastUpdateTransient;
259
- this.#contentContainer.addChild(thinkingMarkdown);
260
- this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
261
- thinkingIndex += 1;
262
- if (hasVisibleContentAfter) {
263
- this.#contentContainer.addChild(new Spacer(1));
264
- }
251
+ const thinkingText = content.thinking.trim();
252
+ // Thinking traces in thinkingText color, italic
253
+ const thinkingMarkdown = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
254
+ color: (text: string) => theme.fg("thinkingText", text),
255
+ italic: true,
256
+ });
257
+ thinkingMarkdown.transientRenderCache = this.#lastUpdateTransient;
258
+ this.#contentContainer.addChild(thinkingMarkdown);
259
+ this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
260
+ thinkingIndex += 1;
261
+ if (hasVisibleContentAfter) {
262
+ this.#contentContainer.addChild(new Spacer(1));
265
263
  }
266
264
  }
267
265
  }
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import { stripVTControlCharacters } from "node:util";
3
4
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
5
  import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
@@ -65,7 +66,8 @@ export class FooterComponent implements Component {
65
66
  }
66
67
 
67
68
  try {
68
- this.#gitWatcher = fs.watch(head.headPath, () => {
69
+ const watchPath = head.isReftable ? path.join(head.gitDir, "reftable") : head.headPath;
70
+ this.#gitWatcher = fs.watch(watchPath, () => {
69
71
  this.#cachedBranch = undefined; // Invalidate cache
70
72
  if (this.#onBranchChange) {
71
73
  this.#onBranchChange();
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
4
  import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
4
5
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
@@ -120,6 +121,18 @@ function tokensForMessage(msg: AgentMessage): number {
120
121
  return tokens;
121
122
  }
122
123
 
124
+ interface MessageTokenTotalsCache {
125
+ messagesRef: readonly AgentMessage[];
126
+ stableCount: number;
127
+ stableTokens: number;
128
+ lastStableMessage: AgentMessage | undefined;
129
+ lastStableFingerprint: string | undefined;
130
+ }
131
+
132
+ function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
133
+ return segments.includes("context_pct") || segments.includes("context_total");
134
+ }
135
+
123
136
  // ═══════════════════════════════════════════════════════════════════════════
124
137
  // StatusLineComponent
125
138
  // ═══════════════════════════════════════════════════════════════════════════
@@ -129,6 +142,7 @@ export class StatusLineComponent implements Component {
129
142
  #effectiveSettings: EffectiveStatusLineSettings | undefined;
130
143
  #cachedBranch: string | null | undefined = undefined;
131
144
  #cachedBranchRepoId: string | null | undefined = undefined;
145
+ #cachedBranchCwd: string | undefined = undefined;
132
146
  #gitWatcher: fs.FSWatcher | null = null;
133
147
  #onBranchChange: (() => void) | null = null;
134
148
  #autoCompactEnabled: boolean = true;
@@ -159,20 +173,19 @@ export class StatusLineComponent implements Component {
159
173
  } | null = null;
160
174
  #usageFetchedAt = 0;
161
175
  #usageInFlight = false;
162
- // Context breakdown — incremental cache. Replaces the previous 2-second
163
- // TTL design (which re-walked every message on each refresh and produced
164
- // ~1.1 s sync freezes on 2,000+ message sessions because `updateEditorTopBorder`
165
- // is called on every agent event in event-controller). The new scheme
166
- // caches by message-object identity (a Symbol-keyed sidecar on each
167
- // message) plus a cheap content fingerprint, so in-place mutations of
168
- // an existing message (post-hoc error attachment, retry-truncated
169
- // branch rebuild, replaceMessages with the same length) are detected
170
- // and recomputed.
176
+ // Context breakdown — incremental rolling cache. The status line refreshes
177
+ // on every agent event, so the hot path must not re-tokenize the full
178
+ // message list. Stable messages are accumulated once; normal streaming
179
+ // refreshes only recompute the current tail message and newly appended
180
+ // entries. History rewrites/compaction replace or shrink the message array
181
+ // and rebuild this cache. Stable messages are treated as immutable after
182
+ // promotion, matching the normal append-only session flow.
171
183
  // Cached non-message total (system prompt + tools + skills). Invalidated
172
184
  // when the inputs-identity fingerprint changes (model swap, skill toggle,
173
185
  // tool registration).
174
186
  #nonMessageTokensCache: number | undefined;
175
187
  #nonMessageInputsKey: string | undefined;
188
+ #messageTokenTotalsCache: MessageTokenTotalsCache | undefined;
176
189
 
177
190
  constructor(private readonly session: AgentSession) {
178
191
  this.#settings = {
@@ -238,11 +251,15 @@ export class StatusLineComponent implements Component {
238
251
  this.#gitWatcher = null;
239
252
  }
240
253
 
241
- const gitHeadPath = git.repo.resolveSync(getProjectDir())?.headPath ?? null;
242
- if (!gitHeadPath) return;
254
+ const repository = git.repo.resolveSync(getProjectDir());
255
+ if (!repository) return;
256
+
257
+ const watchPath = git.repo.isReftableSync(repository)
258
+ ? path.join(repository.gitDir, "reftable")
259
+ : repository.headPath;
243
260
 
244
261
  try {
245
- this.#gitWatcher = fs.watch(gitHeadPath, () => {
262
+ this.#gitWatcher = fs.watch(watchPath, () => {
246
263
  this.#invalidateGitCaches();
247
264
  if (this.#onBranchChange) {
248
265
  this.#onBranchChange();
@@ -267,15 +284,18 @@ export class StatusLineComponent implements Component {
267
284
  #invalidateGitCaches(): void {
268
285
  this.#cachedBranch = undefined;
269
286
  this.#cachedBranchRepoId = undefined;
287
+ this.#cachedBranchCwd = undefined;
270
288
  this.#cachedPrContext = undefined;
271
289
  }
272
290
  #getCurrentBranch(): string | null {
273
- const head = git.head.resolveSync(getProjectDir());
274
- const gitHeadPath = head?.headPath ?? null;
275
- if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
291
+ const cwd = getProjectDir();
292
+ if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === cwd) {
276
293
  return this.#cachedBranch;
277
294
  }
278
295
 
296
+ const head = git.head.resolveSync(cwd);
297
+ const gitHeadPath = head?.headPath ?? null;
298
+ this.#cachedBranchCwd = cwd;
279
299
  this.#cachedBranchRepoId = gitHeadPath;
280
300
  if (!head) {
281
301
  this.#cachedBranch = null;
@@ -503,24 +523,79 @@ export class StatusLineComponent implements Component {
503
523
  this.#nonMessageInputsKey = inputsKey;
504
524
  }
505
525
 
506
- // 2) Message tokens — incremental. The sidecar cache lives on the
507
- // message object itself (Symbol-keyed), keyed by identity and
508
- // validated by a cheap content fingerprint. Mutations that
509
- // replace messages (replaceMessages, branch rebuild, compaction)
510
- // yield fresh objects cache miss recompute. In-place
511
- // mutations on the same object are caught by fingerprint
512
- // mismatch. The LAST message is always recomputed because it
513
- // may still be growing during streaming.
514
- let messagesTokens = 0;
515
- const lastIdx = messages.length - 1;
516
- for (let i = 0; i < messages.length; i++) {
517
- messagesTokens += i === lastIdx ? estimateTokens(messages[i]) : tokensForMessage(messages[i]);
518
- }
526
+ // 2) Message tokens — incremental rolling total. The sidecar cache lives
527
+ // on each stable message object (all but the current tail). Normal
528
+ // streaming turns only recompute the last message and newly appended
529
+ // entries. Full rebuild only when the message array is replaced,
530
+ // shrinks, or the recently-promoted stable tail mutates in place.
531
+ const messagesTokens = this.#getCachedMessageTokens(messages);
519
532
 
520
533
  const usedTokens = this.#nonMessageTokensCache + messagesTokens;
521
534
  return { usedTokens, contextWindow };
522
535
  }
523
536
 
537
+ #getCachedMessageTokens(messages: readonly AgentMessage[]): number {
538
+ const cache = this.#messageTokenTotalsCache;
539
+ if (!cache || cache.messagesRef !== messages || messages.length <= cache.stableCount) {
540
+ return this.#rebuildMessageTokenTotals(messages);
541
+ }
542
+
543
+ let stableTokens = cache.stableTokens;
544
+ let stableCount = cache.stableCount;
545
+ const stableLimit = Math.max(0, messages.length - 1);
546
+
547
+ if (
548
+ cache.lastStableMessage &&
549
+ stableCount > 0 &&
550
+ messages[stableCount - 1] === cache.lastStableMessage &&
551
+ cache.lastStableFingerprint !== undefined &&
552
+ cache.lastStableFingerprint !== messageFingerprint(cache.lastStableMessage)
553
+ ) {
554
+ return this.#rebuildMessageTokenTotals(messages);
555
+ }
556
+
557
+ while (stableCount < stableLimit) {
558
+ const promoted = messages[stableCount]!;
559
+ stableTokens += tokensForMessage(promoted);
560
+ stableCount++;
561
+ }
562
+
563
+ const lastStableMessage = stableCount > 0 ? messages[stableCount - 1] : undefined;
564
+ const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
565
+ const lastMessage = messages.at(-1);
566
+ const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
567
+ this.#messageTokenTotalsCache = {
568
+ messagesRef: messages,
569
+ stableCount,
570
+ stableTokens,
571
+ lastStableMessage,
572
+ lastStableFingerprint,
573
+ };
574
+ return stableTokens + lastTokens;
575
+ }
576
+
577
+ #rebuildMessageTokenTotals(messages: readonly AgentMessage[]): number {
578
+ let stableTokens = 0;
579
+ const stableLimit = Math.max(0, messages.length - 1);
580
+ for (let i = 0; i < stableLimit; i++) {
581
+ stableTokens += tokensForMessage(messages[i]!);
582
+ }
583
+
584
+ const lastStableMessage = stableLimit > 0 ? messages[stableLimit - 1] : undefined;
585
+ const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
586
+ const lastMessage = messages.at(-1);
587
+ const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
588
+
589
+ this.#messageTokenTotalsCache = {
590
+ messagesRef: messages,
591
+ stableCount: stableLimit,
592
+ stableTokens,
593
+ lastStableMessage,
594
+ lastStableFingerprint,
595
+ };
596
+ return stableTokens + lastTokens;
597
+ }
598
+
524
599
  /**
525
600
  * Build an identity fingerprint for the non-message inputs (system prompt,
526
601
  * tools, skills). When this changes, the non-message token cache must be
@@ -535,7 +610,11 @@ export class StatusLineComponent implements Component {
535
610
  return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
536
611
  }
537
612
 
538
- #buildSegmentContext(width: number, segmentOptions: StatusLineSettings["segmentOptions"]): SegmentContext {
613
+ #buildSegmentContext(
614
+ width: number,
615
+ segmentOptions: StatusLineSettings["segmentOptions"],
616
+ includeContext: boolean,
617
+ ): SegmentContext {
539
618
  const state = this.session.state;
540
619
 
541
620
  // Trigger background fetch (5-min TTL); render uses cached value
@@ -555,10 +634,13 @@ export class StatusLineComponent implements Component {
555
634
  tokensPerSecond: this.#getTokensPerSecond(),
556
635
  };
557
636
 
558
- // Context usage — aligned with /context command so both surfaces report the same value
559
- const breakdown = this.getCachedContextBreakdown();
560
- const contextTokens = breakdown.usedTokens;
561
- const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
637
+ let contextTokens = 0;
638
+ let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
639
+ if (includeContext) {
640
+ const breakdown = this.getCachedContextBreakdown();
641
+ contextTokens = breakdown.usedTokens;
642
+ contextWindow = breakdown.contextWindow || contextWindow;
643
+ }
562
644
  const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
563
645
 
564
646
  return {
@@ -626,7 +708,9 @@ export class StatusLineComponent implements Component {
626
708
 
627
709
  #buildStatusLine(width: number): string {
628
710
  const effectiveSettings = this.#resolveSettings();
629
- const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions);
711
+ const includeContext =
712
+ hasContextSegment(effectiveSettings.leftSegments) || hasContextSegment(effectiveSettings.rightSegments);
713
+ const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions, includeContext);
630
714
  const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
631
715
 
632
716
  const bgAnsi = theme.getBgAnsi("statusLineBg");
@@ -934,7 +934,7 @@ export class CommandController {
934
934
  this.ctx.bashComponent.appendOutput(chunk);
935
935
  }
936
936
  },
937
- { excludeFromContext },
937
+ { excludeFromContext, useUserShell: true },
938
938
  );
939
939
 
940
940
  if (this.ctx.bashComponent) {
@@ -467,6 +467,7 @@ export class InputController {
467
467
  this.ctx.session.sessionId,
468
468
  this.ctx.session.model,
469
469
  provider => this.ctx.session.agent.metadataForProvider(provider),
470
+ this.ctx.titleSystemPrompt,
470
471
  )
471
472
  .then(async title => {
472
473
  // Re-check: a concurrent attempt for an earlier message may have
@@ -46,9 +46,14 @@ import { theme } from "../theme/theme";
46
46
  import type { InteractiveModeContext } from "../types";
47
47
  import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
48
48
 
49
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
49
+ const MCP_MANUAL_INPUT_PROVIDER_ID = "mcp";
50
+ const MCP_MANUAL_LOGIN_TIP = "Headless? Paste the redirect URL or code with /login <value>.";
51
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string, onTimeout?: () => void): Promise<T> {
50
52
  const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
51
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
53
+ const timer = setTimeout(() => {
54
+ onTimeout?.();
55
+ reject(new Error(message));
56
+ }, timeoutMs);
52
57
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
53
58
  }
54
59
 
@@ -591,6 +596,15 @@ export class MCPCommandController {
591
596
  const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined;
592
597
  const resolvedClientSecret = clientSecret.trim() || undefined;
593
598
 
599
+ const manualInput = this.ctx.oauthManualInput;
600
+ if (manualInput.hasPending()) {
601
+ const pendingProvider = manualInput.pendingProviderId ?? "another provider";
602
+ throw new Error(
603
+ `OAuth login already in progress for ${pendingProvider}. Complete or cancel it before starting MCP OAuth.`,
604
+ );
605
+ }
606
+ let manualInputClaim: { promise: Promise<string>; clear: (reason?: string) => void } | undefined;
607
+ const oauthTimeout = new AbortController();
594
608
  try {
595
609
  // Create OAuth flow
596
610
  const flow = new MCPOAuthFlow(
@@ -620,6 +634,7 @@ export class MCPCommandController {
620
634
  0,
621
635
  ),
622
636
  );
637
+ block.addChild(new Text(theme.fg("muted", MCP_MANUAL_LOGIN_TIP), 1, 0));
623
638
  block.addChild(new Spacer(1));
624
639
  block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
625
640
  // Try to open browser automatically
@@ -644,11 +659,29 @@ export class MCPCommandController {
644
659
  onProgress: (message: string) => {
645
660
  this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
646
661
  },
662
+ onManualCodeInput: () => {
663
+ if (manualInputClaim) return manualInputClaim.promise;
664
+ const pendingInput = manualInput.tryClaimInput(MCP_MANUAL_INPUT_PROVIDER_ID);
665
+ if (!pendingInput) {
666
+ const pendingProvider = manualInput.pendingProviderId ?? "another provider";
667
+ throw new Error(
668
+ `OAuth login already in progress for ${pendingProvider}. Complete or cancel it before starting MCP OAuth.`,
669
+ );
670
+ }
671
+ manualInputClaim = pendingInput;
672
+ return pendingInput.promise;
673
+ },
674
+ signal: oauthTimeout.signal,
647
675
  },
648
676
  );
649
677
 
650
678
  // Execute OAuth flow with 5 minute timeout
651
- const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes");
679
+ const credentials = await withTimeout(
680
+ flow.login(),
681
+ 5 * 60 * 1000,
682
+ "OAuth flow timed out after 5 minutes",
683
+ () => oauthTimeout.abort("MCP OAuth flow timed out"),
684
+ );
652
685
 
653
686
  this.ctx.present([
654
687
  new Spacer(1),
@@ -687,6 +720,8 @@ export class MCPCommandController {
687
720
  } else {
688
721
  throw new Error(`OAuth authentication failed: ${errorMsg}`);
689
722
  }
723
+ } finally {
724
+ manualInputClaim?.clear("Manual MCP OAuth input cleared");
690
725
  }
691
726
  }
692
727