@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.
- package/CHANGELOG.md +42 -0
- package/package.json +7 -7
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +29 -9
- package/src/internal-urls/docs-index.generated.ts +54 -54
- package/src/ipy/gateway-coordinator.ts +2 -1
- package/src/modes/controllers/todo-command-controller.ts +22 -74
- package/src/modes/interactive-mode.ts +9 -6
- package/src/modes/types.ts +0 -2
- package/src/prompts/system/eager-todo.md +1 -1
- package/src/prompts/tools/atom.md +3 -2
- package/src/prompts/tools/browser.md +61 -16
- package/src/prompts/tools/todo-write.md +19 -19
- package/src/session/agent-session.ts +23 -29
- package/src/tools/browser/attach.ts +175 -0
- package/src/tools/browser/launch.ts +554 -0
- package/src/tools/browser/readable.ts +90 -0
- package/src/tools/browser/registry.ts +417 -0
- package/src/tools/browser/render.ts +212 -0
- package/src/tools/browser/vm.ts +792 -0
- package/src/tools/browser.ts +249 -1568
- package/src/tools/plan-mode-guard.ts +27 -1
- package/src/tools/renderers.ts +2 -0
- package/src/tools/todo-write.ts +157 -195
- package/examples/custom-tools/todo/index.ts +0 -211
- package/examples/extensions/todo.ts +0 -295
|
@@ -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
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
129
|
-
const
|
|
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 =>
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
288
|
+
targetPhase = { name: "Todos", tasks: [] };
|
|
338
289
|
next.push(targetPhase);
|
|
339
290
|
}
|
|
340
291
|
|
|
341
|
-
const
|
|
342
|
-
let n = 1;
|
|
343
|
-
while (usedTaskIds.has(`task-${n}`)) n++;
|
|
292
|
+
const finalContent = titleCaseSentence(content);
|
|
344
293
|
targetPhase.tasks.push({
|
|
345
|
-
|
|
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}: ${
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) : "#
|
|
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
|
|
711
|
+
const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]);
|
|
712
|
+
const activePhase = phases[activeIdx];
|
|
711
713
|
if (!activePhase) return;
|
|
712
|
-
lines.push(
|
|
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
|
-
|
|
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
|
},
|
package/src/modes/types.ts
CHANGED
|
@@ -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 `
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 —
|
|
5
|
-
-
|
|
6
|
-
- `"
|
|
7
|
-
- `
|
|
8
|
-
- `
|
|
9
|
-
- `
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- `
|
|
13
|
-
- `
|
|
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
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
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
|
-
|
|
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
|
-
|`
|
|
9
|
-
|`start`|`task`|
|
|
10
|
-
|`done`|`task` or `phase
|
|
11
|
-
|`drop`|`task` or `phase
|
|
12
|
-
|`rm`|`task` or `phase
|
|
13
|
-
|`append`|`phase`, `items:
|
|
14
|
-
|`note`|`task`, `text`|Append a note to
|
|
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
|
|
18
|
-
- **Phase
|
|
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
|
|
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
|
-
-
|
|
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":"
|
|
35
|
-
# Initial setup (single phase
|
|
36
|
-
`{"ops":[{"op":"
|
|
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":"
|
|
38
|
+
`{"ops":[{"op":"done","task":"Wire workspace"}]}`
|
|
39
39
|
# Complete a whole phase
|
|
40
|
-
`{"ops":[{"op":"done","phase":"
|
|
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":"
|
|
44
|
+
`{"ops":[{"op":"drop","task":"Run cargo test"}]}`
|
|
45
45
|
# Append tasks to a phase
|
|
46
|
-
`{"ops":[{"op":"append","phase":"
|
|
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 {
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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 [
|
|
3376
|
-
if (!
|
|
3369
|
+
for (const [key, timer] of this.#todoClearTimers) {
|
|
3370
|
+
if (!doneKeys.has(key)) {
|
|
3377
3371
|
clearTimeout(timer);
|
|
3378
|
-
this.#todoClearTimers.delete(
|
|
3372
|
+
this.#todoClearTimers.delete(key);
|
|
3379
3373
|
}
|
|
3380
3374
|
}
|
|
3381
3375
|
|
|
3382
3376
|
// Schedule new timers for newly-done tasks
|
|
3383
|
-
for (const
|
|
3384
|
-
if (this.#todoClearTimers.has(
|
|
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(
|
|
3388
|
-
this.#todoClearTimers.set(
|
|
3381
|
+
const timer = setTimeout(() => this.#runTodoAutoClear(key), 0);
|
|
3382
|
+
this.#todoClearTimers.set(key, timer);
|
|
3389
3383
|
} else {
|
|
3390
|
-
const timer = setTimeout(() => this.#runTodoAutoClear(
|
|
3391
|
-
this.#todoClearTimers.set(
|
|
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(
|
|
3398
|
-
this.#todoClearTimers.delete(
|
|
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.
|
|
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 => ({
|
|
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);
|