@mjasnikovs/pi-task 0.7.0 β†’ 0.7.2

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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  <div align="center">
2
2
 
3
+ <img src="./assets/pipeline.svg" alt="pi-task pipeline: a /task request runs through refine, research, grill, compose and critique, then the final spec is delivered to your main pi session in the same chat. Every phase boundary is persisted to .pi-tasks/TASK_NNNN.md, so the task is crash-safe and resumable." width="820"/>
4
+
3
5
  # 🧩 pi-task
4
6
 
5
7
  **Deterministic spec-orchestration for local models β€” with bundled web, docs, fetch, and worker sub-agent tools.**
@@ -7,7 +9,7 @@
7
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
8
10
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
10
- [![tests](https://img.shields.io/badge/tests-326%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-559%20passing-3fb950)](#development)
11
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
12
14
 
13
15
  </div>
@@ -16,24 +18,7 @@
16
18
 
17
19
  ## What it does
18
20
 
19
- Local models drift. Ask one to plan a non-trivial change and it skips context, hallucinates APIs, and forgets what you actually asked. `pi-task` fixes this by **not trusting a single prompt** β€” it drives your request through a fixed, persisted pipeline of small, verifiable steps, then hands the main session a clean spec to execute.
20
-
21
- ```
22
- /task add rate-limiting to the public API
23
- β”‚
24
- β–Ό
25
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
26
- β”‚ refine │──▢│ research │──▢│ grill │──▢│ compose │──▢│ critique β”‚
27
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
28
- sharpen the parallel clarifying assemble triage +
29
- raw prompt sub-agents: questions the spec rewrite if
30
- files Β· APIs Β· (auto- or the draft
31
- context Β· you answer) isn't clean
32
- tooling
33
- β”‚
34
- β–Ό
35
- final spec ──▢ main pi session (you keep working in the same chat)
36
- ```
21
+ Local models drift. Ask one to plan a non-trivial change and it skips context, hallucinates APIs, and forgets what you actually asked. `pi-task` fixes this by **not trusting a single prompt** β€” it drives your request through a fixed, persisted pipeline of small, verifiable steps (shown above), then hands the main session a clean spec to execute.
37
22
 
38
23
  Every phase boundary is written to `.pi-tasks/TASK_NNNN.md`, so a task survives a crash, a restart, or a `/task-cancel` β€” pick it back up with `/task-resume`.
39
24
 
@@ -51,7 +36,7 @@ Every phase boundary is written to `.pi-tasks/TASK_NNNN.md`, so a task survives
51
36
  pi install npm:@mjasnikovs/pi-task
52
37
  ```
53
38
 
54
- > Requires [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (the Earendil coding agent) β‰₯ 0.75.
39
+ > Requires [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (the Earendil coding agent) β‰₯ 0.78.
55
40
 
56
41
  ## Slash commands
57
42
 
@@ -82,22 +67,11 @@ The finished spec is delivered to your main `pi` conversation via `sendUserMessa
82
67
 
83
68
  A real feature is usually several tasks, not one. `/task-auto` is a thin planner on top of the single-task pipeline:
84
69
 
85
- ```
86
- /task-auto add multi-tenant billing
87
- β”‚
88
- β–Ό
89
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
90
- β”‚ clarify │──▢│ decompose│──▢│ TASK_AUTO_… β”‚ resumable list of task titles
91
- β”‚ gray β”‚ β”‚ β†’ titles β”‚ β”‚ .md (titles) β”‚
92
- β”‚ areas β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
93
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
94
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
95
- β”‚ for each unchecked title β”‚
96
- β”‚ β†’ full /task pipeline β”‚ (spec + implement)
97
- β”‚ β†’ wait until it finishes β”‚
98
- β”‚ β†’ check the box, next β”‚
99
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
100
- ```
70
+ <div align="center">
71
+
72
+ <img src="./assets/task-auto.svg" alt="/task-auto plans a feature: it clarifies the gray areas, decomposes the answers into an ordered list of task titles written to TASK_AUTO_NNNN.md, then runs each unchecked title through the full /task pipeline one at a time, ticking the box before moving on." width="820"/>
73
+
74
+ </div>
101
75
 
102
76
  - **It only produces titles.** All the depth β€” refine, research, grill, compose, critique β€” is `/task`'s job, run fresh per title. `/task-auto` never researches or specs anything itself.
103
77
  - **Clarify first.** It asks the few clarifying questions whose answers change how the feature splits, then decomposes the answers into an ordered list of task titles written to `.pi-tasks/TASK_AUTO_NNNN.md`.
@@ -114,6 +88,26 @@ The remote server is **always on** β€” it starts automatically with each session
114
88
 
115
89
  Prompts use a **first-answer-wins race**: the same question shows in the local TUI *and* every connected browser, and whoever answers first wins β€” the other surfaces dismiss. With nobody connected, `/task` behaves exactly as before; the remote path is purely additive.
116
90
 
91
+ ### Push notifications
92
+
93
+ Tap the bell (β—― β†’ β—‰) in the remote header to get pushed a notification β€” even with the app backgrounded or the phone locked β€” when:
94
+
95
+ - a **grill / clarify question** needs answering (*"pi needs your input"*),
96
+ - a **task finishes** (*"Task finished"*), or
97
+ - the agent hits an **error** (*"Agent error"*).
98
+
99
+ Delivery is **server β†’ push service β†’ device** over the [Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) standard (service worker + VAPID), so it reaches a suspended device. It works on desktop browsers and on iOS home-screen PWAs.
100
+
101
+ **iOS setup** (these are Apple's requirements, not ours):
102
+
103
+ 1. Open the **HTTPS** Tailscale URL (`/remote` lists it). iOS only allows push from a secure context β€” the plain `http://` LAN URL won't work.
104
+ 2. **Share β†’ Add to Home Screen**, then open the app from that icon. iOS only permits notifications for installed PWAs.
105
+ 3. Launch the app, **tap the bell**, and **Allow** when prompted.
106
+
107
+ The subscription is kept in memory, so after restarting `pi` just reload the app (it re-subscribes automatically) or tap the bell again. Notifications are suppressed while the app is focused in the foreground β€” the in-page UI already shows the prompt there.
108
+
109
+ VAPID keys are generated once and persisted to `${XDG_DATA_HOME:-~/.local/share}/pi-task/vapid.json` (deleting them invalidates existing subscriptions). The JWT contact (`sub`) defaults to the project URL; override it with `PI_REMOTE_PUSH_SUBJECT` (e.g. your own `mailto:you@domain.com`). To debug delivery, set `PI_REMOTE_PUSH_DEBUG=1` and tail `/tmp/pi-task-push.log` β€” it records each push and the **push-service HTTP status** (`201` delivered, `403`/`400` token/key problem, `410` stale subscription).
110
+
117
111
  `/remote stop` shuts the server down for the rest of the session (it comes back on the next session start). There is **no authentication** β€” it's a personal LAN/Tailscale tool. Don't expose the port to untrusted networks.
118
112
 
119
113
  ## Bundled tools
@@ -129,9 +123,9 @@ Runs a Brave Search query and returns a compact markdown list (title Β· URL Β· s
129
123
  > **Requires** `BRAVE_SEARCH_API_KEY` (also accepted as `BRAVE_API_KEY`). Grab a free key at [api.search.brave.com/app/keys](https://api.search.brave.com/app/keys).
130
124
 
131
125
  ### `pi-worker-fetch`
132
- Fetches a URL, cleans the HTML to markdown ([Readability](https://github.com/mozilla/readability) + [Turndown](https://github.com/mixmark-io/turndown)), then hands it to an isolated child that extracts **only** the content answering your `query`. The parent never sees the raw page.
126
+ Fetches a URL, cleans HTML to markdown ([Readability](https://github.com/mozilla/readability) + [Turndown](https://github.com/mixmark-io/turndown)), then hands it to an isolated child that extracts **only** the content answering your `query`. The parent never sees the raw page.
133
127
 
134
- - Only `text/html` responses β€” PDFs, JSON, etc. return a clear error.
128
+ - HTML is cleaned; text formats (plain text, markdown, JSON, XML/feeds, `llms.txt`, …) pass through verbatim. Binary responses β€” PDFs, images, octet-streams β€” return a clear error.
135
129
  - Bodies over 2 MB are rejected.
136
130
  - The extraction child runs with `--no-tools` to mitigate visible-text prompt injection.
137
131
 
@@ -148,6 +142,10 @@ Resolves an installed npm package, indexes its `.d.ts` files and README into a l
148
142
  | --- | --- | --- |
149
143
  | `BRAVE_SEARCH_API_KEY` / `BRAVE_API_KEY` | `pi-worker-search`, research enrichment | Required for web search. |
150
144
  | `XDG_CACHE_HOME` | `pi-worker-docs` | Overrides the docs cache location (defaults to `~/.cache`). |
145
+ | `XDG_DATA_HOME` | remote push | Where the VAPID keypair is stored (defaults to `~/.local/share`). |
146
+ | `PI_REMOTE_PUSH_SUBJECT` | remote push | VAPID JWT `sub` contact. Defaults to the project URL; set your own `mailto:you@domain.com` or `https://…`. |
147
+ | `PI_REMOTE_PUSH_DEBUG` | remote push | When set (e.g. `1`), logs push delivery and push-service HTTP status. Off by default. |
148
+ | `PI_REMOTE_PUSH_LOG` | remote push | Path for the debug log (defaults to `/tmp/pi-task-push.log`). |
151
149
 
152
150
  Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your `.gitignore` if you don't want them checked in.
153
151
 
@@ -155,7 +153,7 @@ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your
155
153
 
156
154
  ```sh
157
155
  bun install
158
- bun test src/ # 326 tests across 28 files
156
+ bun test src/ # 559 tests across 46 files
159
157
  bun run lint # prettier + eslint + tsc --noEmit
160
158
  bun run build # tsc β†’ dist/
161
159
  ```
@@ -0,0 +1,90 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 396" width="900" height="396" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" role="img" aria-label="pi-task pipeline: a /task request runs through refine, research, grill, compose and critique phases, then the final spec is delivered to your main pi session in the same chat. Every phase boundary is persisted to .pi-tasks/TASK_NNNN.md, so the task is crash-safe and resumable.">
2
+ <defs>
3
+ <marker id="ah" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
4
+ <path d="M0,0 L10,5 L0,10 z" fill="#6e7681"/>
5
+ </marker>
6
+ <style>
7
+ .title { font-size:17px; font-weight:600; fill:#e6edf3; }
8
+ .cap { font-size:11.5px; fill:#8b949e; }
9
+ .num { font-size:11px; font-weight:700; }
10
+ .code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
11
+ .foot { font-size:11.5px; fill:#768390; font-style:italic; }
12
+ </style>
13
+ </defs>
14
+
15
+ <rect x="6" y="6" width="888" height="384" rx="16" fill="#0d1117" stroke="#30363d" stroke-width="1.5"/>
16
+
17
+ <!-- input -->
18
+ <rect x="220" y="32" width="460" height="38" rx="19" fill="#161b22" stroke="#7c3aed" stroke-width="1.5"/>
19
+ <text x="450" y="57" text-anchor="middle" class="code" font-size="14"><tspan fill="#a371f7" font-weight="700">/task</tspan><tspan fill="#c9d1d9" dx="7">add rate-limiting to the public API</tspan></text>
20
+ <line x1="450" y1="70" x2="450" y2="106" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah)"/>
21
+
22
+ <!-- phase row -->
23
+ <!-- refine -->
24
+ <g>
25
+ <rect x="32" y="118" width="150" height="72" rx="10" fill="#161b22" stroke="#30363d"/>
26
+ <rect x="32" y="118" width="5" height="72" rx="2.5" fill="#58a6ff"/>
27
+ <text x="50" y="138" class="num" fill="#58a6ff">1</text>
28
+ <text x="107" y="161" text-anchor="middle" class="title">refine</text>
29
+ </g>
30
+ <text x="107" y="210" text-anchor="middle" class="cap">sharpen the</text>
31
+ <text x="107" y="225" text-anchor="middle" class="cap">raw prompt</text>
32
+
33
+ <!-- research -->
34
+ <g>
35
+ <rect x="203.5" y="118" width="150" height="72" rx="10" fill="#161b22" stroke="#30363d"/>
36
+ <rect x="203.5" y="118" width="5" height="72" rx="2.5" fill="#39c5cf"/>
37
+ <text x="221.5" y="138" class="num" fill="#39c5cf">2</text>
38
+ <text x="278.5" y="161" text-anchor="middle" class="title">research</text>
39
+ </g>
40
+ <text x="278.5" y="210" text-anchor="middle" class="cap">parallel sub-agents:</text>
41
+ <text x="278.5" y="225" text-anchor="middle" class="cap">files Β· APIs Β·</text>
42
+ <text x="278.5" y="240" text-anchor="middle" class="cap">context Β· tooling</text>
43
+
44
+ <!-- grill -->
45
+ <g>
46
+ <rect x="375" y="118" width="150" height="72" rx="10" fill="#161b22" stroke="#30363d"/>
47
+ <rect x="375" y="118" width="5" height="72" rx="2.5" fill="#d29922"/>
48
+ <text x="393" y="138" class="num" fill="#d29922">3</text>
49
+ <text x="450" y="161" text-anchor="middle" class="title">grill</text>
50
+ </g>
51
+ <text x="450" y="210" text-anchor="middle" class="cap">clarifying Q&amp;A</text>
52
+ <text x="450" y="225" text-anchor="middle" class="cap">(auto, or you answer)</text>
53
+
54
+ <!-- compose -->
55
+ <g>
56
+ <rect x="546.5" y="118" width="150" height="72" rx="10" fill="#161b22" stroke="#30363d"/>
57
+ <rect x="546.5" y="118" width="5" height="72" rx="2.5" fill="#3fb950"/>
58
+ <text x="564.5" y="138" class="num" fill="#3fb950">4</text>
59
+ <text x="621.5" y="161" text-anchor="middle" class="title">compose</text>
60
+ </g>
61
+ <text x="621.5" y="210" text-anchor="middle" class="cap">assemble</text>
62
+ <text x="621.5" y="225" text-anchor="middle" class="cap">the spec</text>
63
+
64
+ <!-- critique -->
65
+ <g>
66
+ <rect x="718" y="118" width="150" height="72" rx="10" fill="#161b22" stroke="#30363d"/>
67
+ <rect x="718" y="118" width="5" height="72" rx="2.5" fill="#a371f7"/>
68
+ <text x="736" y="138" class="num" fill="#a371f7">5</text>
69
+ <text x="793" y="161" text-anchor="middle" class="title">critique</text>
70
+ </g>
71
+ <text x="793" y="210" text-anchor="middle" class="cap">triage, rewrite</text>
72
+ <text x="793" y="225" text-anchor="middle" class="cap">if not clean</text>
73
+
74
+ <!-- inter-phase arrows -->
75
+ <line x1="182" y1="154" x2="201.5" y2="154" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah)"/>
76
+ <line x1="353.5" y1="154" x2="373" y2="154" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah)"/>
77
+ <line x1="525" y1="154" x2="544.5" y2="154" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah)"/>
78
+ <line x1="696.5" y1="154" x2="716" y2="154" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah)"/>
79
+
80
+ <!-- to result -->
81
+ <line x1="450" y1="252" x2="450" y2="292" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah)"/>
82
+
83
+ <!-- result -->
84
+ <rect x="120" y="296" width="660" height="48" rx="12" fill="#0f261a" stroke="#2ea043" stroke-width="1.5"/>
85
+ <text x="450" y="318" text-anchor="middle" font-size="14" font-weight="600" fill="#e6edf3">final spec β†’ delivered to your main <tspan class="code" fill="#7ee787">pi</tspan> session</text>
86
+ <text x="450" y="334" text-anchor="middle" class="cap">same chat β€” no handoff, no copy-paste</text>
87
+
88
+ <!-- footnote -->
89
+ <text x="450" y="372" text-anchor="middle" class="foot">every phase boundary is persisted to <tspan class="code" font-style="normal">.pi-tasks/TASK_NNNN.md</tspan> β€” crash-safe &amp; resumable</text>
90
+ </svg>
@@ -0,0 +1,65 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 332" width="900" height="332" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" role="img" aria-label="/task-auto plans a feature: it clarifies the gray areas, decomposes the answers into an ordered list of task titles written to TASK_AUTO_NNNN.md, then runs each unchecked title through the full /task pipeline one at a time, ticking the box before moving on.">
2
+ <defs>
3
+ <marker id="ah2" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
4
+ <path d="M0,0 L10,5 L0,10 z" fill="#6e7681"/>
5
+ </marker>
6
+ <style>
7
+ .title { font-size:17px; font-weight:600; fill:#e6edf3; }
8
+ .titlec { font-size:15px; font-weight:600; fill:#e6edf3; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
9
+ .cap { font-size:11.5px; fill:#8b949e; }
10
+ .num { font-size:11px; font-weight:700; }
11
+ .code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
12
+ </style>
13
+ </defs>
14
+
15
+ <rect x="6" y="6" width="888" height="320" rx="16" fill="#0d1117" stroke="#30363d" stroke-width="1.5"/>
16
+
17
+ <!-- input -->
18
+ <rect x="240" y="30" width="420" height="38" rx="19" fill="#161b22" stroke="#7c3aed" stroke-width="1.5"/>
19
+ <text x="450" y="55" text-anchor="middle" class="code" font-size="14"><tspan fill="#a371f7" font-weight="700">/task-auto</tspan><tspan fill="#c9d1d9" dx="7">add multi-tenant billing</tspan></text>
20
+ <line x1="450" y1="68" x2="450" y2="100" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah2)"/>
21
+
22
+ <!-- clarify -->
23
+ <g>
24
+ <rect x="32" y="110" width="200" height="72" rx="10" fill="#161b22" stroke="#30363d"/>
25
+ <rect x="32" y="110" width="5" height="72" rx="2.5" fill="#d29922"/>
26
+ <text x="50" y="130" class="num" fill="#d29922">1</text>
27
+ <text x="132" y="153" text-anchor="middle" class="title">clarify gray areas</text>
28
+ </g>
29
+ <text x="132" y="202" text-anchor="middle" class="cap">ask only what</text>
30
+ <text x="132" y="217" text-anchor="middle" class="cap">changes the split</text>
31
+
32
+ <!-- decompose -->
33
+ <g>
34
+ <rect x="350" y="110" width="200" height="72" rx="10" fill="#161b22" stroke="#30363d"/>
35
+ <rect x="350" y="110" width="5" height="72" rx="2.5" fill="#58a6ff"/>
36
+ <text x="368" y="130" class="num" fill="#58a6ff">2</text>
37
+ <text x="450" y="153" text-anchor="middle" class="title">decompose β†’ titles</text>
38
+ </g>
39
+ <text x="450" y="202" text-anchor="middle" class="cap">ordered list of</text>
40
+ <text x="450" y="217" text-anchor="middle" class="cap">task titles only</text>
41
+
42
+ <!-- file -->
43
+ <g>
44
+ <rect x="668" y="110" width="200" height="72" rx="10" fill="#161b22" stroke="#30363d"/>
45
+ <rect x="668" y="110" width="5" height="72" rx="2.5" fill="#3fb950"/>
46
+ <text x="686" y="130" class="num" fill="#3fb950">3</text>
47
+ <text x="768" y="154" text-anchor="middle" class="titlec">TASK_AUTO_NNNN.md</text>
48
+ </g>
49
+ <text x="768" y="202" text-anchor="middle" class="cap">resumable checklist</text>
50
+ <text x="768" y="217" text-anchor="middle" class="cap">of titles</text>
51
+
52
+ <!-- arrows between -->
53
+ <line x1="232" y1="146" x2="348" y2="146" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah2)"/>
54
+ <line x1="550" y1="146" x2="666" y2="146" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah2)"/>
55
+
56
+ <!-- to loop -->
57
+ <line x1="768" y1="227" x2="768" y2="248" stroke="#6e7681" stroke-width="1.5"/>
58
+ <path d="M768,248 H450 V248" fill="none" stroke="#6e7681" stroke-width="1.5"/>
59
+ <line x1="450" y1="244" x2="450" y2="252" stroke="#6e7681" stroke-width="1.5" marker-end="url(#ah2)"/>
60
+
61
+ <!-- loop -->
62
+ <rect x="120" y="256" width="660" height="54" rx="12" fill="#0d1117" stroke="#a371f7" stroke-width="1.5"/>
63
+ <text x="450" y="278" text-anchor="middle" font-size="14" font-weight="600" fill="#e6edf3">↻ for each unchecked title</text>
64
+ <text x="450" y="296" text-anchor="middle" class="cap">run the full <tspan class="code" fill="#a371f7">/task</tspan> pipeline β†’ implement β†’ wait until done β†’ tick the box β†’ next</text>
65
+ </svg>
@@ -1,14 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import type { ContextUsage, PromptMessage, ServerMessage } from './protocol.js';
2
+ import type { ServerMessage } from './protocol.js';
3
3
  export interface BridgeState {
4
4
  /** promptId β†’ resolver that settles the remote side of an ask() race. */
5
5
  pending: Map<string, (value: string | undefined) => void>;
6
- /** The prompt currently awaiting an answer (replayed to late joiners), or null. */
7
- activePrompt: PromptMessage | null;
8
- /** Last lines pushed per widget key (replayed to late joiners). */
9
- activeWidgets: Map<string, string[]>;
10
- /** Most recent context-window usage (replayed to seed late joiners' bar), or null. */
11
- lastContextUsage: ContextUsage | null;
12
6
  nextId: number;
13
7
  /** Command name β†’ handler, populated as pi-task registers its commands. */
14
8
  commands: Map<string, (args: string, ctx: ExtensionCommandContext) => unknown>;
@@ -43,9 +37,6 @@ export declare class SessionUI {
43
37
  /** Race the local input against a remote answer; first to settle wins. */
44
38
  ask(spec: AskSpec): Promise<string | undefined>;
45
39
  }
46
- /** Mirror a status widget to browsers and remember it for late joiners.
47
- * `lines === undefined` clears the widget (broadcast as `lines: null`). */
48
- export declare function publishWidget(key: string, lines: string[] | undefined): void;
49
40
  export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
50
41
  export declare function publishViewer(title: string, text: string): void;
51
42
  /**
@@ -1,13 +1,11 @@
1
1
  import { broadcast as wsBroadcast } from './broadcast.js';
2
2
  import { pushNotify } from './push.js';
3
+ import { setPrompt, clearPrompt } from './session-state.js';
3
4
  const g = globalThis;
4
5
  export function getBridge() {
5
6
  if (!g.__piBridge) {
6
7
  g.__piBridge = {
7
8
  pending: new Map(),
8
- activePrompt: null,
9
- activeWidgets: new Map(),
10
- lastContextUsage: null,
11
9
  nextId: 0,
12
10
  commands: new Map(),
13
11
  currentCtx: null,
@@ -56,8 +54,7 @@ export class SessionUI {
56
54
  recommended: spec.recommended,
57
55
  allowSkip: spec.allowSkip
58
56
  };
59
- b.activePrompt = prompt;
60
- b.broadcast(prompt);
57
+ setPrompt(prompt);
61
58
  // Reaches a backgrounded/suspended phone, which the in-page UI can't.
62
59
  void pushNotify('pi needs your input', spec.question, 'pi-prompt').catch(() => { });
63
60
  // Local: resolves to a value/undefined, or undefined on abort. Swallow
@@ -78,23 +75,10 @@ export class SessionUI {
78
75
  }
79
76
  finally {
80
77
  b.pending.delete(id);
81
- b.activePrompt = null;
82
- b.broadcast({ type: 'prompt_resolved', id });
78
+ clearPrompt(id);
83
79
  }
84
80
  }
85
81
  }
86
- /** Mirror a status widget to browsers and remember it for late joiners.
87
- * `lines === undefined` clears the widget (broadcast as `lines: null`). */
88
- export function publishWidget(key, lines) {
89
- const b = getBridge();
90
- if (lines === undefined) {
91
- b.activeWidgets.delete(key);
92
- b.broadcast({ type: 'widget', key, lines: null });
93
- return;
94
- }
95
- b.activeWidgets.set(key, lines);
96
- b.broadcast({ type: 'widget', key, lines });
97
- }
98
82
  export function publishNotify(message, level) {
99
83
  getBridge().broadcast({ type: 'notify', message, level });
100
84
  }
@@ -1,3 +1,4 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
- import type { HistoryBuffer } from './history.js';
3
- export declare function setupEvents(pi: ExtensionAPI, history: HistoryBuffer, broadcastFn: (msg: unknown) => void): void;
2
+ /** Mirror pi agent events into the authoritative SessionState. Each handler
3
+ * drives a mutator, which updates the snapshot AND broadcasts the live delta. */
4
+ export declare function setupEvents(pi: ExtensionAPI): void;
@@ -1,22 +1,18 @@
1
1
  import { setAgentIdle } from './state.js';
2
- import { getBridge } from './bridge.js';
3
2
  import { pushNotify } from './push.js';
4
- export function setupEvents(pi, history, broadcastFn) {
5
- let currentText = '';
6
- const currentTools = [];
7
- const pendingArgs = new Map();
3
+ import { publishNotify } from './bridge.js';
4
+ import { agentStart, appendText, textEnd, startTool, updateTool, endTool, agentEnd, addUserTurn, addError, addSystemNote } from './session-state.js';
5
+ /** Mirror pi agent events into the authoritative SessionState. Each handler
6
+ * drives a mutator, which updates the snapshot AND broadcasts the live delta. */
7
+ export function setupEvents(pi) {
8
8
  pi.on('agent_start', (_event, _ctx) => {
9
- currentText = '';
10
- currentTools.length = 0;
11
- pendingArgs.clear();
12
9
  setAgentIdle(false);
13
- broadcastFn({ type: 'agent_start' });
10
+ agentStart();
14
11
  });
15
12
  pi.on('message_update', (event, _ctx) => {
16
13
  const ae = event.assistantMessageEvent;
17
14
  if (ae.type === 'text_delta' && 'delta' in ae && typeof ae.delta === 'string') {
18
- currentText += ae.delta;
19
- broadcastFn({ type: 'text_delta', delta: ae.delta });
15
+ appendText(ae.delta);
20
16
  }
21
17
  else if (ae.type === 'error') {
22
18
  const errorMessage = 'error' in ae && ae.error && typeof ae.error.errorMessage === 'string' ?
@@ -25,63 +21,42 @@ export function setupEvents(pi, history, broadcastFn) {
25
21
  // Skip silent user aborts (no message); only surface genuine failures.
26
22
  if (errorMessage || ae.reason === 'error') {
27
23
  const message = errorMessage || 'Request failed';
28
- history.addError(message);
29
- broadcastFn({ type: 'agent_error', message });
24
+ addError(message);
30
25
  void pushNotify('Agent error', message, 'pi-error').catch(() => { });
31
26
  }
32
27
  }
33
28
  });
34
29
  pi.on('message_end', (_event, _ctx) => {
35
- broadcastFn({ type: 'text_end' });
30
+ textEnd();
36
31
  });
37
32
  pi.on('tool_execution_start', (event, _ctx) => {
38
- pendingArgs.set(event.toolCallId, event.args);
39
- broadcastFn({
40
- type: 'tool_start',
41
- toolCallId: event.toolCallId,
42
- toolName: event.toolName,
43
- args: event.args
44
- });
33
+ startTool(event.toolCallId, event.toolName, event.args);
45
34
  });
46
35
  pi.on('tool_execution_update', (event, _ctx) => {
47
- broadcastFn({
48
- type: 'tool_update',
49
- toolCallId: event.toolCallId,
50
- partialResult: event.partialResult
51
- });
36
+ updateTool(event.toolCallId, event.partialResult);
52
37
  });
53
38
  pi.on('tool_execution_end', (event, _ctx) => {
54
- const args = pendingArgs.get(event.toolCallId);
55
- pendingArgs.delete(event.toolCallId);
56
- currentTools.push({
57
- toolName: event.toolName,
58
- args,
59
- result: event.result,
60
- isError: event.isError
61
- });
62
- broadcastFn({
63
- type: 'tool_end',
64
- toolCallId: event.toolCallId,
65
- toolName: event.toolName,
66
- result: event.result,
67
- isError: event.isError
68
- });
39
+ endTool(event.toolCallId, event.toolName, event.result, event.isError);
69
40
  });
70
41
  pi.on('agent_end', (_event, ctx) => {
71
- const contextUsage = ctx.getContextUsage();
72
42
  setAgentIdle(true);
73
- // Remember it so a browser that connects mid-session gets its bar seeded.
74
- getBridge().lastContextUsage = contextUsage;
75
- history.addAssistantTurn(currentText, [...currentTools]);
76
- broadcastFn({ type: 'agent_end', contextUsage });
43
+ agentEnd(ctx.getContextUsage());
77
44
  void pushNotify('Task finished', '', 'pi-end').catch(() => { });
78
- currentText = '';
79
- currentTools.length = 0;
80
45
  });
81
46
  pi.on('input', (event, _ctx) => {
82
47
  if (event.source === 'interactive' && typeof event.text === 'string') {
83
- history.addUserMessage(event.text);
84
- broadcastFn({ type: 'user_message', text: event.text });
48
+ addUserTurn(event.text);
85
49
  }
86
50
  });
51
+ // Context-window compaction (incl. the auto-compaction triggered by a context
52
+ // overflow) is invisible to a remote viewer otherwise β€” mirror it as a toast so
53
+ // they see the same "compacting…" status the terminal shows.
54
+ pi.on('session_before_compact', (_event, _ctx) => {
55
+ publishNotify('Context full β€” compacting…', 'warning');
56
+ });
57
+ pi.on('session_compact', (_event, _ctx) => {
58
+ // Persistent inline note so it's still visible after a reconnect, not just a
59
+ // transient toast.
60
+ addSystemNote('Context compacted');
61
+ });
87
62
  }
@@ -1,13 +1,24 @@
1
- export interface ToolSummary {
1
+ export interface TextPart {
2
+ kind: 'text';
3
+ text: string;
4
+ }
5
+ export interface ToolPart {
6
+ kind: 'tool';
7
+ toolCallId: string;
2
8
  toolName: string;
3
9
  args: unknown;
4
10
  result: unknown;
5
11
  isError: boolean;
12
+ /** false while the tool is still running (result not in yet). */
13
+ done: boolean;
6
14
  }
15
+ export type Part = TextPart | ToolPart;
7
16
  export interface Turn {
8
- role: 'user' | 'assistant';
9
- text: string;
10
- tools: ToolSummary[];
17
+ role: 'user' | 'assistant' | 'system';
18
+ /** User text, error text, or a system note. Assistant content lives in `parts`. */
19
+ text?: string;
20
+ /** Ordered assistant content (text + tools). */
21
+ parts?: Part[];
11
22
  error?: boolean;
12
23
  }
13
24
  export declare class HistoryBuffer {
@@ -15,8 +26,10 @@ export declare class HistoryBuffer {
15
26
  private readonly limit;
16
27
  constructor(limit?: number);
17
28
  addUserMessage(text: string): void;
18
- addAssistantTurn(text: string, tools: ToolSummary[]): void;
29
+ addAssistantTurn(parts: Part[]): void;
19
30
  addError(text: string): void;
31
+ /** A persistent, inline system note (e.g. "Context compacted"). */
32
+ addSystemNote(text: string): void;
20
33
  getEntries(): Turn[];
21
34
  private _push;
22
35
  }
@@ -1,3 +1,6 @@
1
+ // An assistant turn is an ORDERED list of parts β€” text segments and tool calls
2
+ // interleaved exactly as they happened β€” so the remote transcript reproduces the
3
+ // terminal's layout instead of collapsing a whole run into one text blob.
1
4
  export class HistoryBuffer {
2
5
  entries = [];
3
6
  limit;
@@ -5,13 +8,17 @@ export class HistoryBuffer {
5
8
  this.limit = limit;
6
9
  }
7
10
  addUserMessage(text) {
8
- this._push({ role: 'user', text, tools: [] });
11
+ this._push({ role: 'user', text });
9
12
  }
10
- addAssistantTurn(text, tools) {
11
- this._push({ role: 'assistant', text, tools });
13
+ addAssistantTurn(parts) {
14
+ this._push({ role: 'assistant', parts });
12
15
  }
13
16
  addError(text) {
14
- this._push({ role: 'assistant', text, tools: [], error: true });
17
+ this._push({ role: 'assistant', text, error: true });
18
+ }
19
+ /** A persistent, inline system note (e.g. "Context compacted"). */
20
+ addSystemNote(text) {
21
+ this._push({ role: 'system', text });
15
22
  }
16
23
  getEntries() {
17
24
  return [...this.entries];
@@ -9,9 +9,9 @@ export interface PromptResolvedMessage {
9
9
  type: 'prompt_resolved';
10
10
  id: string;
11
11
  }
12
+ /** The single task-widget slot. `lines: null` clears it. */
12
13
  export interface WidgetMessage {
13
14
  type: 'widget';
14
- key: string;
15
15
  lines: string[] | null;
16
16
  }
17
17
  export interface NotifyMessage {
@@ -40,10 +40,13 @@ export interface ContextMessage {
40
40
  export interface ResetMessage {
41
41
  type: 'reset';
42
42
  }
43
- /** Server β†’ browser messages added by the integration. The existing
44
- * history / text_delta / tool_* / agent_* / client_count / user_message messages are
45
- * emitted ad hoc by events.ts / server.ts and are not enumerated here. */
46
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage;
43
+ /** The full authoritative state sent to a (re)connecting client. Defined in
44
+ * session-state.ts (its serializer); re-exported here as part of the wire type. */
45
+ export type { SnapshotMessage } from './session-state.js';
46
+ /** Server β†’ browser messages. The live text_delta / tool_* / agent_* /
47
+ * client_count / user_message deltas are emitted by the SessionState mutators
48
+ * and not all enumerated here; the snapshot below carries the full state. */
49
+ export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
47
50
  /** Browser β†’ server messages. */
48
51
  export interface ClientChatMessage {
49
52
  type: 'message';