@oh-my-pi/pi-coding-agent 14.5.10 → 14.5.12

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.
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { createServer } from "node:net";
3
3
  import * as path from "node:path";
4
+ import { Process } from "@oh-my-pi/pi-natives";
4
5
  import { getAgentDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
5
6
  import type { Subprocess } from "bun";
6
7
  import { Settings } from "../config/settings";
@@ -300,7 +301,7 @@ async function startGatewayProcess(
300
301
 
301
302
  async function killGateway(pid: number, context: string): Promise<void> {
302
303
  try {
303
- await procmgr.terminate({ target: pid });
304
+ await Process.fromPid(pid)?.terminate();
304
305
  } catch (err) {
305
306
  logger.warn("Failed to kill shared gateway process", {
306
307
  error: err instanceof Error ? err.message : String(err),
@@ -21,7 +21,7 @@ const USAGE = [
21
21
  " /todo export <path> Write todos as Markdown to <path>",
22
22
  " /todo import <path> Replace todos from Markdown at <path>",
23
23
  " /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created",
24
- " /todo start <task> Mark task in_progress (id or fuzzy content)",
24
+ " /todo start <task> Mark task in_progress (fuzzy content match)",
25
25
  " /todo done [<task|phase>] Mark task/phase/all completed",
26
26
  " /todo drop [<task|phase>] Mark task/phase/all abandoned",
27
27
  " /todo rm [<task|phase>] Remove task/phase/all",
@@ -59,44 +59,9 @@ function tokenize(input: string): string[] {
59
59
  }
60
60
 
61
61
  // =============================================================================
62
- // Roman numerals + name normalization
62
+ // Name normalization
63
63
  // =============================================================================
64
64
 
65
- const ROMAN_PAIRS: Array<[number, string]> = [
66
- [1000, "M"],
67
- [900, "CM"],
68
- [500, "D"],
69
- [400, "CD"],
70
- [100, "C"],
71
- [90, "XC"],
72
- [50, "L"],
73
- [40, "XL"],
74
- [10, "X"],
75
- [9, "IX"],
76
- [5, "V"],
77
- [4, "IV"],
78
- [1, "I"],
79
- ];
80
-
81
- function toRoman(n: number): string {
82
- if (n <= 0) return "I";
83
- let out = "";
84
- let rem = n;
85
- for (const [value, sym] of ROMAN_PAIRS) {
86
- while (rem >= value) {
87
- out += sym;
88
- rem -= value;
89
- }
90
- }
91
- return out;
92
- }
93
-
94
- const PHASE_PREFIX_RE = /^([IVXLCDM]+|[A-Z]|\d+)\.\s*/i;
95
-
96
- function stripPrefix(name: string): string {
97
- return name.replace(PHASE_PREFIX_RE, "").trim();
98
- }
99
-
100
65
  function titleCase(s: string): string {
101
66
  return s
102
67
  .split(/\s+/)
@@ -105,13 +70,6 @@ function titleCase(s: string): string {
105
70
  .join(" ");
106
71
  }
107
72
 
108
- function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
109
- const stripped = stripPrefix(rawName.trim());
110
- if (!stripped) return `${toRoman(existingPhases.length + 1)}. Todos`;
111
- const titled = titleCase(stripped);
112
- return `${toRoman(existingPhases.length + 1)}. ${titled}`;
113
- }
114
-
115
73
  // =============================================================================
116
74
  // Fuzzy matching
117
75
  // =============================================================================
@@ -119,20 +77,13 @@ function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
119
77
  function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined {
120
78
  const q = query.trim().toLowerCase();
121
79
  if (!q) return undefined;
122
- // Exact id
123
- const byId = phases.find(p => p.id.toLowerCase() === q);
124
- if (byId) return byId;
125
80
  // Exact name (case-insensitive)
126
81
  const byName = phases.find(p => p.name.toLowerCase() === q);
127
82
  if (byName) return byName;
128
- // Stripped name match
129
- const strippedQ = stripPrefix(q);
130
- const byStripped = phases.find(p => stripPrefix(p.name).toLowerCase() === strippedQ);
131
- if (byStripped) return byStripped;
132
- // Substring (prefer prefix match on stripped name)
133
- const prefixMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().startsWith(strippedQ));
83
+ // Substring (prefer prefix match)
84
+ const prefixMatches = phases.filter(p => p.name.toLowerCase().startsWith(q));
134
85
  if (prefixMatches.length === 1) return prefixMatches[0];
135
- const subMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().includes(strippedQ));
86
+ const subMatches = phases.filter(p => p.name.toLowerCase().includes(q));
136
87
  if (subMatches.length === 1) return subMatches[0];
137
88
  return undefined;
138
89
  }
@@ -140,9 +91,10 @@ function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefin
140
91
  function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; phase: TodoPhase } | undefined {
141
92
  const q = query.trim().toLowerCase();
142
93
  if (!q) return undefined;
94
+ // Exact content (case-insensitive)
143
95
  for (const phase of phases) {
144
96
  for (const task of phase.tasks) {
145
- if (task.id.toLowerCase() === q) return { task, phase };
97
+ if (task.content.toLowerCase() === q) return { task, phase };
146
98
  }
147
99
  }
148
100
  const matches: Array<{ task: TodoItem; phase: TodoPhase }> = [];
@@ -169,7 +121,7 @@ function buildSystemReminder(action: string, phases: TodoPhase[]): string {
169
121
  return [
170
122
  "<system-reminder>",
171
123
  `The user manually modified the todo list (${action}).`,
172
- "Current todo list (note task ids may have been reassigned by /todo edit):",
124
+ "Current todo list:",
173
125
  "",
174
126
  md,
175
127
  "</system-reminder>",
@@ -327,28 +279,24 @@ export class TodoCommandController {
327
279
  if (phaseName) {
328
280
  targetPhase = findPhaseFuzzy(next, phaseName);
329
281
  if (!targetPhase) {
330
- const newName = buildPhaseName(phaseName, next);
331
- targetPhase = { id: `phase-${next.length + 1}`, name: newName, tasks: [] };
282
+ targetPhase = { name: titleCase(phaseName), tasks: [] };
332
283
  next.push(targetPhase);
333
284
  }
334
285
  } else if (next.length > 0) {
335
286
  targetPhase = next[next.length - 1];
336
287
  } else {
337
- targetPhase = { id: "phase-1", name: `${toRoman(1)}. Todos`, tasks: [] };
288
+ targetPhase = { name: "Todos", tasks: [] };
338
289
  next.push(targetPhase);
339
290
  }
340
291
 
341
- const usedTaskIds = new Set(next.flatMap(p => p.tasks.map(t => t.id)));
342
- let n = 1;
343
- while (usedTaskIds.has(`task-${n}`)) n++;
292
+ const finalContent = titleCaseSentence(content);
344
293
  targetPhase.tasks.push({
345
- id: `task-${n}`,
346
- content: titleCaseSentence(content),
294
+ content: finalContent,
347
295
  status: "pending",
348
296
  });
349
297
 
350
298
  this.#commit(next, `/todo append → ${targetPhase.name}`);
351
- this.ctx.showStatus(`Appended to ${targetPhase.name}: ${content}`);
299
+ this.ctx.showStatus(`Appended to ${targetPhase.name}: ${finalContent}`);
352
300
  }
353
301
 
354
302
  // ------------------------------------------------------------- start / done / drop / rm
@@ -364,12 +312,12 @@ export class TodoCommandController {
364
312
  this.ctx.showError(`No task matched "${rest}". Use /todo to list current tasks.`);
365
313
  return;
366
314
  }
367
- const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.id }]);
315
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]);
368
316
  if (errors.length > 0) {
369
317
  this.ctx.showError(errors.join("; "));
370
318
  return;
371
319
  }
372
- this.#commit(phases, `/todo start ${hit.task.id}`);
320
+ this.#commit(phases, `/todo start ${hit.task.content}`);
373
321
  this.ctx.showStatus(`Started: ${hit.task.content}`);
374
322
  }
375
323
 
@@ -391,19 +339,19 @@ export class TodoCommandController {
391
339
 
392
340
  const taskHit = findTaskFuzzy(current, trimmed);
393
341
  if (taskHit) {
394
- const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.id }]);
342
+ const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.content }]);
395
343
  if (errors.length > 0) {
396
344
  this.ctx.showError(errors.join("; "));
397
345
  return;
398
346
  }
399
- this.#commit(phases, `/todo ${op} ${taskHit.task.id}`);
347
+ this.#commit(phases, `/todo ${op} ${taskHit.task.content}`);
400
348
  this.ctx.showStatus(`Marked ${target}: ${taskHit.task.content}`);
401
349
  return;
402
350
  }
403
351
 
404
352
  const phaseHit = findPhaseFuzzy(current, trimmed);
405
353
  if (phaseHit) {
406
- const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.id }]);
354
+ const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.name }]);
407
355
  if (errors.length > 0) {
408
356
  this.ctx.showError(errors.join("; "));
409
357
  return;
@@ -426,18 +374,18 @@ export class TodoCommandController {
426
374
  }
427
375
  const taskHit = findTaskFuzzy(current, trimmed);
428
376
  if (taskHit) {
429
- const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.id }]);
377
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.content }]);
430
378
  if (errors.length > 0) {
431
379
  this.ctx.showError(errors.join("; "));
432
380
  return;
433
381
  }
434
- this.#commit(phases, `/todo rm ${taskHit.task.id}`);
382
+ this.#commit(phases, `/todo rm ${taskHit.task.content}`);
435
383
  this.ctx.showStatus(`Removed: ${taskHit.task.content}`);
436
384
  return;
437
385
  }
438
386
  const phaseHit = findPhaseFuzzy(current, trimmed);
439
387
  if (phaseHit) {
440
- const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.id }]);
388
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.name }]);
441
389
  if (errors.length > 0) {
442
390
  this.ctx.showError(errors.join("; "));
443
391
  return;
@@ -460,7 +408,7 @@ export class TodoCommandController {
460
408
 
461
409
  const current = this.#currentPhases();
462
410
  const initialMarkdown =
463
- current.length > 0 ? phasesToMarkdown(current) : "# I. Todos\n- [ ] (replace this with your tasks)\n";
411
+ current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] (replace this with your tasks)\n";
464
412
 
465
413
  const fileHandle = await this.#openTtyHandle();
466
414
  this.ctx.ui.stop();
@@ -48,6 +48,7 @@ import { getRecentSessions } from "../session/session-manager";
48
48
  import { STTController, type SttState } from "../stt";
49
49
  import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
50
50
  import { normalizeLocalScheme } from "../tools/path-utils";
51
+ import { formatPhaseDisplayName } from "../tools/todo-write";
51
52
  import type { EventBus } from "../utils/event-bus";
52
53
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
53
54
  import { getSessionAccentAnsi, getSessionAccentHexForTitle } from "../utils/session-color";
@@ -707,9 +708,12 @@ export class InteractiveMode implements InteractiveModeContext {
707
708
  const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
708
709
 
709
710
  if (!this.todoExpanded) {
710
- const activePhase = this.#getActivePhase(phases);
711
+ const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]);
712
+ const activePhase = phases[activeIdx];
711
713
  if (!activePhase) return;
712
- lines.push(`${indent}${theme.fg("accent", `${hook} ${activePhase.name}`)}`);
714
+ lines.push(
715
+ `${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(activePhase.name, activeIdx + 1)}`)}`,
716
+ );
713
717
  const visibleTasks = activePhase.tasks.slice(0, 5);
714
718
  visibleTasks.forEach((todo, index) => {
715
719
  const prefix = `${indent}${index === 0 ? hook : " "} `;
@@ -723,13 +727,13 @@ export class InteractiveMode implements InteractiveModeContext {
723
727
  return;
724
728
  }
725
729
 
726
- for (const phase of phases) {
727
- lines.push(`${indent}${theme.fg("accent", `${hook} ${phase.name}`)}`);
730
+ phases.forEach((phase, phaseIndex) => {
731
+ lines.push(`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(phase.name, phaseIndex + 1)}`)}`);
728
732
  phase.tasks.forEach((todo, index) => {
729
733
  const prefix = `${indent}${index === 0 ? hook : " "} `;
730
734
  lines.push(this.#formatTodoLine(todo, prefix));
731
735
  });
732
- }
736
+ });
733
737
 
734
738
  this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
735
739
  }
@@ -1712,7 +1716,6 @@ export class InteractiveMode implements InteractiveModeContext {
1712
1716
  } else {
1713
1717
  this.todoPhases = [
1714
1718
  {
1715
- id: "default",
1716
1719
  name: "Todos",
1717
1720
  tasks: todos as TodoItem[],
1718
1721
  },
@@ -42,7 +42,6 @@ export type SubmittedUserInput = {
42
42
  export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
43
43
 
44
44
  export type TodoItem = {
45
- id: string;
46
45
  content: string;
47
46
  status: TodoStatus;
48
47
  details?: string;
@@ -50,7 +49,6 @@ export type TodoItem = {
50
49
  };
51
50
 
52
51
  export type TodoPhase = {
53
- id: string;
54
52
  name: string;
55
53
  tasks: TodoItem[];
56
54
  };
@@ -2,7 +2,7 @@
2
2
  Before doing substantive work on the upcoming user request, create a comprehensive phased todo first.
3
3
 
4
4
  You **MUST** call `todo_write` first in this turn.
5
- You **MUST** initialize the todo list with a single `replace` op.
5
+ You **MUST** initialize the todo list with a single `init` op.
6
6
  You **MUST** cover the entire request from investigation through implementation and verification — not just the next immediate step.
7
7
  You **MUST** make task descriptions specific enough that a future turn can execute them without re-planning.
8
8
  You **MUST** keep task `content` to a short label (5-10 words). Put file paths, implementation steps, and specifics in `details`.
@@ -36,7 +36,7 @@ Lid= blank the anchored line's content but KEEP the line (results in an em
36
36
  - To insert ABOVE a line, you **MUST** use `^Lid` then `+TEXT`. To insert above line 1, you **MUST** use `^` (BOF) then `+TEXT`. To insert below a line, you **MUST** use `@Lid` then `+TEXT`.
37
37
  - Multiple `---PATH` sections **MAY** appear in one input; each section is applied in order.
38
38
  - `!rm` / `!mv DEST` **MUST NOT** be combined with line edits in the same section.
39
- - Lids contain a content hash. If a line has changed since you read it, the tool rejects the edit and shows the current content; you **MUST** re-read and retry with fresh Lids. Small drift (≤5 lines) where the original hash still matches a nearby line auto-rebases with a warning. Larger shifts may show a hash-only candidate, but two-letter hashes collide; verify surrounding content or re-read before using it.
39
+ - Lids contain a content hash. If a line has changed since you read it, the tool rejects the edit and shows the current content; you **MUST** re-read and retry with fresh Lids.
40
40
  - After `+TEXT` (or `+`) the cursor advances past the inserted line, so consecutive `+TEXT` ops stack in order. After `Lid=TEXT` the cursor sits on the modified anchor; after `-Lid` it sits on the slot the deleted line vacated. You **MUST** use a fresh `@Lid` / `^Lid` / `^` / `$` to reposition.
41
41
  - The tool is syntax-blind: it will not check brackets, indentation, table column counts, or fence integrity. You **MUST** verify indentation-sensitive or structured files after editing (Python, Markdown tables/fences).
42
42
  - A section whose PATH does not yet exist creates the file from your `+TEXT` lines (use `^` or `$` then `+TEXT…`). No separate "create file" op is needed.
@@ -83,7 +83,7 @@ Lid= blank the anchored line's content but KEEP the line (results in an em
83
83
  \ return (name || DEF).trim().toUpperCase();
84
84
  \}
85
85
 
86
- # Replace a block with a longer multi-line block, including blank lines (canonical form for refactors)
86
+ # Replace one contiguous block when the existing lines themselves change; the replacement may have more/fewer lines than the selected range
87
87
  ---a.ts
88
88
  {{hrefr 3}}..{{hrefr 6}}=/** Format a display label, falling back to DEF when empty. */
89
89
  \export function label(name: string): string {
@@ -139,6 +139,7 @@ $
139
139
  - Current/added preview lines include fresh `LINE+hash|content` anchors. Removed preview lines show deleted content and **MUST NOT** be reused as anchors.
140
140
  - You **MUST** emit only lines that change. You **MUST NOT** echo unchanged context; the anchor implies position.
141
141
  - You **MUST NOT** write `Lid=<sameTextThatIsAlreadyOnThatLine>`; the tool reports a no-op (no change applied). Emit `Lid=TEXT` only when TEXT differs.
142
+ - You **MUST NOT** use `Lid=<originalLineContent>` + `\continuations` as an "insert after" idiom. That form is a *replacement*: its first line lands at the anchor, and its continuations push the original next line down. When the anchor is a closing brace and your continuations also end in `}`, the original line below — often itself `}` (a sibling block, mod, or impl closer) — sits adjacent to yours and you ship a duplicate `}`. For pure insertion, use `@Lid` + `+TEXT…` (after) or `^Lid` + `+TEXT…` (before). Never re-state the anchor's content as the first line of a replacement.
142
143
  - A line of the form `Lid|content` (a Lid, then `|`, then text, with NO leading `+`/`-`/`^`/`@`/`\`/`=`/`..`) is **FORBIDDEN**. That shape only appears in `read`/`grep` output as an anchor for *you*; it is never an edit op. If you copy a `Lid|content` line verbatim from a read into a patch, you have made an error — every edit op must start with `+`, `-`, `^`, `@`, `\`, `$`, `!`, or a Lid immediately followed by `=` or `..`.
143
144
  - To replace a contiguous block with new content, the canonical form is `LidA..LidB=FIRST_LINE` + `\NEXT_LINE…`. You **MUST NOT** write the old block and then the new block — that is unified-diff thinking and the tool does not understand it. If you find yourself emitting pre-image lines (with or without operators) before your new content, STOP and rewrite the section as a single range-replace.
144
145
  - TEXT after `=`, `+`, or `\` includes leading whitespace verbatim. You **MUST NOT** trim or re-indent it.
@@ -1,25 +1,70 @@
1
- Navigates, clicks, types, scrolls, drags, queries DOM content, and captures screenshots.
1
+ Drives a real Chromium tab with full puppeteer access via JS execution.
2
2
 
3
3
  <instruction>
4
- - For fetching static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL — it returns clean reader-mode text without spinning up a browser. Use this tool only when you need JS execution, authentication, or interactive actions.
5
- - `"open"` starts a headless session (or implicitly on first action); `"goto"` navigates to `url`; `"close"` releases the browser
6
- - `"observe"` captures a numbered accessibility snapshot prefer `click_id`/`type_id`/`fill_id` using returned `element_id` values; flags: `include_all`, `viewport_only`
7
- - `"click"`, `"type"`, `"fill"`, `"press"`, `"scroll"`, `"drag"` for selector-based interactions prefer ARIA/text selectors (`p-aria/[name="Sign in"]`, `p-text/Continue`) over brittle CSS
8
- - `"click_id"`, `"type_id"`, `"fill_id"` to interact with observed elements without selectors
9
- - `"wait_for_selector"` before interacting when the page is dynamic
10
- - `"evaluate"` runs a JS expression in page context
11
- - `"get_text"`, `"get_html"`, `"get_attribute"` for DOM queries batch via `args: [{ selector, attribute? }]`
12
- - `"extract_readable"` returns reader-mode content; `format`: `"markdown"` (default) or `"text"`
13
- - `"screenshot"` captures images (optionally with `selector`); can save to disk via `path`
4
+ - For fetching static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL — reader-mode text without spinning up a browser. Use this tool when you need JS execution, authentication, or interactive actions.
5
+ - Three actions only:
6
+ - `open` — acquire (or reuse) a named tab. `name` defaults to `"main"`. Optional `url` navigates after the tab is ready. Optional `viewport` sets dimensions. Optional `dialogs: "accept" | "dismiss"` auto-handles `alert`/`confirm`/`beforeunload` so navigation/clicks don't hang (default: leave dialogs unhandled — page hangs until caller wires `page.on('dialog', …)`).
7
+ - `close` release a tab by `name`, or every tab with `all: true`. For spawned-app browsers, set `kill: true` to terminate the process tree (default leaves it running).
8
+ - `run` — execute JS against an existing tab. The `code` is the body of an async function with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. The function's return value is JSON-stringified into the tool result; multiple `display(value)` calls accumulate text/images.
9
+ - Tabs survive across `run` calls and across in-process subagents. Open once, reuse many times.
10
+ - Browser kinds, selected by the `app` field on `open`:
11
+ - default (no `app`) headless Chromium with stealth patches.
12
+ - `app.path` spawn an absolute binary (Electron/CDP). If a running instance already exposes a CDP port, it is reused; otherwise stale instances are killed and a fresh one is spawned. No stealth patches — never tamper with a real desktop app.
13
+ - `app.cdp_url` connect to an existing CDP endpoint (e.g. `http://127.0.0.1:9222`).
14
+ - `app.target` (with `path`/`cdp_url`) — substring matched against url+title to pick a BrowserWindow when the app exposes several.
15
+ - Inside `run`, `tab` exposes high-level helpers; reach for `page` (raw puppeteer Page) when you need anything they don't cover. Available helpers:
16
+ - `tab.goto(url, { waitUntil? })` — clears the element cache and navigates.
17
+ - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot. Returns `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Element ids are stable until the next observe/goto.
18
+ - `tab.id(n)` — resolves an element id from the most recent observe to a real `ElementHandle` you can `.click()`, `.type()`, etc.
19
+ - `tab.click(selector)` / `tab.type(selector, text)` / `tab.fill(selector, value)` / `tab.press(key, { selector? })` / `tab.scroll(dx, dy)` — selector-based actions.
20
+ - `tab.waitFor(selector)` — waits until the selector is attached, returns the resolved `ElementHandle` for chaining (e.g. `const btn = await tab.waitFor('text/Submit'); await btn.click();`).
21
+ - `tab.drag(from, to)` — drag from one point to another. Each endpoint is either a selector string (drag center-to-center) or a `{ x, y }` viewport-coordinate point (e.g. for canvases, sliders).
22
+ - `tab.scrollIntoView(selector)` — scroll the matching element to the center of the viewport (use before clicking off-screen elements).
23
+ - `tab.select(selector, …values)` — set the selected option(s) on a `<select>`. Returns the values that ended up selected. `tab.fill` does **NOT** work for selects.
24
+ - `tab.uploadFile(selector, …filePaths)` — attach files to an `<input type="file">`. Paths resolve relative to cwd.
25
+ - `tab.waitForUrl(pattern, { timeout? })` — pattern is a substring or `RegExp`. Polls `location.href` so it works for SPA pushState navigations, not just real navigations. Returns the matched URL.
26
+ - `tab.waitForResponse(pattern, { timeout? })` — pattern is a substring, `RegExp`, or `(response) => boolean`. Returns the raw puppeteer `HTTPResponse` (call `.text()` / `.json()` / `.status()` / `.headers()` on it).
27
+ - `tab.evaluate(fn, …args)` — sugar for `page.evaluate` with the abort signal already wired. Use this instead of dropping to `page.evaluate` for ad-hoc DOM reads.
28
+ - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — auto-attaches the image to the tool output unless `silent: true`. Saves full-res to `save` (or `browser.screenshotDir` setting) and a downscaled copy to the model.
29
+ - `tab.extract(format = "markdown")` — Readability-extracted page content.
30
+ - Selectors accept CSS as well as puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`. Playwright-style `p-aria/[name="…"]`, `p-text/…`, etc. are normalized.
31
+ - Default to `tab.observe()` over `tab.screenshot()` for understanding page state. Screenshot only when visual appearance matters.
14
32
  </instruction>
15
33
 
16
34
  <critical>
17
- **You **MUST** default to `observe`, not `screenshot`.**
18
- - `observe` is cheaper, faster, and returns structured datause it to understand page state, find elements, and plan interactions.
19
- - You **SHOULD** only use `screenshot` when visual appearance matters (verifying layout, debugging CSS, capturing a visual artifact for the user).
20
- - You **MUST NOT** screenshot just to "see what's on the page" `observe` gives you that with element IDs you can act on immediately.
35
+ - You **MUST** call `open` before `run`. `run` does not implicitly create a tab.
36
+ - You **MUST NOT** screenshot just to "see what's on the page" `tab.observe()` returns structured data with element ids you can act on immediately.
37
+ - After a `tab.goto()` or any navigation, prior element ids from `tab.observe()` are invalidated. Re-observe before referencing them.
38
+ - `code` runs with full Node access. Treat it as your code, not sandboxed code.
21
39
  </critical>
22
40
 
41
+ <examples>
42
+ # Open a tab and read structured page data
43
+ `{"action":"open","name":"docs","url":"https://example.com"}`
44
+ `{"action":"run","name":"docs","code":"const obs = await tab.observe(); display(obs); return obs.elements.length;"}`
45
+
46
+ # Click an observed element by id
47
+ `{"action":"run","name":"docs","code":"const obs = await tab.observe(); const link = obs.elements.find(e => e.role === 'link' && e.name === 'Sign in'); assert(link, 'Sign in link missing'); await (await tab.id(link.id)).click();"}`
48
+
49
+ # Save a full-page screenshot to disk
50
+ `{"action":"run","name":"docs","code":"await tab.screenshot({ fullPage: true, save: 'screenshot.png' });"}`
51
+
52
+ # Fill and submit a form via selectors
53
+ `{"action":"run","name":"docs","code":"await tab.fill('input[name=email]', 'me@example.com'); await tab.click('text/Continue');"}`
54
+
55
+ # Attach to an existing Electron app
56
+ `{"action":"open","name":"cursor","app":{"path":"/Applications/Cursor.app/Contents/MacOS/Cursor"}}`
57
+
58
+ # Close one tab (browser stays alive if other tabs reference it)
59
+ `{"action":"close","name":"docs"}`
60
+
61
+ # Close every tab; leave spawned apps running
62
+ `{"action":"close","all":true}`
63
+
64
+ # Close every tab and kill spawned-app processes too
65
+ `{"action":"close","all":true,"kill":true}`
66
+ </examples>
67
+
23
68
  <output>
24
- Text for navigation/DOM queries, images for screenshots.
69
+ Per call: any `display(value)` outputs (text/images) followed by the JSON-stringified return value of the `code` function. `run` always produces at least a status line.
25
70
  </output>
@@ -5,23 +5,23 @@ The next pending task is auto-promoted to `in_progress` after each completion.
5
5
 
6
6
  |`op`|Required fields|Effect|
7
7
  |---|---|---|
8
- |`replace`|`phases`|Replace the full list (initial setup, full restructure)|
9
- |`start`|`task`|Set task to `in_progress`|
10
- |`done`|`task` or `phase` (or neither = all)|Mark completed|
11
- |`drop`|`task` or `phase` (or neither = all)|Mark abandoned|
12
- |`rm`|`task` or `phase` (or neither = all)|Remove|
13
- |`append`|`phase`, `items: {id, label}[]`|Append tasks; creates phase if missing|
14
- |`note`|`task`, `text`|Append a note to `task.notes`. Only use to leave reminders for future-you.|
8
+ |`init`|`list`|Initialize the full list|
9
+ |`start`|`task`|Mark in progress|
10
+ |`done`|`task` or `phase`|Mark completed|
11
+ |`drop`|`task` or `phase`|Mark abandoned|
12
+ |`rm`|`task` or `phase`|Remove|
13
+ |`append`|`phase`, `items: string[]`|Append tasks; lazily creates phase|
14
+ |`note`|`task`, `text`|Append a note to a task. Reminders for future-you only.|
15
15
 
16
16
  ## Anatomy
17
- - **Task `label`**: 5–10 words, what is being done, not how.
18
- - **Phase `name`**: short noun phrase prefixed with a roman numeral — `I. Foundation`, `II. Auth`, `III. Verification`. Single-phase plans still use `I.`. Never use snake_case, arabic numerals, or letter prefixes.
17
+ - **Task content**: 5–10 words, what is being done, not how. Used as the task identifier — unique.
18
+ - **Phase name**: short noun phrase (e.g. `Foundation`, `Auth`, `Verification`). Used as the phase identifier unique. Do not add prefixes like `1.`, `A)`, `Phase 1:`, etc.
19
19
 
20
20
  ## Rules
21
- - Mark tasks done immediately after finishing — never defer.
21
+ - Mark tasks done immediately after finishing.
22
22
  - Complete phases in order.
23
- - On blockers, `append` a new task to the active phase.
24
- - Keep ids stable once introduced.
23
+ - On blockers, `append` a new task to the active phase to unblock yourself, or `drop`.
24
+ - `task` and `phase` fields reference content/name verbatim; keep them stable once introduced.
25
25
 
26
26
  ## When to create a list
27
27
  - Task requires 3+ distinct steps
@@ -31,17 +31,17 @@ The next pending task is auto-promoted to `in_progress` after each completion.
31
31
 
32
32
  <examples>
33
33
  # Initial setup (multi-phase)
34
- `{"ops":[{"op":"replace","phases":[{"name":"I. Foundation","tasks":[{"content":"Scaffold crate"},{"content":"Wire workspace"}]},{"name":"II. Auth","tasks":[{"content":"Port credential store"},{"content":"Wire OAuth providers"}]},{"name":"III. Verification","tasks":[{"content":"Run cargo test"}]}]}]}`
35
- # Initial setup (single phase — still prefixed)
36
- `{"ops":[{"op":"replace","phases":[{"name":"I. Implementation","tasks":[{"content":"Apply fix"},{"content":"Run tests"}]}]}]}`
34
+ `{"ops":[{"op":"init","list":[{"phase":"Foundation","items":["Scaffold crate","Wire workspace"]},{"phase":"Auth","items":["Port credential store","Wire OAuth providers"]},{"phase":"Verification","items":["Run cargo test"]}]}]}`
35
+ # Initial setup (single phase)
36
+ `{"ops":[{"op":"init","list":[{"phase":"Implementation","items":["Apply fix","Run tests"]}]}]}`
37
37
  # Complete one task
38
- `{"ops":[{"op":"done","task":"task-2"}]}`
38
+ `{"ops":[{"op":"done","task":"Wire workspace"}]}`
39
39
  # Complete a whole phase
40
- `{"ops":[{"op":"done","phase":"II. Auth"}]}`
40
+ `{"ops":[{"op":"done","phase":"Auth"}]}`
41
41
  # Remove all tasks
42
42
  `{"ops":[{"op":"rm"}]}`
43
43
  # Drop one task
44
- `{"ops":[{"op":"drop","task":"task-7"}]}`
44
+ `{"ops":[{"op":"drop","task":"Run cargo test"}]}`
45
45
  # Append tasks to a phase
46
- `{"ops":[{"op":"append","phase":"II. Auth","items":[{"id":"task-8","label":"Handle retries"},{"id":"task-9","label":"Run tests"}]}]}`
46
+ `{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}`
47
47
  </examples>
@@ -52,16 +52,8 @@ import {
52
52
  parseRateLimitReason,
53
53
  streamSimple,
54
54
  } from "@oh-my-pi/pi-ai";
55
- import { killTree, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
56
- import {
57
- abortableSleep,
58
- getAgentDbPath,
59
- isEnoent,
60
- logger,
61
- prompt,
62
- Snowflake,
63
- setNativeKillTree,
64
- } from "@oh-my-pi/pi-utils";
55
+ import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
56
+ import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
65
57
  import type { AsyncJob, AsyncJobManager } from "../async";
66
58
  import type { Rule } from "../capability/rule";
67
59
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
@@ -387,6 +379,11 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
387
379
  return `${selector.provider}/${selector.id}`;
388
380
  }
389
381
 
382
+ /** Composite key for auto-clear timers, keyed by phase name + task content. */
383
+ function todoClearKey(phaseName: string, taskContent: string): string {
384
+ return `${phaseName}\u0000${taskContent}`;
385
+ }
386
+
390
387
  const noOpUIContext: ExtensionUIContext = {
391
388
  select: async (_title, _options, _dialogOptions) => undefined,
392
389
  confirm: async (_title, _message, _dialogOptions) => false,
@@ -575,8 +572,6 @@ export class AgentSession {
575
572
  }
576
573
 
577
574
  constructor(config: AgentSessionConfig) {
578
- setNativeKillTree(killTree);
579
-
580
575
  this.agent = config.agent;
581
576
  this.sessionManager = config.sessionManager;
582
577
  this.settings = config.settings;
@@ -3347,10 +3342,9 @@ export class AgentSession {
3347
3342
 
3348
3343
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
3349
3344
  return phases.map(phase => ({
3350
- id: phase.id,
3351
3345
  name: phase.name,
3352
3346
  tasks: phase.tasks.map(task => {
3353
- const out: TodoItem = { id: task.id, content: task.content, status: task.status };
3347
+ const out: TodoItem = { content: task.content, status: task.status };
3354
3348
  if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
3355
3349
  return out;
3356
3350
  }),
@@ -3362,43 +3356,43 @@ export class AgentSession {
3362
3356
  const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60;
3363
3357
  if (delaySec < 0) return; // "Never" — no auto-clear
3364
3358
  const delayMs = delaySec * 1000;
3365
- const doneTaskIds = new Set<string>();
3359
+ const doneKeys = new Set<string>();
3366
3360
  for (const phase of phases) {
3367
3361
  for (const task of phase.tasks) {
3368
3362
  if (task.status === "completed" || task.status === "abandoned") {
3369
- doneTaskIds.add(task.id);
3363
+ doneKeys.add(todoClearKey(phase.name, task.content));
3370
3364
  }
3371
3365
  }
3372
3366
  }
3373
3367
 
3374
3368
  // Cancel timers for tasks that are no longer done (e.g. status was reverted)
3375
- for (const [id, timer] of this.#todoClearTimers) {
3376
- if (!doneTaskIds.has(id)) {
3369
+ for (const [key, timer] of this.#todoClearTimers) {
3370
+ if (!doneKeys.has(key)) {
3377
3371
  clearTimeout(timer);
3378
- this.#todoClearTimers.delete(id);
3372
+ this.#todoClearTimers.delete(key);
3379
3373
  }
3380
3374
  }
3381
3375
 
3382
3376
  // Schedule new timers for newly-done tasks
3383
- for (const id of doneTaskIds) {
3384
- if (this.#todoClearTimers.has(id)) continue;
3377
+ for (const key of doneKeys) {
3378
+ if (this.#todoClearTimers.has(key)) continue;
3385
3379
  if (delayMs === 0) {
3386
3380
  // Instant — run synchronously on next microtask to batch removals
3387
- const timer = setTimeout(() => this.#runTodoAutoClear(id), 0);
3388
- this.#todoClearTimers.set(id, timer);
3381
+ const timer = setTimeout(() => this.#runTodoAutoClear(key), 0);
3382
+ this.#todoClearTimers.set(key, timer);
3389
3383
  } else {
3390
- const timer = setTimeout(() => this.#runTodoAutoClear(id), delayMs);
3391
- this.#todoClearTimers.set(id, timer);
3384
+ const timer = setTimeout(() => this.#runTodoAutoClear(key), delayMs);
3385
+ this.#todoClearTimers.set(key, timer);
3392
3386
  }
3393
3387
  }
3394
3388
  }
3395
3389
 
3396
3390
  /** Remove a single completed task and notify the UI. */
3397
- #runTodoAutoClear(taskId: string): void {
3398
- this.#todoClearTimers.delete(taskId);
3391
+ #runTodoAutoClear(key: string): void {
3392
+ this.#todoClearTimers.delete(key);
3399
3393
  let removed = false;
3400
3394
  for (const phase of this.#todoPhases) {
3401
- const idx = phase.tasks.findIndex(t => t.id === taskId);
3395
+ const idx = phase.tasks.findIndex(t => todoClearKey(phase.name, t.content) === key);
3402
3396
  if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
3403
3397
  phase.tasks.splice(idx, 1);
3404
3398
  removed = true;
@@ -4568,7 +4562,7 @@ export class AgentSession {
4568
4562
  (task): task is TodoItem & { status: "pending" | "in_progress" } =>
4569
4563
  task.status === "pending" || task.status === "in_progress",
4570
4564
  )
4571
- .map(task => ({ id: task.id, content: task.content, status: task.status })),
4565
+ .map(task => ({ content: task.content, status: task.status })),
4572
4566
  }))
4573
4567
  .filter(phase => phase.tasks.length > 0);
4574
4568
  const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);