@oh-my-pi/pi-coding-agent 14.5.9 → 14.5.11

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 (37) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -15
  3. package/scripts/build-binary.ts +1 -1
  4. package/src/cli/update-cli.ts +25 -1
  5. package/src/config/model-registry.ts +21 -19
  6. package/src/config/settings-schema.ts +11 -16
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.ts +50 -19
  9. package/src/edit/modes/hashline.ts +171 -110
  10. package/src/export/html/template.generated.ts +1 -1
  11. package/src/export/html/template.js +14 -2
  12. package/src/extensibility/extensions/runner.ts +34 -1
  13. package/src/extensibility/extensions/types.ts +8 -0
  14. package/src/internal-urls/docs-index.generated.ts +54 -54
  15. package/src/lsp/client.ts +27 -35
  16. package/src/memories/index.ts +5 -0
  17. package/src/modes/components/settings-defs.ts +1 -1
  18. package/src/modes/controllers/selector-controller.ts +2 -2
  19. package/src/modes/controllers/todo-command-controller.ts +22 -74
  20. package/src/modes/interactive-mode.ts +36 -9
  21. package/src/modes/theme/theme.ts +10 -1
  22. package/src/modes/types.ts +1 -3
  23. package/src/modes/utils/ui-helpers.ts +19 -6
  24. package/src/prompts/system/auto-continue.md +1 -0
  25. package/src/prompts/system/eager-todo.md +1 -1
  26. package/src/prompts/tools/github.md +3 -3
  27. package/src/prompts/tools/todo-write.md +19 -19
  28. package/src/sdk.ts +13 -2
  29. package/src/session/agent-session.ts +196 -96
  30. package/src/session/session-manager.ts +19 -2
  31. package/src/tools/bash.ts +9 -4
  32. package/src/tools/gh.ts +267 -119
  33. package/src/tools/todo-write.ts +157 -195
  34. package/src/utils/git.ts +61 -2
  35. package/src/web/search/providers/searxng.ts +71 -13
  36. package/examples/custom-tools/todo/index.ts +0 -211
  37. package/examples/extensions/todo.ts +0 -295
package/src/lsp/client.ts CHANGED
@@ -47,7 +47,7 @@ function startIdleChecker(): void {
47
47
  const now = Date.now();
48
48
  for (const [key, client] of Array.from(clients.entries())) {
49
49
  if (now - client.lastActivity > idleTimeoutMs) {
50
- shutdownClient(key);
50
+ void shutdownClient(key);
51
51
  }
52
52
  }
53
53
  }, IDLE_CHECK_INTERVAL_MS);
@@ -762,22 +762,25 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
762
762
  /**
763
763
  * Shutdown a specific client by key.
764
764
  */
765
- export function shutdownClient(key: string): void {
766
- const client = clients.get(key);
767
- if (!client) return;
768
-
769
- // Reject all pending requests
765
+ async function shutdownClientInstance(client: LspClient): Promise<void> {
766
+ const err = new Error("LSP client shutdown");
770
767
  for (const pending of Array.from(client.pendingRequests.values())) {
771
- pending.reject(new Error("LSP client shutdown"));
768
+ pending.reject(err);
772
769
  }
773
770
  client.pendingRequests.clear();
774
771
 
775
- // Send shutdown request (best effort, don't wait)
776
- sendRequest(client, "shutdown", null).catch(() => {});
777
-
778
- // Kill process
772
+ const timeout = Bun.sleep(5_000);
773
+ const shutdown = sendRequest(client, "shutdown", null).catch(() => {});
774
+ await Promise.race([shutdown, timeout]);
779
775
  client.proc.kill();
776
+ await Promise.race([client.proc.exited.catch(() => {}), Bun.sleep(1_000)]);
777
+ }
778
+
779
+ export async function shutdownClient(key: string): Promise<void> {
780
+ const client = clients.get(key);
781
+ if (!client) return;
780
782
  clients.delete(key);
783
+ await shutdownClientInstance(client);
781
784
  }
782
785
 
783
786
  // =============================================================================
@@ -890,27 +893,10 @@ export async function sendNotification(client: LspClient, method: string, params
890
893
  /**
891
894
  * Shutdown all LSP clients.
892
895
  */
893
- export function shutdownAll(): void {
896
+ export async function shutdownAll(): Promise<void> {
894
897
  const clientsToShutdown = Array.from(clients.values());
895
898
  clients.clear();
896
-
897
- const err = new Error("LSP client shutdown");
898
- for (const client of clientsToShutdown) {
899
- /// Reject all pending requests
900
- const reqs = Array.from(client.pendingRequests.values());
901
- client.pendingRequests.clear();
902
- for (const pending of reqs) {
903
- pending.reject(err);
904
- }
905
-
906
- void (async () => {
907
- // Send shutdown request (best effort, don't wait)
908
- const timeout = Bun.sleep(5_000);
909
- const result = sendRequest(client, "shutdown", null).catch(() => {});
910
- await Promise.race([result, timeout]);
911
- client.proc.kill();
912
- })().catch(() => {});
913
- }
899
+ await Promise.allSettled(clientsToShutdown.map(client => shutdownClientInstance(client)));
914
900
  }
915
901
 
916
902
  /** Status of an LSP server */
@@ -938,13 +924,19 @@ export function getActiveClients(): LspServerStatus[] {
938
924
 
939
925
  // Register cleanup on module unload
940
926
  if (typeof process !== "undefined") {
941
- process.on("beforeExit", shutdownAll);
927
+ process.on("beforeExit", () => {
928
+ void shutdownAll();
929
+ });
942
930
  process.on("SIGINT", () => {
943
- shutdownAll();
944
- process.exit(0);
931
+ void (async () => {
932
+ await shutdownAll();
933
+ process.exit(0);
934
+ })();
945
935
  });
946
936
  process.on("SIGTERM", () => {
947
- shutdownAll();
948
- process.exit(0);
937
+ void (async () => {
938
+ await shutdownAll();
939
+ process.exit(0);
940
+ })();
949
941
  });
950
942
  }
@@ -277,6 +277,11 @@ async function runPhase1(options: {
277
277
  });
278
278
 
279
279
  if (result.kind === "failed") {
280
+ logger.error("Memory phase1 stage1 job failed", {
281
+ threadId: claim.threadId,
282
+ rolloutPath: claim.rolloutPath,
283
+ reason: result.reason,
284
+ });
280
285
  markStage1Failed(db, {
281
286
  threadId: claim.threadId,
282
287
  ownershipToken: claim.ownershipToken,
@@ -348,7 +348,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
348
348
  { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
349
349
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
350
350
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
351
- { value: "searxng", label: "SearXNG", description: "Self-hosted metasearch; set searxng.endpoint" },
351
+ { value: "searxng", label: "SearXNG", description: "Requires searxng.endpoint" },
352
352
  ],
353
353
  "providers.image": [
354
354
  {
@@ -659,9 +659,9 @@ export class SelectorController {
659
659
  return;
660
660
  }
661
661
 
662
- // Update UI
662
+ // Update UI — pass the context built by navigateTree to skip a second O(N) walk.
663
663
  this.ctx.chatContainer.clear();
664
- this.ctx.renderInitialMessages();
664
+ this.ctx.renderInitialMessages(result.sessionContext);
665
665
  await this.ctx.reloadTodos();
666
666
  if (result.editorText && !this.ctx.editor.getText().trim()) {
667
667
  this.ctx.editor.setText(result.editorText);
@@ -21,7 +21,7 @@ const USAGE = [
21
21
  " /todo export <path> Write todos as Markdown to <path>",
22
22
  " /todo import <path> Replace todos from Markdown at <path>",
23
23
  " /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created",
24
- " /todo start <task> Mark task in_progress (id or fuzzy content)",
24
+ " /todo start <task> Mark task in_progress (fuzzy content match)",
25
25
  " /todo done [<task|phase>] Mark task/phase/all completed",
26
26
  " /todo drop [<task|phase>] Mark task/phase/all abandoned",
27
27
  " /todo rm [<task|phase>] Remove task/phase/all",
@@ -59,44 +59,9 @@ function tokenize(input: string): string[] {
59
59
  }
60
60
 
61
61
  // =============================================================================
62
- // Roman numerals + name normalization
62
+ // Name normalization
63
63
  // =============================================================================
64
64
 
65
- const ROMAN_PAIRS: Array<[number, string]> = [
66
- [1000, "M"],
67
- [900, "CM"],
68
- [500, "D"],
69
- [400, "CD"],
70
- [100, "C"],
71
- [90, "XC"],
72
- [50, "L"],
73
- [40, "XL"],
74
- [10, "X"],
75
- [9, "IX"],
76
- [5, "V"],
77
- [4, "IV"],
78
- [1, "I"],
79
- ];
80
-
81
- function toRoman(n: number): string {
82
- if (n <= 0) return "I";
83
- let out = "";
84
- let rem = n;
85
- for (const [value, sym] of ROMAN_PAIRS) {
86
- while (rem >= value) {
87
- out += sym;
88
- rem -= value;
89
- }
90
- }
91
- return out;
92
- }
93
-
94
- const PHASE_PREFIX_RE = /^([IVXLCDM]+|[A-Z]|\d+)\.\s*/i;
95
-
96
- function stripPrefix(name: string): string {
97
- return name.replace(PHASE_PREFIX_RE, "").trim();
98
- }
99
-
100
65
  function titleCase(s: string): string {
101
66
  return s
102
67
  .split(/\s+/)
@@ -105,13 +70,6 @@ function titleCase(s: string): string {
105
70
  .join(" ");
106
71
  }
107
72
 
108
- function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
109
- const stripped = stripPrefix(rawName.trim());
110
- if (!stripped) return `${toRoman(existingPhases.length + 1)}. Todos`;
111
- const titled = titleCase(stripped);
112
- return `${toRoman(existingPhases.length + 1)}. ${titled}`;
113
- }
114
-
115
73
  // =============================================================================
116
74
  // Fuzzy matching
117
75
  // =============================================================================
@@ -119,20 +77,13 @@ function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
119
77
  function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined {
120
78
  const q = query.trim().toLowerCase();
121
79
  if (!q) return undefined;
122
- // Exact id
123
- const byId = phases.find(p => p.id.toLowerCase() === q);
124
- if (byId) return byId;
125
80
  // Exact name (case-insensitive)
126
81
  const byName = phases.find(p => p.name.toLowerCase() === q);
127
82
  if (byName) return byName;
128
- // Stripped name match
129
- const strippedQ = stripPrefix(q);
130
- const byStripped = phases.find(p => stripPrefix(p.name).toLowerCase() === strippedQ);
131
- if (byStripped) return byStripped;
132
- // Substring (prefer prefix match on stripped name)
133
- const prefixMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().startsWith(strippedQ));
83
+ // Substring (prefer prefix match)
84
+ const prefixMatches = phases.filter(p => p.name.toLowerCase().startsWith(q));
134
85
  if (prefixMatches.length === 1) return prefixMatches[0];
135
- const subMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().includes(strippedQ));
86
+ const subMatches = phases.filter(p => p.name.toLowerCase().includes(q));
136
87
  if (subMatches.length === 1) return subMatches[0];
137
88
  return undefined;
138
89
  }
@@ -140,9 +91,10 @@ function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefin
140
91
  function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; phase: TodoPhase } | undefined {
141
92
  const q = query.trim().toLowerCase();
142
93
  if (!q) return undefined;
94
+ // Exact content (case-insensitive)
143
95
  for (const phase of phases) {
144
96
  for (const task of phase.tasks) {
145
- if (task.id.toLowerCase() === q) return { task, phase };
97
+ if (task.content.toLowerCase() === q) return { task, phase };
146
98
  }
147
99
  }
148
100
  const matches: Array<{ task: TodoItem; phase: TodoPhase }> = [];
@@ -169,7 +121,7 @@ function buildSystemReminder(action: string, phases: TodoPhase[]): string {
169
121
  return [
170
122
  "<system-reminder>",
171
123
  `The user manually modified the todo list (${action}).`,
172
- "Current todo list (note task ids may have been reassigned by /todo edit):",
124
+ "Current todo list:",
173
125
  "",
174
126
  md,
175
127
  "</system-reminder>",
@@ -327,28 +279,24 @@ export class TodoCommandController {
327
279
  if (phaseName) {
328
280
  targetPhase = findPhaseFuzzy(next, phaseName);
329
281
  if (!targetPhase) {
330
- const newName = buildPhaseName(phaseName, next);
331
- targetPhase = { id: `phase-${next.length + 1}`, name: newName, tasks: [] };
282
+ targetPhase = { name: titleCase(phaseName), tasks: [] };
332
283
  next.push(targetPhase);
333
284
  }
334
285
  } else if (next.length > 0) {
335
286
  targetPhase = next[next.length - 1];
336
287
  } else {
337
- targetPhase = { id: "phase-1", name: `${toRoman(1)}. Todos`, tasks: [] };
288
+ targetPhase = { name: "Todos", tasks: [] };
338
289
  next.push(targetPhase);
339
290
  }
340
291
 
341
- const usedTaskIds = new Set(next.flatMap(p => p.tasks.map(t => t.id)));
342
- let n = 1;
343
- while (usedTaskIds.has(`task-${n}`)) n++;
292
+ const finalContent = titleCaseSentence(content);
344
293
  targetPhase.tasks.push({
345
- id: `task-${n}`,
346
- content: titleCaseSentence(content),
294
+ content: finalContent,
347
295
  status: "pending",
348
296
  });
349
297
 
350
298
  this.#commit(next, `/todo append → ${targetPhase.name}`);
351
- this.ctx.showStatus(`Appended to ${targetPhase.name}: ${content}`);
299
+ this.ctx.showStatus(`Appended to ${targetPhase.name}: ${finalContent}`);
352
300
  }
353
301
 
354
302
  // ------------------------------------------------------------- start / done / drop / rm
@@ -364,12 +312,12 @@ export class TodoCommandController {
364
312
  this.ctx.showError(`No task matched "${rest}". Use /todo to list current tasks.`);
365
313
  return;
366
314
  }
367
- const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.id }]);
315
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]);
368
316
  if (errors.length > 0) {
369
317
  this.ctx.showError(errors.join("; "));
370
318
  return;
371
319
  }
372
- this.#commit(phases, `/todo start ${hit.task.id}`);
320
+ this.#commit(phases, `/todo start ${hit.task.content}`);
373
321
  this.ctx.showStatus(`Started: ${hit.task.content}`);
374
322
  }
375
323
 
@@ -391,19 +339,19 @@ export class TodoCommandController {
391
339
 
392
340
  const taskHit = findTaskFuzzy(current, trimmed);
393
341
  if (taskHit) {
394
- const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.id }]);
342
+ const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.content }]);
395
343
  if (errors.length > 0) {
396
344
  this.ctx.showError(errors.join("; "));
397
345
  return;
398
346
  }
399
- this.#commit(phases, `/todo ${op} ${taskHit.task.id}`);
347
+ this.#commit(phases, `/todo ${op} ${taskHit.task.content}`);
400
348
  this.ctx.showStatus(`Marked ${target}: ${taskHit.task.content}`);
401
349
  return;
402
350
  }
403
351
 
404
352
  const phaseHit = findPhaseFuzzy(current, trimmed);
405
353
  if (phaseHit) {
406
- const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.id }]);
354
+ const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.name }]);
407
355
  if (errors.length > 0) {
408
356
  this.ctx.showError(errors.join("; "));
409
357
  return;
@@ -426,18 +374,18 @@ export class TodoCommandController {
426
374
  }
427
375
  const taskHit = findTaskFuzzy(current, trimmed);
428
376
  if (taskHit) {
429
- const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.id }]);
377
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.content }]);
430
378
  if (errors.length > 0) {
431
379
  this.ctx.showError(errors.join("; "));
432
380
  return;
433
381
  }
434
- this.#commit(phases, `/todo rm ${taskHit.task.id}`);
382
+ this.#commit(phases, `/todo rm ${taskHit.task.content}`);
435
383
  this.ctx.showStatus(`Removed: ${taskHit.task.content}`);
436
384
  return;
437
385
  }
438
386
  const phaseHit = findPhaseFuzzy(current, trimmed);
439
387
  if (phaseHit) {
440
- const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.id }]);
388
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.name }]);
441
389
  if (errors.length > 0) {
442
390
  this.ctx.showError(errors.join("; "));
443
391
  return;
@@ -460,7 +408,7 @@ export class TodoCommandController {
460
408
 
461
409
  const current = this.#currentPhases();
462
410
  const initialMarkdown =
463
- current.length > 0 ? phasesToMarkdown(current) : "# I. Todos\n- [ ] (replace this with your tasks)\n";
411
+ current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] (replace this with your tasks)\n";
464
412
 
465
413
  const fileHandle = await this.#openTtyHandle();
466
414
  this.ctx.ui.stop();
@@ -14,7 +14,17 @@ import {
14
14
  type UsageReport,
15
15
  } from "@oh-my-pi/pi-ai";
16
16
  import type { Component, SlashCommand } from "@oh-my-pi/pi-tui";
17
- import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI, visibleWidth } from "@oh-my-pi/pi-tui";
17
+ import {
18
+ Container,
19
+ clearRenderCache,
20
+ Loader,
21
+ Markdown,
22
+ ProcessTerminal,
23
+ Spacer,
24
+ Text,
25
+ TUI,
26
+ visibleWidth,
27
+ } from "@oh-my-pi/pi-tui";
18
28
  import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
19
29
  import chalk from "chalk";
20
30
  import { KeybindingsManager } from "../config/keybindings";
@@ -38,6 +48,7 @@ import { getRecentSessions } from "../session/session-manager";
38
48
  import { STTController, type SttState } from "../stt";
39
49
  import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
40
50
  import { normalizeLocalScheme } from "../tools/path-utils";
51
+ import { formatPhaseDisplayName } from "../tools/todo-write";
41
52
  import type { EventBus } from "../utils/event-bus";
42
53
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
43
54
  import { getSessionAccentAnsi, getSessionAccentHexForTitle } from "../utils/session-color";
@@ -442,6 +453,7 @@ export class InteractiveMode implements InteractiveModeContext {
442
453
 
443
454
  // Set up theme file watcher
444
455
  onThemeChange(() => {
456
+ clearRenderCache();
445
457
  this.ui.invalidate();
446
458
  this.updateEditorBorderColor();
447
459
  this.ui.requestRender();
@@ -696,9 +708,12 @@ export class InteractiveMode implements InteractiveModeContext {
696
708
  const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
697
709
 
698
710
  if (!this.todoExpanded) {
699
- const activePhase = this.#getActivePhase(phases);
711
+ const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]);
712
+ const activePhase = phases[activeIdx];
700
713
  if (!activePhase) return;
701
- lines.push(`${indent}${theme.fg("accent", `${hook} ${activePhase.name}`)}`);
714
+ lines.push(
715
+ `${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(activePhase.name, activeIdx + 1)}`)}`,
716
+ );
702
717
  const visibleTasks = activePhase.tasks.slice(0, 5);
703
718
  visibleTasks.forEach((todo, index) => {
704
719
  const prefix = `${indent}${index === 0 ? hook : " "} `;
@@ -712,13 +727,13 @@ export class InteractiveMode implements InteractiveModeContext {
712
727
  return;
713
728
  }
714
729
 
715
- for (const phase of phases) {
716
- lines.push(`${indent}${theme.fg("accent", `${hook} ${phase.name}`)}`);
730
+ phases.forEach((phase, phaseIndex) => {
731
+ lines.push(`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(phase.name, phaseIndex + 1)}`)}`);
717
732
  phase.tasks.forEach((todo, index) => {
718
733
  const prefix = `${indent}${index === 0 ? hook : " "} `;
719
734
  lines.push(this.#formatTodoLine(todo, prefix));
720
735
  });
721
- }
736
+ });
722
737
 
723
738
  this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
724
739
  }
@@ -867,6 +882,19 @@ export class InteractiveMode implements InteractiveModeContext {
867
882
  } else {
868
883
  await this.session.setModelTemporary(prev.model, prev.thinkingLevel);
869
884
  }
885
+ // If #applyPlanModeModel queued a deferred switch to the plan-role model
886
+ // (because the session was streaming on entry), drop it now: we are
887
+ // leaving plan mode, so flushing it on the next agent_end would land the
888
+ // session on the plan-role model after the user has exited plan mode
889
+ // (issue #816). Only clear when the pending target matches the plan-role
890
+ // model — leave any unrelated user-queued switch intact.
891
+ const pending = this.#pendingModelSwitch;
892
+ if (pending) {
893
+ const planResolution = this.session.resolveRoleModelWithThinking("plan");
894
+ if (planResolution.model && modelsAreEqual(pending.model, planResolution.model)) {
895
+ this.#pendingModelSwitch = undefined;
896
+ }
897
+ }
870
898
  }
871
899
  this.session.setPlanModeState(undefined);
872
900
  this.planModeEnabled = false;
@@ -1334,8 +1362,8 @@ export class InteractiveMode implements InteractiveModeContext {
1334
1362
  this.#uiHelpers.renderSessionContext(sessionContext, options);
1335
1363
  }
1336
1364
 
1337
- renderInitialMessages(): void {
1338
- this.#uiHelpers.renderInitialMessages();
1365
+ renderInitialMessages(prebuiltContext?: SessionContext): void {
1366
+ this.#uiHelpers.renderInitialMessages(prebuiltContext);
1339
1367
  }
1340
1368
 
1341
1369
  getUserMessageText(message: Message): string {
@@ -1688,7 +1716,6 @@ export class InteractiveMode implements InteractiveModeContext {
1688
1716
  } else {
1689
1717
  this.todoPhases = [
1690
1718
  {
1691
- id: "default",
1692
1719
  name: "Todos",
1693
1720
  tasks: todos as TodoItem[],
1694
1721
  },
@@ -2328,8 +2328,14 @@ export function getSymbolTheme(): SymbolTheme {
2328
2328
  };
2329
2329
  }
2330
2330
 
2331
+ let _markdownTheme: MarkdownTheme | undefined;
2332
+ let _markdownThemeRef: Theme | undefined;
2333
+
2331
2334
  export function getMarkdownTheme(): MarkdownTheme {
2332
- return {
2335
+ if (_markdownTheme !== undefined && _markdownThemeRef === theme) {
2336
+ return _markdownTheme;
2337
+ }
2338
+ const markdownTheme: MarkdownTheme = {
2333
2339
  heading: (text: string) => theme.fg("mdHeading", text),
2334
2340
  link: (text: string) => theme.fg("mdLink", text),
2335
2341
  linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
@@ -2355,6 +2361,9 @@ export function getMarkdownTheme(): MarkdownTheme {
2355
2361
  }
2356
2362
  },
2357
2363
  };
2364
+ _markdownTheme = markdownTheme;
2365
+ _markdownThemeRef = theme;
2366
+ return markdownTheme;
2358
2367
  }
2359
2368
 
2360
2369
  export function getSelectListTheme(): SelectListTheme {
@@ -42,7 +42,6 @@ export type SubmittedUserInput = {
42
42
  export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
43
43
 
44
44
  export type TodoItem = {
45
- id: string;
46
45
  content: string;
47
46
  status: TodoStatus;
48
47
  details?: string;
@@ -50,7 +49,6 @@ export type TodoItem = {
50
49
  };
51
50
 
52
51
  export type TodoPhase = {
53
- id: string;
54
52
  name: string;
55
53
  tasks: TodoItem[];
56
54
  };
@@ -159,7 +157,7 @@ export interface InteractiveModeContext {
159
157
  sessionContext: SessionContext,
160
158
  options?: { updateFooter?: boolean; populateHistory?: boolean },
161
159
  ): void;
162
- renderInitialMessages(): void;
160
+ renderInitialMessages(prebuiltContext?: SessionContext): void;
163
161
  getUserMessageText(message: Message): string;
164
162
  findLastAssistantMessage(): AssistantMessage | undefined;
165
163
  extractAssistantText(message: AssistantMessage): string;
@@ -414,7 +414,7 @@ export class UiHelpers {
414
414
  this.ctx.ui.requestRender();
415
415
  }
416
416
 
417
- renderInitialMessages(): void {
417
+ renderInitialMessages(prebuiltContext?: SessionContext): void {
418
418
  // This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
419
419
  // Clear existing rendered chat first to avoid duplicating the full session in the container.
420
420
  this.ctx.chatContainer.clear();
@@ -422,8 +422,8 @@ export class UiHelpers {
422
422
  this.ctx.pendingBashComponents = [];
423
423
  this.ctx.pendingPythonComponents = [];
424
424
 
425
- // Get aligned messages and entries from session context
426
- const context = this.ctx.sessionManager.buildSessionContext();
425
+ // Reuse a pre-built context when available (e.g. from navigateTree) to avoid a second O(N) walk.
426
+ const context = prebuiltContext ?? this.ctx.sessionManager.buildSessionContext();
427
427
  this.ctx.renderSessionContext(context, {
428
428
  updateFooter: true,
429
429
  populateHistory: true,
@@ -610,9 +610,22 @@ export class UiHelpers {
610
610
  await this.ctx.session.prompt(message.text);
611
611
  }
612
612
 
613
- const promptPromise = this.ctx.session.prompt(firstPrompt.text).catch((error: unknown) => {
614
- restoreQueue(error);
615
- });
613
+ // Pass streamingBehavior so that if the session is still streaming when
614
+ // compaction-end fires (race window between isStreaming flipping false and
615
+ // the event landing here), prompt() routes the message into the steer/
616
+ // follow-up queue instead of throwing AgentBusyError. When the session is
617
+ // genuinely idle, streamingBehavior is ignored and a fresh prompt runs as
618
+ // before. This keeps the steer preview honest: if delivery has to be
619
+ // deferred, the message lands in the same queue every other consumer
620
+ // (Alt+Up dequeue, post-stream drain) already drains, instead of being
621
+ // stranded in compactionQueuedMessages with no drainer.
622
+ const promptPromise = this.ctx.session
623
+ .prompt(firstPrompt.text, {
624
+ streamingBehavior: firstPrompt.mode === "followUp" ? "followUp" : "steer",
625
+ })
626
+ .catch((error: unknown) => {
627
+ restoreQueue(error);
628
+ });
616
629
 
617
630
  for (const message of rest) {
618
631
  if (this.ctx.isKnownSlashCommand(message.text)) {
@@ -0,0 +1 @@
1
+ Resume work on the user's most recent intent. Re-read the kept recent messages above the summary to confirm what the user asked for last; if their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.
@@ -2,7 +2,7 @@
2
2
  Before doing substantive work on the upcoming user request, create a comprehensive phased todo first.
3
3
 
4
4
  You **MUST** call `todo_write` first in this turn.
5
- You **MUST** initialize the todo list with a single `replace` op.
5
+ You **MUST** initialize the todo list with a single `init` op.
6
6
  You **MUST** cover the entire request from investigation through implementation and verification — not just the next immediate step.
7
7
  You **MUST** make task descriptions specific enough that a future turn can execute them without re-planning.
8
8
  You **MUST** keep task `content` to a short label (5-10 words). Put file paths, implementation steps, and specifics in `details`.
@@ -4,9 +4,9 @@ GitHub CLI tool with a single op-based dispatch. Wraps `gh` for repository, issu
4
4
  Pick the operation via `op`. Each op uses a subset of the parameters:
5
5
  - `repo_view` — Read repository metadata. Optional `repo` (owner/repo) and `branch`. Falls back to the current checkout or default `gh` repo.
6
6
  - `issue_view` — Read an issue. Required `issue` (number or URL). Optional `repo`. Set `comments: false` to skip discussion.
7
- - `pr_view` — Read a pull request, including reviews and inline review comments. Optional `pr` (number, URL, or branch); omitting it targets the current branch's PR. Optional `repo`. Set `comments: false` for a lighter summary.
8
- - `pr_diff` — Read a pull request diff. Optional `pr`, `repo`. Set `nameOnly: true` for changed file names. Use `exclude` to drop generated paths from the diff.
9
- - `pr_checkout` — Check a pull request out into a dedicated git worktree. Optional `pr`, `repo`, `branch` (local), `worktree` (path), `force` (reset existing local branch).
7
+ - `pr_view` — Read one or more pull requests, including reviews and inline review comments. Optional `pr` (number, URL, branch, or array of any — pass an array to fetch multiple PRs in one call); omitting it targets the current branch's PR. Optional `repo`. Set `comments: false` for a lighter summary.
8
+ - `pr_diff` — Read one or more pull request diffs. Optional `pr` (single identifier or array for batch). Optional `repo`. Set `nameOnly: true` for changed file names. Use `exclude` to drop generated paths from the diff.
9
+ - `pr_checkout` — Check one or more pull requests out into dedicated git worktrees. Optional `pr` (number, URL, branch, or array of any of those — pass an array to batch-check-out multiple PRs in one call), `repo`, `force` (reset existing local branch).
10
10
  - `pr_push` — Push a checked-out PR branch back to its source branch. Requires the branch to have been checked out via `op: pr_checkout` (carries push metadata). Optional `branch`; defaults to the current checked-out git branch. Optional `forceWithLease`.
11
11
  - `search_issues` — Search issues using normal GitHub issue search syntax. Required `query`. Optional `repo`, `limit`.
12
12
  - `search_prs` — Search pull requests using normal GitHub PR search syntax. Required `query`. Optional `repo`, `limit`.
@@ -5,23 +5,23 @@ The next pending task is auto-promoted to `in_progress` after each completion.
5
5
 
6
6
  |`op`|Required fields|Effect|
7
7
  |---|---|---|
8
- |`replace`|`phases`|Replace the full list (initial setup, full restructure)|
9
- |`start`|`task`|Set task to `in_progress`|
10
- |`done`|`task` or `phase` (or neither = all)|Mark completed|
11
- |`drop`|`task` or `phase` (or neither = all)|Mark abandoned|
12
- |`rm`|`task` or `phase` (or neither = all)|Remove|
13
- |`append`|`phase`, `items: {id, label}[]`|Append tasks; creates phase if missing|
14
- |`note`|`task`, `text`|Append a note to `task.notes`. Only use to leave reminders for future-you.|
8
+ |`init`|`list`|Initialize the full list|
9
+ |`start`|`task`|Mark in progress|
10
+ |`done`|`task` or `phase`|Mark completed|
11
+ |`drop`|`task` or `phase`|Mark abandoned|
12
+ |`rm`|`task` or `phase`|Remove|
13
+ |`append`|`phase`, `items: string[]`|Append tasks; lazily creates phase|
14
+ |`note`|`task`, `text`|Append a note to a task. Reminders for future-you only.|
15
15
 
16
16
  ## Anatomy
17
- - **Task `label`**: 5–10 words, what is being done, not how.
18
- - **Phase `name`**: short noun phrase prefixed with a roman numeral — `I. Foundation`, `II. Auth`, `III. Verification`. Single-phase plans still use `I.`. Never use snake_case, arabic numerals, or letter prefixes.
17
+ - **Task content**: 5–10 words, what is being done, not how. Used as the task identifier — unique.
18
+ - **Phase name**: short noun phrase (e.g. `Foundation`, `Auth`, `Verification`). Used as the phase identifier unique. Do not add prefixes like `1.`, `A)`, `Phase 1:`, etc.
19
19
 
20
20
  ## Rules
21
- - Mark tasks done immediately after finishing — never defer.
21
+ - Mark tasks done immediately after finishing.
22
22
  - Complete phases in order.
23
- - On blockers, `append` a new task to the active phase.
24
- - Keep ids stable once introduced.
23
+ - On blockers, `append` a new task to the active phase to unblock yourself, or `drop`.
24
+ - `task` and `phase` fields reference content/name verbatim; keep them stable once introduced.
25
25
 
26
26
  ## When to create a list
27
27
  - Task requires 3+ distinct steps
@@ -31,17 +31,17 @@ The next pending task is auto-promoted to `in_progress` after each completion.
31
31
 
32
32
  <examples>
33
33
  # Initial setup (multi-phase)
34
- `{"ops":[{"op":"replace","phases":[{"name":"I. Foundation","tasks":[{"content":"Scaffold crate"},{"content":"Wire workspace"}]},{"name":"II. Auth","tasks":[{"content":"Port credential store"},{"content":"Wire OAuth providers"}]},{"name":"III. Verification","tasks":[{"content":"Run cargo test"}]}]}]}`
35
- # Initial setup (single phase — still prefixed)
36
- `{"ops":[{"op":"replace","phases":[{"name":"I. Implementation","tasks":[{"content":"Apply fix"},{"content":"Run tests"}]}]}]}`
34
+ `{"ops":[{"op":"init","list":[{"phase":"Foundation","items":["Scaffold crate","Wire workspace"]},{"phase":"Auth","items":["Port credential store","Wire OAuth providers"]},{"phase":"Verification","items":["Run cargo test"]}]}]}`
35
+ # Initial setup (single phase)
36
+ `{"ops":[{"op":"init","list":[{"phase":"Implementation","items":["Apply fix","Run tests"]}]}]}`
37
37
  # Complete one task
38
- `{"ops":[{"op":"done","task":"task-2"}]}`
38
+ `{"ops":[{"op":"done","task":"Wire workspace"}]}`
39
39
  # Complete a whole phase
40
- `{"ops":[{"op":"done","phase":"II. Auth"}]}`
40
+ `{"ops":[{"op":"done","phase":"Auth"}]}`
41
41
  # Remove all tasks
42
42
  `{"ops":[{"op":"rm"}]}`
43
43
  # Drop one task
44
- `{"ops":[{"op":"drop","task":"task-7"}]}`
44
+ `{"ops":[{"op":"drop","task":"Run cargo test"}]}`
45
45
  # Append tasks to a phase
46
- `{"ops":[{"op":"append","phase":"II. Auth","items":[{"id":"task-8","label":"Handle retries"},{"id":"task-9","label":"Run tests"}]}]}`
46
+ `{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}`
47
47
  </examples>