pi-aside 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,156 @@
1
+ # pi-aside: implementation notes
2
+
3
+ Status: implemented against Pi 1.3.0. This document maps the code to [design.md](design.md) (engine, rev 11) and
4
+ [ux.md](ux.md) (interaction, rev 5), lists where the implementation departs from them and why, and records how it was
5
+ verified.
6
+
7
+ ## 1. Map
8
+
9
+ | Module | Design | Role |
10
+ |---|---|---|
11
+ | `src/observe.ts` | design §4.1 | One logical request L at a time: `context_with_system` opens it with T, the leaf and the recipe; H, R, S attribute the wire attempt; the first `message_update` or `message_end` makes it ready. Ambiguity (a second H, a warmer replay, a request without H) and failures are recorded, never guessed. |
12
+ | `src/tap.ts` | new, amends §4.1 | The final-payload tap (§2.1 below). |
13
+ | `src/payload.ts` | design §4.2 | The per-API table (content list, trailing configuration items E, marker key), `strip()`, ΔH, and the checks C1, C2 and C3. |
14
+ | `src/fork.ts` | design §4.2, §4.8 | Conversion under the frozen recipe (Pi's `convertToLlm` with image blocking, `normalizeContext`), the dry run B0 (onPayload records and aborts, nothing is sent), G with "still running" placeholders, the same-tick send with the currency check, F = B1 ⊕ ΔH, and T1 (fresh session id, SSE, no stored-response continuation). |
15
+ | `src/transport.ts` | design §4.2 routing | Request options and attribution headers synthesized from the current settings, as Pi's next request would get them, never copied from the observation. |
16
+ | `src/thread.ts` | design §4.4, §4.5; ux §3.2 | The side thread: the notice, read-only tools (`read`, `grep`, `find`, `ls`, at most 8 rounds), follow-ups, re-forking when the main session moved, accounting. |
17
+ | `src/usage.ts` | design §4.6 | Usage entries, appended only while the agent is idle; dropped and counted when a busy session is left. |
18
+ | `src/qmark.ts`, `src/reply.ts` | ux §3.1, §3.3, §3.4 | The question-mark rule (U9) and the route and handoff lines. |
19
+ | `src/busy.ts` | ux §3.1, §3.8 | The `background-work` convention on `pi.events`. |
20
+ | `src/panel.ts` | ux §3.2, §3.3, §3.5 | The answer panel, the hint and ghost line, the money line, the `/aside` reader. |
21
+ | `src/index.ts` | all | Wiring: events, routing, the `/aside` command, widgets. |
22
+
23
+ ## 2. Departures from the design
24
+
25
+ ### 2.1 The final-payload tap (amends design §4.1)
26
+
27
+ `before_provider_request` handlers run in extension load order, and a handler may return a replacement payload.
28
+ Extensions loaded earlier never see the replacement. The design makes every guarantee relative to that order and
29
+ expects pi-aside to be installed last. On a real relay this failed silently. pi-zip 0.4.11 returns a replacement
30
+ payload that adds a sticky `metadata.user_id`, which the relay uses to choose the upstream account. With pi-aside
31
+ loaded before it (`-e` extensions load before packages), P lacked the field, C1–C3 passed against that stale P, the
32
+ aside went out without the sticky id, and it read 0 cached tokens on one run out of two.
33
+
34
+ Pi sends every session request through the model runtime that also backs `ctx.modelRegistry`, and passes the handler
35
+ chain as `onPayload`. pi-aside wraps that runtime's `streamSimple` once and records the payload the chain returns for
36
+ the latest attempt. That payload is what Pi sends, whatever the load order. pi-aside's own requests carry a marker
37
+ and are not tapped. If a Pi build has no such runtime, the observation falls back to the R event's payload by
38
+ reference, which is the design's behaviour. Test: "a payload replacement by an extension loaded after pi-aside
39
+ reaches the aside" fails without the tap and passes with it.
40
+
41
+ Handlers of `context_with_system` that run after pi-aside still make T stale. C1 then fails, and the aside uses T1:
42
+ correct, but it does not read the cache. Installing pi-aside last avoids this; `pi install` appends.
43
+
44
+ ### 2.2 Enter is routed through Pi's `input` event (amends ux §4)
45
+
46
+ ux.md planned to intercept the submit key with `onTerminalInput`. pi-aside instead handles Pi's `input` event, which
47
+ Pi emits for every interactive submit before acting on it, with `streamingBehavior` telling a steer from a follow-up.
48
+ Returning `handled` takes the text; `continue` keeps Pi's meaning. This removes four risks of the original plan:
49
+ - A remapped `tui.input.submit` works without finding the key.
50
+ - A bracketed paste containing `?` and a newline never submits.
51
+ - IME composition is irrelevant, because only committed, submitted text is seen.
52
+ - No keystroke is ever consumed except Tab on an empty editor with a pending suggestion.
53
+
54
+ `onTerminalInput` is used only for that Tab and to refresh the hint after each key.
55
+
56
+ ### 2.3 Ghost text is a dim line below the editor
57
+
58
+ Ghost text drawn inside the editor would need `setEditorComponent`, which conflicts with other editor extensions
59
+ (the user's own setup replaces the editor frame). The suggestion is a dim line `⇥ <suggestion> (tab accepts)` in a
60
+ `belowEditor` widget. It shows only while the editor is empty, as ux §3.3 specifies for the ghost text.
61
+
62
+ ### 2.4 An answer still streaming does not collapse
63
+
64
+ ux §3.2 collapses the panel when the user next sends anything to the agent. pi-aside applies this only to finished
65
+ answers. Sending a steer while the answer streams leaves it visible, because the user is waiting for it. The next
66
+ send after it finishes collapses it.
67
+
68
+ ### 2.5 The cacheable prefix on OpenAI APIs
69
+
70
+ The detail line compares the aside's cache read with the main request's cacheable prefix, which design §4.6 defines
71
+ as cache read plus cache write. OpenAI APIs report no cache writes, so the prefix there is the whole prompt (input
72
+ plus cache read). Anthropic reports the counts in its first stream event, so the figure is available while the main
73
+ reply still streams.
74
+
75
+ ### 2.6 Asides think at a low effort when that keeps the cache (amends design §4.2 C2, C3)
76
+
77
+ An aside inherited the main session's thinking level. At `xhigh` the panel stayed empty for seconds while the
78
+ model thought about a side question. Some models take effort changes in the middle of a conversation
79
+ (`compat.supportsMidConvoEffort`): pi-ai then sends the level as a trailing configuration item (E: an Anthropic
80
+ `system` item with `output_config.effort`, a Responses `configuration_update`), after the cached prefix, and keeps
81
+ the top-level fields unchanged.
82
+
83
+ On such models the aside asks at `low` when the main level is higher (`asideLevel` in `src/fork.ts`; a level the
84
+ model's `thinkingLevelMap` lacks or maps to nothing is not used). The change is proved, not assumed: when the fork
85
+ is made, a second dry run of T at the lower level must differ from B0 in E only (same core, same other top-level
86
+ fields). Then C2 compares B1 with that dry run, and C3 compares E(F) with its E instead of E(P). Every other check
87
+ is unchanged, so the request still extends P byte for byte up to E. Without the proof the aside keeps the main
88
+ level. T1 uses the same rule on its own requests.
89
+
90
+ Measured on the Anthropic relay (Claude Opus 5.5, main at `xhigh`, idle aside, two runs each): the first words came
91
+ after 3.8 s at the main level and after 1.7–1.9 s at `low`; the cache read was the same (3,557 tokens, `exact`).
92
+
93
+ ### 2.7 The panel while an answer has no words yet, Markdown, and putting it away (amends ux §3.2)
94
+
95
+ - Before the first words the panel shows Pi's spinner with `working` and the seconds so far. Once the model
96
+ thinks, the line says `thinking` and the last three lines of its (summarized) thinking follow, dim and italic.
97
+ Tool calls show as before (`reading src/a.ts`), with the spinner. While the answer streams, the spinner sits in
98
+ the title.
99
+ - Answers, thinking and the reader are rendered with Pi's Markdown component and Markdown theme.
100
+ - Enter on an empty editor puts the panel away: the frame collapses to its one dim line, and the next such Enter
101
+ clears that line. Pi does nothing on an empty Enter, so no meaning changes. While the editor is empty, the
102
+ frame's lower edge says `↵ hides`. A handoff suggestion goes away with the panel. `/aside` still rereads the
103
+ thread.
104
+ - Pi's "more" key (`app.tools.expand`, ctrl+o, which Pi's own header teaches as `ctrl+o more`) also expands the
105
+ aside: a long answer gets the terminal's room (its height minus 14 lines) instead of 10 lines, and a collapsed
106
+ panel opens again. Pi still toggles its tool output, so the key keeps its meaning: show more. A truncated answer
107
+ ends with `… ctrl+o more` (the key as bound); an answer longer than the room ends with `… /aside to read all`.
108
+ - The `/aside` reader opens at the latest question instead of the first.
109
+
110
+ ### 2.8 Not implemented
111
+
112
+ - **The fork-point breakpoint (ux §5.5).** The aside's markers are pi-ai's defaults. When the main cache has
113
+ expired, the aside pays the cold write, and the money line says so.
114
+ - **A `background-work` emitter in pi-durable-subagents.** Until an emitter exists, "busy" means running, as ux §3.1
115
+ specifies for that case. So while the agent is idle and only subagents run, a `?` message goes to the agent as a
116
+ prompt (seen in real use, dsa 1.2.0); `/aside <question>` asks on the side meanwhile.
117
+ - **The user studies of ux §6 (U1) and the 40-message misroute set (U8).** Both need real users and a real model.
118
+ The route check is implemented and tested with scripted replies.
119
+
120
+ ## 3. Verification
121
+
122
+ ### 3.1 Offline (`bun run check`, about 30 s, 111 tests)
123
+
124
+ A local capture server speaks the three APIs as pi-ai consumes them. Real Pi 1.3.0 runs over RPC and, for the
125
+ interaction, in a private tmux server. Nothing touches `~/.pi` or the network.
126
+
127
+ | Area | Tests |
128
+ |---|---|
129
+ | Exact fork, idle, per API | The aside's request extends the main request element by element (strip applied), every other top-level field is equal, the credential and SDK headers match, a handler's sticky field is carried, no extension hook sees the aside, and one usage entry is written with `tier=exact`. |
130
+ | Concurrent, per API, with native effort (Anthropic `output_config`, Responses `configuration_update`) | Asked while the main reply is withheld: the aside waits for readiness, then extends the observed request with the same trailing configuration. Its usage entry follows the main reply in the session file. |
131
+ | Tool rounds | After a main tool round; during a running main tool (still-running placeholder); the aside's own read-only tools, refusal of `bash` (no file created), continuation requests still exact. |
132
+ | Fallbacks | A handler that rewrites the transcript, which gives T1 with a fresh session id; no main request yet, which gives T1 with the session's system prompt. |
133
+ | Order | A payload replacement by an extension loaded after pi-aside (the tap). Loading as an installed package. |
134
+ | Follow-ups | The fork continues; after the main session moves, it forks again with the earlier exchange quoted. |
135
+ | TUI | The hint, a busy `?` asked on the side and never seen by the main session, collapse on the next send, CJK `?`, a steer without `?`, an idle `?` as a prompt, Esc aborting with the panel visible, Tab accepting the suggestion and Enter sending it with the side note, `route: agent` sent as a steer, a failed aside sent as a steer, a bracketed paste, the waiting state, the spinner and thinking before the first words, Markdown, and Enter on an empty editor putting the panel away. |
136
+ | Effort | On models with mid-conversation effort, the aside's E carries `low` and everything else matches the main request (Anthropic and Responses); other models keep the main level. |
137
+ | Units | The text rules (71 gate cases), strip, lists, C1–C3, the observer state machine, the usage queue, background work, placeholders, the notice, money. |
138
+
139
+ ### 3.2 Real provider (2026-10-21, a few cents in total)
140
+
141
+ | Setup | Result |
142
+ |---|---|
143
+ | Anthropic relay, idle aside, no other extensions | `exact`, read 2,935 of 2,935 cacheable, $0.0013 (the main turn cost $0.0075) |
144
+ | OpenAI Responses provider, idle aside | `exact`, read 5,120 of 5,245 (OpenAI caches in 128-token blocks) |
145
+ | Anthropic relay, asked after the first streamed token | Answered in 1.4–2.0 s while the main reply still streamed; read 2,953 of 2,953; the main reply was unaffected |
146
+ | Anthropic relay, every installed extension (pi-zip, pi-durable-subagents, …), pi-aside loaded first | Without the tap: read 0 on one run of two (§2.1). With the tap: the sticky `metadata.user_id` is carried, and the aside read 16,729 of 16,729 |
147
+ | The real TUI with every installed extension | Hint under the custom editor frame, panel above it, answer while the agent streamed |
148
+
149
+ ## 4. Corrections for design.md
150
+
151
+ design.md is maintained separately. These changes are proposed, not applied:
152
+ - §4.1: observe P through the final-payload tap where available (§2.1). The R event's object by reference remains the
153
+ fallback.
154
+ - §3, line 130: the cold write does not serve the main session's next turn, because the aside's marker sits after Q
155
+ (ux §5.5).
156
+ - §4.5: a follow-up forks again when the main session gained context since the thread's last request (ux §3.2).
package/docs/ux.md ADDED
@@ -0,0 +1,312 @@
1
+ # pi-aside: interaction design
2
+
3
+ Status: implemented, revision 6 (§3.2: the panel is never an empty box, renders Markdown, and an Enter on an empty editor puts it away). Revision 5 (§4: Enter is routed through Pi's `input` event, and the ghost text is a line below the editor; §3.2: an answer still streaming does not collapse; see `implementation.md` §2). Revision 4: busy includes waiting for subagents (§3.1, §3.8), and follow-ups see the latest main state (§3.2). This document covers only what the user sees and does. The engine in `design.md`
4
+ (observation, T0/T1, C1–C3, routing, accounting) changes in two places only: follow-ups fork again when the main session has moved (§3.2), and a fork-point breakpoint is proposed (§5.5). Revision 1 is critiqued in §7.
5
+
6
+ ## 1. The goal
7
+
8
+ > It looks like nothing is there, yet everything is there. Nobody has to be taught, and it feels great to use.
9
+
10
+ In practice this means five rules:
11
+ 1. **Change nothing that already works.** Fix only the moment that hurts.
12
+ 2. **No new keys, no new words.** Reuse what Pi users already know: Enter, Alt+Enter, Esc and Tab, each with the
13
+ meaning Pi already gives it. Never show `tier`, `cache` or `fork` on the normal path.
14
+ 3. **No modes.** A key means the same thing whatever is on screen. Anything that changes what Enter does must be
15
+ visible *before* the user commits.
16
+ 4. **Lossless recovery over prediction.** When the system has to guess, a wrong guess must cost about two
17
+ keystrokes and lose no text.
18
+ 5. **Guesses only fall back to Pi.** The user opens the side path with their own punctuation. The system may
19
+ close it again and do what Pi would have done, but it never moves a message away from Pi's meaning on its
20
+ own. Its errors then cost no more than Pi without pi-aside.
21
+
22
+ ## 2. The moment that hurts
23
+
24
+ The agent is working, and the user has a question. Today Pi offers:
25
+
26
+ | Key (Pi's meaning) | Effect on a question |
27
+ |---|---|
28
+ | Enter = steer | It interrupts after the current turn. The agent answers, often loses its thread, and the Q&A stays in the context forever |
29
+ | Alt+Enter = follow-up | It waits until the agent is done. Usually too late |
30
+ | Esc = abort | Stops everything |
31
+
32
+ Asking while the agent is really done already works: it is just the next turn. It does not work when Pi is idle
33
+ but the agent is waiting, for example for subagents that will wake it when they finish. A question then starts a
34
+ main turn: the Q&A stays in the orchestrator's context, the turn costs a full main request, and a subagent report
35
+ that lands meanwhile is merged into that turn at its next boundary (pi-durable-subagents injects reports at
36
+ `turn_end` and `agent_before_settle`, `dist/agent/main.js:211-212`). pi-aside treats running and waiting alike
37
+ (§3.1). An agent that is idle with nothing pending keeps Pi's behaviour, and `/aside` covers it.
38
+
39
+ ## 3. The design
40
+
41
+ ### 3.1 The question mark is the interface
42
+
43
+ **While the agent is busy, a message ending in a question mark is asked on the side**, unless the route check
44
+ (§3.4) finds a request or a correction. Everything else keeps Pi's meaning.
45
+
46
+ The ending is language-neutral: the last character after trailing whitespace, closing brackets and closing quotes
47
+ are removed must be one of `?` (ASCII), `?` (full-width, what Chinese and Japanese IMEs type), `؟` (Arabic) or
48
+ `;` (U+037E, Greek). Spanish `¿…?` ends in `?`. A message whose end lies inside a fenced code block never
49
+ qualifies. No sentence-final words are inspected: `吗` and `呢` (Chinese question particles), `right` and `eh` stay with Pi. A message without a
50
+ question mark is a steer, exactly as today.
51
+
52
+ ```text
53
+ agent busy, editor text ends with "?" → Enter asks on the side
54
+ agent running, anything else → Enter steers (Pi)
55
+ agent waiting, anything else → Enter sends a prompt (Pi)
56
+ agent busy, Alt+Enter → Pi's meaning, "?" or not
57
+ agent idle, nothing pending → Pi, unchanged
58
+ slash commands → Pi, unchanged
59
+ ```
60
+
61
+ **Busy** means running or waiting:
62
+ - *Running*: `ctx.isIdle()` is false.
63
+ - *Waiting*: Pi is idle, but an extension reports background work that will wake the agent. Pi has no such
64
+ state, so it comes from a small convention on `pi.events`. An extension emits `background-work` with
65
+ `{ source, pending }` whenever its count changes, and again in reply to `background-work:query`, which pi-aside
66
+ emits at `session_start`. pi-durable-subagents would report the unfinished workflows that will wake this
67
+ session. pi-aside keeps the latest count per source and clears all counts on a session switch. With no emitter
68
+ installed, waiting never occurs, and the rule is the running-only rule of revision 3.
69
+ - Waiting is visible: the subagent summary line sits above the editor, and the hint below it appears exactly as
70
+ in the running case.
71
+ - A really idle agent keeps Pi's meaning even for questions. Its Q&A *is* the main conversation ("what are the
72
+ options?", then "go with 2"). Sending it aside would take the discussion away from the agent, a guess away
73
+ from Pi (rule 5).
74
+
75
+ - The destination is shown before the user commits. While the text ends in `?`, one dim line sits below the
76
+ editor: `↵ asks on the side · corrections still reach the agent`. If the text has no `?`, nothing new is
77
+ shown.
78
+ - The rule is one character long and lives in the user's own text. To steer with a question, the user deletes the
79
+ `?`. To ask the agent itself, the user presses Alt+Enter.
80
+ - People already put `?` on questions, so the rule is learned by using it, not by reading about it.
81
+
82
+ ### 3.2 The answer sits above the editor and never takes focus
83
+
84
+ ```text
85
+ │ ● editing src/adapter.ts ← main session keeps streaming
86
+ │
87
+ │ ╭─ why did it pick B? ─────────────────────────╮
88
+ │ │ A touches three callers; B stays inside the │ ← widget (aboveEditor),
89
+ │ │ adapter and keeps the retry logic intact.▌ │ not focusable
90
+ │ ╰───────────────────────────────────────────────╯
91
+ ├───────────────────────────────────────────────────┤
92
+ │ █ │ ← editor keeps every key
93
+ └───────────────────────────────────────────────────┘
94
+ ```
95
+
96
+ - **No focus, so no modes.** Esc still aborts the agent and Tab still completes. Enter still follows §3.1.
97
+ - **Follow-ups.** Another `?` message while the panel is expanded continues the same side thread. That is how a
98
+ conversation already works. If the main session has gained messages since the thread's last request (a
99
+ subagent report, a finished tool call, a steer), the follow-up is forked again from the latest observation,
100
+ and the earlier side exchange rides inside the new appended message as quoted context. A follow-up therefore
101
+ never answers from a stale snapshot. This amends `design.md` §4.5, which always continues
102
+ `T ++ G ++ Q ++ A1 ++ Q2`.
103
+ - **Dismissal needs no key.** When the user next sends anything to the agent, the panel collapses to one dim line
104
+ (`▸ why did it pick B?`). An answer still streaming stays expanded: the user is waiting for it. The next aside replaces it, and a session switch clears it.
105
+ - **Putting it away by hand is the same Enter.** Enter on an empty editor sends nothing, so it collapses the panel
106
+ the same way, and a second one clears the line (revision 6). While the editor is empty, the frame's lower edge
107
+ says `↵ hides`. `/aside` still rereads the thread.
108
+ - **Never an empty box.** Before the first words, the panel shows Pi's spinner with `working` and the seconds so
109
+ far; once the model thinks, `thinking` and the last lines of its thinking, dim. Answers render as Markdown.
110
+ On models that take effort changes mid-conversation, an aside thinks at a low effort, which keeps the cache
111
+ (`implementation.md` §2.6), so its first words come sooner.
112
+ - **Long answers.** While streaming, the panel shows the last 10 lines. When the answer is done, it shows the
113
+ first 10 lines and `… ctrl+o more`: Pi's own "more" key expands the aside with everything else (revision 6),
114
+ up to the terminal's room, and reopens a collapsed panel. Beyond that, `… /aside to read all`. `/aside` with no argument opens the latest aside in a reader overlay.
115
+ The reader is an explicit modal with its own footer, opens at the latest question, and Esc closes it, as in
116
+ every Pi selector.
117
+ - Read-only tools run silently. A dim line such as `reading src/adapter.ts` appears while one runs.
118
+ - **The session does not fork.** The side thread is an in-memory continuation of the main request
119
+ (`design.md` §4.2: `T ++ G ++ Q`, where G is what the branch gained since the observed request). It sees the
120
+ conversation up to the last completed message, not the reply still streaming. Nothing appears in `/tree`, the
121
+ transcript or the model context. The session file gains one usage entry per aside request, written once the
122
+ agent is idle (`design.md` §4.6). A session switch discards the thread.
123
+
124
+ ### 3.3 Handing off is ghost text
125
+
126
+ Sometimes the answer shows that the agent should do something else. The handoff reuses what everyone already
127
+ knows from shells and IDEs: dim suggested text that Tab accepts.
128
+
129
+ ```text
130
+ │ ╰───────────────────────────────────────────────╯
131
+ ├───────────────────────────────────────────────────┤
132
+ │ █ switch to approach A (see side note) │ ← dim ghost text in the empty editor
133
+ └───────────────────────────────────────────────────┘
134
+ ⇥ accept
135
+ ```
136
+
137
+ - The aside model is told in its notice to end with an optional one-line `handoff`, and only when the question
138
+ implies a change to the agent's work. Without a handoff line, no ghost text appears.
139
+ - **Tab** on an empty editor accepts the suggestion into the editor, using Pi's existing autocomplete meaning. The
140
+ user can edit it. **Enter** sends it with Pi's meaning: a steer while running, the next prompt while idle.
141
+ - A sent handoff carries the side Q&A as a custom message next to it, so the agent gets the reasoning as well.
142
+ Nothing reaches the main session without the user's own Enter (A3).
143
+ - Typing anything replaces the ghost text, as ghost text always works.
144
+
145
+ This also covers a wrong guess. `isn't foo.ts generated?` is a correction phrased as a question. It goes to the
146
+ side, gets an answer within seconds, and comes back as a sharper steer through Tab and Enter, with no lost text.
147
+ Esc still stops the agent at once if something urgent happens meanwhile.
148
+
149
+ ### 3.4 The route check
150
+
151
+ A question mark does not make a message a question. `can you also add tests?` is a request, and
152
+ `why are you deleting the tests?` usually means "stop". Sent to the side, the first loses an instruction, and the
153
+ second lets the agent keep doing harm. These are the only misroutes that cost more than keystrokes.
154
+
155
+ The aside request itself answers the routing question, so no extra request is needed:
156
+ - The notice appended to the question asks the model to start with one hidden line, `route: side` or
157
+ `route: agent`. `agent` means the user is asking the agent to do, stop or change something. The model sees the
158
+ whole conversation, including what the agent is doing, so it judges this much better than any word list. The
159
+ notice lives in the appended message, not in tools or system, because those are part of the cached prefix.
160
+ - `route: side` (the usual case) shows the answer as in §3.2.
161
+ - While waiting (§3.1), `route: agent` sends the text as a prompt, which starts a turn: again Pi's meaning.
162
+ - `route: agent` stops the aside and sends the user's original text as a steer, which is what Enter would have
163
+ done without pi-aside. The panel shows one line:
164
+
165
+ ```text
166
+ │ ╭─ why are you deleting the tests? ────────────────╮
167
+ │ │ ↪ sent to the agent · it reads as a correction │
168
+ │ ╰──────────────────────────────────────────────────╯
169
+ ```
170
+
171
+ - If the aside fails before the route line, the text is sent as a steer.
172
+ - The check runs only on messages the user already sent to the side, and it can only send them back to Pi's
173
+ meaning (rule 5). A false `agent` costs what Pi costs today: the question enters the main context. A false
174
+ `side` is the case of §5.2.
175
+ - Delay: the route line arrives with the first tokens of a cache-read request, usually within one or two seconds.
176
+ A steer waits for the end of the current turn anyway, so the added delay is usually hidden in that wait.
177
+ - Esc remains the brake. It aborts the agent at once, as in Pi.
178
+
179
+ ### 3.5 Money is shown only when it is news
180
+
181
+ | Engine outcome | What the user sees |
182
+ |---|---|
183
+ | T0, cache read (the normal case, about $0.03) | Nothing |
184
+ | T0, cold cache, written, at or above $0.25 | One dim line: `main cache had expired · $0.77`. The write is **not** a prepayment today: Anthropic writes an entry only at a breakpoint, the aside's message breakpoint is after Q, and the main session's next request does not contain Q. A fork-point breakpoint (§5.5) would make it one |
185
+ | T1 rebuilt, at or above $0.25 | One dim line in the panel: `rebuilt context · $0.71 · model changed since the last turn` |
186
+ | Three T1 asides in a row in one session | One notice with the reason, once |
187
+
188
+ - Cost already lives in Pi's footer. Every aside's usage entry feeds it (`design.md` §4.6), so the totals stay
189
+ true without new UI.
190
+ - The tier, `read x of y` and the hash of P appear in the `/aside` reader's detail line and in the usage note. They
191
+ are for debugging, not for everyday use.
192
+ - No confirmation dialogs. The expensive cases are rare and labelled after the fact.
193
+
194
+ ### 3.6 Where "everything" hides
195
+
196
+ | Capability | Where it lives |
197
+ |---|---|
198
+ | Ask without derailing | The `?` you already type |
199
+ | Ask while subagents run | The same `?`: waiting counts as busy (§3.1, §3.8) |
200
+ | Ask when idle | `/aside …`, found through `/` completion |
201
+ | Read the codebase | Read-only tools, run silently |
202
+ | Act on the answer | Ghost text, then Tab and Enter |
203
+ | Requests and corrections that end in `?` | The route check sends them to the agent |
204
+ | Recover from a wrong guess | Same ghost text |
205
+ | Reread or scroll | `/aside` |
206
+ | Stop the agent | Esc, unchanged |
207
+ | Cheap answers | The engine, invisible |
208
+ | Cost accounting | Pi's footer, unchanged |
209
+ | Settings | None (A4) |
210
+
211
+ ### 3.7 All keys
212
+
213
+ | Key | Meaning | New? |
214
+ |---|---|---|
215
+ | Enter | Send. While busy, a question-mark ending asks on the side unless the route check finds a request or correction (shown before sending). On an empty editor, put the aside panel away (`↵ hides` on its frame) | Extended, visibly |
216
+ | Alt+Enter | Queue a follow-up | Pi |
217
+ | Esc | Abort the agent; close the reader overlay | Pi |
218
+ | Tab | Autocomplete, which now also accepts the handoff ghost text in an empty editor | Pi |
219
+ | ctrl+o | Show more: Pi's tool output, and now the aside panel too | Pi |
220
+
221
+ Revision 1 had Tab with three meanings, Esc meaning "dismiss", and a focus mode. Revision 2 has no new key.
222
+
223
+ ### 3.8 Waiting for subagents
224
+
225
+ - **A report is never held up by an aside.** An aside is a separate request, not a main turn. A subagent report
226
+ that arrives during an aside starts its turn at once, and the aside keeps streaming.
227
+ - **The thread follows the main session.** After the report's turn, the next `?` follow-up is forked again from
228
+ the latest state (§3.2), so it sees the report.
229
+ - **What the aside cannot see.** It knows the main conversation and the files. It does not see a subagent's live
230
+ progress: `subagents` is not one of the read-only built-ins, so a call gets "not available in an aside"
231
+ (`design.md` §4.4). It answers from the last status in the conversation and says how old that is. `/subagents`
232
+ remains the place to watch them. Letting an extension declare read-only calls (such as `status`) to the aside
233
+ is possible later; it is not part of this design.
234
+
235
+ ## 4. Feasibility (Pi extension API, checked in `dist/core/extensions/types.d.ts`)
236
+
237
+ | Need | API |
238
+ |---|---|
239
+ | The route check's fallback steer | `pi.sendUserMessage` with steer delivery, as in `design.md` §4.7 |
240
+ | The waiting state (§3.1) | `pi.events` (`emit`, `on`; `dist/core/event-bus.d.ts`); one emitter in pi-durable-subagents |
241
+ | Route a submitted text that ends in `?` while the agent runs | Pi's `input` event (`handled` or `continue`; `streamingBehavior` tells a steer from a follow-up), `ctx.isIdle()`. Implemented this way instead of intercepting the key, so a remapped submit key, bracketed paste and IME need no special handling |
242
+ | Tab on an empty editor with a pending handoff; refreshing the hint | `ctx.ui.onTerminalInput` (returns `consume`), `getEditorText` |
243
+ | The hint below the editor and the panel above it | `ctx.ui.setWidget(key, …, { placement })` |
244
+ | Accept the handoff | `ctx.ui.setEditorText` |
245
+ | Reader overlay | `ctx.ui.custom(…, { overlay: true })` |
246
+ | The handoff's side note | `pi.sendMessage` (custom message), delivered with the user's message |
247
+
248
+ Ghost text drawn *inside* the editor would need `setEditorComponent`, which conflicts with other editor
249
+ replacements. The implementation draws it as a dim `belowEditor` widget line, `⇥ <suggestion> (tab accepts)`, shown
250
+ only while the editor is empty.
251
+
252
+ ## 5. Risks and how they are measured
253
+
254
+ 1. **The route check says `agent` for a real question.** The question becomes a steer, which is Pi's behaviour
255
+ today. Measurement: the rate of `route: agent`, and a local sample of the texts for review. If it is high, the
256
+ notice is tuned. No user harm beyond the status quo.
257
+ 2. **The route check says `side` for a request or a correction.** Its recovery is the
258
+ handoff (§3.3). Measurement: how often a `?` aside is followed within 30 s by a handoff or by a steer with
259
+ similar text. If that happens in more than 15 % of asides over a week of real use, the rule becomes opt-in, and
260
+ the hint offers the side instead (key decided then). A correction misrouted this way lets the agent keep going
261
+ until the user hands off or presses Esc. A steer does not stop the agent at once either, because it lands after
262
+ the current turn, and Esc is the real brake in both cases. This is the one misroute that does harm.
263
+ Measurement: an Esc within 10 s of a `side` aside. Before release, a fixed set of 40 messages (English and
264
+ Chinese requests, corrections and questions, from real sessions) must route with no `side` error on a request
265
+ or correction. If real use shows users getting burned, the rule becomes opt-in regardless of the 15 % figure.
266
+ 3. **Discovery depends on typing a question mark.** Users who never end a question with `?` or `?` (common in
267
+ Chinese chat) never see the hint. They keep Pi's behaviour and can use `/aside`. Matching words such as `吗`
268
+ (a Chinese question particle) was rejected: word lists are per language, and `吧` or `呢` often end requests.
269
+ 4. **Raw-input interception is fragile.** Bracketed paste, IME composition and remapped `tui.input.submit` must
270
+ keep working. S0-UX tests them, and the extension reads Pi's keybindings instead of hard-coding Enter.
271
+ 5. **The cold write is wasted.** Writes happen only at breakpoints (Anthropic, "How automatic prefix checking
272
+ works"), and pi-ai marks only the last user block, so a cold aside's entry ends after Q, and the main session's
273
+ next turn cannot read it. `design.md` §3 ("The write serves the main session's next turn") has the same
274
+ error. Proposed engine change: one more breakpoint at the fork point, so the main session's next request finds it
275
+ within its 20-position lookback. The fork point is the last block of `T ++ G` that the next main request will
276
+ repeat byte for byte, so it is never a "still running" placeholder result, whose real result will differ. A
277
+ cold cache usually means an idle session, where G is the final reply and has no placeholder. To check in S0: a free marker slot (3 used
278
+ with an API key, 4 with OAuth, so one would move), a marker on an assistant block when G ends with one, and
279
+ that I4 and C1–C3 still hold because markers are stripped.
280
+ 6. **Nothing here is proven yet.** "Nobody has to be taught" is a test result, not a claim. See U1.
281
+ 7. **A stale waiting count.** If an emitter never reports zero, questions to a really idle agent keep going to the
282
+ side. The hint shows this before every send, a session switch clears the counts, and U10 tests that the
283
+ emitter reports zero when the last workflow ends.
284
+
285
+ ## 6. Acceptance (adds to `design.md` §7)
286
+
287
+ | ID | Assertion | Method |
288
+ |---|---|---|
289
+ | U1 | Untaught use: three Pi users who were not told about pi-aside get a side answer and act on it in a scripted scenario | Observed session; no hints from the observer |
290
+ | U2 | No key changes meaning based on what is on screen; Esc aborts the agent with the panel visible | Key replay |
291
+ | U3 | The hint below the editor is present exactly when Enter will ask on the side | Snapshot plus key replay |
292
+ | U4 | Normal-path screens contain no tier, cache, token or price text | Snapshot |
293
+ | U5 | Nothing reaches the main session without the user's Enter; a handoff adds exactly one custom message | Capture server plus session file |
294
+ | U6 | Paste, IME and a remapped submit key behave as without pi-aside | Key replay |
295
+ | U7 | Misroute rates are logged locally (§5.1, §5.2) | Usage note field |
296
+ | U8 | Route check: the 40-message set routes with no `side` error on a request or correction; an aside failure before the route line sends a steer | Fixture set against the capture server; injected failure |
297
+ | U9 | Question-mark detection: `?`, `?`, `؟` and U+037E qualify after trailing whitespace, brackets and quotes; an end inside a code fence does not; `吗` without a mark does not | Unit fixtures |
298
+ | U10 | Waiting: with a pi-durable-subagents workflow pending and Pi idle, a `?` message shows the hint, asks on the side and starts no turn; a report during the aside starts its turn at once; the next `?` sees the report; the count returns to zero when the last workflow ends, and the hint disappears | Scripted session with a stub workflow |
299
+
300
+ ## 7. Why revision 1 was not good enough
301
+
302
+ Revision 1 was designed without checking Pi's keymap:
303
+ - **It collided with Pi.** Esc is `app.interrupt`, so "Esc hides the note" would have taken over the user's way to
304
+ stop the agent. Tab is autocomplete.
305
+ - **It broke its own rule.** Tab meant ask, reopen and hand off. Two Tabs could send something to the main
306
+ session.
307
+ - **It had a silent wrong destination.** While a note was open, Enter went to the aside. A steer typed then
308
+ (`stop, use A`) would never reach the agent. That is the worst class of interaction error.
309
+ - **Its cost confirmation was misplaced.** Cold writes are rare, and a dialog on every one adds friction to the
310
+ common cheap case; a label after the fact is enough.
311
+ - **It invented UI** (a focusable note, a four-state machine) where Pi's existing widgets and keys already do the
312
+ job.
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "pi-aside",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Pi extension: ask a side question about a running session without touching it, at the cache-read price.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/purboo/pi-aside.git"
13
+ },
14
+ "scripts": {
15
+ "test": "bun test",
16
+ "typecheck": "tsc --noEmit",
17
+ "check": "tsc --noEmit && bun test",
18
+ "link-pi": "D=\"$(dirname \"$(readlink -f \"$(command -v pi)\")\")/../../..\" && mkdir -p node_modules/@earendil-works && for p in pi-ai pi-agent-core pi-coding-agent pi-tui; do rm -rf node_modules/@earendil-works/$p; ln -sfn \"$(cd \"$D/$p\" && pwd)\" node_modules/@earendil-works/$p; done"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "1.3.13",
22
+ "@types/node": "^26.6.4",
23
+ "typescript": "5.9.3"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-agent-core": "*",
27
+ "@earendil-works/pi-ai": "*",
28
+ "@earendil-works/pi-coding-agent": "*",
29
+ "@earendil-works/pi-tui": "*"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "@earendil-works/pi-agent-core": {
33
+ "optional": true
34
+ },
35
+ "@earendil-works/pi-ai": {
36
+ "optional": true
37
+ },
38
+ "@earendil-works/pi-coding-agent": {
39
+ "optional": true
40
+ },
41
+ "@earendil-works/pi-tui": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "pi": {
46
+ "extensions": [
47
+ "./src/index.ts"
48
+ ]
49
+ },
50
+ "files": [
51
+ "src",
52
+ "docs",
53
+ "README.md",
54
+ "LICENSE",
55
+ "CHANGELOG.md"
56
+ ],
57
+ "engines": {
58
+ "node": ">=22.19"
59
+ }
60
+ }
package/src/busy.ts ADDED
@@ -0,0 +1,54 @@
1
+ // "Waiting" (docs/ux.md §3.1): Pi is idle, but an extension reports background work that will wake the agent.
2
+ // Convention on `pi.events`: an emitter sends `background-work` with `{ source, pending }` whenever its count changes,
3
+ // and again in reply to `background-work:query`.
4
+
5
+ export const BACKGROUND_WORK = "background-work";
6
+ export const BACKGROUND_WORK_QUERY = "background-work:query";
7
+
8
+ interface EventBus {
9
+ emit(channel: string, data: unknown): void;
10
+ on(channel: string, handler: (data: unknown) => void): () => void;
11
+ }
12
+
13
+ export class BackgroundWork {
14
+ private counts = new Map<string, number>();
15
+ private off: (() => void) | undefined;
16
+
17
+ constructor(
18
+ private readonly events: EventBus,
19
+ private readonly onChange: () => void,
20
+ ) {
21
+ this.off = events.on(BACKGROUND_WORK, (data) => this.receive(data));
22
+ }
23
+
24
+ private receive(data: unknown): void {
25
+ if (!data || typeof data !== "object") return;
26
+ const { source, pending } = data as { source?: unknown; pending?: unknown };
27
+ if (typeof source !== "string" || source.length === 0) return;
28
+ const count = typeof pending === "number" && Number.isFinite(pending) && pending > 0 ? Math.floor(pending) : 0;
29
+ const before = this.counts.get(source) ?? 0;
30
+ if (count === 0) this.counts.delete(source);
31
+ else this.counts.set(source, count);
32
+ if (before !== count) this.onChange();
33
+ }
34
+
35
+ /** Pending work across all sources. */
36
+ get pending(): number {
37
+ let total = 0;
38
+ for (const count of this.counts.values()) total += count;
39
+ return total;
40
+ }
41
+
42
+ /** Clear every count (a session switch) and ask the emitters to report again. */
43
+ reset(): void {
44
+ const had = this.counts.size > 0;
45
+ this.counts.clear();
46
+ if (had) this.onChange();
47
+ this.events.emit(BACKGROUND_WORK_QUERY, {});
48
+ }
49
+
50
+ dispose(): void {
51
+ this.off?.();
52
+ this.off = undefined;
53
+ }
54
+ }