@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3

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 (187) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli.js +3621 -3579
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/runtime.d.ts +15 -1
  8. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  9. package/dist/types/advisor/watchdog.d.ts +20 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/config/model-roles.d.ts +1 -1
  12. package/dist/types/config/settings-schema.d.ts +113 -12
  13. package/dist/types/debug/log-viewer.d.ts +1 -0
  14. package/dist/types/debug/raw-sse.d.ts +1 -0
  15. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/edit/index.d.ts +18 -0
  18. package/dist/types/edit/streaming.d.ts +30 -0
  19. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  20. package/dist/types/extensibility/shared-events.d.ts +1 -0
  21. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  22. package/dist/types/extensibility/utils.d.ts +12 -0
  23. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  24. package/dist/types/mcp/transports/index.d.ts +1 -0
  25. package/dist/types/mcp/transports/sse.d.ts +20 -0
  26. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  27. package/dist/types/modes/components/index.d.ts +1 -0
  28. package/dist/types/modes/components/model-selector.d.ts +9 -1
  29. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  30. package/dist/types/modes/components/status-line/component.d.ts +30 -3
  31. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/interactive-mode.d.ts +10 -4
  34. package/dist/types/modes/skill-command.d.ts +32 -0
  35. package/dist/types/modes/types.d.ts +7 -2
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +84 -12
  38. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  39. package/dist/types/session/messages.d.ts +32 -7
  40. package/dist/types/session/messages.test.d.ts +1 -0
  41. package/dist/types/session/session-entries.d.ts +31 -3
  42. package/dist/types/session/session-history-format.d.ts +6 -0
  43. package/dist/types/session/session-loader.d.ts +9 -1
  44. package/dist/types/session/session-manager.d.ts +6 -4
  45. package/dist/types/session/session-storage.d.ts +11 -0
  46. package/dist/types/session/session-title-slot.d.ts +19 -0
  47. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  48. package/dist/types/session/turn-persistence.d.ts +88 -0
  49. package/dist/types/ssh/connection-manager.d.ts +47 -0
  50. package/dist/types/ssh/utils.d.ts +16 -0
  51. package/dist/types/task/executor.d.ts +3 -16
  52. package/dist/types/task/render.d.ts +0 -5
  53. package/dist/types/task/renderer.d.ts +13 -0
  54. package/dist/types/task/types.d.ts +16 -0
  55. package/dist/types/task/yield-assembly.d.ts +28 -0
  56. package/dist/types/tiny/text.d.ts +8 -0
  57. package/dist/types/tools/render-utils.d.ts +2 -0
  58. package/dist/types/tools/review.d.ts +6 -4
  59. package/dist/types/tools/ssh.d.ts +1 -1
  60. package/dist/types/tools/todo.d.ts +6 -0
  61. package/dist/types/tools/yield.d.ts +8 -3
  62. package/dist/types/utils/thinking-display.d.ts +4 -0
  63. package/package.json +12 -12
  64. package/src/advisor/__tests__/advisor.test.ts +438 -10
  65. package/src/advisor/__tests__/config.test.ts +173 -0
  66. package/src/advisor/advise-tool.ts +11 -6
  67. package/src/advisor/config.ts +256 -0
  68. package/src/advisor/index.ts +1 -0
  69. package/src/advisor/runtime.ts +77 -4
  70. package/src/advisor/transcript-recorder.ts +25 -2
  71. package/src/advisor/watchdog.ts +57 -31
  72. package/src/auto-thinking/classifier.ts +2 -2
  73. package/src/autoresearch/index.ts +7 -2
  74. package/src/cli/gc-cli.ts +17 -10
  75. package/src/collab/guest.ts +43 -7
  76. package/src/config/model-registry.ts +80 -18
  77. package/src/config/model-resolver.ts +5 -1
  78. package/src/config/model-roles.ts +3 -3
  79. package/src/config/settings-schema.ts +107 -8
  80. package/src/debug/index.ts +32 -7
  81. package/src/debug/log-viewer.ts +111 -53
  82. package/src/debug/raw-sse.ts +68 -48
  83. package/src/discovery/codex.ts +13 -5
  84. package/src/discovery/omp-extension-roots.ts +38 -13
  85. package/src/edit/hashline/diff.ts +57 -4
  86. package/src/edit/index.ts +21 -0
  87. package/src/edit/streaming.ts +170 -0
  88. package/src/eval/js/shared/local-module-loader.ts +23 -1
  89. package/src/export/html/template.js +13 -7
  90. package/src/extensibility/custom-tools/types.ts +1 -0
  91. package/src/extensibility/extensions/loader.ts +5 -3
  92. package/src/extensibility/extensions/wrapper.ts +9 -3
  93. package/src/extensibility/hooks/loader.ts +3 -3
  94. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  95. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  96. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  97. package/src/extensibility/plugins/manager.ts +76 -5
  98. package/src/extensibility/shared-events.ts +1 -0
  99. package/src/extensibility/tool-event-input.ts +23 -0
  100. package/src/extensibility/utils.ts +74 -0
  101. package/src/internal-urls/docs-index.generated.txt +1 -1
  102. package/src/mcp/client.ts +3 -1
  103. package/src/mcp/manager.ts +12 -5
  104. package/src/mcp/oauth-discovery.ts +5 -29
  105. package/src/mcp/transports/http.ts +3 -1
  106. package/src/mcp/transports/index.ts +1 -0
  107. package/src/mcp/transports/sse.ts +377 -0
  108. package/src/memories/index.ts +15 -6
  109. package/src/mnemopi/backend.ts +2 -2
  110. package/src/modes/acp/acp-agent.ts +1 -1
  111. package/src/modes/components/advisor-config.ts +555 -0
  112. package/src/modes/components/advisor-message.ts +9 -2
  113. package/src/modes/components/agent-hub.ts +9 -4
  114. package/src/modes/components/assistant-message.ts +5 -5
  115. package/src/modes/components/index.ts +2 -0
  116. package/src/modes/components/model-selector.ts +79 -48
  117. package/src/modes/components/settings-selector.ts +1 -0
  118. package/src/modes/components/status-line/component.ts +145 -14
  119. package/src/modes/components/status-line/segments.ts +47 -22
  120. package/src/modes/components/status-line/types.ts +13 -1
  121. package/src/modes/components/tool-execution.ts +47 -6
  122. package/src/modes/controllers/command-controller.ts +23 -2
  123. package/src/modes/controllers/event-controller.ts +114 -11
  124. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  125. package/src/modes/controllers/input-controller.ts +61 -61
  126. package/src/modes/controllers/selector-controller.ts +100 -9
  127. package/src/modes/interactive-mode.ts +65 -10
  128. package/src/modes/print-mode.ts +1 -1
  129. package/src/modes/skill-command.ts +116 -0
  130. package/src/modes/types.ts +7 -2
  131. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  132. package/src/modes/utils/ui-helpers.ts +46 -27
  133. package/src/prompts/agents/reviewer.md +11 -10
  134. package/src/prompts/review-custom-request.md +1 -2
  135. package/src/prompts/review-request.md +1 -2
  136. package/src/prompts/system/interrupted-thinking.md +7 -0
  137. package/src/prompts/system/recap-user.md +9 -0
  138. package/src/prompts/system/subagent-system-prompt.md +8 -5
  139. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  140. package/src/prompts/system/system-prompt.md +0 -1
  141. package/src/prompts/tools/irc.md +2 -2
  142. package/src/prompts/tools/read.md +2 -2
  143. package/src/sdk.ts +40 -50
  144. package/src/session/agent-session.ts +1139 -600
  145. package/src/session/indexed-session-storage.ts +86 -13
  146. package/src/session/messages.test.ts +125 -0
  147. package/src/session/messages.ts +192 -21
  148. package/src/session/redis-session-storage.ts +49 -2
  149. package/src/session/session-entries.ts +39 -2
  150. package/src/session/session-history-format.ts +29 -2
  151. package/src/session/session-listing.ts +54 -24
  152. package/src/session/session-loader.ts +66 -3
  153. package/src/session/session-manager.ts +113 -19
  154. package/src/session/session-persistence.ts +96 -3
  155. package/src/session/session-storage.ts +36 -0
  156. package/src/session/session-title-slot.ts +141 -0
  157. package/src/session/settings-stream-fn.ts +49 -0
  158. package/src/session/sql-session-storage.ts +71 -11
  159. package/src/session/turn-persistence.ts +142 -0
  160. package/src/session/unexpected-stop-classifier.ts +2 -2
  161. package/src/slash-commands/builtin-registry.ts +16 -3
  162. package/src/slash-commands/helpers/mcp.ts +2 -1
  163. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  164. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  165. package/src/ssh/connection-manager.ts +139 -12
  166. package/src/ssh/file-transfer.ts +23 -18
  167. package/src/ssh/ssh-executor.ts +2 -13
  168. package/src/ssh/utils.ts +19 -0
  169. package/src/task/executor.ts +21 -23
  170. package/src/task/render.ts +162 -20
  171. package/src/task/renderer.ts +14 -0
  172. package/src/task/types.ts +17 -0
  173. package/src/task/yield-assembly.ts +207 -0
  174. package/src/tiny/models.ts +8 -6
  175. package/src/tiny/text.ts +37 -7
  176. package/src/tools/ask.ts +55 -4
  177. package/src/tools/image-gen.ts +2 -1
  178. package/src/tools/render-utils.ts +2 -0
  179. package/src/tools/renderers.ts +8 -2
  180. package/src/tools/review.ts +17 -7
  181. package/src/tools/ssh.ts +8 -4
  182. package/src/tools/todo.ts +17 -1
  183. package/src/tools/tts.ts +2 -1
  184. package/src/tools/yield.ts +140 -31
  185. package/src/utils/thinking-display.ts +15 -0
  186. package/src/utils/title-generator.ts +1 -1
  187. package/src/web/search/providers/tavily.ts +36 -19
@@ -14,6 +14,23 @@ export const ADVISOR_TRANSCRIPT_FILENAME = `${ADVISOR_TRANSCRIPT_STEM}.jsonl`;
14
14
 
15
15
  const JSONL_SUFFIX = ".jsonl";
16
16
 
17
+ /**
18
+ * Transcript filename for an advisor: `__advisor.jsonl` for the legacy/default
19
+ * advisor (empty slug), `__advisor.<slug>.jsonl` for a named advisor. The `.`
20
+ * separator keeps named files out of the output manager's `-<n>` bump namespace.
21
+ */
22
+ export function advisorTranscriptFilename(slug: string): string {
23
+ return slug ? `${ADVISOR_TRANSCRIPT_STEM}.${slug}${JSONL_SUFFIX}` : ADVISOR_TRANSCRIPT_FILENAME;
24
+ }
25
+
26
+ /** Whether a filename is any advisor transcript (`__advisor.jsonl` or `__advisor.<slug>.jsonl`). */
27
+ export function isAdvisorTranscriptName(name: string): boolean {
28
+ return (
29
+ name === ADVISOR_TRANSCRIPT_FILENAME ||
30
+ (name.startsWith(`${ADVISOR_TRANSCRIPT_STEM}.`) && name.endsWith(JSONL_SUFFIX))
31
+ );
32
+ }
33
+
17
34
  /**
18
35
  * Append-only persister for an advisor agent's transcript.
19
36
  *
@@ -38,19 +55,25 @@ const JSONL_SUFFIX = ".jsonl";
38
55
  export class AdvisorTranscriptRecorder {
39
56
  #manager: SessionManager | undefined;
40
57
  #file: string | undefined;
58
+ #filename: string;
41
59
  /** Serializes the async open/close against synchronous appends so records land in order. */
42
60
  #queue: Promise<void>;
43
61
 
44
62
  /**
63
+ * @param filename Transcript filename within the session dir. Defaults to
64
+ * `__advisor.jsonl`; named advisors pass `__advisor.<slug>.jsonl` via
65
+ * {@link advisorTranscriptFilename}.
45
66
  * @param after Optional barrier the queue starts behind — used on the advisor
46
67
  * on→off→on toggle so a fresh recorder's first `open` waits for the prior
47
- * recorder's `close` and the two never hold the same `__advisor.jsonl` at once.
68
+ * recorder's `close` and the two never hold the same file at once.
48
69
  */
49
70
  constructor(
50
71
  private readonly resolveSessionFile: () => string | undefined,
51
72
  private readonly resolveCwd: () => string,
73
+ filename: string = ADVISOR_TRANSCRIPT_FILENAME,
52
74
  after?: Promise<unknown>,
53
75
  ) {
76
+ this.#filename = filename;
54
77
  this.#queue = after
55
78
  ? after.then(
56
79
  () => {},
@@ -83,7 +106,7 @@ export class AdvisorTranscriptRecorder {
83
106
  }
84
107
  const sessionFile = this.resolveSessionFile();
85
108
  if (!sessionFile?.endsWith(JSONL_SUFFIX)) return;
86
- const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), ADVISOR_TRANSCRIPT_FILENAME);
109
+ const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), this.#filename);
87
110
  const cwd = this.resolveCwd();
88
111
  this.#enqueue(async () => {
89
112
  if (file !== this.#file) {
@@ -31,79 +31,105 @@ export function formatAdvisorContextPrompt(
31
31
  }
32
32
 
33
33
  /**
34
- * Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir.
35
- * Returns formatted watchdog file blocks ready to be appended to the advisor system prompt.
34
+ * A readable config candidate discovered on the watchdog/advisor search path,
35
+ * with raw (un-expanded) content and its position metadata.
36
36
  */
37
- export async function discoverWatchdogFiles(cwd: string, agentDir?: string): Promise<string[]> {
37
+ export interface ConfigCandidate {
38
+ path: string;
39
+ content: string;
40
+ level: "user" | "project";
41
+ depth: number;
42
+ }
43
+
44
+ /**
45
+ * Walk the watchdog/advisor config search path — the user agent dir plus every
46
+ * directory from `cwd` up to the repo root (or home), probing both `<F>` and
47
+ * `.omp/<F>` for each given filename — and return the readable candidates with
48
+ * their raw content, sorted user-first then project ancestor→leaf (depth
49
+ * descending, so the leaf directory is most specific/last). Shared by
50
+ * {@link discoverWatchdogFiles} and `discoverAdvisorConfigs`. Content is returned
51
+ * verbatim (no `@import` expansion); callers expand what they need.
52
+ */
53
+ export async function collectConfigCandidates(
54
+ cwd: string,
55
+ agentDir: string | undefined,
56
+ filenames: string[],
57
+ ): Promise<ConfigCandidate[]> {
38
58
  const home = os.homedir();
39
59
  const resolvedAgentDir = agentDir ?? getAgentDir();
40
- const userPath = resolvedAgentDir ? path.resolve(resolvedAgentDir, "WATCHDOG.md") : null;
60
+ const userPaths = new Set<string>();
41
61
  let repoRoot: string | null = null;
42
62
  try {
43
63
  repoRoot = await repo.root(cwd);
44
64
  } catch (err) {
45
- logger.debug("Failed to resolve git root for watchdog discovery", { err: String(err) });
65
+ logger.debug("Failed to resolve git root for config discovery", { err: String(err) });
46
66
  }
47
67
 
48
68
  const candidates = new Set<string>();
49
69
 
50
- // 1. User level: ~/.omp/WATCHDOG.md (or active profile agent dir)
70
+ // 1. User level: ~/.omp/<F> (or active profile agent dir)
51
71
  if (resolvedAgentDir) {
52
- candidates.add(path.resolve(resolvedAgentDir, "WATCHDOG.md"));
72
+ for (const filename of filenames) {
73
+ const userPath = path.resolve(resolvedAgentDir, filename);
74
+ candidates.add(userPath);
75
+ userPaths.add(userPath);
76
+ }
53
77
  }
54
78
 
55
79
  // 2. Project levels (both standalone and native config .omp/): walk up from cwd to repoRoot / home
56
80
  let current = cwd;
57
81
  while (true) {
58
- candidates.add(path.resolve(current, ".omp", "WATCHDOG.md"));
59
- candidates.add(path.resolve(current, "WATCHDOG.md"));
60
-
82
+ for (const filename of filenames) {
83
+ candidates.add(path.resolve(current, ".omp", filename));
84
+ candidates.add(path.resolve(current, filename));
85
+ }
61
86
  if (current === (repoRoot ?? home)) break;
62
87
  const parent = path.dirname(current);
63
88
  if (parent === current) break;
64
89
  current = parent;
65
90
  }
66
91
 
67
- const items: Array<{ path: string; content: string; level: "user" | "project"; depth: number }> = [];
68
-
92
+ const items: ConfigCandidate[] = [];
69
93
  for (const candidate of candidates) {
70
94
  try {
71
95
  const content = await Bun.file(candidate).text();
72
- const expanded = await expandAtImports(content, candidate);
73
96
  const parent = path.dirname(candidate);
74
97
  const baseName = parent.split(path.sep).pop() ?? "";
75
-
76
- const isUser = userPath !== null && candidate === userPath;
98
+ const isUser = userPaths.has(candidate);
77
99
  const ownerDir = baseName === ".omp" ? path.dirname(parent) : parent;
78
100
  const ownerBaseName = ownerDir.split(path.sep).pop() ?? "";
79
-
80
101
  if (isUser || !ownerBaseName.startsWith(".") || baseName === ".omp") {
81
102
  const relative = path.relative(cwd, ownerDir);
82
103
  const depth = relative === "" ? 0 : relative.split(path.sep).filter(Boolean).length;
83
- items.push({
84
- path: candidate,
85
- content: expanded,
86
- level: isUser ? "user" : "project",
87
- depth,
88
- });
104
+ items.push({ path: candidate, content, level: isUser ? "user" : "project", depth });
89
105
  }
90
106
  } catch (err) {
91
107
  if (!isEnoent(err)) {
92
- logger.warn("Failed to read WATCHDOG.md candidate", { path: candidate, error: String(err) });
108
+ logger.warn("Failed to read config candidate", { path: candidate, error: String(err) });
93
109
  }
94
110
  }
95
111
  }
96
112
 
97
- // Sort files so that user level comes first, then project level sorted by depth (descending).
98
- // This means user-level rules are first, then project-level rules from ancestor directories down to the leaf directory (depth 0 is last/most prominent).
113
+ // User level first, then project levels sorted by depth descending — ancestor
114
+ // directories first, the leaf (depth 0) last/most prominent.
99
115
  items.sort((a, b) => {
100
- if (a.level !== b.level) {
101
- return a.level === "user" ? -1 : 1;
102
- }
116
+ if (a.level !== b.level) return a.level === "user" ? -1 : 1;
103
117
  return b.depth - a.depth;
104
118
  });
105
119
 
106
- return items.map(item => {
107
- return `Especially pay attention to:\n<attention>\n${item.content}\n</attention>`;
108
- });
120
+ return items;
121
+ }
122
+
123
+ /**
124
+ * Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir.
125
+ * Returns formatted watchdog file blocks ready to be appended to the advisor system prompt.
126
+ */
127
+ export async function discoverWatchdogFiles(cwd: string, agentDir?: string): Promise<string[]> {
128
+ const items = await collectConfigCandidates(cwd, agentDir, ["WATCHDOG.md"]);
129
+ const blocks: string[] = [];
130
+ for (const item of items) {
131
+ const expanded = await expandAtImports(item.content, item.path);
132
+ blocks.push(`Especially pay attention to:\n<attention>\n${expanded}\n</attention>`);
133
+ }
134
+ return blocks;
109
135
  }
@@ -74,10 +74,10 @@ export async function classifyDifficulty(
74
74
  }
75
75
 
76
76
  async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Promise<Effort> {
77
- const resolved = resolveRoleSelection(["smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
77
+ const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
78
78
  const model = resolved?.model;
79
79
  if (!model) {
80
- throw new Error("auto-thinking: no smol model available for classification");
80
+ throw new Error("auto-thinking: no tiny/smol model available for classification");
81
81
  }
82
82
  const apiKey = await deps.registry.getApiKey(model, deps.sessionId);
83
83
  if (!apiKey) {
@@ -320,6 +320,11 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
320
320
  runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration;
321
321
  runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi;
322
322
  const state = runtime.state;
323
+ // `event.systemPrompt` is typed `string[]`, but upstream code paths can leave
324
+ // it unset (issue #3665). Coerce defensively so the autoresearch block still
325
+ // renders — the model just loses the upstream prefix for this turn, which is
326
+ // strictly better than crashing the handler.
327
+ const basePrompt = Array.isArray(event.systemPrompt) ? event.systemPrompt.join("\n\n") : "";
323
328
  const currentSegmentResults = currentResults(state.results, state.currentSegment);
324
329
  const baselineMetric = findBaselineMetric(state.results, state.currentSegment);
325
330
  const baselineRunNumber = findBaselineRunNumber(state.results, state.currentSegment);
@@ -358,7 +363,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
358
363
  return {
359
364
  systemPrompt: [
360
365
  prompt.render(setupPromptTemplate, {
361
- base_system_prompt: event.systemPrompt.join("\n\n"),
366
+ base_system_prompt: basePrompt,
362
367
  has_goal: goal.trim().length > 0,
363
368
  goal,
364
369
  working_dir: ctx.cwd,
@@ -373,7 +378,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
373
378
  return {
374
379
  systemPrompt: [
375
380
  prompt.render(promptTemplate, {
376
- base_system_prompt: event.systemPrompt.join("\n\n"),
381
+ base_system_prompt: basePrompt,
377
382
  has_goal: goal.trim().length > 0,
378
383
  goal,
379
384
  working_dir: ctx.cwd,
package/src/cli/gc-cli.ts CHANGED
@@ -434,17 +434,24 @@ function sessionIdFromSessionPath(sessionPath: string): string | undefined {
434
434
  }
435
435
 
436
436
  function sessionIdFromSessionText(text: string): string | undefined {
437
- const line = text
438
- .split(/\r?\n/)
439
- .find(candidate => candidate.trim().length > 0)
440
- ?.trim();
441
- if (!line) return undefined;
442
- try {
443
- const record = JSON.parse(line) as { type?: unknown; id?: unknown };
444
- return record.type === "session" && typeof record.id === "string" && record.id.length > 0 ? record.id : undefined;
445
- } catch {
446
- return undefined;
437
+ let sawTitleSlot = false;
438
+ for (const rawLine of text.split(/\r?\n/)) {
439
+ const line = rawLine.trim();
440
+ if (!line) continue;
441
+ try {
442
+ const record = JSON.parse(line) as { type?: unknown; id?: unknown };
443
+ if (!sawTitleSlot && record.type === "title") {
444
+ sawTitleSlot = true;
445
+ continue;
446
+ }
447
+ return record.type === "session" && typeof record.id === "string" && record.id.length > 0
448
+ ? record.id
449
+ : undefined;
450
+ } catch {
451
+ return undefined;
452
+ }
447
453
  }
454
+ return undefined;
448
455
  }
449
456
 
450
457
  async function archivedSessionIdFromFile(file: string): Promise<string | undefined> {
@@ -78,6 +78,46 @@ interface PendingSnapshot {
78
78
  isResync: boolean;
79
79
  }
80
80
 
81
+ /** Minimal context surface the idle-state reconciler mutates. */
82
+ export interface GuestIdleReconcilerCtx {
83
+ statusLine: { markActivityEnd: () => void };
84
+ loadingAnimation: { stop: () => void } | undefined;
85
+ }
86
+
87
+ /**
88
+ * Close the guest UI state held open by an earlier `agent_start` whose
89
+ * matching `agent_end` never reached us — most often because a reconnect
90
+ * dropped the event mid-stream. Triggered from {@link CollabGuestLink}'s
91
+ * `state` reconciler when the host reports `isStreaming === false`:
92
+ * folds the in-flight active-time window into the per-session meter (so
93
+ * `time_spent` stops ticking) and stops the `Working…` loader if one is
94
+ * still animating. No-op when the host is still streaming.
95
+ *
96
+ * Exported for direct unit testing; mutates the loader field on `ctx` so
97
+ * the same loader is not stopped twice on subsequent reconciliations.
98
+ */
99
+ export function reconcileGuestIdleHostState(ctx: GuestIdleReconcilerCtx, isStreaming: boolean): void {
100
+ if (isStreaming) return;
101
+ ctx.statusLine.markActivityEnd();
102
+ if (ctx.loadingAnimation) {
103
+ ctx.loadingAnimation.stop();
104
+ ctx.loadingAnimation = undefined;
105
+ }
106
+ }
107
+
108
+ /** Reconcile a welcome/resync snapshot's host activity state into the guest meter. */
109
+ export interface GuestSnapshotActivityReconcilerCtx extends GuestIdleReconcilerCtx {
110
+ statusLine: GuestIdleReconcilerCtx["statusLine"] & { markActivityStart: () => void };
111
+ }
112
+
113
+ export function reconcileGuestSnapshotHostState(ctx: GuestSnapshotActivityReconcilerCtx, isStreaming: boolean): void {
114
+ if (isStreaming) {
115
+ ctx.statusLine.markActivityStart();
116
+ return;
117
+ }
118
+ reconcileGuestIdleHostState(ctx, false);
119
+ }
120
+
81
121
  export class CollabGuestLink {
82
122
  #ctx: InteractiveModeContext;
83
123
  #socket: CollabSocket | null = null;
@@ -355,6 +395,7 @@ export class CollabGuestLink {
355
395
  this.#clearAgentMirror();
356
396
  await this.#ctx.session.switchSession(replicaPath);
357
397
  this.state = pending.state;
398
+ reconcileGuestSnapshotHostState(this.#ctx, pending.state.isStreaming);
358
399
  this.#applyHostState(pending.state);
359
400
  this.#ctx.resetObserverRegistry();
360
401
  this.#applyAgentSnapshots(pending.agents);
@@ -425,12 +466,7 @@ export class CollabGuestLink {
425
466
  this.#applyHostState(frame.state);
426
467
  setSessionTerminalTitle(frame.state.sessionName, frame.state.cwd);
427
468
  this.#updateStatusSegment();
428
- // Reconciler: events normally drive the loader; clear a stale one if
429
- // the host reports idle (e.g. events lost across a reconnect).
430
- if (!frame.state.isStreaming && this.#ctx.loadingAnimation) {
431
- this.#ctx.loadingAnimation.stop();
432
- this.#ctx.loadingAnimation = undefined;
433
- }
469
+ reconcileGuestIdleHostState(this.#ctx, frame.state.isStreaming);
434
470
  this.#ctx.statusLine.invalidate();
435
471
  this.#ctx.ui.requestRender();
436
472
  break;
@@ -588,7 +624,7 @@ export class CollabGuestLink {
588
624
  await this.#ctx.session.newSession();
589
625
  setSessionTerminalTitle(this.#ctx.sessionManager.getSessionName(), this.#ctx.sessionManager.getCwd());
590
626
  this.#ctx.statusLine.invalidate();
591
- this.#ctx.statusLine.setSessionStartTime(Date.now());
627
+ this.#ctx.statusLine.resetActiveTime();
592
628
  this.#ctx.updateEditorTopBorder();
593
629
  this.#ctx.updateEditorBorderColor();
594
630
  this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
@@ -303,16 +303,82 @@ function resolveConfigValue(valueConfig: string): string | undefined {
303
303
  return valueConfig;
304
304
  }
305
305
 
306
- function resolveConfigHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
307
- if (!headers) return undefined;
306
+ type HeaderSource = Record<string, string> | undefined;
307
+
308
+ interface HeaderResolutionOptions {
309
+ authHeader?: boolean;
310
+ apiKeyConfig?: string;
311
+ }
312
+
313
+ function materializeConfigHeaderSources(
314
+ sources: readonly HeaderSource[],
315
+ options?: HeaderResolutionOptions,
316
+ ): Record<string, string> | undefined {
308
317
  const resolved: Record<string, string> = {};
309
- for (const [key, value] of Object.entries(headers)) {
310
- const next = resolveConfigValue(value);
311
- if (next) resolved[key] = next;
318
+ for (const source of sources) {
319
+ if (!source) continue;
320
+ for (const [key, value] of Object.entries(source)) {
321
+ const next = resolveConfigValue(value);
322
+ if (next) resolved[key] = next;
323
+ }
324
+ }
325
+ if (options?.authHeader && options.apiKeyConfig) {
326
+ const resolvedKey = resolveConfigValue(options.apiKeyConfig);
327
+ if (resolvedKey) resolved.Authorization = `Bearer ${resolvedKey}`;
312
328
  }
313
329
  return Object.keys(resolved).length > 0 ? resolved : undefined;
314
330
  }
315
331
 
332
+ function createLiveConfigHeaders(
333
+ sources: readonly HeaderSource[],
334
+ options?: HeaderResolutionOptions,
335
+ ): Record<string, string> | undefined {
336
+ const liveSources = sources.filter((source): source is Record<string, string> => source !== undefined);
337
+ if (liveSources.length === 0 && (!options?.authHeader || !options.apiKeyConfig)) return undefined;
338
+
339
+ const localHeaders: Record<string, string> = {};
340
+ const allSources = [...liveSources, localHeaders];
341
+ const current = () => materializeConfigHeaderSources(allSources, options) ?? {};
342
+ return new Proxy(localHeaders, {
343
+ get(target, property, receiver) {
344
+ if (typeof property !== "string") return Reflect.get(target, property, receiver);
345
+ return current()[property];
346
+ },
347
+ set(target, property, value) {
348
+ if (typeof property !== "string" || typeof value !== "string") return false;
349
+ target[property] = value;
350
+ return true;
351
+ },
352
+ deleteProperty(target, property) {
353
+ if (typeof property !== "string") return false;
354
+ delete target[property];
355
+ return true;
356
+ },
357
+ has(_target, property) {
358
+ if (typeof property !== "string") return false;
359
+ return Object.hasOwn(current(), property);
360
+ },
361
+ ownKeys() {
362
+ return Reflect.ownKeys(current());
363
+ },
364
+ getOwnPropertyDescriptor(_target, property) {
365
+ if (typeof property !== "string") return undefined;
366
+ const headers = current();
367
+ if (!Object.hasOwn(headers, property)) return undefined;
368
+ return {
369
+ configurable: true,
370
+ enumerable: true,
371
+ value: headers[property],
372
+ writable: true,
373
+ };
374
+ },
375
+ });
376
+ }
377
+
378
+ function resolveConfigHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
379
+ return materializeConfigHeaderSources([headers]);
380
+ }
381
+
316
382
  function extractGoogleOAuthToken(value: string | undefined): string | undefined {
317
383
  if (!isAuthenticated(value)) return undefined;
318
384
  try {
@@ -495,21 +561,15 @@ function mergeCustomModelHeaders(
495
561
  authHeader: boolean | undefined,
496
562
  apiKeyConfig: string | undefined,
497
563
  ): Record<string, string> | undefined {
498
- const resolvedModelHeaders = resolveConfigHeaders(modelHeaders);
499
- return mergeAuthHeader({ ...providerHeaders, ...resolvedModelHeaders }, authHeader, apiKeyConfig);
564
+ return createLiveConfigHeaders([providerHeaders, modelHeaders], { authHeader, apiKeyConfig });
500
565
  }
501
566
 
502
- function mergeAuthHeader(
503
- headers: Record<string, string> | undefined,
567
+ function mergeAuthHeaderSources(
568
+ sources: readonly HeaderSource[],
504
569
  authHeader: boolean | undefined,
505
570
  apiKeyConfig: string | undefined,
506
571
  ): Record<string, string> | undefined {
507
- const nextHeaders = headers && Object.keys(headers).length > 0 ? { ...headers } : undefined;
508
- if (!authHeader || !apiKeyConfig) {
509
- return nextHeaders;
510
- }
511
- const resolvedKey = resolveConfigValue(apiKeyConfig);
512
- return resolvedKey ? { ...nextHeaders, Authorization: `Bearer ${resolvedKey}` } : nextHeaders;
572
+ return createLiveConfigHeaders(sources, { authHeader, apiKeyConfig });
513
573
  }
514
574
 
515
575
  /**
@@ -1661,7 +1721,9 @@ export class ModelRegistry {
1661
1721
  baseUrl: override.baseUrl ?? baseOverride?.baseUrl,
1662
1722
  apiKey: override.apiKey ?? baseOverride?.apiKey,
1663
1723
  authHeader: override.authHeader ?? baseOverride?.authHeader,
1664
- headers: override.headers ? { ...(baseOverride?.headers ?? {}), ...override.headers } : baseOverride?.headers,
1724
+ headers: override.headers
1725
+ ? createLiveConfigHeaders([baseOverride?.headers, override.headers])
1726
+ : baseOverride?.headers,
1665
1727
  compat: override.compat ? mergeCompat(baseOverride?.compat, override.compat) : baseOverride?.compat,
1666
1728
  remoteCompaction: mergeRemoteCompactionConfig(baseOverride?.remoteCompaction, override.remoteCompaction),
1667
1729
  transport: override.transport ?? baseOverride?.transport,
@@ -1676,8 +1738,8 @@ export class ModelRegistry {
1676
1738
  "baseUrl" | "headers" | "authHeader" | "apiKey" | "remoteCompaction" | "transport"
1677
1739
  >,
1678
1740
  ): T {
1679
- const headers = mergeAuthHeader(
1680
- override.headers ? { ...entry.headers, ...override.headers } : entry.headers,
1741
+ const headers = mergeAuthHeaderSources(
1742
+ override.headers ? [entry.headers, override.headers] : [entry.headers],
1681
1743
  override.authHeader,
1682
1744
  override.apiKey,
1683
1745
  );
@@ -882,10 +882,14 @@ function shouldInheritDefaultBeforePriority(role: ModelRole): boolean {
882
882
  * list. The advisor — a second-opinion reviewer — defaults to the `slow`
883
883
  * reasoning chain, but (unlike the `slow` role, see
884
884
  * {@link shouldInheritDefaultBeforePriority}) never inherits the primary's
885
- * model, so it stays a distinct strong model out of the box.
885
+ * model, so it stays a distinct strong model out of the box. The `tiny` role —
886
+ * the override for online title/memory/classifier tasks — reuses the `smol`
887
+ * fast chain so an unset tiny role auto-resolves to the same fast model smol
888
+ * would pick.
886
889
  */
887
890
  const ROLE_PRIORITY_ALIAS: Partial<Record<ModelRole, keyof typeof MODEL_PRIO>> = {
888
891
  advisor: "slow",
892
+ tiny: "smol",
889
893
  };
890
894
 
891
895
  /** Built-in priority patterns for a role, following {@link ROLE_PRIORITY_ALIAS}. */
@@ -13,7 +13,7 @@ export type ModelRole =
13
13
  | "plan"
14
14
  | "designer"
15
15
  | "commit"
16
- | "title"
16
+ | "tiny"
17
17
  | "task"
18
18
  | "advisor";
19
19
 
@@ -33,7 +33,7 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
33
33
  plan: { tag: "PLAN", name: "Architect", color: "muted" },
34
34
  designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
35
35
  commit: { tag: "COMMIT", name: "Commit", color: "dim" },
36
- title: { tag: "TITLE", name: "Title", color: "dim", hidden: true },
36
+ tiny: { tag: "TINY", name: "Tiny", color: "dim" },
37
37
  task: { tag: "TASK", name: "Subtask", color: "muted" },
38
38
  advisor: { tag: "ADVISOR", name: "Advisor", color: "accent" },
39
39
  };
@@ -46,7 +46,7 @@ export const MODEL_ROLE_IDS: ModelRole[] = [
46
46
  "plan",
47
47
  "designer",
48
48
  "commit",
49
- "title",
49
+ "tiny",
50
50
  "task",
51
51
  "advisor",
52
52
  ];