@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.0

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 (95) hide show
  1. package/CHANGELOG.md +146 -198
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  12. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  13. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  14. package/dist/types/main.d.ts +5 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  17. package/dist/types/modes/components/session-selector.d.ts +32 -5
  18. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/interactive-mode.d.ts +9 -2
  22. package/dist/types/modes/types.d.ts +4 -2
  23. package/dist/types/registry/agent-registry.d.ts +1 -1
  24. package/dist/types/sdk.d.ts +2 -2
  25. package/dist/types/session/agent-session.d.ts +4 -2
  26. package/dist/types/session/history-storage.d.ts +16 -1
  27. package/dist/types/session/session-manager.d.ts +4 -0
  28. package/dist/types/task/output-manager.d.ts +6 -15
  29. package/dist/types/tools/find.d.ts +0 -9
  30. package/dist/types/tools/index.d.ts +1 -1
  31. package/dist/types/tools/path-utils.d.ts +16 -0
  32. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  33. package/dist/types/utils/clipboard.d.ts +4 -0
  34. package/dist/types/web/kagi.d.ts +76 -0
  35. package/dist/types/web/search/providers/exa.d.ts +7 -1
  36. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  37. package/package.json +9 -9
  38. package/src/async/job-manager.ts +3 -3
  39. package/src/cli/args.ts +6 -2
  40. package/src/cli/claude-trace-cli.ts +783 -0
  41. package/src/cli/session-picker.ts +36 -10
  42. package/src/cli/update-cli.ts +35 -2
  43. package/src/commands/launch.ts +3 -0
  44. package/src/config/keybindings.ts +6 -0
  45. package/src/config/settings-schema.ts +2 -2
  46. package/src/config/settings.ts +23 -0
  47. package/src/discovery/claude-plugins.ts +7 -9
  48. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  49. package/src/eval/concurrency-bridge.ts +34 -0
  50. package/src/eval/js/shared/prelude.txt +20 -17
  51. package/src/eval/js/tool-bridge.ts +5 -0
  52. package/src/eval/py/prelude.py +23 -15
  53. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  54. package/src/extensibility/skills.ts +0 -1
  55. package/src/internal-urls/docs-index.generated.ts +11 -10
  56. package/src/main.ts +92 -24
  57. package/src/modes/acp/acp-event-mapper.ts +54 -4
  58. package/src/modes/components/custom-editor.ts +10 -0
  59. package/src/modes/components/hook-selector.ts +89 -31
  60. package/src/modes/components/oauth-selector.ts +12 -6
  61. package/src/modes/components/session-selector.ts +179 -24
  62. package/src/modes/components/tool-execution.ts +16 -3
  63. package/src/modes/controllers/command-controller.ts +2 -11
  64. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  65. package/src/modes/controllers/input-controller.ts +19 -1
  66. package/src/modes/controllers/selector-controller.ts +61 -21
  67. package/src/modes/interactive-mode.ts +125 -15
  68. package/src/modes/types.ts +5 -2
  69. package/src/prompts/system/orchestrate-notice.md +5 -3
  70. package/src/prompts/system/workflow-notice.md +2 -2
  71. package/src/prompts/tools/eval.md +5 -5
  72. package/src/prompts/tools/find.md +1 -1
  73. package/src/prompts/tools/irc.md +6 -6
  74. package/src/prompts/tools/search.md +1 -1
  75. package/src/prompts/tools/task.md +1 -1
  76. package/src/registry/agent-registry.ts +1 -1
  77. package/src/sdk.ts +85 -31
  78. package/src/session/agent-session.ts +62 -46
  79. package/src/session/history-storage.ts +56 -12
  80. package/src/session/session-manager.ts +34 -0
  81. package/src/task/output-manager.ts +40 -48
  82. package/src/task/render.ts +3 -8
  83. package/src/tools/browser/tab-worker.ts +8 -5
  84. package/src/tools/find.ts +5 -29
  85. package/src/tools/index.ts +1 -1
  86. package/src/tools/path-utils.ts +144 -1
  87. package/src/tools/read.ts +47 -0
  88. package/src/tools/search.ts +2 -27
  89. package/src/tools/sqlite-reader.ts +92 -9
  90. package/src/utils/clipboard.ts +38 -1
  91. package/src/utils/open.ts +37 -2
  92. package/src/web/kagi.ts +168 -49
  93. package/src/web/search/providers/anthropic.ts +1 -1
  94. package/src/web/search/providers/exa.ts +20 -86
  95. package/src/web/search/providers/kagi.ts +4 -0
@@ -32,12 +32,26 @@ import {
32
32
  TUI,
33
33
  visibleWidth,
34
34
  } from "@oh-my-pi/pi-tui";
35
- import { APP_NAME, adjustHsv, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
35
+ import {
36
+ APP_NAME,
37
+ adjustHsv,
38
+ formatNumber,
39
+ getProjectDir,
40
+ hsvToRgb,
41
+ isEnoent,
42
+ logger,
43
+ postmortem,
44
+ prompt,
45
+ setProjectDir,
46
+ } from "@oh-my-pi/pi-utils";
36
47
  import chalk from "chalk";
48
+ import { reset as resetCapabilities } from "../capability";
37
49
  import { KeybindingsManager } from "../config/keybindings";
38
50
  import { MODEL_ROLES, type ModelRole } from "../config/model-registry";
39
51
  import { isSettingsInitialized, Settings, settings } from "../config/settings";
52
+ import { clearClaudePluginRootsCache } from "../discovery/helpers";
40
53
  import type {
54
+ ContextUsage,
41
55
  ExtensionUIContext,
42
56
  ExtensionUIDialogOptions,
43
57
  ExtensionUISelectItem,
@@ -123,6 +137,7 @@ import type {
123
137
  CompactionQueuedMessage,
124
138
  InteractiveModeContext,
125
139
  InteractiveModeInitOptions,
140
+ InteractiveSelectorDialogOptions,
126
141
  SubmittedUserInput,
127
142
  TodoItem,
128
143
  TodoPhase,
@@ -198,6 +213,8 @@ function formatHudNoteMarker(count: number): string {
198
213
  type GoalSubcommand = "set" | "show" | "pause" | "resume" | "drop" | "budget";
199
214
 
200
215
  const GOAL_SUBCOMMANDS = new Set<GoalSubcommand>(["set", "show", "pause", "resume", "drop", "budget"]);
216
+ const PLAN_KEEP_CONTEXT_OPTION_INDEX = 2;
217
+ const PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT = 95;
201
218
 
202
219
  function parseGoalSubcommand(args: string): { sub: GoalSubcommand | undefined; rest: string } {
203
220
  const trimmed = args.trim();
@@ -211,6 +228,10 @@ function parseGoalSubcommand(args: string): { sub: GoalSubcommand | undefined; r
211
228
  return { sub: undefined, rest: trimmed };
212
229
  }
213
230
 
231
+ function formatContextTokenCount(value: number): string {
232
+ return formatNumber(Math.max(0, Math.round(value))).toLowerCase();
233
+ }
234
+
214
235
  /** Options for creating an InteractiveMode instance (for future API use) */
215
236
  export interface InteractiveModeOptions {
216
237
  /** Providers that were migrated during startup */
@@ -260,6 +281,7 @@ export class InteractiveMode implements InteractiveModeContext {
260
281
  loopPrompt: string | undefined = undefined;
261
282
  loopLimit: LoopLimitRuntime | undefined = undefined;
262
283
  #loopAutoSubmitTimer: NodeJS.Timeout | undefined;
284
+ #todoAutoClearTimer: NodeJS.Timeout | undefined;
263
285
  todoPhases: TodoPhase[] = [];
264
286
  hideThinkingBlock = false;
265
287
  pendingImages: ImageContent[] = [];
@@ -392,6 +414,7 @@ export class InteractiveMode implements InteractiveModeContext {
392
414
  try {
393
415
  this.historyStorage = HistoryStorage.open();
394
416
  this.editor.setHistoryStorage(this.historyStorage);
417
+ this.historyStorage.setSessionResolver(() => this.sessionManager.getSessionId());
395
418
  } catch (error) {
396
419
  logger.warn("History storage unavailable", { error: String(error) });
397
420
  }
@@ -555,6 +578,7 @@ export class InteractiveMode implements InteractiveModeContext {
555
578
  // subagent is doing the work for a still-pending todo) updates as
556
579
  // subagents start, finish, or fail.
557
580
  this.#reconcileTodosWithSubagents();
581
+ this.#syncTodoAutoClearTimer();
558
582
  this.#renderTodoList();
559
583
  this.ui.requestRender();
560
584
  });
@@ -641,6 +665,31 @@ export class InteractiveMode implements InteractiveModeContext {
641
665
  this.session.setSlashCommands(fileCommands);
642
666
  }
643
667
 
668
+ /**
669
+ * Re-point the process and every cwd-derived cache at `newCwd` after the
670
+ * active session's working directory changed (`/move` relocation or resuming
671
+ * a session from another project). The SessionManager's cwd MUST already
672
+ * reflect `newCwd` before this is called.
673
+ */
674
+ async applyCwdChange(newCwd: string): Promise<void> {
675
+ setProjectDir(newCwd);
676
+ // Re-scope project settings (`.claude/settings.yml` etc.) to the new
677
+ // directory in place so the active session and every settings reader pick
678
+ // up the destination project's configuration.
679
+ if (isSettingsInitialized()) {
680
+ await settings.reloadForCwd(newCwd);
681
+ }
682
+ // Re-warm plugin roots, capabilities, slash commands, and the ssh tool so
683
+ // the next prompt sees everything scoped to the new project directory.
684
+ clearClaudePluginRootsCache();
685
+ resetCapabilities();
686
+ await this.refreshSlashCommandState(newCwd);
687
+ await this.session.refreshSshTool({ activateIfAvailable: true });
688
+ setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
689
+ this.statusLine.invalidate();
690
+ this.updateEditorTopBorder();
691
+ }
692
+
644
693
  async getUserInput(): Promise<SubmittedUserInput> {
645
694
  if (this.session.getGoalModeState()?.mode === "exiting") {
646
695
  await this.#exitGoalMode({ reason: "completed", silent: true });
@@ -1043,6 +1092,47 @@ export class InteractiveMode implements InteractiveModeContext {
1043
1092
  this.session.setTodoPhases(next);
1044
1093
  }
1045
1094
 
1095
+ #cancelTodoAutoClearTimer(): void {
1096
+ if (!this.#todoAutoClearTimer) return;
1097
+ clearTimeout(this.#todoAutoClearTimer);
1098
+ this.#todoAutoClearTimer = undefined;
1099
+ }
1100
+
1101
+ #isClosedTodo(task: TodoItem): boolean {
1102
+ return task.status === "completed" || task.status === "abandoned";
1103
+ }
1104
+
1105
+ #hasClosedTodos(phases: TodoPhase[]): boolean {
1106
+ return phases.some(phase => phase.tasks.some(task => this.#isClosedTodo(task)));
1107
+ }
1108
+
1109
+ #removeClosedTodos(phases: TodoPhase[]): TodoPhase[] {
1110
+ const next: TodoPhase[] = [];
1111
+ for (const phase of phases) {
1112
+ const tasks = phase.tasks.filter(task => !this.#isClosedTodo(task));
1113
+ if (tasks.length > 0) next.push({ name: phase.name, tasks });
1114
+ }
1115
+ return next;
1116
+ }
1117
+
1118
+ #syncTodoAutoClearTimer(): void {
1119
+ this.#cancelTodoAutoClearTimer();
1120
+ const delaySeconds = this.settings.get("tasks.todoClearDelay");
1121
+ if (!Number.isFinite(delaySeconds) || delaySeconds < 0 || !this.#hasClosedTodos(this.todoPhases)) return;
1122
+ if (delaySeconds === 0) {
1123
+ this.todoPhases = this.#removeClosedTodos(this.todoPhases);
1124
+ return;
1125
+ }
1126
+
1127
+ this.#todoAutoClearTimer = setTimeout(() => {
1128
+ this.#todoAutoClearTimer = undefined;
1129
+ this.todoPhases = this.#removeClosedTodos(this.todoPhases);
1130
+ this.#renderTodoList();
1131
+ this.ui.requestRender();
1132
+ }, delaySeconds * 1000);
1133
+ this.#todoAutoClearTimer.unref?.();
1134
+ }
1135
+
1046
1136
  #getActivePhase(phases: TodoPhase[]): TodoPhase | undefined {
1047
1137
  const nonEmpty = phases.filter(phase => phase.tasks.length > 0);
1048
1138
  const active = nonEmpty.find(phase =>
@@ -1098,6 +1188,7 @@ export class InteractiveMode implements InteractiveModeContext {
1098
1188
 
1099
1189
  async #loadTodoList(): Promise<void> {
1100
1190
  this.todoPhases = this.session.getTodoPhases();
1191
+ this.#syncTodoAutoClearTimer();
1101
1192
  this.#renderTodoList();
1102
1193
  }
1103
1194
 
@@ -1569,6 +1660,28 @@ export class InteractiveMode implements InteractiveModeContext {
1569
1660
  return `up/down navigate enter select ${externalEditorKey.toLowerCase()} open in editor esc cancel`;
1570
1661
  }
1571
1662
 
1663
+ #getPlanApprovalContextUsage(): ContextUsage | undefined {
1664
+ const executionModel = this.#planModePreviousModelState?.model ?? this.session.model;
1665
+ const contextWindow = executionModel?.contextWindow;
1666
+ if (typeof contextWindow === "number") {
1667
+ return this.session.getContextUsage({ contextWindow });
1668
+ }
1669
+ return this.session.getContextUsage();
1670
+ }
1671
+
1672
+ #formatKeepContextLabel(contextUsage: ContextUsage | undefined): string {
1673
+ if (contextUsage?.tokens == null) {
1674
+ return "Approve and keep context";
1675
+ }
1676
+ const tokens = formatContextTokenCount(contextUsage.tokens);
1677
+ const contextWindow = formatContextTokenCount(contextUsage.contextWindow);
1678
+ return `Approve and keep context (~${tokens} / ${contextWindow})`;
1679
+ }
1680
+
1681
+ #isKeepContextDisabled(contextUsage: ContextUsage | undefined): boolean {
1682
+ return contextUsage?.percent != null && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
1683
+ }
1684
+
1572
1685
  async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
1573
1686
  const editorCmd = getEditorCommand();
1574
1687
  if (!editorCmd) {
@@ -2035,11 +2148,9 @@ export class InteractiveMode implements InteractiveModeContext {
2035
2148
  }
2036
2149
 
2037
2150
  this.#renderPlanPreview(planContent, { append: true });
2038
- const contextUsage = this.session.getContextUsage();
2039
- const keepContextLabel =
2040
- contextUsage?.percent != null
2041
- ? `Approve and keep context (${contextUsage.percent.toFixed(1)}%)`
2042
- : "Approve and keep context";
2151
+ const contextUsage = this.#getPlanApprovalContextUsage();
2152
+ const keepContextLabel = this.#formatKeepContextLabel(contextUsage);
2153
+ const keepContextDisabled = this.#isKeepContextDisabled(contextUsage);
2043
2154
 
2044
2155
  // Model-tier slider: let the operator pick which configured role model
2045
2156
  // (smol/default/slow/…) executes the approved plan. The slider always starts
@@ -2074,6 +2185,7 @@ export class InteractiveMode implements InteractiveModeContext {
2074
2185
  {
2075
2186
  helpText,
2076
2187
  onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath),
2188
+ disabledIndices: keepContextDisabled ? [PLAN_KEEP_CONTEXT_OPTION_INDEX] : undefined,
2077
2189
  },
2078
2190
  { slider },
2079
2191
  );
@@ -2176,6 +2288,7 @@ export class InteractiveMode implements InteractiveModeContext {
2176
2288
  this.loadingAnimation = undefined;
2177
2289
  }
2178
2290
  this.#cleanupMicAnimation();
2291
+ this.#cancelTodoAutoClearTimer();
2179
2292
  this.#cancelGoalContinuation();
2180
2293
  if (this.#sttController) {
2181
2294
  this.#sttController.dispose();
@@ -2231,14 +2344,10 @@ export class InteractiveMode implements InteractiveModeContext {
2231
2344
  // Emit shutdown event to hooks
2232
2345
  await this.session.dispose();
2233
2346
 
2234
- if (this.isInitialized) {
2235
- this.ui.requestRender(true);
2236
- }
2237
-
2238
- // Wait for any pending renders to complete
2239
- // requestRender() uses process.nextTick(), so we wait one tick
2240
- await new Promise(resolve => process.nextTick(resolve));
2241
-
2347
+ // Do not force a final render during teardown: disposed session/UI state can
2348
+ // collapse to an empty frame, clearing the viewport and leaving the parent
2349
+ // shell prompt at row 0. Stop from the last committed frame so the terminal
2350
+ // hands Bash the cursor immediately after visible OMP content.
2242
2351
  // Drain any in-flight Kitty key release events before stopping.
2243
2352
  // This prevents escape sequences from leaking to the parent shell over slow SSH.
2244
2353
  await this.ui.terminal.drainInput(1000);
@@ -2858,6 +2967,7 @@ export class InteractiveMode implements InteractiveModeContext {
2858
2967
  },
2859
2968
  ];
2860
2969
  }
2970
+ this.#syncTodoAutoClearTimer();
2861
2971
  this.#renderTodoList();
2862
2972
  this.ui.requestRender();
2863
2973
  }
@@ -2898,7 +3008,7 @@ export class InteractiveMode implements InteractiveModeContext {
2898
3008
  showHookSelector(
2899
3009
  title: string,
2900
3010
  options: ExtensionUISelectItem[],
2901
- dialogOptions?: ExtensionUIDialogOptions,
3011
+ dialogOptions?: InteractiveSelectorDialogOptions,
2902
3012
  extra?: { slider?: HookSelectorSlider },
2903
3013
  ): Promise<string | undefined> {
2904
3014
  return this.#extensionUiController.showHookSelector(title, options, dialogOptions, extra);
@@ -25,7 +25,7 @@ import type { CustomEditor } from "./components/custom-editor";
25
25
  import type { EvalExecutionComponent } from "./components/eval-execution";
26
26
  import type { HookEditorComponent } from "./components/hook-editor";
27
27
  import type { HookInputComponent } from "./components/hook-input";
28
- import type { HookSelectorComponent } from "./components/hook-selector";
28
+ import type { HookSelectorComponent, HookSelectorOptions } from "./components/hook-selector";
29
29
  import type { StatusLineComponent } from "./components/status-line";
30
30
  import type { ToolExecutionHandle } from "./components/tool-execution";
31
31
  import type { LoopLimitRuntime } from "./loop-limit";
@@ -64,6 +64,8 @@ export interface InteractiveModeInitOptions {
64
64
  suppressWelcomeIntro?: boolean;
65
65
  }
66
66
 
67
+ export type InteractiveSelectorDialogOptions = ExtensionUIDialogOptions & Pick<HookSelectorOptions, "disabledIndices">;
68
+
67
69
  export interface InteractiveModeContext {
68
70
  // UI access
69
71
  ui: TUI;
@@ -242,6 +244,7 @@ export interface InteractiveModeContext {
242
244
  ): Promise<CompactionOutcome>;
243
245
  openInBrowser(urlOrPath: string): void;
244
246
  refreshSlashCommandState(cwd?: string): Promise<void>;
247
+ applyCwdChange(newCwd: string): Promise<void>;
245
248
 
246
249
  // Selector handling
247
250
  showSettingsSelector(): void;
@@ -299,7 +302,7 @@ export interface InteractiveModeContext {
299
302
  showHookSelector(
300
303
  title: string,
301
304
  options: ExtensionUISelectItem[],
302
- dialogOptions?: ExtensionUIDialogOptions,
305
+ dialogOptions?: InteractiveSelectorDialogOptions,
303
306
  ): Promise<string | undefined>;
304
307
  hideHookSelector(): void;
305
308
  showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
@@ -2,19 +2,20 @@
2
2
  The user's message above is an **orchestration request**. Execute it as the orchestrator under the contract below. This contract overrides any default tendency to yield early, narrate, or do the work yourself.
3
3
 
4
4
  <role>
5
- You decompose, dispatch, verify, and iterate. You do **not** edit code. Every file mutation goes through a `task` subagent. Your tool budget is: reading for planning, `task` for dispatch, verification (`bun check`, `bun test`, `lsp diagnostics`), git via `bash`, and `todo_write` for tracking.
5
+ You decompose, dispatch, verify, and iterate. Substantial and parallelizable work goes through `task` subagents — that is the whole point of orchestrating. But you are not forbidden from touching the tree: a trivial, self-contained edit is yours to make directly when spawning a subagent for it would cost more than the edit itself. Your tool budget is: reading for planning, `task` for dispatch, `edit`/`write` for trivial inline fixes only, verification (`bun check`, `bun test`, `lsp diagnostics`), git via `bash`, and `todo_write` for tracking.
6
6
  </role>
7
7
 
8
8
  <rules>
9
9
  1. **Do not yield until everything is closed.** A phase finishing is *not* a yield point — launch the next phase in the same turn. Stop only when every requested item is verifiably done, or you hit a concrete [blocked] state that genuinely requires the user.
10
10
  2. **Enumerate the full surface before dispatching.** If the request references audits, plans, checklists, phase lists, or file lists, expand them into a flat set of items in `todo_write`. "Most of them" or "the important ones" is failure. Re-read the source documents — do not work from memory.
11
- 3. **Parallelize maximally.** Every set of edits with disjoint file scope MUST ship as one `task` batch. Serialize only when one subagent produces a contract (types, schema, shared module) the next consumes — and state the dependency when you do.
11
+ 3. **Parallelize maximally; never launch a one-off task.** Every set of edits with disjoint file scope MUST ship as one `task` batch — fan the work as wide as it decomposes. A single-task batch for divisible work is a failure: split it. If you are about to dispatch exactly one subagent, stop — either there is more to run alongside it (find it and batch them) or the change is small enough to make inline yourself (do it). Serialize only when one subagent produces a contract (types, schema, shared module) the next consumes — and state the dependency when you do.
12
12
  4. **Each `task` assignment is self-contained.** Subagents have no shared context. Spell out: target files (≤3–5 explicit paths, no globs), the change with APIs and patterns, edge cases, and observable acceptance criteria. Do not assume they read the same plan you did.
13
13
  5. **Verify after every phase before launching the next.** Run the appropriate gate: `bun check` for types, package-scoped `bun test` for behavior, `lsp diagnostics` for changed files. If a phase introduced breakage, dispatch fix-up subagents *before* moving on. Never declare a phase done on a red tree.
14
14
  6. **Commit policy.** If the request asks for commits or the repo workflow expects them, commit after each green phase with a focused message. Never commit a red tree. Never commit work the user did not ask to commit.
15
15
  7. **Respawn, do not absorb.** If a subagent returns incomplete or wrong work, spawn a corrective subagent with the specific gap — do not silently fix it yourself.
16
16
  8. **No scope creep, no scope shrink.** Do not add work the user did not ask for. Do not relabel unfinished items as "follow-up", "v1", or "MVP" to imply completion.
17
17
  9. **Subagents do not verify, lint, or format.** Every `task` assignment MUST instruct the subagent to skip all gates and formatters. Their job is the edit only. You — the orchestrator — run verification and formatting **once** at the end of the phase across the union of changed files. Avoids redundant runs and racing formatter passes.
18
+ 10. **Right-size the offload — do not micro-task.** Subagents are for substantial or parallelizable chunks, not every keystroke. A trivial, self-contained mechanical edit — deleting a redundant glob, fixing one line in a config, renaming a single symbol in one file — costs less to *do* than to describe in a Goal/Constraints assignment. Make those yourself with `edit`/`write` and move on; reserve `task`/`quick_task` for work large enough to justify the dispatch overhead. Wrapping a one-line change in a full subagent with scaffolding is pure waste.
18
19
  </rules>
19
20
 
20
21
  <workflow>
@@ -28,7 +29,8 @@ You decompose, dispatch, verify, and iterate. You do **not** edit code. Every fi
28
29
  </workflow>
29
30
 
30
31
  <anti-patterns>
31
- - Editing files yourself "because it's faster".
32
+ - Doing substantial or parallelizable work yourself instead of fanning it out to subagents.
33
+ - Wrapping a single trivial edit (e.g. removing one redundant config line) in a `task`/`quick_task` with full Goal/Constraints scaffolding — just make the edit inline.
32
34
  - Yielding after phase 1 with "ready to continue?".
33
35
  - Dispatching one subagent at a time when five could run in parallel.
34
36
  - Skipping `bun check` between phases because "the change looked safe".
@@ -14,8 +14,8 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
14
14
  State persists across cells, so scout in one cell and fan out in the next. Every cell has:
15
15
 
16
16
  - `agent(prompt, *, agent_type="task", model=None, context=None, label=None, schema=None)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent_type` picks a discovered agent ("explore", "reviewer", "oracle", …); `context` is shared background; `label` names the artifact. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep.
17
- - `parallel(thunks, *, concurrency=4)` — run zero-arg callables concurrently through a bounded pool (default 4, max 16), preserving input order; returns once all finish. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
18
- - `pipeline(items, *stages, concurrency=4)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result.
17
+ - `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool runs as wide as a `task` tool batch (the `task.maxConcurrency` setting; don't hand-tune it — fan out as wide as the work divides). A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
18
+ - `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
19
19
  - `llm(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
20
20
  - `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it.
21
21
  - `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget.
@@ -1,7 +1,7 @@
1
1
  Run code in a persistent kernel using a list of cells.
2
2
 
3
3
  <instruction>
4
- Each call submits one or more cells. Cells run in array order. State persists within each language across cells, tool calls, and subagents spawned with `task`; variables a parent or subagent declares are visible to the other on the same shared executor.
4
+ Each call submits one or more cells. Cells run in array order. State persists within each language across cells, tool calls, and subagents spawned with `task`; variables a parent or subagent declares are visible to the other on the same shared executor. Lean on this: stage helpers, loaded datasets, or live clients once, then fan out `task` subagents that call them directly — no re-importing, re-fetching, or serializing across the boundary.
5
5
 
6
6
  Cell fields:
7
7
 
@@ -48,10 +48,10 @@ llm(prompt, model?="default", system?=None, schema?=None) → str | dict
48
48
  Oneshot, stateless LLM call (no history, no tools). `model` picks a tier: "smol" (fast), "default" (this session's model), "slow" (most capable). Pass `system` for a system prompt. Pass a JSON-Schema `schema` to force structured output and get the parsed object back; otherwise returns the completion text.
49
49
  agent(prompt, agent_type?="task", model?=None, context?=None, label?=None, schema?=None) → str | dict
50
50
  Run a subagent and return its final output. Defaults to the bundled "task" agent; pass `agent_type`/`agentType` for another discovered agent. Pass a JSON-Schema `schema` to force structured output and get the parsed object back.
51
- parallel(thunks, concurrency?=4) → list
52
- Run thunks (callables) through a bounded pool (default 4, max 16), preserving input order. Barrier: returns once all finish; a thunk that throws propagates.
53
- pipeline(items, ...stages, concurrency?=4) → list
54
- Map each item through stages left-to-right; a barrier runs between stages (every item clears stage N before stage N+1). Each stage is a one-arg callable: stage 1 gets the original item, later stages get the previous result.
51
+ parallel(thunks) → list
52
+ Run thunks (callables) through a bounded pool, preserving input order. The pool is as wide as a `task` tool batch (tracks the `task.maxConcurrency` setting), so fan out as wide as the work divides — don't pre-shrink it. Barrier: returns once all finish; a thunk that throws propagates.
53
+ pipeline(items, ...stages) → list
54
+ Map each item through stages left-to-right; a barrier runs between stages (every item clears stage N before stage N+1). Each stage is a one-arg callable: stage 1 gets the original item, later stages get the previous result. Same pool width as parallel().
55
55
  log(message) → None
56
56
  Emit a progress line above the status tree.
57
57
  phase(title) → None
@@ -2,7 +2,7 @@ Finds files and directories using fast pattern matching that works with any code
2
2
 
3
3
  <instruction>
4
4
  - `paths` is required and accepts an array of globs, files, or directories
5
- - Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected)
5
+ - Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`).
6
6
  - `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores
7
7
  - `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored
8
8
  - `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit
@@ -1,7 +1,7 @@
1
1
  Sends short text messages to other live agents in this process and receives their prose replies.
2
2
 
3
3
  <instruction>
4
- - The main agent is addressable as `0-Main`. Subagents reuse their task id (e.g. `0-AuthLoader`).
4
+ - The main agent is addressable as `Main`. Subagents reuse their task id (e.g. `AuthLoader`, or `AuthLoader-2` when the name repeats).
5
5
  - `op: "list"` returns the current set of visible peers. Use it before sending if you are not sure who is live.
6
6
  - `op: "send"` delivers `message` to `to`. `to` may be a specific id or `"all"` to broadcast.
7
7
  - The recipient generates the reply via an ephemeral side-channel turn that uses their current model, system prompt, and history — it does **not** wait for the recipient's main loop to be free, so it is safe to IRC an agent that is currently inside a long-running tool call.
@@ -10,7 +10,7 @@ Sends short text messages to other live agents in this process and receives thei
10
10
 
11
11
  <when_to_use>
12
12
  You SHOULD reach for `irc` proactively when continuing alone is wasteful or wrong. When in doubt, prefer messaging.
13
- - **Unexpected state.** You hit something the original task did not describe — a missing file, a config that contradicts the assignment, an API behaving differently than you were told, a tool failing in a way that suggests the spec is wrong. DM `0-Main` (or the spawning agent) for guidance instead of guessing.
13
+ - **Unexpected state.** You hit something the original task did not describe — a missing file, a config that contradicts the assignment, an API behaving differently than you were told, a tool failing in a way that suggests the spec is wrong. DM `Main` (or the spawning agent) for guidance instead of guessing.
14
14
  - **Blocked by another agent.** A peer holds the file/branch/resource you need, has already started the change you are about to make, or owns a decision you depend on. DM that peer (or broadcast to discover who) before duplicating or stepping on work.
15
15
  - **Decision points outside your scope.** A genuine fork in the road that the assignment did not pre-decide (e.g. which of two viable APIs to use, whether to refactor adjacent code). Ask the requester rather than picking unilaterally.
16
16
  - **Coordination opportunities.** You realize a peer's in-flight work would benefit from yours, or vice-versa.
@@ -25,7 +25,7 @@ These rules apply to both sending and replying.
25
25
  - **Use IRC, not terminal tools, to learn about peers.** Do not `grep` artifacts, read other sessions' JSONL files, or shell-poke around to figure out what another agent is doing. DM them — they have the live answer and you do not.
26
26
  - **One round-trip is enough.** Replies arrive synchronously when the recipient is reachable. Do not follow up with "did you get my message?" — they did. If `delivered` is empty or the result was `failed`, the peer is unavailable; move on or report the blocker, do not retry in a loop.
27
27
  - **Stay terse.** A DM is a chat message, not a memo. One question per send when you can. Share file paths and artifacts via `local://` / `memory://` / `artifact://` URLs instead of pasting blobs.
28
- - **Address peers by id.** Use the exact id from `op: "list"` (e.g. `0-AuthLoader`, `0-Main`). Do not invent friendly names.
28
+ - **Address peers by id.** Use the exact id from `op: "list"` (e.g. `AuthLoader`, `Main`). Do not invent friendly names.
29
29
  - **Do not IRC for things a tool would answer.** If a `read`, `grep`, or build command would resolve the question, do that first.
30
30
  - **When you receive an IRC message, answer it before continuing.** The recipient injects the question + your auto-reply into your history; address it directly, do not repeat it back to the user.
31
31
  </etiquette>
@@ -39,11 +39,11 @@ These rules apply to both sending and replying.
39
39
  # List peers
40
40
  `{"op": "list"}`
41
41
  # Direct message to the main agent (waits for prose reply)
42
- `{"op": "send", "to": "0-Main", "message": "Should I prefer JWT or session cookies for the auth flow?"}`
42
+ `{"op": "send", "to": "Main", "message": "Should I prefer JWT or session cookies for the auth flow?"}`
43
43
  # Unexpected state — ask the originator
44
- `{"op": "send", "to": "0-Main", "message": "Assignment says edit src/auth/jwt.ts but the file does not exist. Is the new path src/server/auth/jwt.ts?"}`
44
+ `{"op": "send", "to": "Main", "message": "Assignment says edit src/auth/jwt.ts but the file does not exist. Is the new path src/server/auth/jwt.ts?"}`
45
45
  # Blocked by a peer — ask them directly
46
- `{"op": "send", "to": "0-AuthLoader", "message": "Are you still touching src/server/auth.ts? I need to add a 401 path; OK to proceed or should I wait?"}`
46
+ `{"op": "send", "to": "AuthLoader", "message": "Are you still touching src/server/auth.ts? I need to add a 401 path; OK to proceed or should I wait?"}`
47
47
  # Broadcast to discover who owns something (no replies, just informs them)
48
48
  `{"op": "send", "to": "all", "message": "About to refactor src/server/middleware/*. Anyone already in there?", "awaitReply": false}`
49
49
  </examples>
@@ -3,7 +3,7 @@ Searches files using powerful regex matching.
3
3
  <instruction>
4
4
  - Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(?<!…)
5
5
  - `paths` is required and accepts either one string or an array of files, directories, globs, or internal URLs
6
- - For multiple targets, pass an array with one target per element. Do not comma-join targets inside one string: pass `["src", "tests"]`, not `"src,tests"` or `["src,tests"]`.
6
+ - For multiple targets, pass an array with one target per element: `["src", "tests"]`.
7
7
  - Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
8
8
  </instruction>
9
9
 
@@ -2,7 +2,7 @@ Launches subagents to parallelize workflows.
2
2
 
3
3
  {{#if asyncEnabled}}
4
4
  - Results are delivered automatically when complete.
5
- - The tool result lists the assigned task ids (e.g. `0-AuthLoader`) — those are the live agent ids.
5
+ - The tool result lists the assigned task ids (e.g. `AuthLoader`) — those are the live agent ids.
6
6
  {{#if ircEnabled}}
7
7
  - Coordinate with running tasks via `irc` using those ids. `job cancel` terminates a task and **cannot carry a message** — only use it for stalled/abandoned work.
8
8
  - If genuinely blocked on completion, wait with `job poll`; otherwise keep working.
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { AgentSession } from "../session/agent-session";
10
10
 
11
- export const MAIN_AGENT_ID = "0-Main";
11
+ export const MAIN_AGENT_ID = "Main";
12
12
 
13
13
  export type AgentStatus = "running" | "idle" | "completed" | "aborted";
14
14
  export type AgentKind = "main" | "sub";
package/src/sdk.ts CHANGED
@@ -102,7 +102,7 @@ import { AgentSession } from "./session/agent-session";
102
102
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
103
103
  import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage";
104
104
  import { type CustomMessage, convertToLlm } from "./session/messages";
105
- import { SessionManager } from "./session/session-manager";
105
+ import { getRestorableSessionModels, SessionManager } from "./session/session-manager";
106
106
  import { closeAllConnections } from "./ssh/connection-manager";
107
107
  import { unmountAll } from "./ssh/sshfs-mount";
108
108
  import {
@@ -323,13 +323,13 @@ export interface CreateAgentSessionOptions {
323
323
  parentHindsightSessionState?: HindsightSessionState;
324
324
  /** Parent Mnemopi state to alias for subagent memory tools. */
325
325
  parentMnemopiSessionState?: MnemopiSessionState;
326
- /** Pre-allocated agent identity for IRC routing. Default: "0-Main" for top-level, parentTaskPrefix-derived for sub. */
326
+ /** Pre-allocated agent identity for IRC routing. Default: "Main" for top-level, parentTaskPrefix-derived for sub. */
327
327
  agentId?: string;
328
328
  /** Display name for the agent in IRC. Default: "main" or "sub". */
329
329
  agentDisplayName?: string;
330
330
  /** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
331
331
  agentRegistry?: AgentRegistry;
332
- /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
332
+ /** Parent task ID prefix for nested artifact naming (e.g., "Extensions") */
333
333
  parentTaskPrefix?: string;
334
334
  /** Inherited eval executor session id for subagents sharing parent eval state. */
335
335
  parentEvalSessionId?: string;
@@ -1008,20 +1008,37 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1008
1008
  );
1009
1009
  let model = options.model;
1010
1010
  let modelFallbackMessage: string | undefined;
1011
- // If session has data, try to restore model from it.
1012
- // Skip restore when an explicit model was requested.
1013
- const defaultModelStr = existingSession.models.default;
1014
- if (!hasExplicitModel && !model && hasExistingSession && defaultModelStr) {
1011
+ // Identify session model strings to restore in fallback order. We do an
1012
+ // initial pass here so model-dependent setup (thinking-level resolution,
1013
+ // host preconnect) can use the restored model; extension-registered
1014
+ // providers aren't visible yet, so we retry the preferred candidates once
1015
+ // extensions register below.
1016
+ const sessionModelStrings =
1017
+ !hasExplicitModel && hasExistingSession
1018
+ ? getRestorableSessionModels(existingSession.models, sessionManager.getLastModelChangeRole())
1019
+ : [];
1020
+ let restoredSessionModelIndex = -1;
1021
+ if (!hasExplicitModel && !model && sessionModelStrings.length > 0) {
1015
1022
  await logger.time("restoreSessionModel", async () => {
1016
- const parsedModel = parseModelString(defaultModelStr);
1017
- if (parsedModel) {
1023
+ let failedSessionModel: string | undefined;
1024
+ for (let i = 0; i < sessionModelStrings.length; i++) {
1025
+ const sessionModelStr = sessionModelStrings[i];
1026
+ const parsedModel = parseModelString(sessionModelStr);
1027
+ if (!parsedModel) {
1028
+ failedSessionModel ??= sessionModelStr;
1029
+ continue;
1030
+ }
1031
+
1018
1032
  const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
1019
1033
  if (restoredModel && (await hasModelApiKey(restoredModel))) {
1020
1034
  model = restoredModel;
1035
+ restoredSessionModelIndex = i;
1036
+ break;
1021
1037
  }
1038
+ failedSessionModel ??= sessionModelStr;
1022
1039
  }
1023
- if (!model) {
1024
- modelFallbackMessage = `Could not restore model ${defaultModelStr}`;
1040
+ if (failedSessionModel) {
1041
+ modelFallbackMessage = `Could not restore model ${failedSessionModel}`;
1025
1042
  }
1026
1043
  });
1027
1044
  }
@@ -1039,26 +1056,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1039
1056
 
1040
1057
  const taskDepth = options.taskDepth ?? 0;
1041
1058
 
1042
- let thinkingLevel = options.thinkingLevel;
1043
-
1044
- // If session has data and includes a thinking entry, restore it
1045
- if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) {
1046
- thinkingLevel = parseThinkingLevel(existingSession.thinkingLevel);
1047
- }
1048
-
1049
- if (thinkingLevel === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) {
1050
- thinkingLevel = defaultRoleSpec.thinkingLevel;
1051
- }
1052
-
1053
- // Prefer the selected model's configured defaultLevel, otherwise fall back
1054
- // to the global settings default.
1055
- if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) {
1056
- thinkingLevel = model.thinking.defaultLevel;
1057
- }
1058
- if (thinkingLevel === undefined) {
1059
- thinkingLevel = settings.get("defaultThinkingLevel");
1060
- }
1061
- const autoThinking = thinkingLevel === AUTO_THINKING;
1059
+ // Resolves the session/agent thinking level using the same precedence we
1060
+ // apply at startup: explicit option → persisted session entry → default
1061
+ // role's explicit selector selected model's defaultLevel global
1062
+ // settings default. Run again after extension role reclaim so the final
1063
+ // model's own defaults aren't masked by an earlier fallback model's.
1064
+ const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => {
1065
+ let level = options.thinkingLevel;
1066
+ if (level === undefined && hasExistingSession && hasThinkingEntry) {
1067
+ level = parseThinkingLevel(existingSession.thinkingLevel);
1068
+ }
1069
+ if (level === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) {
1070
+ level = defaultRoleSpec.thinkingLevel;
1071
+ }
1072
+ if (level === undefined && selectedModel?.thinking?.defaultLevel !== undefined) {
1073
+ level = selectedModel.thinking.defaultLevel;
1074
+ }
1075
+ if (level === undefined) {
1076
+ level = settings.get("defaultThinkingLevel");
1077
+ }
1078
+ return level;
1079
+ };
1080
+ let thinkingLevel = pickInitialThinkingLevel(model);
1081
+ let autoThinking = thinkingLevel === AUTO_THINKING;
1062
1082
  // Concrete level the agent/session start with. With `auto` this is the
1063
1083
  // provisional level shown until the first per-turn classification resolves;
1064
1084
  // `auto` itself stays a session-only concept handled by AgentSession.
@@ -1444,6 +1464,40 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1444
1464
  extensionsResult.runtime.pendingProviderRegistrations = [];
1445
1465
  }
1446
1466
 
1467
+ // Retry session-model candidates now that extension providers are
1468
+ // registered. The initial restore runs before extensions load, so a role
1469
+ // model supplied by an extension would have either fallen back to the
1470
+ // saved default (`restoredSessionModelIndex > 0`) or failed entirely
1471
+ // (`restoredSessionModelIndex === -1`, with the settings default or
1472
+ // downstream fallback filling `model`). Reclaim it here so resume
1473
+ // honors the last active role in either case.
1474
+ const sessionRetryLimit = restoredSessionModelIndex >= 0 ? restoredSessionModelIndex : sessionModelStrings.length;
1475
+ if (!hasExplicitModel && sessionRetryLimit > 0) {
1476
+ for (let i = 0; i < sessionRetryLimit; i++) {
1477
+ const sessionModelStr = sessionModelStrings[i];
1478
+ const parsedModel = parseModelString(sessionModelStr);
1479
+ if (!parsedModel) continue;
1480
+ const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
1481
+ if (restoredModel && (await hasModelApiKey(restoredModel))) {
1482
+ model = restoredModel;
1483
+ modelFallbackMessage = undefined;
1484
+ restoredSessionModelIndex = i;
1485
+ // Recompute thinking-level from scratch against the reclaimed
1486
+ // model: any value derived from the earlier fallback model's
1487
+ // `thinking.defaultLevel` must not become sticky.
1488
+ thinkingLevel = pickInitialThinkingLevel(restoredModel);
1489
+ autoThinking = thinkingLevel === AUTO_THINKING;
1490
+ effectiveThinkingLevel = thinkingLevel === AUTO_THINKING ? undefined : thinkingLevel;
1491
+ effectiveThinkingLevel = logger.time("resolveThinkingLevelForModel", () =>
1492
+ autoThinking
1493
+ ? resolveProvisionalAutoLevel(restoredModel)
1494
+ : resolveThinkingLevelForModel(restoredModel, effectiveThinkingLevel),
1495
+ );
1496
+ preconnectModelHost(restoredModel.baseUrl);
1497
+ break;
1498
+ }
1499
+ }
1500
+ }
1447
1501
  // Resolve deferred --model pattern now that extension models are registered.
1448
1502
  if (!model && options.modelPattern) {
1449
1503
  const availableModels = modelRegistry.getAll();