@oh-my-pi/pi-coding-agent 13.12.4 → 13.12.6

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.12.6] - 2026-03-15
6
+ ### Changed
7
+
8
+ - Updated llama.cpp model discovery to read context window from the `/props` endpoint's `default_generation_settings.n_ctx` field instead of using hardcoded 128000 default
9
+ - Updated llama.cpp model discovery to detect vision capabilities from the `/props` endpoint's `modalities.vision` field instead of defaulting to text-only input
10
+ - Changed llama.cpp `maxTokens` calculation to respect discovered context window limits, capping at 8192 or the server's context window, whichever is smaller
11
+
12
+ ### Fixed
13
+
14
+ - Fixed llama.cpp auto-discovery to read context window and vision support from the native `/props` endpoint instead of relying on hardcoded defaults
15
+
16
+ ## [13.12.5] - 2026-03-15
17
+
18
+ ### Added
19
+
20
+ - Automatic discovery of Ollama model context window from model metadata, enabling accurate token limit configuration
21
+ - Added `attribution` option to `PromptOptions` to explicitly control billing/initiator attribution for prompts
22
+ - Added automatic clearing of completed and abandoned todo tasks after ~1 minute
23
+
24
+ ### Changed
25
+
26
+ - Ollama model registration now uses discovered context window instead of hardcoded 128000 token default
27
+ - Ollama model maxTokens now respects discovered context window constraints
28
+ - Improved session directory migration to handle legacy absolute paths with double-dash format, automatically relocating them to new canonical locations
29
+ - Enhanced session directory encoding to use `-tmp-` prefix for temporary directories instead of legacy double-dash format for better clarity
30
+ - Updated `SessionManager.create()` to require both `cwd` and `sessionDir` parameters for explicit session directory control
31
+ - Improved session directory naming for temporary working directories using `-tmp-` prefix instead of legacy `--` format
32
+ - Made `cwd` and `sessionDir` fields mutable in SessionManager to support session relocation without type casting
33
+ - Changed subagent prompts to explicitly set `attribution: "agent"` for accurate billing attribution
34
+ - Strip already-completed tasks when restoring session from branch history
35
+
36
+ ### Fixed
37
+
38
+ - Fixed automatic migration of legacy session directories to new `-tmp-` prefixed naming scheme for temp-root sessions
39
+
5
40
  ## [13.12.4] - 2026-03-15
6
41
  ### Added
7
42
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.12.4",
4
+ "version": "13.12.6",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.12.4",
45
- "@oh-my-pi/pi-agent-core": "13.12.4",
46
- "@oh-my-pi/pi-ai": "13.12.4",
47
- "@oh-my-pi/pi-natives": "13.12.4",
48
- "@oh-my-pi/pi-tui": "13.12.4",
49
- "@oh-my-pi/pi-utils": "13.12.4",
44
+ "@oh-my-pi/omp-stats": "13.12.6",
45
+ "@oh-my-pi/pi-agent-core": "13.12.6",
46
+ "@oh-my-pi/pi-ai": "13.12.6",
47
+ "@oh-my-pi/pi-natives": "13.12.6",
48
+ "@oh-my-pi/pi-tui": "13.12.6",
49
+ "@oh-my-pi/pi-utils": "13.12.6",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -161,11 +161,17 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
161
161
  let retryCount = 0;
162
162
  const needsChangelog = input.requireChangelog && input.changelogTargets.length > 0;
163
163
 
164
- await session.prompt(prompt, { expandPromptTemplates: false });
164
+ await session.prompt(prompt, {
165
+ attribution: "agent",
166
+ expandPromptTemplates: false,
167
+ });
165
168
  while (retryCount < MAX_RETRIES && !isProposalComplete(state, needsChangelog)) {
166
169
  retryCount += 1;
167
170
  const reminder = buildReminderMessage(state, needsChangelog, retryCount, MAX_RETRIES);
168
- await session.prompt(reminder, { expandPromptTemplates: false });
171
+ await session.prompt(reminder, {
172
+ attribution: "agent",
173
+ expandPromptTemplates: false,
174
+ });
169
175
  }
170
176
 
171
177
  return state;
@@ -366,6 +366,17 @@ interface CustomModelsResult {
366
366
  found: boolean;
367
367
  }
368
368
 
369
+ type OllamaDiscoveredModelMetadata = {
370
+ reasoning: boolean;
371
+ input: ("text" | "image")[];
372
+ contextWindow?: number;
373
+ };
374
+
375
+ type LlamaCppDiscoveredServerMetadata = {
376
+ contextWindow?: number;
377
+ input?: ("text" | "image")[];
378
+ };
379
+
369
380
  /**
370
381
  * Resolve an API key config value to an actual key.
371
382
  * Checks environment variable first, then treats as literal.
@@ -376,6 +387,59 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
376
387
  return keyConfig;
377
388
  }
378
389
 
390
+ function toPositiveNumberOrUndefined(value: unknown): number | undefined {
391
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
392
+ return value;
393
+ }
394
+ if (typeof value === "string" && value.trim()) {
395
+ const parsed = Number(value);
396
+ if (Number.isFinite(parsed) && parsed > 0) {
397
+ return parsed;
398
+ }
399
+ }
400
+ return undefined;
401
+ }
402
+
403
+ function extractOllamaContextWindow(payload: Record<string, unknown>): number | undefined {
404
+ const modelInfo = payload.model_info;
405
+ if (isRecord(modelInfo)) {
406
+ for (const [key, value] of Object.entries(modelInfo)) {
407
+ if (key === "context_length" || key.endsWith(".context_length")) {
408
+ const contextWindow = toPositiveNumberOrUndefined(value);
409
+ if (contextWindow !== undefined) {
410
+ return contextWindow;
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ const parameters = payload.parameters;
417
+ if (typeof parameters !== "string") {
418
+ return undefined;
419
+ }
420
+ const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
421
+ return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
422
+ }
423
+
424
+ function extractLlamaCppContextWindow(payload: Record<string, unknown>): number | undefined {
425
+ const generationSettings = payload.default_generation_settings;
426
+ if (isRecord(generationSettings)) {
427
+ const contextWindow = toPositiveNumberOrUndefined(generationSettings.n_ctx);
428
+ if (contextWindow !== undefined) {
429
+ return contextWindow;
430
+ }
431
+ }
432
+ return toPositiveNumberOrUndefined(payload.n_ctx);
433
+ }
434
+
435
+ function extractLlamaCppInputCapabilities(payload: Record<string, unknown>): ("text" | "image")[] | undefined {
436
+ const modalities = payload.modalities;
437
+ if (!isRecord(modalities)) {
438
+ return undefined;
439
+ }
440
+ return modalities.vision === true ? ["text", "image"] : ["text"];
441
+ }
442
+
379
443
  function extractGoogleOAuthToken(value: string | undefined): string | undefined {
380
444
  if (!isAuthenticated(value)) return undefined;
381
445
  try {
@@ -1096,7 +1160,7 @@ export class ModelRegistry {
1096
1160
  endpoint: string,
1097
1161
  modelId: string,
1098
1162
  headers: Record<string, string> | undefined,
1099
- ): Promise<{ reasoning: boolean; input: ("text" | "image")[] } | null> {
1163
+ ): Promise<OllamaDiscoveredModelMetadata | null> {
1100
1164
  const showUrl = `${endpoint}/api/show`;
1101
1165
  try {
1102
1166
  const response = await fetch(showUrl, {
@@ -1112,6 +1176,7 @@ export class ModelRegistry {
1112
1176
  if (!isRecord(payload)) {
1113
1177
  return null;
1114
1178
  }
1179
+ const contextWindow = extractOllamaContextWindow(payload);
1115
1180
  const capabilities = payload.capabilities;
1116
1181
  if (Array.isArray(capabilities)) {
1117
1182
  const normalized = new Set(
@@ -1121,15 +1186,21 @@ export class ModelRegistry {
1121
1186
  return {
1122
1187
  reasoning: normalized.has("thinking"),
1123
1188
  input: supportsVision ? ["text", "image"] : ["text"],
1189
+ contextWindow,
1124
1190
  };
1125
1191
  }
1126
1192
  if (!isRecord(capabilities)) {
1127
- return null;
1193
+ return {
1194
+ reasoning: false,
1195
+ input: ["text"],
1196
+ contextWindow,
1197
+ };
1128
1198
  }
1129
1199
  const supportsVision = capabilities.vision === true || capabilities.image === true;
1130
1200
  return {
1131
1201
  reasoning: capabilities.thinking === true,
1132
1202
  input: supportsVision ? ["text", "image"] : ["text"],
1203
+ contextWindow,
1133
1204
  };
1134
1205
  } catch {
1135
1206
  return null;
@@ -1170,14 +1241,40 @@ export class ModelRegistry {
1170
1241
  reasoning: metadata?.reasoning ?? false,
1171
1242
  input: metadata?.input ?? ["text"],
1172
1243
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1173
- contextWindow: 128000,
1174
- maxTokens: 8192,
1244
+ contextWindow: metadata?.contextWindow ?? 128000,
1245
+ maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, 8192),
1175
1246
  headers: providerConfig.headers,
1176
1247
  });
1177
1248
  });
1178
1249
  return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
1179
1250
  }
1180
1251
 
1252
+ async #discoverLlamaCppServerMetadata(
1253
+ baseUrl: string,
1254
+ headers: Record<string, string> | undefined,
1255
+ ): Promise<LlamaCppDiscoveredServerMetadata | null> {
1256
+ const propsUrl = `${this.#toLlamaCppNativeBaseUrl(baseUrl)}/props`;
1257
+ try {
1258
+ const response = await fetch(propsUrl, {
1259
+ headers,
1260
+ signal: AbortSignal.timeout(150),
1261
+ });
1262
+ if (!response.ok) {
1263
+ return null;
1264
+ }
1265
+ const payload = (await response.json()) as unknown;
1266
+ if (!isRecord(payload)) {
1267
+ return null;
1268
+ }
1269
+ return {
1270
+ contextWindow: extractLlamaCppContextWindow(payload),
1271
+ input: extractLlamaCppInputCapabilities(payload),
1272
+ };
1273
+ } catch {
1274
+ return null;
1275
+ }
1276
+ }
1277
+
1181
1278
  async #discoverLlamaCppModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1182
1279
  const baseUrl = this.#normalizeLlamaCppBaseUrl(providerConfig.baseUrl);
1183
1280
  const modelsUrl = `${baseUrl}/models`;
@@ -1188,10 +1285,13 @@ export class ModelRegistry {
1188
1285
  headers.Authorization = `Bearer ${apiKey}`;
1189
1286
  }
1190
1287
 
1191
- const response = await fetch(modelsUrl, {
1192
- headers,
1193
- signal: AbortSignal.timeout(250),
1194
- });
1288
+ const [response, serverMetadata] = await Promise.all([
1289
+ fetch(modelsUrl, {
1290
+ headers,
1291
+ signal: AbortSignal.timeout(250),
1292
+ }),
1293
+ this.#discoverLlamaCppServerMetadata(baseUrl, headers),
1294
+ ]);
1195
1295
  if (!response.ok) {
1196
1296
  throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
1197
1297
  }
@@ -1209,10 +1309,10 @@ export class ModelRegistry {
1209
1309
  provider: providerConfig.provider,
1210
1310
  baseUrl,
1211
1311
  reasoning: false,
1212
- input: ["text"],
1312
+ input: serverMetadata?.input ?? ["text"],
1213
1313
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1214
- contextWindow: 128000,
1215
- maxTokens: 8192,
1314
+ contextWindow: serverMetadata?.contextWindow ?? 128000,
1315
+ maxTokens: Math.min(serverMetadata?.contextWindow ?? Number.POSITIVE_INFINITY, 8192),
1216
1316
  headers,
1217
1317
  compat: {
1218
1318
  supportsStore: false,
@@ -1284,6 +1384,18 @@ export class ModelRegistry {
1284
1384
  }
1285
1385
  }
1286
1386
 
1387
+ #toLlamaCppNativeBaseUrl(baseUrl: string): string {
1388
+ try {
1389
+ const parsed = new URL(baseUrl);
1390
+ const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
1391
+ parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath.slice(0, -3) || "/" : trimmedPath || "/";
1392
+ const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
1393
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
1394
+ } catch {
1395
+ return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
1396
+ }
1397
+ }
1398
+
1287
1399
  #normalizeLmStudioBaseUrl(baseUrl?: string): string {
1288
1400
  const defaultBaseUrl = "http://127.0.0.1:1234/v1";
1289
1401
  const raw = baseUrl || defaultBaseUrl;
@@ -275,6 +275,28 @@ handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): str
275
275
  return `${ref}:${text}`;
276
276
  });
277
277
 
278
+ const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/;
279
+ const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/;
280
+
281
+ /**
282
+ * Keep the check source-level and cheap: if the template text contains any explicit
283
+ * inline-arg placeholder syntax, do not append the fallback text again.
284
+ */
285
+ export function templateUsesInlineArgPlaceholders(templateSource: string): boolean {
286
+ return INLINE_ARG_SHELL_PATTERN.test(templateSource) || INLINE_ARG_TEMPLATE_PATTERN.test(templateSource);
287
+ }
288
+
289
+ export function appendInlineArgsFallback(
290
+ rendered: string,
291
+ argsText: string,
292
+ usesInlineArgPlaceholders: boolean,
293
+ ): string {
294
+ if (argsText.length === 0 || usesInlineArgPlaceholders) return rendered;
295
+ if (rendered.length === 0) return argsText;
296
+
297
+ return `${rendered}\n\n${argsText}`;
298
+ }
299
+
278
300
  export function renderPromptTemplate(template: string, context: TemplateContext = {}): string {
279
301
  const compiled = handlebars.compile(template, { noEscape: true, strict: false });
280
302
  const rendered = compiled(context ?? {});
@@ -405,8 +427,10 @@ export function expandPromptTemplate(text: string, templates: PromptTemplate[]):
405
427
  if (template) {
406
428
  const args = parseCommandArgs(argsString);
407
429
  const argsText = args.join(" ");
430
+ const usesInlineArgPlaceholders = templateUsesInlineArgPlaceholders(template.content);
408
431
  const substituted = substituteArgs(template.content, args);
409
- return renderPromptTemplate(substituted, { args, ARGUMENTS: argsText, arguments: argsText });
432
+ const rendered = renderPromptTemplate(substituted, { args, ARGUMENTS: argsText, arguments: argsText });
433
+ return appendInlineArgsFallback(rendered, argsText, usesInlineArgPlaceholders);
410
434
  }
411
435
 
412
436
  return text;
@@ -1292,6 +1292,17 @@ export const SETTINGS_SCHEMA = {
1292
1292
  default: {} as Record<string, string>,
1293
1293
  },
1294
1294
 
1295
+ "tasks.todoClearDelay": {
1296
+ type: "number",
1297
+ default: 60,
1298
+ ui: {
1299
+ tab: "tasks",
1300
+ label: "Todo auto-clear delay",
1301
+ description: "How long to wait before removing completed/abandoned tasks from the list",
1302
+ submenu: true,
1303
+ },
1304
+ },
1305
+
1295
1306
  // Skills
1296
1307
  "skills.enabled": { type: "boolean", default: true },
1297
1308
 
@@ -1,6 +1,10 @@
1
1
  import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
2
2
  import { slashCommandCapability } from "../capability/slash-command";
3
- import { renderPromptTemplate } from "../config/prompt-templates";
3
+ import {
4
+ appendInlineArgsFallback,
5
+ renderPromptTemplate,
6
+ templateUsesInlineArgPlaceholders,
7
+ } from "../config/prompt-templates";
4
8
  import type { SlashCommand } from "../discovery";
5
9
  import { loadCapability } from "../discovery";
6
10
  import {
@@ -217,8 +221,10 @@ export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[
217
221
  if (fileCommand) {
218
222
  const args = parseCommandArgs(argsString);
219
223
  const argsText = args.join(" ");
224
+ const usesInlineArgPlaceholders = templateUsesInlineArgPlaceholders(fileCommand.content);
220
225
  const substituted = substituteArgs(fileCommand.content, args);
221
- return renderPromptTemplate(substituted, { args, ARGUMENTS: argsText, arguments: argsText });
226
+ const rendered = renderPromptTemplate(substituted, { args, ARGUMENTS: argsText, arguments: argsText });
227
+ return appendInlineArgsFallback(rendered, argsText, usesInlineArgPlaceholders);
222
228
  }
223
229
 
224
230
  return text;
@@ -248,6 +248,17 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
248
248
  { value: "1000", label: "1000 lines" },
249
249
  { value: "5000", label: "5000 lines" },
250
250
  ],
251
+ // Todo auto-clear delay
252
+ "tasks.todoClearDelay": [
253
+ { value: "0", label: "Instant" },
254
+ { value: "60", label: "1 minute", description: "Default" },
255
+ { value: "300", label: "5 minutes" },
256
+ { value: "900", label: "15 minutes" },
257
+ { value: "1800", label: "30 minutes" },
258
+ { value: "3600", label: "1 hour" },
259
+ { value: "-1", label: "Never" },
260
+ ],
261
+
251
262
  // Edit fuzzy threshold
252
263
  "edit.fuzzyThreshold": [
253
264
  { value: "0.85", label: "0.85", description: "Lenient" },
@@ -548,6 +548,10 @@ export class EventController {
548
548
  this.ctx.ui.requestRender();
549
549
  break;
550
550
  }
551
+
552
+ case "todo_auto_clear":
553
+ await this.ctx.reloadTodos();
554
+ break;
551
555
  }
552
556
  }
553
557
 
@@ -542,7 +542,7 @@ export class InteractiveMode implements InteractiveModeContext {
542
542
 
543
543
  const indent = " ";
544
544
  const hook = theme.tree.hook;
545
- const lines = [indent + theme.bold(theme.fg("accent", "Todos"))];
545
+ const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
546
546
 
547
547
  if (!this.todoExpanded) {
548
548
  const activePhase = this.#getActivePhase(phases);
@@ -31,6 +31,7 @@ import type {
31
31
  Effort,
32
32
  ImageContent,
33
33
  Message,
34
+ MessageAttribution,
34
35
  Model,
35
36
  ProviderSessionState,
36
37
  ServiceTier,
@@ -154,7 +155,8 @@ export type AgentSessionEvent =
154
155
  | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
155
156
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
156
157
  | { type: "ttsr_triggered"; rules: Rule[] }
157
- | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number };
158
+ | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
159
+ | { type: "todo_auto_clear" };
158
160
 
159
161
  /** Listener function for agent session events */
160
162
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -224,6 +226,8 @@ export interface PromptOptions {
224
226
  toolChoice?: ToolChoice;
225
227
  /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
226
228
  synthetic?: boolean;
229
+ /** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
230
+ attribution?: MessageAttribution;
227
231
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
228
232
  skipCompactionCheck?: boolean;
229
233
  }
@@ -361,6 +365,7 @@ export class AgentSession {
361
365
  // Todo completion reminder state
362
366
  #todoReminderCount = 0;
363
367
  #todoPhases: TodoPhase[] = [];
368
+ #todoClearTimers = new Map<string, Timer>();
364
369
  #nextToolChoiceOverride: ToolChoice | undefined = undefined;
365
370
 
366
371
  // Bash execution state
@@ -1534,6 +1539,7 @@ export class AgentSession {
1534
1539
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
1535
1540
  }
1536
1541
  this.#cancelPostPromptTasks();
1542
+ this.#clearTodoClearTimers();
1537
1543
  const drained = await this.#asyncJobManager?.dispose({ timeoutMs: 3_000 });
1538
1544
  const deliveryState = this.#asyncJobManager?.getDeliveryState();
1539
1545
  if (drained === false && deliveryState) {
@@ -1991,9 +1997,10 @@ export class AgentSession {
1991
1997
  userContent.push(...options.images);
1992
1998
  }
1993
1999
 
2000
+ const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
1994
2001
  const message = options?.synthetic
1995
- ? { role: "developer" as const, content: userContent, attribution: "agent" as const, timestamp: Date.now() }
1996
- : { role: "user" as const, content: userContent, attribution: "user" as const, timestamp: Date.now() };
2002
+ ? { role: "developer" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() }
2003
+ : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
1997
2004
 
1998
2005
  if (eagerTodoPrelude) {
1999
2006
  this.#nextToolChoiceOverride = eagerTodoPrelude.toolChoice;
@@ -2538,10 +2545,17 @@ export class AgentSession {
2538
2545
 
2539
2546
  setTodoPhases(phases: TodoPhase[]): void {
2540
2547
  this.#todoPhases = this.#cloneTodoPhases(phases);
2548
+ this.#scheduleTodoAutoClear(phases);
2541
2549
  }
2542
2550
 
2543
2551
  #syncTodoPhasesFromBranch(): void {
2544
- this.setTodoPhases(getLatestTodoPhasesFromEntries(this.sessionManager.getBranch()));
2552
+ const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
2553
+ // Strip completed/abandoned tasks — they were done in a previous run,
2554
+ // so the auto-clear grace period has already elapsed.
2555
+ for (const phase of phases) {
2556
+ phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
2557
+ }
2558
+ this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
2545
2559
  }
2546
2560
 
2547
2561
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
@@ -2557,6 +2571,68 @@ export class AgentSession {
2557
2571
  }));
2558
2572
  }
2559
2573
 
2574
+ /** Schedule auto-removal of completed/abandoned tasks after a delay. */
2575
+ #scheduleTodoAutoClear(phases: TodoPhase[]): void {
2576
+ const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60;
2577
+ if (delaySec < 0) return; // "Never" — no auto-clear
2578
+ const delayMs = delaySec * 1000;
2579
+ const doneTaskIds = new Set<string>();
2580
+ for (const phase of phases) {
2581
+ for (const task of phase.tasks) {
2582
+ if (task.status === "completed" || task.status === "abandoned") {
2583
+ doneTaskIds.add(task.id);
2584
+ }
2585
+ }
2586
+ }
2587
+
2588
+ // Cancel timers for tasks that are no longer done (e.g. status was reverted)
2589
+ for (const [id, timer] of this.#todoClearTimers) {
2590
+ if (!doneTaskIds.has(id)) {
2591
+ clearTimeout(timer);
2592
+ this.#todoClearTimers.delete(id);
2593
+ }
2594
+ }
2595
+
2596
+ // Schedule new timers for newly-done tasks
2597
+ for (const id of doneTaskIds) {
2598
+ if (this.#todoClearTimers.has(id)) continue;
2599
+ if (delayMs === 0) {
2600
+ // Instant — run synchronously on next microtask to batch removals
2601
+ const timer = setTimeout(() => this.#runTodoAutoClear(id), 0);
2602
+ this.#todoClearTimers.set(id, timer);
2603
+ } else {
2604
+ const timer = setTimeout(() => this.#runTodoAutoClear(id), delayMs);
2605
+ this.#todoClearTimers.set(id, timer);
2606
+ }
2607
+ }
2608
+ }
2609
+
2610
+ /** Remove a single completed task and notify the UI. */
2611
+ #runTodoAutoClear(taskId: string): void {
2612
+ this.#todoClearTimers.delete(taskId);
2613
+ let removed = false;
2614
+ for (const phase of this.#todoPhases) {
2615
+ const idx = phase.tasks.findIndex(t => t.id === taskId);
2616
+ if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
2617
+ phase.tasks.splice(idx, 1);
2618
+ removed = true;
2619
+ break;
2620
+ }
2621
+ }
2622
+ if (!removed) return;
2623
+
2624
+ // Remove empty phases
2625
+ this.#todoPhases = this.#todoPhases.filter(p => p.tasks.length > 0);
2626
+ this.#emit({ type: "todo_auto_clear" });
2627
+ }
2628
+
2629
+ #clearTodoClearTimers(): void {
2630
+ for (const timer of this.#todoClearTimers.values()) {
2631
+ clearTimeout(timer);
2632
+ }
2633
+ this.#todoClearTimers.clear();
2634
+ }
2635
+
2560
2636
  /**
2561
2637
  * Abort current operation and wait for agent to become idle.
2562
2638
  */
@@ -347,6 +347,44 @@ export function migrateSessionEntries(entries: FileEntry[]): void {
347
347
 
348
348
  let sessionDirsMigrated = false;
349
349
 
350
+ /**
351
+ * Merge or rename a legacy session directory into its canonical target.
352
+ * Best effort: callers decide whether migration failures should surface.
353
+ */
354
+ function migrateSessionDirPath(oldPath: string, newPath: string): void {
355
+ const existing = fs.statSync(newPath, { throwIfNoEntry: false });
356
+ if (existing?.isDirectory()) {
357
+ for (const file of fs.readdirSync(oldPath)) {
358
+ const src = path.join(oldPath, file);
359
+ const dst = path.join(newPath, file);
360
+ if (!fs.existsSync(dst)) {
361
+ fs.renameSync(src, dst);
362
+ }
363
+ }
364
+ fs.rmSync(oldPath, { recursive: true, force: true });
365
+ return;
366
+ }
367
+ if (existing) {
368
+ fs.rmSync(newPath, { recursive: true, force: true });
369
+ }
370
+ fs.renameSync(oldPath, newPath);
371
+ }
372
+
373
+ function encodeLegacyAbsoluteSessionDirName(cwd: string): string {
374
+ const resolvedCwd = path.resolve(cwd);
375
+ return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
376
+ }
377
+
378
+ function pathIsWithin(root: string, candidate: string): boolean {
379
+ const relative = path.relative(root, candidate);
380
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
381
+ }
382
+
383
+ function encodeRelativeSessionDirName(prefix: string, root: string, cwd: string): string {
384
+ const relative = path.relative(root, cwd).replace(/[/\\:]/g, "-");
385
+ return relative ? `${prefix}-${relative}` : prefix;
386
+ }
387
+
350
388
  /**
351
389
  * Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
352
390
  * Runs once on first access, best-effort.
@@ -378,34 +416,29 @@ function migrateHomeSessionDirs(): void {
378
416
  continue;
379
417
  }
380
418
 
381
- const newName = `-${remainder}`;
419
+ const newName = remainder ? `-${remainder}` : "-";
382
420
  const oldPath = path.join(sessionsRoot, entry);
383
421
  const newPath = path.join(sessionsRoot, newName);
384
422
 
385
423
  try {
386
- const existing = fs.statSync(newPath, { throwIfNoEntry: false });
387
- if (existing?.isDirectory()) {
388
- // Merge files from old dir into existing new dir
389
- for (const file of fs.readdirSync(oldPath)) {
390
- const src = path.join(oldPath, file);
391
- const dst = path.join(newPath, file);
392
- if (!fs.existsSync(dst)) {
393
- fs.renameSync(src, dst);
394
- }
395
- }
396
- fs.rmSync(oldPath, { recursive: true, force: true });
397
- } else {
398
- if (existing) {
399
- fs.rmSync(newPath, { recursive: true, force: true });
400
- }
401
- fs.renameSync(oldPath, newPath);
402
- }
424
+ migrateSessionDirPath(oldPath, newPath);
403
425
  } catch {
404
426
  // Best effort
405
427
  }
406
428
  }
407
429
  }
408
430
 
431
+ function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string): void {
432
+ const legacyDir = path.join(getSessionsDir(), encodeLegacyAbsoluteSessionDirName(cwd));
433
+ if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return;
434
+
435
+ try {
436
+ migrateSessionDirPath(legacyDir, sessionDir);
437
+ } catch {
438
+ // Best effort
439
+ }
440
+ }
441
+
409
442
  /** Exported for compaction.test.ts */
410
443
  export function parseSessionEntries(content: string): FileEntry[] {
411
444
  return parseJsonlLenient<FileEntry>(content);
@@ -603,23 +636,31 @@ export function buildSessionContext(
603
636
  /**
604
637
  * Encode a cwd into a safe directory name for session storage.
605
638
  * Home-relative paths use single-dash format: `/Users/x/Projects/pi` → `-Projects-pi`
606
- * Absolute paths use double-dash format: `/tmp/foo` → `--tmp-foo--`
639
+ * Temp-root paths use `-tmp-` prefixes: `/tmp/foo` → `-tmp-foo`
640
+ * Other absolute paths keep the legacy double-dash format for compatibility.
607
641
  */
608
642
  function encodeSessionDirName(cwd: string): string {
609
- const home = os.homedir();
610
- if (cwd === home || cwd.startsWith(`${home}/`) || cwd.startsWith(`${home}\\`)) {
611
- const relative = cwd.slice(home.length).replace(/^[/\\]/, "");
612
- return `-${relative.replace(/[/\\:]/g, "-")}`;
643
+ const resolvedCwd = path.resolve(cwd);
644
+ const home = path.resolve(os.homedir());
645
+ if (pathIsWithin(home, resolvedCwd)) {
646
+ return encodeRelativeSessionDirName("-", home, resolvedCwd);
647
+ }
648
+ const tempRoot = path.resolve(os.tmpdir());
649
+ if (pathIsWithin(tempRoot, resolvedCwd)) {
650
+ return encodeRelativeSessionDirName("-tmp", tempRoot, resolvedCwd);
613
651
  }
614
- return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
652
+ return encodeLegacyAbsoluteSessionDirName(resolvedCwd);
615
653
  }
654
+
616
655
  /**
617
656
  * Compute the default session directory for a cwd.
618
657
  * Encodes cwd into a safe directory name under ~/.omp/agent/sessions/.
619
658
  */
620
659
  function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
660
+ const resolvedCwd = path.resolve(cwd);
621
661
  migrateHomeSessionDirs();
622
- const sessionDir = path.join(getSessionsDir(), encodeSessionDirName(cwd));
662
+ const sessionDir = path.join(getSessionsDir(), encodeSessionDirName(resolvedCwd));
663
+ migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir);
623
664
  storage.ensureDirSync(sessionDir);
624
665
  return sessionDir;
625
666
  }
@@ -1307,7 +1348,7 @@ export class SessionManager {
1307
1348
  #fileEntries: FileEntry[] = [];
1308
1349
  #byId: Map<string, SessionEntry> = new Map();
1309
1350
  #labelsById: Map<string, string> = new Map();
1310
- #leafId = null as string | null;
1351
+ #leafId: string | null = null;
1311
1352
  #usageStatistics = {
1312
1353
  input: 0,
1313
1354
  output: 0,
@@ -1316,7 +1357,7 @@ export class SessionManager {
1316
1357
  premiumRequests: 0,
1317
1358
  cost: 0,
1318
1359
  } satisfies UsageStatistics;
1319
- #persistWriter = undefined as NdjsonFileWriter | undefined;
1360
+ #persistWriter: NdjsonFileWriter | undefined;
1320
1361
  #persistWriterPath: string | undefined;
1321
1362
  #persistChain: Promise<void> = Promise.resolve();
1322
1363
  #persistError: Error | undefined;
@@ -1326,8 +1367,8 @@ export class SessionManager {
1326
1367
  readonly #blobStore: BlobStore;
1327
1368
 
1328
1369
  private constructor(
1329
- private readonly cwd: string,
1330
- private readonly sessionDir: string,
1370
+ private cwd: string,
1371
+ private sessionDir: string,
1331
1372
  private readonly persist: boolean,
1332
1373
  private readonly storage: SessionStorage,
1333
1374
  ) {
@@ -1429,7 +1470,7 @@ export class SessionManager {
1429
1470
  this.#sessionName = newHeader.title;
1430
1471
 
1431
1472
  // Replace the header in fileEntries
1432
- const entries = this.#fileEntries.filter(e => e.type !== "session") as SessionEntry[];
1473
+ const entries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
1433
1474
  this.#fileEntries = [newHeader, ...entries];
1434
1475
 
1435
1476
  // Write the new session file
@@ -1506,9 +1547,9 @@ export class SessionManager {
1506
1547
  this.#sessionFile = newSessionFile;
1507
1548
  }
1508
1549
 
1509
- // Update cwd and sessionDir (controlled mutation of readonly fields)
1510
- (this as unknown as { cwd: string }).cwd = resolvedCwd;
1511
- (this as unknown as { sessionDir: string }).sessionDir = newSessionDir;
1550
+ // Update cwd and sessionDir after the move succeeds.
1551
+ this.cwd = resolvedCwd;
1552
+ this.sessionDir = newSessionDir;
1512
1553
 
1513
1554
  // Update the session header in fileEntries
1514
1555
  const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
@@ -1078,7 +1078,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1078
1078
  }
1079
1079
  });
1080
1080
 
1081
- await session.prompt(task);
1081
+ await session.prompt(task, { attribution: "agent" });
1082
1082
  await session.waitForIdle();
1083
1083
 
1084
1084
  const reminderToolChoice = buildNamedToolChoice("submit_result", session.model);
@@ -1092,7 +1092,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1092
1092
  maxRetries: MAX_SUBMIT_RESULT_RETRIES,
1093
1093
  });
1094
1094
 
1095
- await session.prompt(reminder, reminderToolChoice ? { toolChoice: reminderToolChoice } : undefined);
1095
+ await session.prompt(reminder, {
1096
+ attribution: "agent",
1097
+ ...(reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1098
+ });
1096
1099
  await session.waitForIdle();
1097
1100
  } catch (err) {
1098
1101
  logger.error("Subagent prompt failed", {