@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.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 (57) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/commands/dry-balance.d.ts +31 -0
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/config/models-config-schema.d.ts +3 -0
  6. package/dist/types/config/settings.d.ts +11 -0
  7. package/dist/types/discovery/helpers.d.ts +1 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  9. package/dist/types/hindsight/bank.d.ts +17 -9
  10. package/dist/types/hindsight/mental-models.d.ts +1 -1
  11. package/dist/types/hindsight/state.d.ts +9 -3
  12. package/dist/types/mcp/manager.d.ts +1 -1
  13. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  14. package/dist/types/session/agent-session.d.ts +9 -0
  15. package/dist/types/session/auth-storage.d.ts +2 -2
  16. package/dist/types/task/types.d.ts +2 -0
  17. package/dist/types/tools/index.d.ts +16 -0
  18. package/dist/types/tools/path-utils.d.ts +11 -0
  19. package/package.json +9 -9
  20. package/src/cli/dry-balance-cli.ts +823 -0
  21. package/src/cli-commands.ts +1 -0
  22. package/src/commands/dry-balance.ts +43 -0
  23. package/src/config/model-registry.ts +6 -0
  24. package/src/config/models-config-schema.ts +2 -0
  25. package/src/config/settings.ts +38 -0
  26. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  27. package/src/discovery/github.ts +37 -1
  28. package/src/discovery/helpers.ts +3 -1
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  30. package/src/hindsight/backend.ts +184 -35
  31. package/src/hindsight/bank.ts +32 -22
  32. package/src/hindsight/mental-models.ts +1 -1
  33. package/src/hindsight/state.ts +21 -7
  34. package/src/internal-urls/docs-index.generated.ts +4 -4
  35. package/src/internal-urls/omp-protocol.ts +8 -2
  36. package/src/mcp/manager.ts +40 -21
  37. package/src/modes/components/transcript-container.ts +14 -3
  38. package/src/modes/components/tree-selector.ts +29 -2
  39. package/src/modes/controllers/input-controller.ts +8 -2
  40. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  41. package/src/prompts/agents/explore.md +1 -0
  42. package/src/prompts/agents/librarian.md +1 -0
  43. package/src/prompts/dry-balance-bench.md +8 -0
  44. package/src/sdk.ts +82 -9
  45. package/src/session/agent-session.ts +66 -7
  46. package/src/session/auth-storage.ts +4 -0
  47. package/src/task/executor.ts +6 -2
  48. package/src/task/index.ts +8 -7
  49. package/src/task/types.ts +2 -0
  50. package/src/tools/bash.ts +3 -4
  51. package/src/tools/index.ts +16 -0
  52. package/src/tools/job.ts +3 -3
  53. package/src/tools/memory-reflect.ts +2 -2
  54. package/src/tools/path-utils.ts +21 -0
  55. package/src/tools/search.ts +18 -1
  56. package/src/utils/file-mentions.ts +7 -107
  57. package/src/utils/title-generator.ts +58 -37
@@ -12,12 +12,16 @@ export type {
12
12
  AuthStorageOptions,
13
13
  OAuthCredential,
14
14
  SerializedAuthStorage,
15
+ SnapshotResponse,
15
16
  StoredAuthCredential,
16
17
  } from "@oh-my-pi/pi-ai";
17
18
  export {
18
19
  AuthBrokerClient,
19
20
  AuthStorage,
21
+ DEFAULT_SNAPSHOT_CACHE_TTL_MS,
20
22
  REMOTE_REFRESH_SENTINEL,
21
23
  RemoteAuthCredentialStore,
24
+ readAuthBrokerSnapshotCache,
22
25
  SqliteAuthCredentialStore,
26
+ writeAuthBrokerSnapshotCache,
23
27
  } from "@oh-my-pi/pi-ai";
@@ -531,7 +531,7 @@ function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
531
531
  });
532
532
  }
533
533
 
534
- function createSubagentSettings(baseSettings: Settings): Settings {
534
+ function createSubagentSettings(baseSettings: Settings, overrides?: Partial<Record<SettingPath, unknown>>): Settings {
535
535
  const snapshot: Partial<Record<SettingPath, unknown>> = {};
536
536
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
537
537
  snapshot[key] = baseSettings.get(key);
@@ -545,6 +545,7 @@ function createSubagentSettings(baseSettings: Settings): Settings {
545
545
  // the parent task approval is the authorization boundary. Use yolo mode
546
546
  // to preserve unattended subagent execution. User `tools.approval` policies still apply.
547
547
  "tools.approvalMode": "yolo",
548
+ ...overrides,
548
549
  });
549
550
  }
550
551
 
@@ -619,7 +620,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
619
620
  }
620
621
 
621
622
  const settings = options.settings ?? Settings.isolated();
622
- const subagentSettings = createSubagentSettings(settings);
623
+ const subagentSettings = createSubagentSettings(
624
+ settings,
625
+ agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
626
+ );
623
627
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
624
628
  const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
625
629
  const parentDepth = options.taskDepth ?? 0;
package/src/task/index.ts CHANGED
@@ -17,9 +17,8 @@ import * as os from "node:os";
17
17
  import path from "node:path";
18
18
  import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Usage } from "@oh-my-pi/pi-ai";
20
- import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
20
+ import { $env, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
21
21
  import type { ToolSession } from "..";
22
- import { AsyncJobManager } from "../async";
23
22
  import { resolveAgentModelPatterns } from "../config/model-resolver";
24
23
  import { MCPManager } from "../mcp/manager";
25
24
  import type { Theme } from "../modes/theme/theme";
@@ -343,12 +342,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
343
342
  return this.#executeSync(_toolCallId, params, signal, onUpdate);
344
343
  }
345
344
 
346
- const manager = AsyncJobManager.instance();
345
+ const manager = this.session.asyncJobManager;
347
346
  if (!manager) {
348
- return {
349
- content: [{ type: "text", text: "Async execution is enabled but no async job manager is available." }],
350
- details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
351
- };
347
+ // Async was requested but no manager is registered (e.g. an
348
+ // orphaned session whose host never wired one up). Falling back
349
+ // to the sync path keeps the tool usable; only background/job-poll
350
+ // semantics are lost.
351
+ logger.warn("task: async.enabled but no AsyncJobManager registered; falling back to sync execution");
352
+ return this.#executeSync(_toolCallId, params, signal, onUpdate);
352
353
  }
353
354
 
354
355
  const taskItems = params.tasks ?? [];
package/src/task/types.ts CHANGED
@@ -174,6 +174,8 @@ export interface AgentDefinition {
174
174
  output?: unknown;
175
175
  blocking?: boolean;
176
176
  autoloadSkills?: string[];
177
+ /** When `false`, the agent's `read` tool returns verbatim file content instead of structural summaries. */
178
+ readSummarize?: boolean;
177
179
  source: AgentSource;
178
180
  filePath?: string;
179
181
  }
package/src/tools/bash.ts CHANGED
@@ -10,7 +10,6 @@ import type { Component } from "@oh-my-pi/pi-tui";
10
10
  import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
11
11
  import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
12
12
  import * as z from "zod/v4";
13
- import { AsyncJobManager } from "../async";
14
13
  import { type BashResult, executeBash } from "../exec/bash-executor";
15
14
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
16
15
  import { InternalUrlRouter } from "../internal-urls";
@@ -489,7 +488,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
489
488
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
490
489
  startBackgrounded: boolean;
491
490
  }): ManagedBashJobHandle {
492
- const manager = AsyncJobManager.instance();
491
+ const manager = this.session.asyncJobManager;
493
492
  if (!manager) {
494
493
  throw new ToolError("Background job manager unavailable for this session.");
495
494
  }
@@ -716,7 +715,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
716
715
  if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
717
716
 
718
717
  if (asyncRequested) {
719
- if (!AsyncJobManager.instance()) {
718
+ if (!this.session.asyncJobManager) {
720
719
  throw new ToolError("Async job manager unavailable for this session.");
721
720
  }
722
721
  const job = this.#startManagedBashJob({
@@ -737,7 +736,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
737
736
  });
738
737
  }
739
738
 
740
- const autoBgManager = AsyncJobManager.instance();
739
+ const autoBgManager = this.session.asyncJobManager;
741
740
  if (this.#autoBackgroundEnabled && !pty && autoBgManager) {
742
741
  const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
743
742
  const startBackgrounded = autoBackgroundWaitMs === 0;
@@ -2,6 +2,7 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
+ import type { AsyncJobManager } from "../async/job-manager";
5
6
  import type { PromptTemplate } from "../config/prompt-templates";
6
7
  import type { Settings } from "../config/settings";
7
8
  import { EditTool } from "../edit";
@@ -183,6 +184,21 @@ export interface ToolSession {
183
184
  modelRegistry?: import("../config/model-registry").ModelRegistry;
184
185
  /** Agent output manager for unique agent:// IDs across task invocations */
185
186
  agentOutputManager?: AgentOutputManager;
187
+ /**
188
+ * Async job manager scoped to this session.
189
+ *
190
+ * - Top-level session that constructed one: its own manager.
191
+ * - Subagent (`parentTaskPrefix` set): the parent's manager, so background
192
+ * bash/task work and `onJobComplete` deliveries flow into the conversation
193
+ * that spawned it.
194
+ * - Secondary in-process top-level session that found a singleton already
195
+ * installed (issue #1923): `undefined`. Tools refuse async work rather
196
+ * than silently route completions into the owning session's `yieldQueue`.
197
+ *
198
+ * Tools MUST use this instead of `AsyncJobManager.instance()` so a secondary
199
+ * session never borrows the owning session's manager by accident.
200
+ */
201
+ asyncJobManager?: AsyncJobManager;
186
202
  /** MCP manager visible to subagents without relying on the process-global singleton. */
187
203
  mcpManager?: MCPManager;
188
204
  /** Local protocol root to propagate to nested subagents and eval-created agents. */
package/src/tools/job.ts CHANGED
@@ -3,7 +3,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import * as z from "zod/v4";
6
- import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
6
+ import { type AsyncJob, type AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
8
  import type { Theme } from "../modes/theme/theme";
9
9
  import jobDescription from "../prompts/tools/job.md" with { type: "text" };
@@ -90,7 +90,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
90
90
  onUpdate?: AgentToolUpdateCallback<JobToolDetails>,
91
91
  _context?: AgentToolContext,
92
92
  ): Promise<AgentToolResult<JobToolDetails>> {
93
- const manager = AsyncJobManager.instance();
93
+ const manager = this.session.asyncJobManager;
94
94
  if (!manager) {
95
95
  return {
96
96
  content: [{ type: "text", text: "Async execution is disabled; no background jobs are available." }],
@@ -254,7 +254,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
254
254
  ): JobSnapshot[] {
255
255
  const now = Date.now();
256
256
  return jobs.map(j => {
257
- const current = AsyncJobManager.instance()?.getJob(j.id);
257
+ const current = this.session.asyncJobManager?.getJob(j.id);
258
258
  const latest = current ?? j;
259
259
  return {
260
260
  id: latest.id,
@@ -1,7 +1,7 @@
1
1
  import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import * as z from "zod/v4";
4
- import { ensureBankMission } from "../hindsight/bank";
4
+ import { ensureBankExists } from "../hindsight/bank";
5
5
  import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" };
6
6
  import type { ToolSession } from ".";
7
7
 
@@ -67,7 +67,7 @@ export class MemoryReflectTool implements AgentTool<typeof memoryReflectSchema>
67
67
  }
68
68
 
69
69
  try {
70
- await ensureBankMission(state.client, state.bankId, state.config, state.missionsSet);
70
+ await ensureBankExists(state.client, state.bankId, state.config, state.banksSet);
71
71
  const response = await state.client.reflect(state.bankId, params.query, {
72
72
  context: params.context,
73
73
  budget: state.config.recallBudget,
@@ -217,6 +217,27 @@ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null
217
217
  return merged as [LineRange, ...LineRange[]];
218
218
  }
219
219
 
220
+ /**
221
+ * Extract the line-range component from a read-tool selector that may also
222
+ * carry a verbatim/index display mode (`raw`, `conflicts`) — alone or compounded
223
+ * with a range (`raw:50-100`, `50-100:raw`). Returns the parsed ranges when the
224
+ * selector names any, otherwise `undefined` (pure `raw`/`conflicts`/none).
225
+ *
226
+ * Used by content search, which honors line ranges as a match filter but has no
227
+ * use for verbatim/conflict display modes — so those selectors are accepted and
228
+ * treated as an unfiltered, whole-resource search rather than rejected.
229
+ */
230
+ export function selectorLineRanges(sel: string | undefined): [LineRange, ...LineRange[]] | undefined {
231
+ if (!sel) return undefined;
232
+ for (const chunk of sel.split(":")) {
233
+ const lower = chunk.toLowerCase();
234
+ if (lower === "raw" || lower === "conflicts") continue;
235
+ const ranges = parseLineRanges(chunk);
236
+ if (ranges) return ranges;
237
+ }
238
+ return undefined;
239
+ }
240
+
220
241
  /** Return `true` when `lineNumber` (1-indexed) falls in any of the supplied ranges. */
221
242
  export function isLineInRanges(lineNumber: number, ranges: readonly LineRange[]): boolean {
222
243
  for (const range of ranges) {
@@ -38,6 +38,8 @@ import {
38
38
  type ResolvedSearchTarget,
39
39
  resolveReadPath,
40
40
  resolveToolSearchScope,
41
+ selectorLineRanges,
42
+ splitInternalUrlSel,
41
43
  splitPathAndSel,
42
44
  } from "./path-utils";
43
45
  import {
@@ -109,6 +111,21 @@ interface SearchPathSpec {
109
111
  function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
110
112
  const specs: SearchPathSpec[] = [];
111
113
  for (const entry of rawEntries) {
114
+ // Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
115
+ // which peels selector-shaped tails only for selector-capable schemes and
116
+ // leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
117
+ // verbatim/index display modes (`raw`, `conflicts`) carry no meaning for
118
+ // content search, so we accept them — searching the whole resource — and
119
+ // still honor any embedded line range as a match filter.
120
+ const internalSplit = splitInternalUrlSel(entry);
121
+ if (internalSplit.sel !== undefined) {
122
+ specs.push({
123
+ original: entry,
124
+ clean: internalSplit.path,
125
+ ranges: selectorLineRanges(internalSplit.sel),
126
+ });
127
+ continue;
128
+ }
112
129
  const split = splitPathAndSel(entry);
113
130
  let clean = entry;
114
131
  let ranges: [LineRange, ...LineRange[]] | undefined;
@@ -259,7 +276,7 @@ interface IndexedContentLines {
259
276
  }
260
277
 
261
278
  const INTERNAL_URL_DISPLAY_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
262
- const OMP_ROOT_URL_RE = /^omp:\/\/\/?$/i;
279
+ const OMP_ROOT_URL_RE = /^omp:\/\/(?:\/?|docs\/?)$/i;
263
280
 
264
281
  function normalizeSearchLine(line: string): string {
265
282
  return line.endsWith("\r") ? line.slice(0, -1) : line;
@@ -10,8 +10,6 @@ import path from "node:path";
10
10
  import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline";
11
11
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
12
12
  import type { ImageContent } from "@oh-my-pi/pi-ai";
13
- import { glob } from "@oh-my-pi/pi-natives";
14
- import { fuzzyMatch } from "@oh-my-pi/pi-tui";
15
13
  import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
16
14
  import { normalizeToLF } from "../edit/normalize";
17
15
  import type { FileMentionMessage } from "../session/messages";
@@ -30,27 +28,6 @@ const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
30
28
  const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
31
29
  const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
32
30
  const DEFAULT_DIR_LIMIT = 500;
33
- const MIN_FUZZY_QUERY_LENGTH = 5;
34
- const MAX_RESOLUTION_CANDIDATES = 20_000;
35
- const PATH_SEPARATOR_REGEX = /[/._\-\s]+/g;
36
-
37
- type MentionDiscoveryProfile = {
38
- hidden: boolean;
39
- gitignore: boolean;
40
- includeNodeModules: boolean;
41
- maxResults: number;
42
- cache: boolean;
43
- };
44
-
45
- function getMentionCandidateDiscoveryProfile(): MentionDiscoveryProfile {
46
- return {
47
- hidden: true,
48
- gitignore: true,
49
- cache: true,
50
- includeNodeModules: true,
51
- maxResults: MAX_RESOLUTION_CANDIDATES,
52
- };
53
- }
54
31
 
55
32
  // Avoid OOM when users @mention very large files. Above these limits we skip
56
33
  // auto-reading and only include the path in the message.
@@ -70,16 +47,6 @@ function sanitizeMentionPath(rawPath: string): string | null {
70
47
  return cleaned.length > 0 ? cleaned : null;
71
48
  }
72
49
 
73
- type MentionCandidate = {
74
- path: string;
75
- pathLower: string;
76
- normalizedPath: string;
77
- };
78
-
79
- function normalizeMentionQuery(query: string): string {
80
- return query.toLowerCase().replace(PATH_SEPARATOR_REGEX, "");
81
- }
82
-
83
50
  async function pathExists(filePath: string): Promise<boolean> {
84
51
  try {
85
52
  await Bun.file(filePath).stat();
@@ -89,75 +56,13 @@ async function pathExists(filePath: string): Promise<boolean> {
89
56
  }
90
57
  }
91
58
 
92
- async function listMentionCandidates(cwd: string): Promise<MentionCandidate[]> {
93
- let entries: string[];
94
- try {
95
- const discoveryProfile = getMentionCandidateDiscoveryProfile();
96
- const result = await glob({
97
- pattern: "**/*",
98
- path: cwd,
99
- ...discoveryProfile,
100
- });
101
- entries = result.matches.map(match => match.path);
102
- } catch {
103
- return [];
104
- }
105
-
106
- entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
107
- const candidates: MentionCandidate[] = [];
108
- for (const entry of entries) {
109
- const pathLower = entry.toLowerCase();
110
- const normalizedPath = normalizeMentionQuery(entry);
111
- if (normalizedPath.length === 0) {
112
- continue;
113
- }
114
- candidates.push({ path: entry, pathLower, normalizedPath });
115
- }
116
- return candidates;
117
- }
118
-
119
- async function resolveMentionPath(
120
- filePath: string,
121
- cwd: string,
122
- getMentionCandidates: () => Promise<MentionCandidate[]>,
123
- ): Promise<string | null> {
59
+ async function resolveMentionPath(filePath: string, cwd: string): Promise<string | null> {
60
+ // Exact resolution only. The TUI @-selector inserts the real, complete path, so a
61
+ // mention that does not resolve to an existing file or directory is prose, not a file
62
+ // reference. Fuzzy/prefix guessing here previously dragged in unrelated same-named
63
+ // files; that disambiguation belongs to the selector's display, not post-send.
124
64
  const absolutePath = resolveReadPath(filePath, cwd);
125
- if (await pathExists(absolutePath)) {
126
- return filePath;
127
- }
128
-
129
- const queryLower = filePath.toLowerCase();
130
- const candidates = await getMentionCandidates();
131
- const prefixMatches = candidates.filter(candidate => candidate.pathLower.startsWith(queryLower));
132
- if (prefixMatches.length === 1) {
133
- return prefixMatches[0]?.path ?? null;
134
- }
135
- if (prefixMatches.length > 1) {
136
- return null;
137
- }
138
-
139
- const normalizedQuery = normalizeMentionQuery(filePath);
140
- if (normalizedQuery.length < MIN_FUZZY_QUERY_LENGTH) {
141
- return null;
142
- }
143
-
144
- const scored = candidates
145
- .map(candidate => ({ candidate, match: fuzzyMatch(normalizedQuery, candidate.normalizedPath) }))
146
- .filter(entry => entry.match.matches)
147
- .sort((a, b) => {
148
- if (a.match.score !== b.match.score) {
149
- return a.match.score - b.match.score;
150
- }
151
- return a.candidate.path.localeCompare(b.candidate.path);
152
- });
153
-
154
- if (scored.length === 0) {
155
- return null;
156
- }
157
-
158
- const best = scored[0];
159
-
160
- return best?.candidate.path ?? null;
65
+ return (await pathExists(absolutePath)) ? filePath : null;
161
66
  }
162
67
 
163
68
  function buildTextOutput(textContent: string): { output: string; lineCount: number } {
@@ -285,14 +190,9 @@ export async function generateFileMentionMessages(
285
190
  const autoResizeImages = options?.autoResizeImages ?? true;
286
191
 
287
192
  const files: FileMentionMessage["files"] = [];
288
- let mentionCandidatesPromise: Promise<MentionCandidate[]> | null = null;
289
- const getMentionCandidates = (): Promise<MentionCandidate[]> => {
290
- mentionCandidatesPromise ??= listMentionCandidates(cwd);
291
- return mentionCandidatesPromise;
292
- };
293
193
 
294
194
  for (const filePath of filePaths) {
295
- const resolvedPath = await resolveMentionPath(filePath, cwd, getMentionCandidates);
195
+ const resolvedPath = await resolveMentionPath(filePath, cwd);
296
196
  if (!resolvedPath) {
297
197
  continue;
298
198
  }
@@ -149,7 +149,10 @@ export async function generateSessionTitle(
149
149
  // tiny title model can't reliably decline trivial input, so this happens
150
150
  // deterministically before any model is invoked; the caller retries on the
151
151
  // next user message while the session stays unnamed.
152
- if (isLowSignalTitleInput(firstMessage)) return null;
152
+ if (isLowSignalTitleInput(firstMessage)) {
153
+ logger.debug("title-generator: skipped low-signal input", { sessionId, reason: "low-signal" });
154
+ return null;
155
+ }
153
156
 
154
157
  const tinyModel = settings.get("providers.tinyModel");
155
158
  if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
@@ -159,7 +162,14 @@ export async function generateSessionTitle(
159
162
  const onlineAbortController = new AbortController();
160
163
  const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
161
164
  title => title || null,
162
- () => null,
165
+ err => {
166
+ logger.warn("title-generator: local model error", {
167
+ sessionId,
168
+ model: tinyModel,
169
+ error: err instanceof Error ? err.message : String(err),
170
+ });
171
+ return null;
172
+ },
163
173
  );
164
174
  const startOnline = (): Promise<string | null> =>
165
175
  generateTitleOnline(
@@ -188,49 +198,48 @@ export async function generateTitleOnline(
188
198
  ): Promise<string | null> {
189
199
  const model = getTitleModel(registry, settings, currentModel);
190
200
  if (!model) {
191
- logger.debug("title-generator: no title model found");
201
+ logger.warn("title-generator: no title model found", { sessionId, reason: "no-title-model" });
192
202
  return null;
193
203
  }
194
204
 
195
205
  const userMessage = formatTitleUserMessage(firstMessage);
196
-
197
- const apiKey = await registry.getApiKey(model, sessionId);
198
- if (!apiKey) {
199
- logger.debug("title-generator: no API key for smol model", {
200
- provider: model.provider,
201
- id: model.id,
202
- });
203
- return null;
204
- }
205
- // Resolve metadata after getApiKey so the session-sticky credential for this
206
- // request is already recorded; metadataResolver can then return the correct
207
- // account_uuid rather than the snapshot-at-call-site value.
208
- const metadata = metadataResolver?.(model.provider);
209
-
210
- // Title generation is a 3-6 word task, but some reasoning backends ignore
211
- // disableReasoning. Keep the normal cheap budget for non-reasoning models
212
- // while reserving enough output room for reasoning models to still emit
213
- // the forced tool call after any unavoidable thinking tokens.
214
- const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
215
- const request = {
216
- model: `${model.provider}/${model.id}`,
217
- systemPrompt: TITLE_SYSTEM_PROMPT,
218
- userMessage,
219
- maxTokens,
206
+ const modelName = `${model.provider}/${model.id}`;
207
+ const modelContext = {
208
+ sessionId,
209
+ provider: model.provider,
210
+ id: model.id,
211
+ model: modelName,
220
212
  };
221
- logger.debug("title-generator: request", request);
213
+ logger.debug("title-generator: start", modelContext);
222
214
 
223
215
  try {
216
+ const apiKey = await registry.getApiKey(model, sessionId);
217
+ if (!apiKey) {
218
+ logger.warn("title-generator: no API key", { ...modelContext, reason: "missing-api-key" });
219
+ return null;
220
+ }
221
+ // Resolve metadata after getApiKey so the session-sticky credential for this
222
+ // request is already recorded; metadataResolver can then return the correct
223
+ // account_uuid rather than the snapshot-at-call-site value.
224
+ const metadata = metadataResolver?.(model.provider);
225
+
226
+ // Title generation is a 3-6 word task, but some reasoning backends ignore
227
+ // disableReasoning. Keep the normal cheap budget for non-reasoning models
228
+ // while reserving enough output room for reasoning models to still emit
229
+ // the forced tool call after any unavoidable thinking tokens.
230
+ const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
231
+ logger.debug("title-generator: request", { ...modelContext, maxTokens });
232
+
224
233
  const response = await completeSimple(
225
234
  model,
226
235
  {
227
- systemPrompt: [request.systemPrompt],
228
- messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
236
+ systemPrompt: [TITLE_SYSTEM_PROMPT],
237
+ messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
229
238
  tools: [setTitleTool],
230
239
  },
231
240
  {
232
241
  apiKey,
233
- maxTokens: request.maxTokens,
242
+ maxTokens,
234
243
  disableReasoning: true,
235
244
  toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
236
245
  metadata,
@@ -239,8 +248,9 @@ export async function generateTitleOnline(
239
248
  );
240
249
 
241
250
  if (response.stopReason === "error") {
242
- logger.debug("title-generator: response error", {
243
- model: request.model,
251
+ logger.warn("title-generator: response error", {
252
+ ...modelContext,
253
+ reason: "provider-response-error",
244
254
  stopReason: response.stopReason,
245
255
  errorMessage: response.errorMessage,
246
256
  });
@@ -249,8 +259,18 @@ export async function generateTitleOnline(
249
259
 
250
260
  const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content));
251
261
 
252
- logger.debug("title-generator: response", {
253
- model: request.model,
262
+ if (!title) {
263
+ logger.debug("title-generator: no title returned", {
264
+ ...modelContext,
265
+ reason: "model-returned-none",
266
+ usage: response.usage,
267
+ stopReason: response.stopReason,
268
+ });
269
+ return null;
270
+ }
271
+
272
+ logger.debug("title-generator: success", {
273
+ ...modelContext,
254
274
  title,
255
275
  usage: response.usage,
256
276
  stopReason: response.stopReason,
@@ -258,8 +278,9 @@ export async function generateTitleOnline(
258
278
 
259
279
  return title;
260
280
  } catch (err) {
261
- logger.debug("title-generator: error", {
262
- model: request.model,
281
+ logger.warn("title-generator: error", {
282
+ ...modelContext,
283
+ reason: "exception",
263
284
  error: err instanceof Error ? err.message : String(err),
264
285
  });
265
286
  return null;