@mjasnikovs/pi-task 0.18.47 → 0.18.49

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
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-1936%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-2077%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -65,7 +65,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
65
65
  | `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
66
66
  | `/task-auto-resume [--unattended]` | Resume the active `/task-auto` run at the next unfinished task. `--unattended` is the boot-hook form: in-flight runs only. |
67
67
  | `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
68
- | `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, orientation, verify work, enforce guidelines, command timeout, stream watchdog, and the extension whitelist for child sessions. |
68
+ | `/task-config` | Toggle pi-task settings in an editor dialog: remote control, compress thinking, auto-commit, verify work, enforce guidelines, project tour, command timeout, stuck reply retry, and one `ext:` toggle per installed host extension. |
69
69
  | `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
70
70
 
71
71
  ## The pipeline
@@ -171,25 +171,25 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
171
171
 
172
172
  | Setting | Default | What it does |
173
173
  | --- | --- | --- |
174
- | **remote** | on | The remote UI server (QR code, phone access). Turn off to never start it. |
175
- | **compress reasoning** | on | After each message, compresses the model's `<think>` blocks down to the decisions/constraints/facts that matter later — keeping long local-model runs from drowning their own context in self-talk. |
174
+ | **remote control** | on | The remote UI server (QR code, phone access). Turn off to never start it. |
175
+ | **compress thinking** | on | After each message, compresses the model's `<think>` blocks down to the decisions/constraints/facts that matter later — keeping long local-model runs from drowning their own context in self-talk. |
176
176
  | **auto-commit** | on | Snapshots the working tree into one git commit per `/task-auto` sub-task (see above). |
177
- | **orientation** | on | Pre-reads the project's core files (manifest, config, domain types, schema, entrypoints, API surface) once and hands the contents to the read-heavy research workers, so they skip re-discovering the same files cold. Bounded by a hard byte budget; applied only where it helps (FILES/APIS workers). |
178
177
  | **verify work** | on | After each `/task` (and `/task-auto` task) implements — but **before** it's checked off or committed — actually **runs** the spec's own `VERIFY` block in the real workspace. pi-task otherwise only _authors_ a VERIFY block and never executes it, so a task that doesn't build is indistinguishable from one that works. A fresh `read` + `bash` child of the same local model runs the declared check, observes the real output, and reports **PASS/FAIL** (a legitimately no-op VERIFY is a PASS). On FAIL the run doesn't dead-stop: you get a boxed picker — **Autofix** (re-run the implementation turn against the failure, then re-verify; no attempt cap) or **Accept** (override a misjudged artifact) — and dismissing it pauses the run, resumable. A genuine clean pass is also the behavioral signal that lets **enforce guidelines** fix in place (see below). |
179
178
  | **enforce guidelines** | on | After each `/task` (and `/task-auto` task) is committed, re-checks that commit's work against the project's `AGENTS.md` / `CLAUDE.md` (in the working directory). A bare fix-in-place pass trashes working code (A/B-proven), so enforcement is gated on the **verify work** signal. **With** a genuine verify pass: a fresh `read` + `edit` child of the same local model reads the **last commit's** diff and fixes violations in place; its fixes are committed **separately** as an `ENFORCE GUIDELINES` commit, then the verify signal is re-run against the enforced tree — a regression **reverts** the enforce commit and keeps the verified work. **Without** that signal (verify off, no spec, or an accept-override): the pass runs read-only and only **reports** violations, never rewrites logic. Either way a violation it can't clear (or a pass that can't run) only **warns** — the task commit already landed, so the run continues. Skipped when nothing was committed for the task. |
179
+ | **project tour** | on | Pre-reads the project's core files (manifest, config, domain types, schema, entrypoints, API surface) once and hands the contents to the read-heavy research workers, so they skip re-discovering the same files cold. Bounded by a hard byte budget; applied only where it helps (FILES/APIS workers). |
180
180
  | **parallel research** | off | Run the four research workers concurrently instead of one at a time. Leave off on a single-GPU local backend (concurrent streams split the GPU and slow each other down); turn on only for a parallel-capable model server. |
181
181
  | **research cache** | on | Cache docs/search/fetch worker results for the duration of one `/task-auto` run so sibling tasks re-asking the same package/URL + query reuse the first pipeline's digest instead of re-fetching. Per-run isolated, external-only (project-source `.` lookups excluded), success-only. |
182
- | **search provider** | Exa | Engine behind `pi-worker-search` and freshness/enrichment checks. **Exa** (default) and **DuckDuckGo** need no API key; **Brave** requires `BRAVE_SEARCH_API_KEY`. |
182
+ | **search engine** | Exa | Engine behind `pi-worker-search` and freshness/enrichment checks. **Exa** (default) and **DuckDuckGo** need no API key; **Brave** requires `BRAVE_SEARCH_API_KEY`. |
183
183
  | **command timeout** | 15 min | Wall-clock ceiling on a **single** tool execution. Local models routinely run a command that never returns (a hung build, a dev server, a check with no timeout) and the run wedges until you abort by hand — pi's bash tool has an optional timeout with no default, so this is the missing one. One knob, two surfaces: in the main session the overrun call is cancelled (killing the tool's whole process tree) plus a reminder turn; in the verify/fix gate children the child is killed and re-spawned with a hint, halving the ceiling on repeat hangs. Choices: 5/10/15/30 min or **off** — off unguards both surfaces, gates included. |
184
- | **stream watchdog** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
184
+ | **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
185
185
  | **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
186
- | **extension whitelist** | empty | Host `pi` extensions to load into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. `/task-config` enumerates the currently installed extensions as individual `ext: …` toggles; the list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
186
+ | **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
187
187
 
188
188
  ## Configuration
189
189
 
190
190
  | Variable | Used by | Notes |
191
191
  | --- | --- | --- |
192
- | `BRAVE_SEARCH_API_KEY` / `BRAVE_API_KEY` | `pi-worker-search`, research enrichment | Required only when the **Brave** search provider is selected in `/task-config`. |
192
+ | `BRAVE_SEARCH_API_KEY` / `BRAVE_API_KEY` | `pi-worker-search`, research enrichment | Required only when the **Brave** search engine is selected in `/task-config`. |
193
193
  | `XDG_CACHE_HOME` | `pi-worker-docs` | Overrides the docs cache location (defaults to `~/.cache`). |
194
194
  | `XDG_DATA_HOME` | remote push | Where the VAPID keypair is stored (defaults to `~/.local/share`). |
195
195
  | `PI_REMOTE_PUSH_SUBJECT` | remote push | VAPID JWT `sub` contact. Defaults to the project URL; set your own `mailto:you@domain.com` or `https://…`. |
@@ -202,7 +202,7 @@ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your
202
202
 
203
203
  ```sh
204
204
  bun install
205
- bun run test # 1939 tests across 121 files
205
+ bun run test # 2078 tests across 129 files
206
206
  bun run lint # prettier + eslint + tsc --noEmit
207
207
  bun run build # tsc → dist/
208
208
  ```
@@ -1,5 +1,42 @@
1
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
1
+ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
+ import type { Component } from '@earendil-works/pi-tui';
3
+ import { type PiTaskConfig } from './config.js';
2
4
  import { type InstalledExtension } from './extension-list.js';
5
+ type Theme = ExtensionCommandContext['ui']['theme'];
6
+ /**
7
+ * Frames a child component (the settings list) in a rounded border with a title
8
+ * woven into the top edge. Without this the overlay's content sits flush against
9
+ * the chat scrollback and reads as just more console text; the border gives it a
10
+ * distinct panel. Layout mirrors {@link renderQuestionBox}'s box chrome: render
11
+ * the child at the inner width, pad each line so the right edge lines up, then
12
+ * frame. Input and invalidation are forwarded straight through to the child.
13
+ */
14
+ declare class BorderedBox implements Component {
15
+ private readonly child;
16
+ private readonly title;
17
+ private readonly border;
18
+ private readonly titleColor;
19
+ /**
20
+ * Body lines to pad out to. The settings list grows and shrinks with the
21
+ * selected item's description, so without a floor the whole panel jumps
22
+ * a few rows every time the cursor moves. Padding to the tallest layout
23
+ * keeps the border still while the contents change.
24
+ */
25
+ private readonly minBodyLines;
26
+ constructor(child: Component & {
27
+ handleInput(data: string): void;
28
+ }, title: string, border: (s: string) => string, titleColor: (s: string) => string,
29
+ /**
30
+ * Body lines to pad out to. The settings list grows and shrinks with the
31
+ * selected item's description, so without a floor the whole panel jumps
32
+ * a few rows every time the cursor moves. Padding to the tallest layout
33
+ * keeps the border still while the contents change.
34
+ */
35
+ minBodyLines?: number);
36
+ render(width: number): string[];
37
+ invalidate(): void;
38
+ handleInput(data: string): void;
39
+ }
3
40
  export declare function extensionItems(extensions: InstalledExtension[], whitelist: readonly string[]): {
4
41
  id: string;
5
42
  label: string;
@@ -9,4 +46,28 @@ export declare function extensionItems(extensions: InstalledExtension[], whiteli
9
46
  }[];
10
47
  /** Apply an extension toggle to the config's whitelist (idempotent both ways). */
11
48
  export declare function applyExtensionToggle(whitelist: readonly string[], entryPath: string, on: boolean): string[];
49
+ /**
50
+ * Tallest body the settings list can render, so {@link BorderedBox} can pad
51
+ * every frame to it and hold the border still. Mirrors SettingsList's own
52
+ * layout: one pad row, the visible rows, the scroll counter, a blank, the
53
+ * selected item's wrapped description, a blank, the key hint.
54
+ */
55
+ export declare function settingsBodyHeight(descriptions: string[], maxVisible: number, wrapWidth: number): number;
56
+ /** A settings row as {@link SettingsList} wants it. */
57
+ export type PanelItem = {
58
+ id: string;
59
+ label: string;
60
+ description: string;
61
+ currentValue: string;
62
+ values: string[];
63
+ };
64
+ /**
65
+ * Builds the framed settings panel. Split out of the command handler so the
66
+ * exact component the overlay shows can be rendered to a string in a test or a
67
+ * preview script, rather than only being inspectable by opening the TUI.
68
+ */
69
+ export declare function createSettingsPanel(items: PanelItem[], theme: Theme, onChange: (id: string, newValue: string) => void, onCancel: () => void): BorderedBox;
70
+ /** The full settings row list for the current config, in menu order. */
71
+ export declare function panelItems(cfg: PiTaskConfig, installed: InstalledExtension[]): PanelItem[];
12
72
  export declare function registerConfig(pi: ExtensionAPI): void;
73
+ export {};
@@ -1,9 +1,12 @@
1
- import { SettingsList, visibleWidth } from '@earendil-works/pi-tui';
1
+ import { SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
2
  import { registerBridgeCommand } from '../remote/bridge.js';
3
+ import { readPkgVersion } from '../shared/pkg-version.js';
3
4
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
4
5
  import { COMMAND_TIMEOUT_OPTIONS, getConfig, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
5
6
  import { listInstalledExtensions } from './extension-list.js';
6
- const CONFIG_TITLE = 'pi-task settings';
7
+ // Version in the title so a bug report or screenshot says which build it came
8
+ // from without anyone having to go look it up.
9
+ const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
7
10
  /**
8
11
  * Frames a child component (the settings list) in a rounded border with a title
9
12
  * woven into the top edge. Without this the overlay's content sits flush against
@@ -17,11 +20,20 @@ class BorderedBox {
17
20
  title;
18
21
  border;
19
22
  titleColor;
20
- constructor(child, title, border, titleColor) {
23
+ minBodyLines;
24
+ constructor(child, title, border, titleColor,
25
+ /**
26
+ * Body lines to pad out to. The settings list grows and shrinks with the
27
+ * selected item's description, so without a floor the whole panel jumps
28
+ * a few rows every time the cursor moves. Padding to the tallest layout
29
+ * keeps the border still while the contents change.
30
+ */
31
+ minBodyLines = 0) {
21
32
  this.child = child;
22
33
  this.title = title;
23
34
  this.border = border;
24
35
  this.titleColor = titleColor;
36
+ this.minBodyLines = minBodyLines;
25
37
  }
26
38
  render(width) {
27
39
  const innerWidth = Math.max(1, width - 4); // "│ " + content + " │"
@@ -33,7 +45,13 @@ class BorderedBox {
33
45
  const top = rest >= 0 ?
34
46
  this.border(`╭${dash(lead)}`) + this.titleColor(tag) + this.border(`${dash(rest)}╮`)
35
47
  : this.border(`╭${dash(width - 2)}╮`);
36
- const body = this.child.render(innerWidth).map(line => {
48
+ // One blank row under the title so the first setting is not welded to
49
+ // the border, then the child, then padding up to the stable height.
50
+ const childLines = this.child.render(innerWidth);
51
+ const raw = ['', ...childLines];
52
+ while (raw.length < this.minBodyLines)
53
+ raw.push('');
54
+ const body = raw.map(line => {
37
55
  const pad = innerWidth - visibleWidth(line);
38
56
  const padded = pad > 0 ? line + ' '.repeat(pad) : line;
39
57
  return this.border('│ ') + padded + this.border(' │');
@@ -53,83 +71,95 @@ class BorderedBox {
53
71
  * toggle on/off; enum settings list their values and cycle through them.
54
72
  */
55
73
  const ITEMS = [
56
- { id: 'remote', label: 'remote', description: 'Remote UI server (QR code, phone access)' },
74
+ {
75
+ id: 'remote',
76
+ label: 'remote control',
77
+ description: 'Serve the task UI on your local network so you can follow and steer a run from '
78
+ + 'your phone. Prints a QR code to scan when it starts'
79
+ },
57
80
  {
58
81
  id: 'compressReasoning',
59
- label: 'compress reasoning',
60
- description: 'Compress <think> blocks after each message'
82
+ label: 'compress thinking',
83
+ description: "Shrink the model's thinking blocks once it has moved on, so a long run keeps more "
84
+ + 'room for the work itself'
61
85
  },
62
86
  {
63
87
  id: 'autoCommit',
64
88
  label: 'auto-commit',
65
- description: 'git commit around each /task-auto sub-task (checkpoint before, snapshot after)'
66
- },
67
- {
68
- id: 'orientation',
69
- label: 'orientation',
70
- description: 'Pre-supply the project core (manifest, types, schema…) to the read-heavy research workers'
89
+ description: 'Make a git commit before and after every sub-task, so each step is a checkpoint '
90
+ + 'you can read back or roll back to'
71
91
  },
72
92
  {
73
93
  id: 'verifyWork',
74
94
  label: 'verify work',
75
- description: "After each /task (and /task-auto task), RUN its spec's VERIFY block in the workspace and report a PASS/FAIL verdict (also the signal that lets 'enforce guidelines' fix safely). Enabling it makes /task wait for the implementation"
95
+ description: 'When a task says it is done, actually run the checks its spec asks for and report '
96
+ + "PASS or FAIL instead of taking the model's word for it. This is also what lets "
97
+ + '"enforce guidelines" fix things safely. /task waits for the work to finish'
76
98
  },
77
99
  {
78
100
  id: 'enforceGuidelines',
79
101
  label: 'enforce guidelines',
80
- description: "Check each /task and /task-auto commit against AGENTS.md/CLAUDE.md. Needs 'verify work' to FIX drift (fixes are reverted if they regress verification); without it, only reports violations. Enabling it makes /task wait for the implementation"
102
+ description: 'Check what each task committed against your AGENTS.md / CLAUDE.md rules. With '
103
+ + '"verify work" on it also fixes what it finds, undoing any fix that breaks the '
104
+ + 'checks; on its own it only reports. /task waits for the work to finish'
105
+ },
106
+ {
107
+ id: 'orientation',
108
+ label: 'project tour',
109
+ description: 'Show the research workers the shape of the project first — package manifest, '
110
+ + 'types, schema — so they spend their steps on the question instead of on finding '
111
+ + 'their way around'
81
112
  },
82
113
  {
83
114
  id: 'parallelResearchWorkers',
84
115
  label: 'parallel research',
85
- description: 'Run the 4 research workers concurrently. Leave OFF on a single-GPU local server (serial is measurably faster there); turn on only for a parallel-capable model backend'
116
+ description: 'Run the 4 research workers at once instead of one after another. Only faster if '
117
+ + 'your model backend can answer several requests at the same time — on a single '
118
+ + 'local GPU it is measurably slower, so leave it off there'
86
119
  },
87
120
  {
88
121
  id: 'researchCache',
89
122
  label: 'research cache',
90
- description: 'Cache docs/search/fetch results within one /task-auto run so sibling tasks reuse the first pipeline’s digest instead of re-fetching the same external docs. Per-run isolated, external-only, success-only'
123
+ description: 'Remember docs and web pages for the length of one run, so later tasks reuse what '
124
+ + 'the first one already fetched instead of downloading it again. Only external '
125
+ + 'sources, only successful fetches, and it is dropped when the run ends'
91
126
  },
92
127
  {
93
128
  id: 'searchProvider',
94
- label: 'search provider',
95
- description: 'Engine behind web search (pi-worker-search + freshness checks). Exa and DuckDuckGo need no API key; Brave needs BRAVE_SEARCH_API_KEY',
129
+ label: 'search engine',
130
+ description: 'Which engine backs web search. Exa and DuckDuckGo work with no setup; Brave needs '
131
+ + 'a BRAVE_SEARCH_API_KEY in your environment',
96
132
  // Display full engine names; the stored config value stays the short id.
97
133
  values: SEARCH_PROVIDERS.map(p => SEARCH_PROVIDER_LABELS[p])
98
134
  },
99
135
  {
100
136
  id: 'requestTimeoutMs',
101
137
  label: 'command timeout',
102
- description: 'Cancel a single command that runs longer than this and remind the model to set '
103
- + 'its own timeout. Catches a local model that runs a command which never returns '
104
- + '(hung build, dev server, no-timeout check) so the run stops itself instead of '
105
- + 'waiting for a manual abort. One knob for both surfaces: the main session AND '
106
- + 'the verify/fix gate children. off disables it everywhere — gates can then hang '
107
- + 'unbounded',
138
+ description: 'Give up on any single command that runs this long, and tell the model to set its '
139
+ + 'own timeout next time. Stops a run from waiting forever on a dev server or a '
140
+ + 'hung build. Covers the main session and the checking steps; "off" lets a stuck '
141
+ + 'command hang the run until you notice',
108
142
  // Display human labels; the stored config value stays the ms number.
109
143
  values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label)
110
144
  },
111
145
  {
112
146
  id: 'streamInactivityMs',
113
- label: 'stream watchdog',
114
- description: 'Abort and retry a model request whose stream goes SILENT for this long — a hung '
115
- + 'or dropped stream reports no error at all, so nothing else catches it (mx5 run '
116
- + '14 lost ~2.9h to three of them while the model server stayed healthy). Counts '
117
- + 'time since the last stream event of any kind, so a slow model that keeps '
118
- + 'emitting tokens is never touched, and it pauses while a tool runs. Keep it '
119
- + 'generous on local backends: prompt processing on a large context legitimately '
120
- + 'emits nothing for minutes. off disables it on both the main session and children',
147
+ label: 'stuck reply retry',
148
+ description: 'Give up on a model reply that has sent nothing for this long and ask again. A '
149
+ + 'dropped connection looks exactly like a model thinking hard and reports no '
150
+ + 'error, so a run can sit dead for hours. Only total silence counts, and the clock '
151
+ + 'pauses while a command runs — local models go quiet for minutes on long prompts, '
152
+ + 'so leave room',
121
153
  // Display human labels; the stored config value stays the ms number.
122
154
  values: STREAM_INACTIVITY_OPTIONS.map(o => o.label)
123
155
  },
124
156
  {
125
157
  id: 'yoloMode',
126
158
  label: 'yolo mode',
127
- description: 'UNATTENDED: auto-answer every question with the option pi already recommends, '
128
- + 'and show no prompts at all — clarify/grill answers, the verify-FAIL picker '
129
- + '(auto-ACCEPT, recorded as a yolo debt), and the final-gate picker (autofix '
130
- + 'while the budget lasts, then leave the run FAILED). Every auto-pick is stamped '
131
- + '(YOLO) in the task file and debt ledger. For THROWAWAY/TEST projects you are '
132
- + 'not watching — a real run should decide these itself'
159
+ description: 'Stop asking you anything: every question takes the option pi recommends, a failed '
160
+ + 'check is accepted and written down as debt, and a failed final check is retried '
161
+ + 'until the budget runs out. Each auto-answer is marked (YOLO) in the task file. '
162
+ + 'For throwaway projects you are not watching'
133
163
  }
134
164
  ];
135
165
  /** Human label for the stored command-timeout ms (falls back to the raw ms). */
@@ -162,10 +192,10 @@ export function extensionItems(extensions, whitelist) {
162
192
  return extensions.map(e => ({
163
193
  id: EXT_ID_PREFIX + e.path,
164
194
  label: `ext: ${e.label}`,
165
- description: `Also load this extension (${e.origin}) in pi-task child sessions — needed when it `
166
- + 'registers the model provider the children must use (e.g. pi-lmstudio). Children '
167
- + 'otherwise run with extensions off; whitelist only provider-type extensions you '
168
- + `trust, since children also get its tools and hooks. ${e.path}`,
195
+ description: `Load this ${e.origin} extension in the helper sessions pi-task spawns. They run `
196
+ + 'with extensions off by default, so turn this on when the extension provides the '
197
+ + 'model they need (pi-lmstudio, for example). They also inherit its tools and '
198
+ + `hooks, so only enable ones you trust. ${e.path}`,
169
199
  currentValue: whitelist.includes(e.path) ? 'on' : 'off',
170
200
  values: ['on', 'off']
171
201
  }));
@@ -175,15 +205,62 @@ export function applyExtensionToggle(whitelist, entryPath, on) {
175
205
  const rest = whitelist.filter(p => p !== entryPath);
176
206
  return on ? [...rest, entryPath] : rest;
177
207
  }
208
+ /** Overlay width; the list gets `- 4` of it, the description `- 4` again. */
209
+ const OVERLAY_WIDTH = 68;
210
+ /** Settings rows shown at once before the list scrolls. */
211
+ const MAX_VISIBLE = 9;
212
+ /**
213
+ * Tallest body the settings list can render, so {@link BorderedBox} can pad
214
+ * every frame to it and hold the border still. Mirrors SettingsList's own
215
+ * layout: one pad row, the visible rows, the scroll counter, a blank, the
216
+ * selected item's wrapped description, a blank, the key hint.
217
+ */
218
+ export function settingsBodyHeight(descriptions, maxVisible, wrapWidth) {
219
+ const tallestDescription = Math.max(0, ...descriptions.map(d => wrapTextWithAnsi(d, wrapWidth).length));
220
+ return 1 + maxVisible + 1 + 1 + tallestDescription + 1 + 1;
221
+ }
178
222
  function makeTheme(theme) {
179
223
  return {
180
- label: (text, selected) => (selected ? theme.fg('accent', text) : text),
181
- value: text => (text === 'on' ? theme.fg('success', text) : theme.fg('muted', text)),
224
+ label: (text, selected) => selected ? theme.fg('accent', theme.bold(text)) : theme.fg('text', text),
225
+ // A filled/hollow dot makes the on/off column scannable at a glance
226
+ // without reading a word on every row. Enum values (an engine name, a
227
+ // duration) are real content, so they stay readable rather than muted.
228
+ value: (text, selected) => {
229
+ if (text === 'on')
230
+ return theme.fg('success', '● on');
231
+ if (text === 'off')
232
+ return theme.fg('dim', '○ off');
233
+ return theme.fg(selected ? 'accent' : 'muted', text);
234
+ },
182
235
  description: text => theme.fg('muted', text),
183
- cursor: theme.fg('accent', '>'),
236
+ // Two cells wide to match the unselected row's " " indent — a
237
+ // single-cell cursor shifts the selected label one column left.
238
+ cursor: theme.fg('accent', '❯') + ' ',
184
239
  hint: text => theme.fg('dim', text)
185
240
  };
186
241
  }
242
+ /**
243
+ * Builds the framed settings panel. Split out of the command handler so the
244
+ * exact component the overlay shows can be rendered to a string in a test or a
245
+ * preview script, rather than only being inspectable by opening the TUI.
246
+ */
247
+ export function createSettingsPanel(items, theme, onChange, onCancel) {
248
+ const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme), onChange, onCancel);
249
+ return new BorderedBox(list, CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
250
+ }
251
+ /** The full settings row list for the current config, in menu order. */
252
+ export function panelItems(cfg, installed) {
253
+ return [
254
+ ...ITEMS.map(({ id, label, description, values }) => ({
255
+ id: id,
256
+ label,
257
+ description,
258
+ currentValue: displayValue(cfg, id, Boolean(values)),
259
+ values: values ?? ['on', 'off']
260
+ })),
261
+ ...extensionItems(installed, cfg.extensionWhitelist)
262
+ ];
263
+ }
187
264
  async function handleTaskConfig(_args, ctx) {
188
265
  const cfg = { ...getConfig(), extensionWhitelist: [...getConfig().extensionWhitelist] };
189
266
  // Enumerated live at open so an installed extension appears and an
@@ -199,50 +276,36 @@ async function handleTaskConfig(_args, ctx) {
199
276
  ctx.ui.notify(lines.join(' | '), 'info');
200
277
  return;
201
278
  }
202
- await ctx.ui.custom((_tui, theme, _kb, done) => {
203
- const listTheme = makeTheme(theme);
204
- const items = [
205
- ...ITEMS.map(({ id, label, description, values }) => ({
206
- id: id,
207
- label,
208
- description,
209
- currentValue: displayValue(cfg, id, Boolean(values)),
210
- values: values ?? ['on', 'off']
211
- })),
212
- ...extensionItems(installed, cfg.extensionWhitelist)
213
- ];
214
- const list = new SettingsList(items, 10, listTheme, (id, newValue) => {
215
- if (id.startsWith(EXT_ID_PREFIX)) {
216
- cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, id.slice(EXT_ID_PREFIX.length), newValue === 'on');
217
- }
218
- else if (id === 'searchProvider') {
219
- const provider = providerForLabel(newValue);
220
- if (provider)
221
- cfg.searchProvider = provider;
222
- }
223
- else if (id === 'requestTimeoutMs') {
224
- const opt = COMMAND_TIMEOUT_OPTIONS.find(o => o.label === newValue);
225
- if (opt)
226
- cfg.requestTimeoutMs = opt.ms;
227
- }
228
- else if (id === 'streamInactivityMs') {
229
- const opt = STREAM_INACTIVITY_OPTIONS.find(o => o.label === newValue);
230
- if (opt)
231
- cfg.streamInactivityMs = opt.ms;
232
- }
233
- else {
234
- ;
235
- cfg[id] = newValue === 'on';
236
- }
237
- saveConfig(cfg).catch(() => { });
238
- }, () => done(undefined));
239
- return new BorderedBox(list, CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)));
240
- }, { overlay: true, overlayOptions: { width: 58 } });
279
+ await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed), theme, (id, newValue) => {
280
+ if (id.startsWith(EXT_ID_PREFIX)) {
281
+ cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, id.slice(EXT_ID_PREFIX.length), newValue === 'on');
282
+ }
283
+ else if (id === 'searchProvider') {
284
+ const provider = providerForLabel(newValue);
285
+ if (provider)
286
+ cfg.searchProvider = provider;
287
+ }
288
+ else if (id === 'requestTimeoutMs') {
289
+ const opt = COMMAND_TIMEOUT_OPTIONS.find(o => o.label === newValue);
290
+ if (opt)
291
+ cfg.requestTimeoutMs = opt.ms;
292
+ }
293
+ else if (id === 'streamInactivityMs') {
294
+ const opt = STREAM_INACTIVITY_OPTIONS.find(o => o.label === newValue);
295
+ if (opt)
296
+ cfg.streamInactivityMs = opt.ms;
297
+ }
298
+ else {
299
+ ;
300
+ cfg[id] = newValue === 'on';
301
+ }
302
+ saveConfig(cfg).catch(() => { });
303
+ }, () => done(undefined)), { overlay: true, overlayOptions: { width: OVERLAY_WIDTH } });
241
304
  }
242
305
  export function registerConfig(pi) {
243
306
  registerBridgeCommand(pi, 'task-config', {
244
- description: 'Configure pi-task settings (remote, compress reasoning, auto-commit, orientation, '
245
- + 'enforce guidelines, command timeout, extension whitelist for child sessions).',
307
+ description: 'Configure pi-task settings (remote control, auto-commit, verify work, enforce '
308
+ + 'guidelines, research, timeouts, extensions for helper sessions).',
246
309
  handler: handleTaskConfig
247
310
  });
248
311
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The installed pi-task version, read from package.json at runtime so nothing
3
+ * has to be regenerated on release. Two levels up holds for both src/<dir>
4
+ * (tests) and dist/<dir> (build), since tsc preserves the layout under rootDir.
5
+ * Falls back to '0.0.0' rather than throwing: every caller here is cosmetic
6
+ * (a User-Agent, a title bar) and none is worth failing over.
7
+ */
8
+ export declare function readPkgVersion(): string;
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * The installed pi-task version, read from package.json at runtime so nothing
6
+ * has to be regenerated on release. Two levels up holds for both src/<dir>
7
+ * (tests) and dist/<dir> (build), since tsc preserves the layout under rootDir.
8
+ * Falls back to '0.0.0' rather than throwing: every caller here is cosmetic
9
+ * (a User-Agent, a title bar) and none is worth failing over.
10
+ */
11
+ export function readPkgVersion() {
12
+ try {
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
15
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
16
+ }
17
+ catch {
18
+ return '0.0.0';
19
+ }
20
+ }
@@ -1,9 +1,7 @@
1
- import { readFileSync } from 'node:fs';
2
- import { fileURLToPath } from 'node:url';
3
- import { dirname, join } from 'node:path';
4
1
  import { parseHTML } from 'linkedom';
5
2
  import { Readability } from '@mozilla/readability';
6
3
  import TurndownService from 'turndown';
4
+ import { readPkgVersion } from '../shared/pkg-version.js';
7
5
  const turndown = new TurndownService({
8
6
  codeBlockStyle: 'fenced',
9
7
  headingStyle: 'atx',
@@ -33,21 +31,9 @@ export function cleanHtml(html, baseUrl) {
33
31
  }
34
32
  const DEFAULT_TIMEOUT_MS = 15_000;
35
33
  const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; // 2 MB
34
+ // Read at runtime so the User-Agent never drifts out of sync with releases.
36
35
  const PKG_VERSION = readPkgVersion();
37
36
  const USER_AGENT = `pi-worker/${PKG_VERSION} (+https://npmjs.com/package/@mjasnikovs/pi-worker)`;
38
- // Read the version from package.json at runtime so the User-Agent never drifts
39
- // out of sync with releases. Two levels up holds for both src/workers (tests)
40
- // and dist/workers (build) since tsc preserves the layout under rootDir.
41
- function readPkgVersion() {
42
- try {
43
- const here = dirname(fileURLToPath(import.meta.url));
44
- const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
45
- return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
46
- }
47
- catch {
48
- return '0.0.0';
49
- }
50
- }
51
37
  // Decide how to handle a response based on its content-type. HTML is run through
52
38
  // the readability/turndown pipeline; text-ish formats (markdown, plain text,
53
39
  // JSON, XML/feeds) are already clean and pass through verbatim; binary formats
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.47",
3
+ "version": "0.18.49",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",