pi-bro 0.1.0 → 0.3.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.
- package/README.md +263 -6
- package/bro.ts +140 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,13 +6,266 @@ adding extra messages to your conversation context.
|
|
|
6
6
|
`pi-bro` is a small extension for
|
|
7
7
|
[Earendil Pi](https://github.com/earendil-works/pi). It uses the
|
|
8
8
|
[Google Antigravity CLI](https://antigravity.google/docs/cli-install) (`agy`)
|
|
9
|
-
and a Gemini model to
|
|
9
|
+
and a Gemini model to stream plain-language explanations.
|
|
10
|
+
|
|
11
|
+
## Bro in action
|
|
12
|
+
|
|
13
|
+
**Before**
|
|
14
|
+
|
|
15
|
+
[](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-before.png)
|
|
16
|
+
|
|
17
|
+
**After `/bro`**
|
|
18
|
+
|
|
19
|
+
[](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-after.png)
|
|
20
|
+
|
|
21
|
+
Bro optimizes for understanding, not simply for fewer words. The examples below
|
|
22
|
+
are synthetic coding-agent answers run through Bro's default prompt and edited
|
|
23
|
+
lightly for presentation and safety. Click a screenshot to see it at full size.
|
|
24
|
+
|
|
25
|
+
<details>
|
|
26
|
+
<summary><strong>Short:</strong> TypeScript says a value is <code>never</code></summary>
|
|
27
|
+
|
|
28
|
+
### Before
|
|
29
|
+
|
|
30
|
+
> The diagnostic is not evidence that `result` is intrinsically uninhabitable; it is an artifact of TypeScript's intraprocedural control-flow graph. Because `result` is initialized to `null` and only assigned inside the `forEach` callback, the outer scope does not model that callback as definitely executing. After `if (!result) return`, the remaining branch therefore collapses to `never`, producing the property-access error. Replace the callback-driven mutation with a control-flow-visible loop: `for (const item of items) { if (matches(item)) { result = item; break; } }`. Alternatively, derive the value expressionally with `const result = items.find(matches);` and guard `if (!result) return;` before dereferencing it. Avoid an `as Result` assertion; it suppresses the symptom while preserving the unsound state transition that caused the narrowing anomaly.
|
|
31
|
+
|
|
32
|
+
### After `/bro`
|
|
33
|
+
|
|
34
|
+
#### What happened
|
|
35
|
+
|
|
36
|
+
TypeScript showed an error saying your variable `result` cannot be used or
|
|
37
|
+
accessed.
|
|
38
|
+
|
|
39
|
+
This happened because `result` was set to `null` outside a `forEach` loop and
|
|
40
|
+
then modified inside the loop's helper function (callback). TypeScript tracks
|
|
41
|
+
code line by line. It cannot guarantee that code inside a `forEach` callback
|
|
42
|
+
will actually run.
|
|
43
|
+
|
|
44
|
+
#### What it means
|
|
45
|
+
|
|
46
|
+
Because TypeScript assumes the loop's callback might not run:
|
|
47
|
+
|
|
48
|
+
1. It believes `result` might still be `null`.
|
|
49
|
+
2. When you check `if (!result) return`, TypeScript thinks `result` can never
|
|
50
|
+
hold a valid value after that check.
|
|
51
|
+
3. It sets the type of `result` to `never` (a special type meaning "impossible
|
|
52
|
+
value") and blocks you from reading its properties.
|
|
53
|
+
|
|
54
|
+
The error is a limitation in how TypeScript analyzes loops with callbacks.
|
|
55
|
+
|
|
56
|
+
#### What you should do next
|
|
57
|
+
|
|
58
|
+
Choose one of these two fixes:
|
|
59
|
+
|
|
60
|
+
**Option 1: Use a standard `for...of` loop**
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
if (matches(item)) {
|
|
65
|
+
result = item;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Option 2: Use `Array.prototype.find`**
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const result = items.find(matches);
|
|
75
|
+
if (!result) return;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Avoid using a type assertion like `as Result`. It only hides the warning
|
|
79
|
+
without fixing the underlying tracking issue.
|
|
80
|
+
|
|
81
|
+
</details>
|
|
82
|
+
|
|
83
|
+
<details>
|
|
84
|
+
<summary><strong>Medium:</strong> Login succeeds, but the user is still logged out</summary>
|
|
85
|
+
|
|
86
|
+
### Before
|
|
87
|
+
|
|
88
|
+
> What you're observing is almost certainly not an authentication failure in the credential-validation layer; it is a post-authentication session persistence failure caused by an environment-dependent mismatch between Express's transport-security inference and the TLS termination topology in production.
|
|
89
|
+
>
|
|
90
|
+
> Locally, Express sees the request as plain HTTP and your development cookie configuration probably permits a non-secure session cookie. In production, however, the browser connects over HTTPS to Nginx, a load balancer, or an ingress controller, which terminates TLS and forwards the request to Node over HTTP. Unless Express trusts that intermediary, `req.secure` remains `false`, even when the proxy sends `X-Forwarded-Proto: https`. With `cookie.secure: true`, `express-session` can consequently decline to emit the cookie because it believes the originating request was insecure. The login route may still return `200`, making the credentials appear accepted, but the next request has no session identifier and is treated as anonymous.
|
|
91
|
+
>
|
|
92
|
+
> Configure proxy trust before registering session middleware, and make the cookie policy reflect whether the frontend and API are same-site or genuinely cross-site:
|
|
93
|
+
>
|
|
94
|
+
> ```js
|
|
95
|
+
> app.set("trust proxy", 1);
|
|
96
|
+
>
|
|
97
|
+
> app.use(session({
|
|
98
|
+
> secret: process.env.SESSION_SECRET,
|
|
99
|
+
> resave: false,
|
|
100
|
+
> saveUninitialized: false,
|
|
101
|
+
> proxy: true,
|
|
102
|
+
> cookie: {
|
|
103
|
+
> httpOnly: true,
|
|
104
|
+
> secure: true,
|
|
105
|
+
> sameSite: "lax",
|
|
106
|
+
> maxAge: 1000 * 60 * 60 * 24
|
|
107
|
+
> }
|
|
108
|
+
> }));
|
|
109
|
+
> ```
|
|
110
|
+
>
|
|
111
|
+
> Use `sameSite: "none"` instead of `"lax"` only when the browser must send the cookie in a cross-site context, such as a frontend and API on unrelated registrable domains. Browsers require `Secure` when `SameSite=None`, so that combination must remain HTTPS-only. Separate subdomains such as `app.example.com` and `api.example.com` are usually same-site, though they are still cross-origin.
|
|
112
|
+
>
|
|
113
|
+
> Also verify that the proxy explicitly forwards `X-Forwarded-Proto`, that the browser receives a `Set-Cookie` response header, and that frontend requests use `credentials: "include"` when crossing origins. If CORS is involved, the server must return the exact allowed origin plus `Access-Control-Allow-Credentials: true`; a wildcard origin is incompatible with credentialed requests.
|
|
114
|
+
>
|
|
115
|
+
> Avoid setting a cookie `domain` unless sharing across subdomains is intentional. A host-only cookie is safer and eliminates another production-only failure mode.
|
|
116
|
+
|
|
117
|
+
### After `/bro`
|
|
118
|
+
|
|
119
|
+
#### What happened
|
|
120
|
+
|
|
121
|
+
Your username and password check succeeded, and the login route returned `200
|
|
122
|
+
OK`. However, the browser did not receive or return a usable session cookie in
|
|
123
|
+
production.
|
|
124
|
+
|
|
125
|
+
#### What it means
|
|
126
|
+
|
|
127
|
+
In production, secure web traffic (HTTPS) hits an intermediary, such as a load
|
|
128
|
+
balancer or Nginx, before reaching your Node/Express app as plain HTTP.
|
|
129
|
+
|
|
130
|
+
Because your session cookie requires HTTPS (`secure: true`), Express may withhold
|
|
131
|
+
it when it does not trust the proxy forwarding the request. The exact trust
|
|
132
|
+
setting must match your deployment's proxy topology.
|
|
133
|
+
|
|
134
|
+
#### What you should do next
|
|
135
|
+
|
|
136
|
+
1. Configure Express to trust only your actual proxy topology. Put this before
|
|
137
|
+
the session setup. For example, use `1` only when there is exactly one trusted
|
|
138
|
+
proxy hop:
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
app.set("trust proxy", 1);
|
|
142
|
+
|
|
143
|
+
app.use(session({
|
|
144
|
+
secret: process.env.SESSION_SECRET,
|
|
145
|
+
resave: false,
|
|
146
|
+
saveUninitialized: false,
|
|
147
|
+
cookie: {
|
|
148
|
+
httpOnly: true,
|
|
149
|
+
secure: true,
|
|
150
|
+
sameSite: "lax",
|
|
151
|
+
maxAge: 1000 * 60 * 60 * 24
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
2. Check your cookie settings:
|
|
157
|
+
- Keep `sameSite: "lax"` if your frontend and backend share the same main
|
|
158
|
+
domain, such as `app.example.com` and `api.example.com`.
|
|
159
|
+
- Use `sameSite: "none"` only when the cookie must be sent cross-site.
|
|
160
|
+
Browsers require `secure: true` with this setting.
|
|
161
|
+
- Avoid defining a custom cookie `domain` unless you deliberately need to
|
|
162
|
+
share cookies across subdomains.
|
|
163
|
+
|
|
164
|
+
3. Verify the proxy, browser, and CORS settings:
|
|
165
|
+
- Ensure the proxy forwards the `X-Forwarded-Proto` header.
|
|
166
|
+
- Check that the response contains a `Set-Cookie` header.
|
|
167
|
+
- Include `credentials: "include"` in cross-origin frontend requests.
|
|
168
|
+
- Return the exact allowed origin with
|
|
169
|
+
`Access-Control-Allow-Credentials: true`. Do not use a wildcard (`*`) for
|
|
170
|
+
credentialed requests.
|
|
171
|
+
|
|
172
|
+
</details>
|
|
173
|
+
|
|
174
|
+
<details>
|
|
175
|
+
<summary><strong>Long:</strong> A CI build fails only when its cache is restored</summary>
|
|
176
|
+
|
|
177
|
+
### Before
|
|
178
|
+
|
|
179
|
+
> The failure is not a nondeterministic pnpm installation problem, despite the misleading first error being emitted from the test runner immediately after dependency hydration. It is a state-contamination problem at the intersection of the repository cache, the package export map, and an incorrectly scoped cache key. The decisive observation is that the same commit succeeds in a clean checkout and in CI when cache restoration is disabled, but fails when the pre-build workspace cache is restored. That isolates the variable to files materialized outside Git rather than source, Node, or runner image drift.
|
|
180
|
+
>
|
|
181
|
+
> The affected package, `@acme/config`, was recently migrated from a dual CommonJS/ESM build to an ESM-oriented TypeScript output. Its root `package.json` declares `"type": "module"` and maps the `import` condition to `./dist/index.js`, while the legacy `require` condition still maps to `./dist/index.cjs`. The current compiler emits `index.js` but does not remove the previous build directory first. An older cached `dist` directory therefore contributes two files that no longer belong to the current build graph: `dist/index.cjs` and `dist/package.json`, the latter declaring `"type": "commonjs"`. The new compiler overlays `dist/index.js` but leaves both obsolete files intact. Because nested package boundaries override the root package type, Node interprets the newly emitted ESM `index.js` as CommonJS in that restored workspace and reports `Unexpected token 'export'`. Test processes entering through `require()` instead resolve the obsolete `index.cjs`, which references a removed chunk and can instead produce `MODULE_NOT_FOUND`. These apparently different errors are two projections of the same dirty-output condition.
|
|
182
|
+
>
|
|
183
|
+
> The cache configuration makes that contaminated state persistent. The workflow computes the key with `hashFiles('packages/**/pnpm-lock.yaml')`, but this workspace has only the root-level `pnpm-lock.yaml`. GitHub Actions consequently evaluates the hash expression to an empty value, yielding a key equivalent to `Linux-node20-workspace-`. A broad restore key then permits an archive produced before the module-format migration to satisfy the lookup. That archive combines the pnpm content-addressable store, Turborepo metadata, and every package's `dist` directory. Those data classes do not share valid invalidation semantics: pnpm store entries are immutable by content, Turborepo artifacts are task-hash addressed, and arbitrary build directories are mutable snapshots whose correctness depends on complete deletion or exact provenance. Treating them as one cache effectively elevates obsolete untracked files into undeclared build inputs.
|
|
184
|
+
>
|
|
185
|
+
> The evidence is visible by comparing the restored and clean workspaces before compilation. In the failing job, `packages/config/dist/package.json` exists with `"type":"commonjs"` and `packages/config/dist/index.cjs` has a timestamp and checksum predating the current commit. Neither file appears after `git clean -ffdx` followed by installation and build. The Actions cache log also shows the truncated key without a lockfile digest, while `git diff` remains empty because the problematic files are ignored. Finally, deleting only `packages/config/dist` before rerunning the unchanged test command converts the failure to a pass, which excludes dependency resolution and network variability as primary causes.
|
|
186
|
+
>
|
|
187
|
+
> The minimal durable remediation has three parts. First, stop caching package output directories. Cache the pnpm store separately, using a key that includes the actual root lockfile:
|
|
188
|
+
>
|
|
189
|
+
> ```yaml
|
|
190
|
+
> key: ${{ runner.os }}-node20-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
|
191
|
+
> restore-keys: |
|
|
192
|
+
> ${{ runner.os }}-node20-pnpm-
|
|
193
|
+
> ```
|
|
194
|
+
>
|
|
195
|
+
> A broad fallback is acceptable for the pnpm store because pnpm validates content-addressed entries; it is not acceptable for mutable `dist` snapshots. If remote or local Turborepo caching is retained, cache only `.turbo` and allow Turbo's task hash to govern artifact reuse rather than wrapping raw workspace outputs in a generic archive.
|
|
196
|
+
>
|
|
197
|
+
> Second, make every package build replace, rather than merge into, its output directory. `@acme/config` should delete `dist` before invoking `tsc`, `tsup`, or the existing bundler. The CI workflow should also run the repository's recursive clean target before the first build as a defense against future cache or runner reuse. This is not a substitute for fixing cache scope, but it restores the invariant that generated output is a function of the checked-out sources and declared toolchain.
|
|
198
|
+
>
|
|
199
|
+
> Third, make the export contract agree with emitted files. If `@acme/config` is intentionally ESM-only, remove the `require` condition and the obsolete CommonJS metadata. If CommonJS consumers remain supported, restore an explicit CJS build that deterministically emits `dist/index.cjs`; do not let a historical artifact impersonate that support. In either case, package publication should reject export targets that do not exist.
|
|
200
|
+
>
|
|
201
|
+
> Verification should exercise both cache states and both module entry paths. Run the full pipeline once with no cache, save only the corrected caches, and rerun the identical commit expecting a cache hit and the same result. Inspect the built package to confirm the nested CommonJS `package.json` and legacy chunks are absent unless intentionally generated. Add a lightweight smoke check using dynamic `import('@acme/config')`; if CommonJS remains supported, add a second check through `createRequire`. Finally, change the root lockfile and confirm the logged cache key changes. That matrix validates clean-build reproducibility, restored-cache reproducibility, export-map coherence, and dependency-cache invalidation independently rather than relying on a single green run.
|
|
202
|
+
|
|
203
|
+
### After `/bro`
|
|
204
|
+
|
|
205
|
+
#### What happened
|
|
206
|
+
|
|
207
|
+
Your build failed because old files were left behind by a broken cache (a saved
|
|
208
|
+
snapshot of previous build files).
|
|
209
|
+
|
|
210
|
+
The package `@acme/config` recently switched its module format to modern
|
|
211
|
+
JavaScript (ESM). However, the build process did not delete the old output
|
|
212
|
+
folder (`dist`) before creating new files.
|
|
213
|
+
|
|
214
|
+
Because the GitHub Actions setting searches the wrong path
|
|
215
|
+
(`hashFiles('packages/**/pnpm-lock.yaml')`), GitHub could not find the root
|
|
216
|
+
`pnpm-lock.yaml`. It restored an outdated cache containing `dist/index.cjs` and
|
|
217
|
+
a `dist/package.json` configured for older JavaScript (CommonJS). The new build
|
|
218
|
+
wrote some files but left the old ones behind, confusing Node and causing errors
|
|
219
|
+
such as `Unexpected token 'export'` and `MODULE_NOT_FOUND`.
|
|
220
|
+
|
|
221
|
+
A clean checkout without the cache succeeds. This proves the problem is old
|
|
222
|
+
cached files, not your source code or dependencies.
|
|
223
|
+
|
|
224
|
+
#### What it means
|
|
225
|
+
|
|
226
|
+
- **Cache contamination:** The CI workflow restores mutable `dist` folders
|
|
227
|
+
alongside package-manager files, mixing obsolete files into new builds.
|
|
228
|
+
- **Mismatched module settings:** The package says it supports both modern
|
|
229
|
+
(`import`) and legacy (`require`) formats, but it only builds the modern one
|
|
230
|
+
while relying on leftover cached files for the legacy one.
|
|
231
|
+
|
|
232
|
+
#### What you should do next
|
|
233
|
+
|
|
234
|
+
1. **Fix the GitHub Actions cache:** Stop caching `dist` folders. Cache only the
|
|
235
|
+
pnpm store, using the root lockfile:
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
key: ${{ runner.os }}-node20-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
|
239
|
+
restore-keys: |
|
|
240
|
+
${{ runner.os }}-node20-pnpm-
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
If you use Turborepo, cache only `.turbo`.
|
|
244
|
+
|
|
245
|
+
2. **Clean output folders before building:** Make `@acme/config` delete `dist`
|
|
246
|
+
before running `tsc`, `tsup`, or the bundler. Run the repository's recursive
|
|
247
|
+
clean command before CI builds.
|
|
248
|
+
|
|
249
|
+
3. **Update the package exports:**
|
|
250
|
+
- If `@acme/config` is ESM-only, remove the `require` entry and old CommonJS
|
|
251
|
+
metadata.
|
|
252
|
+
- If CommonJS is still needed, add a real build step for `dist/index.cjs`.
|
|
253
|
+
|
|
254
|
+
4. **Verify the fix:**
|
|
255
|
+
- Delete the generated `packages/config/dist` folder, then run a clean build.
|
|
256
|
+
Do not use `git clean -ffdx` in a working copy with files you need.
|
|
257
|
+
- Run the workflow without a cache, save the new cache, and rerun the same
|
|
258
|
+
commit to verify that a cache hit also passes.
|
|
259
|
+
- Test `import('@acme/config')`, and test `createRequire` if CommonJS is
|
|
260
|
+
supported.
|
|
261
|
+
|
|
262
|
+
</details>
|
|
10
263
|
|
|
11
264
|
## Requirements
|
|
12
265
|
|
|
13
266
|
- Earendil Pi `>=0.78.1 <1` (tested on `0.84.2`)
|
|
14
267
|
- Node.js `>=22.19.0`
|
|
15
|
-
- `agy` installed, authenticated, and on your `PATH` (tested on `1.1.13`)
|
|
268
|
+
- `agy >=1.1.8` installed, authenticated, and on your `PATH` (tested on `1.1.13`)
|
|
16
269
|
- Pi's interactive terminal UI
|
|
17
270
|
|
|
18
271
|
Run `agy` once in your terminal to complete sign-in before using Bro.
|
|
@@ -93,9 +346,9 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
|
93
346
|
- **Memory cache**: The latest explanation is stored only in process memory for
|
|
94
347
|
`/bro open`. It clears when you switch Pi sessions, reload extensions, or quit
|
|
95
348
|
Pi.
|
|
96
|
-
- **File safety**: Bro does not modify project files. It runs Agy in
|
|
97
|
-
|
|
98
|
-
|
|
349
|
+
- **File safety**: Bro does not modify project files. It runs Agy in sandbox
|
|
350
|
+
mode inside a temporary empty folder. This reduces project access, but it is
|
|
351
|
+
not a security boundary.
|
|
99
352
|
- **Provider data**: Agy and your model provider may retain logs and request data
|
|
100
353
|
according to their own settings and privacy policies.
|
|
101
354
|
- **Clipboard**: Pressing **C** copies the text to your system clipboard, where
|
|
@@ -106,7 +359,11 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
|
106
359
|
- Supports only Agy/Gemini in v0.1.
|
|
107
360
|
- Keeps only the latest explanation in memory.
|
|
108
361
|
- Does not store history or export directly to files.
|
|
109
|
-
- Mouse
|
|
362
|
+
- Mouse-wheel and trackpad scrolling work in Pi's fullscreen mode
|
|
363
|
+
(`pi --tui-mode fullscreen`). In regular mode, use the arrow keys so Bro does
|
|
364
|
+
not interfere with your terminal's native text selection.
|
|
365
|
+
- In fullscreen mode, mouse text selection may visually extend outside the Bro
|
|
366
|
+
window. Press **C** to copy the full explanation instead.
|
|
110
367
|
|
|
111
368
|
## Development
|
|
112
369
|
|
package/bro.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
3
|
import { homedir, tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
4
6
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
6
8
|
import { Markdown, matchesKey, truncateToWidth, visibleWidth, type Focusable } from "@earendil-works/pi-tui";
|
|
@@ -20,9 +22,22 @@ Quoted response as a JSON string:
|
|
|
20
22
|
|
|
21
23
|
type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
22
24
|
type TuiLike = { requestRender(): void };
|
|
23
|
-
type ModalKind = "loading" | "result" | "help" | "empty" | "error";
|
|
25
|
+
type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
|
|
24
26
|
type AssistantSource = { id: string; text: string };
|
|
25
27
|
type BroResult = { source: AssistantSource; text: string };
|
|
28
|
+
type AgyEvent = {
|
|
29
|
+
event?: string;
|
|
30
|
+
step_update?: { step_type?: string; text_delta?: unknown };
|
|
31
|
+
result?: { status?: string; response?: unknown };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function wheelDelta(data: string): number {
|
|
35
|
+
const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
|
|
36
|
+
if (!match) return 0;
|
|
37
|
+
const button = Number.parseInt(match[1], 10);
|
|
38
|
+
if ((button & 64) === 0) return 0;
|
|
39
|
+
return (button & 3) === 0 ? -3 : (button & 3) === 1 ? 3 : 0;
|
|
40
|
+
}
|
|
26
41
|
|
|
27
42
|
const COMMANDS = [
|
|
28
43
|
{ value: "simplify", label: "simplify", description: "Simplify the latest assistant response" },
|
|
@@ -62,20 +77,49 @@ async function promptFor(response: string): Promise<string> {
|
|
|
62
77
|
return parts.join(JSON.stringify(response));
|
|
63
78
|
}
|
|
64
79
|
|
|
65
|
-
|
|
80
|
+
function parseAgyLine(line: string): { delta?: string; result?: string } {
|
|
81
|
+
let event: AgyEvent;
|
|
82
|
+
try {
|
|
83
|
+
event = JSON.parse(line) as AgyEvent;
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error("Agy returned invalid streaming data.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
event.event === "step_update" &&
|
|
90
|
+
event.step_update?.step_type === "agent_response" &&
|
|
91
|
+
typeof event.step_update.text_delta === "string"
|
|
92
|
+
) {
|
|
93
|
+
return { delta: event.step_update.text_delta };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (event.event === "result") {
|
|
97
|
+
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
98
|
+
throw new Error("Agy did not complete the explanation successfully.");
|
|
99
|
+
}
|
|
100
|
+
return { result: event.result.response };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function simplify(
|
|
107
|
+
response: string,
|
|
108
|
+
signal: AbortSignal,
|
|
109
|
+
onProgress?: (text: string) => void,
|
|
110
|
+
): Promise<string> {
|
|
66
111
|
const prompt = await promptFor(response);
|
|
67
112
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
113
|
+
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
68
114
|
|
|
69
115
|
try {
|
|
70
|
-
const
|
|
116
|
+
const child = spawn(
|
|
71
117
|
"agy",
|
|
72
118
|
[
|
|
73
|
-
"--mode",
|
|
74
|
-
"plan",
|
|
75
119
|
"--sandbox",
|
|
76
120
|
"--disable-slash-commands",
|
|
77
121
|
"--output-format",
|
|
78
|
-
"
|
|
122
|
+
"stream-json",
|
|
79
123
|
"--model",
|
|
80
124
|
MODEL,
|
|
81
125
|
"--print-timeout",
|
|
@@ -83,17 +127,74 @@ async function simplify(pi: ExtensionAPI, response: string, signal: AbortSignal)
|
|
|
83
127
|
"--print",
|
|
84
128
|
prompt,
|
|
85
129
|
],
|
|
86
|
-
{
|
|
130
|
+
{
|
|
131
|
+
cwd: runDirectory,
|
|
132
|
+
signal,
|
|
133
|
+
timeout: 125_000,
|
|
134
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
},
|
|
87
137
|
);
|
|
88
138
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
let processError: Error | undefined;
|
|
140
|
+
let stderr = "";
|
|
141
|
+
let partial = "";
|
|
142
|
+
let final = "";
|
|
143
|
+
let parseError: Error | undefined;
|
|
144
|
+
|
|
145
|
+
child.stderr.setEncoding("utf8");
|
|
146
|
+
child.stderr.on("data", (chunk: string) => {
|
|
147
|
+
stderr += chunk;
|
|
148
|
+
});
|
|
149
|
+
child.once("error", (error) => {
|
|
150
|
+
processError = error;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
154
|
+
child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
158
|
+
try {
|
|
159
|
+
for await (const line of lines) {
|
|
160
|
+
if (!line.trim()) continue;
|
|
161
|
+
try {
|
|
162
|
+
const event = parseAgyLine(line);
|
|
163
|
+
if (event.delta) {
|
|
164
|
+
partial += event.delta;
|
|
165
|
+
if (onProgress && !updateTimer) {
|
|
166
|
+
updateTimer = setTimeout(() => {
|
|
167
|
+
updateTimer = undefined;
|
|
168
|
+
if (!signal.aborted) onProgress(partial);
|
|
169
|
+
}, 75);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (event.result !== undefined) final = event.result;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
parseError = error instanceof Error ? error : new Error(String(error));
|
|
175
|
+
child.kill();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
lines.close();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { code, exitSignal } = await closed;
|
|
184
|
+
if (signal.aborted) throw new Error("Canceled.");
|
|
185
|
+
if (parseError) throw parseError;
|
|
186
|
+
if (processError) throw processError;
|
|
187
|
+
if (exitSignal || code === null) throw new Error("Simplification timed out.");
|
|
188
|
+
if (code !== 0) throw new Error(stderr.trim() || `Agy exited with code ${code}.`);
|
|
189
|
+
|
|
190
|
+
const text = final.trim();
|
|
191
|
+
if (!text) {
|
|
192
|
+
throw new Error(stderr.trim() || "Agy returned no final explanation.");
|
|
93
193
|
}
|
|
94
194
|
|
|
95
195
|
return text;
|
|
96
196
|
} finally {
|
|
197
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
97
198
|
await rm(runDirectory, { recursive: true, force: true });
|
|
98
199
|
}
|
|
99
200
|
}
|
|
@@ -111,14 +212,17 @@ Bro turns the latest completed assistant response into a clear, plain-language e
|
|
|
111
212
|
|
|
112
213
|
## Controls
|
|
113
214
|
|
|
114
|
-
-
|
|
215
|
+
- **Mouse wheel / trackpad** — scroll in Pi's fullscreen mode
|
|
216
|
+
- **↑ / ↓** — scroll in any mode
|
|
115
217
|
- **C** — copy the full explanation
|
|
116
218
|
- **R** — simplify the same response again
|
|
117
219
|
- **Esc** — close the window, or cancel while Bro is working
|
|
118
220
|
|
|
221
|
+
Mouse text selection may extend outside the Bro window. Press **C** to copy the complete explanation instead.
|
|
222
|
+
|
|
119
223
|
## Privacy and file safety
|
|
120
224
|
|
|
121
|
-
Bro does not modify your project files. It runs the simplifier in
|
|
225
|
+
Bro does not modify your project files. It runs the simplifier in sandbox mode inside a temporary empty folder. This reduces project access, but it is not a security boundary.
|
|
122
226
|
|
|
123
227
|
Bro does not add explanations to Pi's conversation history, session files, or main-agent context. The latest explanation is kept in process memory only so \`/bro open\` can reopen it. It is cleared when you change sessions, reload extensions, or exit Pi.
|
|
124
228
|
|
|
@@ -163,6 +267,10 @@ class BroModal implements Focusable {
|
|
|
163
267
|
this.setContent("loading", `**${LOADING_TEXT}**`, "", false, false);
|
|
164
268
|
}
|
|
165
269
|
|
|
270
|
+
setStreaming(text: string): void {
|
|
271
|
+
this.setContent("streaming", text, "", false, false);
|
|
272
|
+
}
|
|
273
|
+
|
|
166
274
|
setResult(text: string, retryable: boolean, notice = ""): void {
|
|
167
275
|
this.setContent("result", text, text, true, retryable, notice);
|
|
168
276
|
}
|
|
@@ -188,7 +296,7 @@ class BroModal implements Focusable {
|
|
|
188
296
|
this.copyable = copyable;
|
|
189
297
|
this.retryable = retryable;
|
|
190
298
|
this.notice = notice;
|
|
191
|
-
this.offset = 0;
|
|
299
|
+
if (kind !== "streaming") this.offset = 0;
|
|
192
300
|
this.markdown.setText(text);
|
|
193
301
|
this.tui.requestRender();
|
|
194
302
|
}
|
|
@@ -211,6 +319,7 @@ class BroModal implements Focusable {
|
|
|
211
319
|
|
|
212
320
|
private controls(): string {
|
|
213
321
|
if (this.kind === "loading") return "Esc cancel";
|
|
322
|
+
if (this.kind === "streaming") return "Simplifying… · ↑/↓ scroll · Esc cancel";
|
|
214
323
|
if (this.kind === "result") return "↑/↓ scroll · C copy · R simplify again · Esc close";
|
|
215
324
|
if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
|
|
216
325
|
if (this.kind === "error") return "R try again · Esc close";
|
|
@@ -257,8 +366,8 @@ class BroModal implements Focusable {
|
|
|
257
366
|
return;
|
|
258
367
|
}
|
|
259
368
|
|
|
260
|
-
|
|
261
|
-
|
|
369
|
+
const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
|
|
370
|
+
if (delta) {
|
|
262
371
|
this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
|
|
263
372
|
this.notice = "";
|
|
264
373
|
this.tui.requestRender();
|
|
@@ -303,7 +412,11 @@ interface BroModalOptions {
|
|
|
303
412
|
kind?: "help" | "empty";
|
|
304
413
|
copyable?: boolean;
|
|
305
414
|
result?: BroResult;
|
|
306
|
-
run?: (
|
|
415
|
+
run?: (
|
|
416
|
+
signal: AbortSignal,
|
|
417
|
+
source?: AssistantSource,
|
|
418
|
+
onProgress?: (text: string) => void,
|
|
419
|
+
) => Promise<BroResult>;
|
|
307
420
|
onResult?: (result: BroResult) => void;
|
|
308
421
|
}
|
|
309
422
|
|
|
@@ -349,7 +462,10 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
|
|
|
349
462
|
modal.setLoading();
|
|
350
463
|
|
|
351
464
|
void options
|
|
352
|
-
.run(nextController.signal, source)
|
|
465
|
+
.run(nextController.signal, source, (text) => {
|
|
466
|
+
if (closed || nextController.signal.aborted || controller !== nextController) return;
|
|
467
|
+
modal.setStreaming(text);
|
|
468
|
+
})
|
|
353
469
|
.then((result) => {
|
|
354
470
|
if (closed || nextController.signal.aborted) return;
|
|
355
471
|
current = result;
|
|
@@ -415,14 +531,18 @@ export default function bro(pi: ExtensionAPI) {
|
|
|
415
531
|
return;
|
|
416
532
|
}
|
|
417
533
|
|
|
418
|
-
const run = async (
|
|
534
|
+
const run = async (
|
|
535
|
+
signal: AbortSignal,
|
|
536
|
+
source?: AssistantSource,
|
|
537
|
+
onProgress?: (text: string) => void,
|
|
538
|
+
): Promise<BroResult> => {
|
|
419
539
|
let target = source;
|
|
420
540
|
if (!target) {
|
|
421
541
|
await ctx.waitForIdle();
|
|
422
542
|
target = latestAssistant(ctx);
|
|
423
543
|
}
|
|
424
544
|
if (!target) throw new Error("No completed assistant response found.");
|
|
425
|
-
return { source: target, text: await simplify(
|
|
545
|
+
return { source: target, text: await simplify(target.text, signal, onProgress) };
|
|
426
546
|
};
|
|
427
547
|
|
|
428
548
|
if (action === "open") {
|