pi-jar 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/docs/EFFICIENCY_PLAN.md +59 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +35 -20
- package/package.json +1 -1
- package/src/advisor.ts +1 -1
- package/src/delegate.ts +79 -36
- package/src/goal-loop.ts +8 -22
- package/src/perf.ts +21 -4
- package/src/plan.ts +8 -23
- package/src/profile-ui.ts +58 -0
- package/src/profiles.ts +2 -1
- package/src/settings-ui.ts +12 -22
- package/src/side-model.ts +14 -0
- package/src/workflow-context.ts +37 -0
- package/src/profile-wizard.ts +0 -61
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
|
|
|
56
56
|
| `/plan [request]` | Enter plan mode (optionally sending the request). `/plan review` reopens the plan view; `/plan off` exits. |
|
|
57
57
|
| `/goal <outcome>` | Start goal mode. `/goal` edits, `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. |
|
|
58
58
|
| `/roles` | Role manager. `/roles set ROLE provider/model[:effort]\|@role [--project]`, `/roles clear ROLE`, `/roles <role>` activates, `/roles cycle`, `/roles list`. |
|
|
59
|
+
| `/profiles` | Create or switch profiles. `/profiles new` creates (name and theme, then roles), `/profiles <name>` switches. |
|
|
59
60
|
| `/advisor [focus]` | Ask the advisor for a second opinion on the current work; the answer joins the conversation. |
|
|
60
61
|
| `/usage` · `/context` | Usage (cost, tokens per model, plan limits) and context-window breakdown in one tabbed panel. |
|
|
61
62
|
| `/jar commit [note]` | Draft a commit message for the staged changes with the `commit` role, edit it, and commit. Offers `git add -A` when nothing is staged. |
|
|
@@ -92,7 +93,7 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
|
|
|
92
93
|
|
|
93
94
|
Profiles keep separate themes, appearance settings, and model-role configurations. **Default** preserves your existing setup.
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
Run `/profiles` (or `/jar settings` → **Profiles**, **Ctrl+Alt+S**) to create a profile or switch to one. `/profiles new` creates directly and `/profiles <name>` switches. Creating asks only for a name and a theme; the new profile copies the current profile's appearance and model roles. In a fresh, idle session pi-jar then switches to it and opens the roles manager so you can set its models; otherwise it tells you to switch in a fresh session and run `/roles`. Cancelling leaves your existing setup unchanged.
|
|
96
97
|
|
|
97
98
|
Press **Ctrl+Shift+Tab** to cycle profiles **only in a fresh, idle session**. Once the first prompt is sent, the session's profile is locked—even after the agent finishes—and restored when you resume it. Open a new session to switch. Active agents, subagents, and plan/goal workflows also block switching. Configure the selected profile's roles through `/roles`; changing one profile does not change another. The selected profile is remembered across restarts and shown on the welcome screen and beside the composer mascot: `mascot · (Profile) session_name`. Some terminals require an extended keyboard protocol to distinguish Ctrl+Shift+Tab.
|
|
98
99
|
|
|
@@ -268,14 +269,14 @@ This is a pi-jar enhancement: [pi-advisor](https://github.com/philipbrembeck/pi-
|
|
|
268
269
|
- **Context diet (opt-in)** — `/jar settings` → **Pi** → **Context diet (prior-turn reasoning)**. Saved independently per profile; OFF by default. Removes eligible thinking blocks from completed earlier user turns only in outgoing context, never from stored history. Current reasoning signatures, user requests, tool arguments/results, and workflow state remain intact; ambiguous tool chains are left unchanged. Opaque signature character counts are diagnostics, not token savings. Existing SoL-Pi observation packing remains responsible for tool-result projection.
|
|
269
270
|
- **`jar_shell`** — `start` (optional `name`, `watch` regex, `notify`, task/service `purpose`), `list`, `peek`, bounded `wait`, `output`, `kill`. ANSI-stripped logs have per-job caps and a 4 MiB aggregate budget; surfaced finished jobs compact to a 32 KiB tail. Live jobs retain useful tails and are never killed to reclaim logs. Watch/exit notifications are compact and coalesced; reads acknowledge observed events. Active model responses cannot be interrupted by a completion event: the UI previews status without consuming it, and model delivery waits for a safe boundary. When no useful work remains, use bounded `wait` for the relevant finite jobs rather than waiting for an automatic wake-up. Each running shell gets a footer row and an activity view (`/jar shells`) with `x` kill and `f` follow. All shells stop when the session ends.
|
|
270
271
|
- **`jar_delegate`** — spawns retained, session-scoped subagents up to **Max subagents** in `/jar settings` (2, 4, 6, 8 or 16; default 4). Idle, paused and hibernated agents count toward this pool; over-cap batches are rejected. `scout` is fresh/read-only, `fork` inherits conversation read-only, and `worktree` forks context into a path-guarded Git worktree. Settled retained agents close their Pi process after 30 seconds of quiet while keeping their private session and workspace; `resume`/`ask` relaunch that same session. Launch returns before startup or the first turn completes, including legacy one-shot `write: true` mode.
|
|
271
|
-
- **`jar_subagent`** — `peek` reports progress, including the workspace path and changed files for recovery records; `steer` redirects a running worker; `resume` relaunches an idle/paused/hibernated agent. `ask`, `pause` and `stop` return accepted receipts immediately; answers and final handoffs arrive as `pi-jar.subagent` events. `stop` reconciles safe worktree changes; conflicts remain private and can be retried with `stop` after resolving parent drift. `discard` removes an unresolved recovery worktree without applying it. At most four active/recovery worktrees are admitted; resolve or discard one before starting another. Session shutdown reports paths of any preserved worktrees. Controls do not poll the child.
|
|
272
|
+
- **`jar_subagent`** — `peek` reports progress, including the workspace path and changed files for recovery records; `steer` redirects a running worker; `resume` relaunches an idle/paused/hibernated agent; `report` returns an agent's latest full report (a synchronous `jar_delegate` result shares a 16k-character report budget across its runs and points here when it truncates). `ask`, `pause` and `stop` return accepted receipts immediately; answers and final handoffs arrive as `pi-jar.subagent` events. `stop` reconciles safe worktree changes; conflicts remain private and can be retried with `stop` after resolving parent drift. `discard` removes an unresolved recovery worktree without applying it. At most four active/recovery worktrees are admitted; resolve or discard one before starting another. Session shutdown reports paths of any preserved worktrees. Controls do not poll the child.
|
|
272
273
|
- **Subagent capabilities** — each task can set `tools` or `inheritTools: true`; known-tool mode filters and child guards remain in force. See [tool capabilities](docs/SUBAGENT-TOOLS.md).
|
|
273
274
|
- **Worktree finalization** — edits do not touch the parent merely because a worker settles or hibernates. On `jar_subagent stop`, pi-jar checks parent drift and reviewability, applies safe changes and adds them to `/diff`; conflicts remain isolated for retry or explicit discard. Failed worktree transcripts are compacted independently of finished history. Process closure is awaited before finalization, and failed multi-file application rolls back rather than leaving a partial result.
|
|
274
275
|
- **`jar_democracy`** — **exceptional only**, for a persistent super-complex issue with several evidence-backed options after at least two distinct failed approaches. The moderator opens 2–8 options, resumes relevant idle/paused/hibernated fresh-context read-only scouts or spawns new scouts within the pool, and collects one private ballot per voter. A strict majority of invited voters wins; ties, plurality or missing/invalid ballots require user choice. Voting never implements its recommendation. A round has a 10-minute deadline and bounded rationales.
|
|
275
276
|
- **`jar_discuss`** — session-local, parent-owned discussion broker over authenticated local IPC; no shared paper, lock or polling. `ask`/`answer` return stable IDs without echoing your text. `list` reads only relevant unread questions/answers and advances your cursor; `thread` (or `list` with `questionId`) reads one thread; `pending` shows counts without message bodies. `since: "d0"` rereads relevant mail. At most 128 messages, 1600 characters each and 128 KiB are retained; unread/unanswered questions block new posts instead of being silently lost. Messages do not wake idle agents; the next natural turn gets an unread notice.
|
|
276
277
|
- **Activity view** — one split view (<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd>, `/jar activity`, or a click on a footer row) for subagents, background shells and other extensions' teammates: list on the left, live details on the right (auto-follows; scroll up to pause, `f` to follow again). For a subagent the details show its task, its own checklist with subtasks, and a transcript like omp's: each tool call and message is one collapsed line that expands on demand (`Tab` into it, `↑↓`, `Enter`, or click) to show arguments and output, while streaming text shows live. `s` opens a steering input: type a message and press `Enter` to redirect the running subagent. `x` stops just that one. Finished runs stay listed for review.
|
|
277
278
|
- **`/jar history`** — separate, read-only timeline of the active branch (paging, search, expandable details). Pi's native transcript is untouched.
|
|
278
|
-
- Pi's built-in read/shell/edit/write tool cards render compactly: collapsed, each is one padded, state-colored card with its call line and a one-line summary (edit included), and partially streamed arguments never flicker to a fallback; the full output or diff stays one click or <kbd>Ctrl</kbd>+<kbd>O</kbd> away. Every requested frame re-renders the whole transcript, so background sources (subagent streams, shell changes, context and cost updates) share one adaptive repaint budget: at most every 250 ms below 300 branch entries, 500 ms below 800, 1 s below 2,000, and only on visible state changes beyond that; a frame Pi draws on its own satisfies pending updates. User actions and state changes (a subagent or shell starting, settling or failing) still repaint at once. The open activity view repaints live updates every 100 ms, slowing to 500 ms in very large sessions. Context usage is cached: session start, tree navigation, compaction, model changes, renames and the end of a run refresh it at once, while message ends recompute it at most every 750 ms. `/jar perf` shows the counters. The sliding bar some terminals show in the tab while the agent works is Pi's own `OSC 9;4` progress (`terminal.showTerminalProgress` in Pi settings), not pi-jar.
|
|
279
|
+
- Pi's built-in read/shell/edit/write tool cards render compactly: collapsed, each is one padded, state-colored card with its call line and a one-line summary (edit included), and partially streamed arguments never flicker to a fallback; the full output or diff stays one click or <kbd>Ctrl</kbd>+<kbd>O</kbd> away. Every requested frame re-renders the whole transcript, so background sources (subagent streams, shell changes, context and cost updates) share one adaptive repaint budget: at most every 250 ms below 300 branch entries, 500 ms below 800, 1 s below 2,000, and only on visible state changes beyond that; a frame Pi draws on its own satisfies pending updates. User actions and state changes (a subagent or shell starting, settling or failing) still repaint at once. The open activity view repaints live updates every 100 ms, slowing to 500 ms in very large sessions. Context usage is cached: session start, tree navigation, compaction, model changes, renames and the end of a run refresh it at once, while message ends recompute it at most every 750 ms, widening up to 5 s when the measured computation is slow so sampling stays near a 1% duty cycle. `/jar perf` shows the counters. The sliding bar some terminals show in the tab while the agent works is Pi's own `OSC 9;4` progress (`terminal.showTerminalProgress` in Pi settings), not pi-jar.
|
|
279
280
|
|
|
280
281
|
## Settings
|
|
281
282
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Efficiency Plan
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Triaged against the 0.2.1 codebase and partly implemented in v0.2.2. Most of the original proposal was either already shipped by the long-session work ([LONG_SESSION_PERFORMANCE_PLAN.md](./LONG_SESSION_PERFORMANCE_PLAN.md), 0.1.9–0.2.1) or costs more in complexity and risk than it saves. Only items with a measurable win and no behavior change were taken.
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Keep long sessions fast and cheap without losing stored history or changing what the model sees unless the user opted in.
|
|
10
|
+
|
|
11
|
+
## Implemented in 0.2.2
|
|
12
|
+
|
|
13
|
+
### Adaptive context sampling (was item 8)
|
|
14
|
+
|
|
15
|
+
`ContextSampler` (`src/perf.ts`) now widens its mid-turn throttle from the measured cost of `getContextUsage()` instead of a fixed 750 ms: the window is `max(750 ms, 100 × median sample time)`, capped at 5 s. Mid-turn sampling therefore stays near a 1% duty cycle whatever the session size; cheap sessions keep the 750 ms cadence, and lifecycle boundaries (start, tree, compact, model, rename, settle) still refresh immediately. Measured cost is used rather than entry-count tiers because it is the quantity being bounded and needs no extra wiring. `/jar perf` shows the current window.
|
|
16
|
+
|
|
17
|
+
### Profile pin lookup without a copy (part of item 9)
|
|
18
|
+
|
|
19
|
+
`pinnedProfileId` walks the branch backwards in place instead of allocating `[...entries].reverse()`. `conversationStarted` already exits at the first user message, so it needed no change; neither runs per frame, so no incremental cache was added.
|
|
20
|
+
|
|
21
|
+
### Cache-stable workflow prompts (follow-up)
|
|
22
|
+
|
|
23
|
+
Plan and goal prompts used to be pruned to the newest copy on every call. Removing an earlier copy changes the conversation prefix, so each new turn (or goal round) re-sent the whole previous run uncached. `src/workflow-context.ts` now projects them so that every keep/drop decision depends only on earlier messages: identical plan/goal context is sent once, sent reminders and continuations stay in place, and leaving the mode drops them all once. It runs on Pi's message list before provider conversion, so it helps every provider that caches by prefix (Anthropic explicit breakpoints, OpenAI/DeepSeek/Gemini automatic prefix caching, Bedrock). Appending the context at the tail was rejected: pi-ai places the Anthropic cache breakpoint on the last message, so a moving tail would defeat the cache entirely.
|
|
24
|
+
|
|
25
|
+
### Unset side roles are visible (follow-up)
|
|
26
|
+
|
|
27
|
+
`askRole` notifies once per role when an unassigned role (advisor, commit, …) ran on the current main model, with the `/roles set` command to fix it. Behaviour is unchanged.
|
|
28
|
+
|
|
29
|
+
### Bounded synchronous subagent results (follow-up)
|
|
30
|
+
|
|
31
|
+
A synchronous `jar_delegate` result shares a 16k-character report budget across its runs (at least 2k each) instead of up to 12k per run. Truncated reports name `jar_subagent report <key>`, a new read-only action returning the full stored report. Event messages were already capped at 1.2k.
|
|
32
|
+
|
|
33
|
+
### Cheaper subagent subscriber (follow-up)
|
|
34
|
+
|
|
35
|
+
The footer's per-tick registry listener reads `DelegateRegistry.stateSignature()` straight from the map instead of `records()` (a second sweep, a sort and two arrays per 50 ms tick).
|
|
36
|
+
|
|
37
|
+
## Already covered — dropped
|
|
38
|
+
|
|
39
|
+
| Item | Why |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| 5. Tier subagent events | Already tiered: tool `onUpdate` ≤ 1 s, footer status only on state change, key/state changes are `transition` and everything else `background` in `RenderScheduler` (250 ms–1 s by session size, transitions-only beyond), Activity view coalesces at 100–500 ms. The 50 ms stream tick only feeds that scheduler. |
|
|
42
|
+
| 6. Footer snapshot | `renderFooterLayout` already memoizes on a key of width/theme/view; repaints between changes are cache hits. A versioned snapshot would need change notifications from every source for a microsecond saving. |
|
|
43
|
+
| 7. Bounded advisor fingerprints | `callKey` already keeps a 200-char prefix plus a SHA-1 of large inputs, so the loop window never pins large payloads. Prefix/suffix-only hashing would let distinct writes collide and trigger false loop alerts. |
|
|
44
|
+
|
|
45
|
+
## Deferred
|
|
46
|
+
|
|
47
|
+
| Item | Why |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| 1. Single Context Projector | The four `context` hooks (child automation filter, diet, plan, goal) are each one `customType` scan that allocates only when something is stale — negligible next to request serialization. Merging them couples independent modules for no measurable gain. Revisit only if `/jar perf` shows context hooks in a profile. |
|
|
50
|
+
| 2. Context Diet v2 (`stripper` model, `DietSnapshot`) | A model-written summary replaces exact history with a lossy one, adds a side call per compression and duplicates Pi's own compaction. Needs its own design (fidelity, resumability, cache-hit impact) before code. |
|
|
51
|
+
| 3. Diet-aware delegation | Depends on item 2. Today `scout` already starts fresh and `fork` is an explicit mode; changing the defaults alters subagent results. |
|
|
52
|
+
| 4. Strict utility roles | Side calls without a role use the current model by design (`askRole`); making them fail or go deterministic breaks advisor and commit messages for users without roles. Addressed instead by the one-time notice above. |
|
|
53
|
+
| 10. Compact workflow history | Rewriting persisted session history is ruled out; provider context is handled by the cache-stable projection above. |
|
|
54
|
+
|
|
55
|
+
## Success criteria
|
|
56
|
+
|
|
57
|
+
- Mid-turn context sampling cost stays bounded (~1% duty cycle) as sessions grow; visible in `/jar perf`.
|
|
58
|
+
- Plan/goal sessions keep prefix-cache hits across turns and rounds on every provider.
|
|
59
|
+
- Stored session history is unchanged.
|
package/docs/INTEGRATIONS.md
CHANGED
|
@@ -65,7 +65,7 @@ Hidden context messages (`pi-jar.plan-context`, `pi-jar.plan-reminder`, `pi-jar.
|
|
|
65
65
|
| `jar_delegate` | up to four parallel subagents (`tasks: [{task, name?, role?}]`, `write?`) |
|
|
66
66
|
| `jar_advisor` | second opinion from the `advisor` role (`question?`, `draft?`) |
|
|
67
67
|
|
|
68
|
-
`jar_shell` events arrive as a visible `pi-jar.shell` custom message that triggers (or queues) an agent turn;
|
|
68
|
+
`jar_shell` events arrive as a visible `pi-jar.shell` custom message that triggers (or queues) an agent turn; subagent turn ends and `jar_subagent` control completions arrive as one coalesced, visible `pi-jar.subagent` message at the moderator's next turn boundary, or as a wake-up when it is idle. `jar_delegate` runs child processes with `PI_JAR_CHILD=1`, which keeps pi-jar in the child from registering `jar_delegate` again, and publishes each running subagent through the role contract above as `pi-jar.role.delegate-<batch>-<n>`. The advisor publishes `pi-jar.role.advisor` while it is consulting, and its `/advisor` and gate answers arrive as visible `pi-jar.advisor` custom messages.
|
|
69
69
|
|
|
70
70
|
## Commands that overlap other packages
|
|
71
71
|
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -68,7 +68,7 @@ State (`pi-jar.plan` entries, v2: `enabled`, `steps`, `text`, `path`, `title`) f
|
|
|
68
68
|
- **Task gate:** with no open task, `write`/`edit` are blocked, and so are shell commands that are not on the read-only allowlist during the implement phase. During the audit, edits are blocked but verification commands are allowed.
|
|
69
69
|
- **Roles per round:** implement rounds run on the `implement` role and the audit round on `advisor` (each only if assigned). The previous model and effort are restored when the run settles.
|
|
70
70
|
- **Completion:** `jar_goal complete` requires the audit phase, no open tasks, and non-empty evidence. It ends the turn, records the evidence, and Ember shows `(★ᴗ★)`.
|
|
71
|
-
- **Context hygiene:**
|
|
71
|
+
- **Context hygiene:** repeated identical goal context is sent once, and goal prompts already sent are never removed from the middle of the prompt while the goal runs, so any provider's prefix cache (Anthropic, OpenAI, Gemini, Bedrock, …) keeps hitting. A completed goal drops them all.
|
|
72
72
|
|
|
73
73
|
Goal events (`pi-jar.goal`, v2: `set`, `status`, `round`) are append-only session entries; v1 `set`/`clear` still replay.
|
|
74
74
|
|
|
@@ -143,7 +143,7 @@ watch matches or process exits → pi-jar.shell message → agent wakes (o
|
|
|
143
143
|
- `jar_delegate` shares a session-wide **Max subagents** setting in `/jar settings`: exactly 2, 4, 6, 8 or 16, default 4. All retained live children (including idle/paused and live legacy one-shots) count; the moderator does not. Concurrent/reentrant batches reserve capacity atomically and reject over-cap requests before any launch. Lowering the limit preserves the fleet and prevents further spawning until room is available. `scout` runs `pi --mode rpc --no-session`, is read-only and defaults to the `scout` role. `fork` uses `--fork <parent-session>`, is read-only and defaults to `reviewer`. `worktree` uses the same real context fork, defaults to `worker`, and runs in a detached disposable Git worktree. Explicit `task.role` overrides those defaults; each role uses its primary/fallback candidates before falling back to the parent's current model.
|
|
144
144
|
- Retained children do not exit on `agent_settled`. They become `idle`, keeping the same RPC process and model context. `jar_subagent pause` sends `clear_queue` + `abort` and leaves the agent `paused`; `resume` sends a new prompt to that same child. `steer` redirects a working child. `ask` pauses an active task, crosses a settled boundary, then starts a compact BTW-only prompt, so the old task report cannot be mistaken for the answer. It preserves the prior paused state and assigned scope for later resume.
|
|
145
145
|
- `jar_subagent peek` is the moderator's cheap status surface and reads only task progress from memory: key, name, state, done/total checklist leaves, the in-progress task, current activity and the next three open tasks (no git, transcript or report reads). `stop` is destructive/final: abort an active operation if necessary, close the child, return a handoff (workspace, changed/applied files, remaining work, last report), and finalize safe worktree changes.
|
|
146
|
-
- The moderator never has to poll:
|
|
146
|
+
- The moderator never has to poll: settled turns and `pause`/`ask`/`stop` completions wait in pi-jar's own queue, never Pi's irrevocable follow-up queue. While the moderator is running they are coalesced into one visible `pi-jar.subagent` message (their `peek` progress plus the turn's report, clipped to 1200 characters) at the next turn boundary, so the final answer already reflects them instead of being followed by a fresh wake-up. Only events arriving while the moderator is idle wake a turn; an interrupted run keeps them for the next prompt. The message renders as a one-line summary (expand for the report). `pause`, `ask` and `stop` drop queued turn reports for the agent they control.
|
|
147
147
|
- Nothing in the moderator path waits unbounded. `jar_subagent` returns as soon as the tool call is aborted (the child keeps its state); `pause` reports "not settled yet" after 15 s if the child ignores abort and still pauses when it settles; a working child that sends no RPC event for 10 minutes is killed as hung. After a child exits, its stdio gets 2 s to drain: a grandchild (MCP server, shell) that inherited the pipes cannot keep `stop` or session shutdown waiting for `close`.
|
|
148
148
|
- Worktree setup snapshots the exact starting workspace: `HEAD` plus tracked dirty changes and non-ignored untracked files are committed only inside the detached worktree. The worktree persists across idle/pause/resume. On stop, the parent target must still byte/mode-match the snapshot and the change must be trackable by `/diff`; otherwise nothing is overwritten and the workspace/error is reported. Conflicted workspaces survive report-history trimming; after resolving parent drift, retry `stop`. Finalization waits for RPC stdio closure. Snapshot blobs preserve filesystem bytes without executing clean/smudge filters or hooks. The path guard handles Pi path normalization, rejects lexical/symlink/dangling-link escapes and Git control metadata, checks read-tool filename fallbacks (NFD, curly apostrophes, AM/PM spaces), and rechecks both source and destination ancestors during reconciliation. All replacements/rollback backups are prepared before parent mutation; failed multi-file application restores earlier files. Exceptional rollback failures explicitly report actual applied paths for recovery. Worktree children receive only `read,edit,write,grep,find,ls,jar_todo,jar_discuss`; shell tools are absent. This is filesystem-write isolation, not a network/process sandbox.
|
|
149
149
|
- `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields and `answer` references the question id (validated under the same lock as the write); both reply with the new id only, never echoing the text. Each process keeps an in-memory read cursor (the highest entry id it has been shown, reset when the paper path changes). `list` returns only unseen entries written by others — the newest that fit a 4000-character reply, each clipped to 400 characters, stating how many older entries it omitted — and advances the cursor; with nothing new it replies `No new entries (latest dN).` `since: "d12"` lists everything newer than that id instead (`since: "d0"` rereads the whole paper) and `questionId` reads one thread in full without moving the cursor. An `ask`/`answer` reply attaches unseen entries (≤1000 characters) only when all of them are relevant to the caller — answers to its questions or questions addressed to it — otherwise it adds `(N new entries; list to read)` and leaves the cursor alone, so nothing is skipped. Text is capped at 1600 characters per entry, at most 64 entries are retained, and the compact UTF-8 JSON paper is strictly bounded to 64 KiB with oldest-entry eviction and atomic rewrites. The file lock waits by yielding to the event loop (at most ~1 s), never blocking the UI. It is intentionally for terse inter-agent Q/A rather than another transcript; agents are told not to poll it.
|
package/extensions/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { FOOTER_FIELDS } from "../src/footer-settings.ts";
|
|
|
19
19
|
import { defaultVisualSettings, loadVisualSettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
|
|
20
20
|
import { ProfileStore, PROFILE_FILE, PROFILE_ENTRY, conversationStarted, pinnedProfileId } from "../src/profiles.ts";
|
|
21
21
|
import { openJarSettings, type PiPreferences } from "../src/settings-ui.ts";
|
|
22
|
+
import { openProfiles, type ProfileAction } from "../src/profile-ui.ts";
|
|
22
23
|
import { pickSession } from "../src/session-ui.ts";
|
|
23
24
|
import { fetchQuota, QuotaCache, type QuotaProvider } from "../src/quota.ts";
|
|
24
25
|
import { ModelRoleManager } from "../src/model-roles.ts";
|
|
@@ -230,7 +231,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
230
231
|
let subagentSignature = "";
|
|
231
232
|
subagents.subscribe(() => {
|
|
232
233
|
if (!footerTui) return;
|
|
233
|
-
const signature = subagents.
|
|
234
|
+
const signature = subagents.stateSignature();
|
|
234
235
|
repaint("subagents", signature === subagentSignature ? "background" : "transition");
|
|
235
236
|
subagentSignature = signature;
|
|
236
237
|
});
|
|
@@ -710,14 +711,19 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
710
711
|
};
|
|
711
712
|
|
|
712
713
|
let switchingProfile = false;
|
|
713
|
-
const
|
|
714
|
+
const profileSwitchBlocked = (ctx: ExtensionContext): string | undefined => {
|
|
714
715
|
if (switchingProfile || advisor.isBusy() || !ctx.isIdle?.() || working.phase !== "idle" || planMode.isEnabled() || goals?.isActive() || subagents.running() > 0) {
|
|
715
|
-
|
|
716
|
+
return "Finish the active agent/workflow before switching profiles";
|
|
716
717
|
}
|
|
717
718
|
if (conversationStarted(profileEntries(ctx)) || pinnedProfileId(profileEntries(ctx)) !== undefined) {
|
|
718
|
-
|
|
719
|
+
return "This session's profile is locked. Open a new session to switch profiles";
|
|
719
720
|
}
|
|
720
|
-
return
|
|
721
|
+
return undefined;
|
|
722
|
+
};
|
|
723
|
+
const canSwitchProfile = (ctx: ExtensionContext) => {
|
|
724
|
+
const blocked = profileSwitchBlocked(ctx);
|
|
725
|
+
if (blocked) ctx.ui.notify(blocked, "warning");
|
|
726
|
+
return !blocked;
|
|
721
727
|
};
|
|
722
728
|
const switchProfile = async (name: string, ctx: ExtensionContext) => {
|
|
723
729
|
if (!profileStore || name === profileStore.activeName || !canSwitchProfile(ctx)) return;
|
|
@@ -754,29 +760,38 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
754
760
|
ctx.ui.notify(`Could not switch profile: ${(error as Error).message}`, "warning");
|
|
755
761
|
} finally { switchingProfile = false; }
|
|
756
762
|
};
|
|
763
|
+
const profileUi = (ctx: ExtensionContext, action?: ProfileAction) => {
|
|
764
|
+
if (!profileStore) { ctx.ui.notify("Profiles are unavailable in this session", "warning"); return Promise.resolve(); }
|
|
765
|
+
const store = profileStore;
|
|
766
|
+
return openProfiles(ctx, {
|
|
767
|
+
store, themes: ctx.ui.getAllThemes?.().map((theme) => theme.name) ?? [],
|
|
768
|
+
create: (name, theme) => store.add(name, theme, { ...visualSettings, accent: "follow" }, modelRoles.profileRoleConfig()),
|
|
769
|
+
switchBlocked: () => profileSwitchBlocked(ctx),
|
|
770
|
+
select: (name) => switchProfile(name, ctx),
|
|
771
|
+
openRoles: () => openRolesUi(ctx, modelRoles)
|
|
772
|
+
}, action);
|
|
773
|
+
};
|
|
757
774
|
openSettings = async (ctx) => {
|
|
758
775
|
if (settingsOpen) return;
|
|
759
776
|
settingsOpen = true;
|
|
760
777
|
try { await openJarSettings(ctx, () => visualSettings,
|
|
761
778
|
(next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx), profileStore ? {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
create: (name, theme, settings, roles) => { const profile = profileStore!.add(name, theme, { ...settings, accent: "follow" }, roles); ctx.ui.notify(`Created profile ${name}; select it in a fresh session`, "info"); return profile; },
|
|
765
|
-
select: (name) => switchProfile(name, ctx),
|
|
766
|
-
setTheme: (name) => {
|
|
767
|
-
const previousTheme = ctx.ui.theme?.name;
|
|
768
|
-
const next = { ...visualSettings, accent: "follow" as const };
|
|
769
|
-
try { applyProfileTheme(name, ctx); profileStore!.update(profileStore!.activeName, { theme: name, settings: next }); }
|
|
770
|
-
catch (error) { if (previousTheme) applyProfileTheme(previousTheme, ctx); throw error; }
|
|
771
|
-
applyVisualSettings(next, ctx, { persist: false, accent: false });
|
|
772
|
-
if (profileStore!.active().id === "default") {
|
|
773
|
-
try { saveVisualSettings(getAgentDir(), next); }
|
|
774
|
-
catch { ctx.ui.notify("Theme saved, but legacy Default preferences could not be updated", "warning"); }
|
|
775
|
-
}
|
|
776
|
-
}
|
|
779
|
+
activeName: () => profileStore?.activeName ?? "Default",
|
|
780
|
+
open: (action) => profileUi(ctx, action)
|
|
777
781
|
} : undefined); }
|
|
778
782
|
finally { settingsOpen = false; }
|
|
779
783
|
};
|
|
784
|
+
pi.registerCommand("profiles", {
|
|
785
|
+
description: "Create or switch pi-jar profiles: /profiles, /profiles new, /profiles <name>",
|
|
786
|
+
handler: async (args, ctx) => {
|
|
787
|
+
const target = args.trim();
|
|
788
|
+
if (!target) return profileUi(ctx);
|
|
789
|
+
if (["new", "create"].includes(target.toLowerCase())) return profileUi(ctx, "create");
|
|
790
|
+
const profile = profileStore?.list().find((item) => item.name.toLocaleLowerCase() === target.toLocaleLowerCase());
|
|
791
|
+
if (!profile) { ctx.ui.notify("Unknown profile: " + target, "warning"); return; }
|
|
792
|
+
await switchProfile(profile.name, ctx);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
780
795
|
|
|
781
796
|
/** Callers are lifecycle handlers that repaint once through sampleFooter. */
|
|
782
797
|
const updateCost = (ctx: ExtensionContext) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jar",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "A warm, role-aware Pi terminal workspace with switchable profiles, animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/advisor.ts
CHANGED
|
@@ -239,7 +239,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
|
|
|
239
239
|
}
|
|
240
240
|
const selectedRoles = { resolve: () => primary, fallbackSpecs: () => fallbacks,
|
|
241
241
|
resolveSpec: (spec: string) => targets.get(spec)!() } as unknown as ModelRoleManager;
|
|
242
|
-
const selectedContext = { model: ctx.model, modelRegistry: ctx.modelRegistry } as ExtensionContext;
|
|
242
|
+
const selectedContext = { model: ctx.model, modelRegistry: ctx.modelRegistry, ui: ctx.ui } as ExtensionContext;
|
|
243
243
|
const conversation = transcript(sessionBranch(ctx) as readonly Entry[], 12_000);
|
|
244
244
|
const git = await gitState(ctx, controller.signal);
|
|
245
245
|
controller.signal.throwIfAborted();
|
package/src/delegate.ts
CHANGED
|
@@ -41,6 +41,9 @@ const allowedInMode = (mode: DelegateExecutionMode, name: string) => !RECURSIVE_
|
|
|
41
41
|
export type DelegateMode = "scout" | "fork" | "worktree";
|
|
42
42
|
type DelegateExecutionMode = DelegateMode | "direct";
|
|
43
43
|
const MAX_OUTPUT_CHARS = 12_000;
|
|
44
|
+
/** Shared budget for the reports one synchronous jar_delegate result returns; the rest stays one jar_subagent report away. */
|
|
45
|
+
const MAX_RESULT_REPORT_CHARS = 16_000;
|
|
46
|
+
const MIN_RUN_REPORT_CHARS = 2000;
|
|
44
47
|
/** A working child that sends no RPC event for this long is treated as hung and killed. */
|
|
45
48
|
const IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
46
49
|
/** After exit, grandchildren (MCP servers, shells) can hold the stdio pipes open; stop waiting for close. */
|
|
@@ -1090,6 +1093,12 @@ export class DelegateRegistry {
|
|
|
1090
1093
|
for (const record of records) this.entries.set(record.key, { record });
|
|
1091
1094
|
this.notify();
|
|
1092
1095
|
}
|
|
1096
|
+
/** Key/state of every record in insertion order: a cheap change test for per-tick listeners (no sweep, sort or copies). */
|
|
1097
|
+
stateSignature(): string {
|
|
1098
|
+
let signature = "";
|
|
1099
|
+
for (const [key, { record }] of this.entries) signature += key + ":" + record.run.state + " ";
|
|
1100
|
+
return signature;
|
|
1101
|
+
}
|
|
1093
1102
|
notify(): void {
|
|
1094
1103
|
this.sweep();
|
|
1095
1104
|
for (const listener of this.listeners) {
|
|
@@ -1124,7 +1133,7 @@ const fileList = (files: readonly string[], limit: number) => files.slice(0, lim
|
|
|
1124
1133
|
const todoLine = (todo: Todo) => `${todo.parentId ? " " : " "}${todo.status === "completed" ? "[x]" : todo.status === "in_progress" ? "[~]" : "[ ]"} ${todo.title}`;
|
|
1125
1134
|
|
|
1126
1135
|
/** What the parent agent reads back: the run's facts (time, tools, files, tasks, failures), then its own report. */
|
|
1127
|
-
export function runReport(run: DelegateRun, now = Date.now()): string {
|
|
1136
|
+
export function runReport(run: DelegateRun, now = Date.now(), outputLimit = MAX_OUTPUT_CHARS, key?: string): string {
|
|
1128
1137
|
const counts = Object.entries(run.toolCounts).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name}×${count}`).join(", ");
|
|
1129
1138
|
const facts = [
|
|
1130
1139
|
run.startedAt !== undefined ? `took ${duration((run.endedAt ?? now) - run.startedAt)}` : "never started",
|
|
@@ -1143,7 +1152,10 @@ export function runReport(run: DelegateRun, now = Date.now()): string {
|
|
|
1143
1152
|
}
|
|
1144
1153
|
const failures = run.transcript.filter((entry): entry is ToolEntry => entry.kind === "tool" && entry.status === "error").slice(-5);
|
|
1145
1154
|
if (failures.length) lines.push(`- failed tool calls: ${failures.map((entry) => cleanText(`${entry.name} ${entry.hint}`, 80)).join("; ")}`);
|
|
1146
|
-
|
|
1155
|
+
const output = run.output.length > outputLimit
|
|
1156
|
+
? run.output.slice(0, outputLimit) + `\n… [report truncated; jar_subagent report ${key ?? run.name} returns the full text]`
|
|
1157
|
+
: run.output;
|
|
1158
|
+
lines.push("", output || "(no report)");
|
|
1147
1159
|
return lines.join("\n");
|
|
1148
1160
|
}
|
|
1149
1161
|
|
|
@@ -1258,6 +1270,9 @@ const peekRecord = (record: SubagentRecord): string => {
|
|
|
1258
1270
|
const settledRecord = (record: SubagentRecord): string =>
|
|
1259
1271
|
peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
|
|
1260
1272
|
|
|
1273
|
+
/** One queued moderator notification; several coalesce into a single pi-jar.subagent message. */
|
|
1274
|
+
interface SubagentEvent { record: SubagentRecord; kind: "turn" | "control"; label: string; content: string; detail: Record<string, unknown> }
|
|
1275
|
+
|
|
1261
1276
|
/**
|
|
1262
1277
|
* Every active worktree agent may still end up needing recovery, so admission reserves a recovery slot for
|
|
1263
1278
|
* it: unresolved recovery workspaces never exceed RECOVERY_LIMIT, and none is ever deleted to make room.
|
|
@@ -1336,29 +1351,58 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1336
1351
|
(raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
|
|
1337
1352
|
});
|
|
1338
1353
|
|
|
1339
|
-
/** Native RPC drives registry state
|
|
1354
|
+
/** Native RPC drives registry state. Completion events wait in our own queue, never Pi's irrevocable
|
|
1355
|
+
* follow-up queue: while the moderator runs they join the next turn boundary so its final answer already
|
|
1356
|
+
* reflects them, instead of re-waking it after that answer. Only events arriving while idle wake a turn. */
|
|
1340
1357
|
const eventBusAvailable = typeof pi.sendMessage === "function";
|
|
1341
1358
|
const watched = new Map<string, SubagentRecord>();
|
|
1342
|
-
const
|
|
1359
|
+
const pendingEvents = new Map<string, SubagentEvent>();
|
|
1343
1360
|
const deleteSettled = (key: string) => {
|
|
1344
|
-
for (const [
|
|
1361
|
+
for (const [id, event] of pendingEvents) if (event.kind === "turn" && event.record.key === key) pendingEvents.delete(id);
|
|
1345
1362
|
};
|
|
1346
|
-
let
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
} catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
|
|
1363
|
+
let moderatorActive = false;
|
|
1364
|
+
let moderatorInterrupted = false;
|
|
1365
|
+
let wakeTimer: NodeJS.Timeout | undefined;
|
|
1366
|
+
const takeEvents = () => {
|
|
1367
|
+
clearTimeout(wakeTimer);
|
|
1368
|
+
wakeTimer = undefined;
|
|
1369
|
+
const events = [...pendingEvents.values()].filter(({ record }) => registry.get(record.key) === record);
|
|
1370
|
+
pendingEvents.clear();
|
|
1371
|
+
if (!events.length) return;
|
|
1372
|
+
const summary = cleanText(`Subagents · ${events.map(event => event.label).join(" · ")}`, 160);
|
|
1373
|
+
return {
|
|
1374
|
+
customType: SUBAGENT_MESSAGE, display: true, details: { summary, events: events.map(event => event.detail) },
|
|
1375
|
+
content: [`[PI-JAR SUBAGENT] ${summary}`, ...events.map(event => event.content), "Review, then resume, ask or stop retained agents as needed."].join("\n\n")
|
|
1376
|
+
};
|
|
1361
1377
|
};
|
|
1378
|
+
const scheduleWake = () => {
|
|
1379
|
+
if (moderatorActive || moderatorInterrupted || wakeTimer || !pendingEvents.size) return;
|
|
1380
|
+
wakeTimer = setTimeout(() => {
|
|
1381
|
+
wakeTimer = undefined;
|
|
1382
|
+
if (moderatorActive || moderatorInterrupted) return;
|
|
1383
|
+
const message = takeEvents();
|
|
1384
|
+
if (!message) return;
|
|
1385
|
+
try { pi.sendMessage?.(message, { triggerTurn: true }); }
|
|
1386
|
+
catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
|
|
1387
|
+
}, NOTIFY_COALESCE_MS);
|
|
1388
|
+
wakeTimer.unref?.();
|
|
1389
|
+
};
|
|
1390
|
+
const queueEvent = (id: string, event: SubagentEvent) => { pendingEvents.set(id, event); scheduleWake(); };
|
|
1391
|
+
const boundary = (event: { outcome?: string; context?: { canContinue?: boolean } }) => {
|
|
1392
|
+
if (event.outcome !== "completed" || event.context?.canContinue === false) return;
|
|
1393
|
+
const message = takeEvents();
|
|
1394
|
+
if (message) return { entries: [{ type: "custom_message" as const, ...message }], continue: true };
|
|
1395
|
+
};
|
|
1396
|
+
pi.registerMessageRenderer?.<{ summary?: string }>(SUBAGENT_MESSAGE, (message, { expanded }, theme) => {
|
|
1397
|
+
const text = typeof message.content === "string" ? message.content : "Subagents";
|
|
1398
|
+
const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Subagents";
|
|
1399
|
+
return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for report"), 0, 0);
|
|
1400
|
+
});
|
|
1401
|
+
pi.on?.("agent_start", () => { moderatorActive = true; moderatorInterrupted = false; clearTimeout(wakeTimer); wakeTimer = undefined; });
|
|
1402
|
+
pi.on?.("turn_end", boundary);
|
|
1403
|
+
pi.on?.("agent_before_settle", (event) => { moderatorInterrupted = event.outcome !== "completed"; return boundary(event); });
|
|
1404
|
+
// An interrupted run keeps its events for the user's next prompt rather than auto-waking.
|
|
1405
|
+
pi.on?.("agent_settled", () => { moderatorActive = false; scheduleWake(); });
|
|
1362
1406
|
registry.subscribe(() => {
|
|
1363
1407
|
if (!watched.size) return;
|
|
1364
1408
|
for (const [key, record] of watched) {
|
|
@@ -1367,14 +1411,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1367
1411
|
watched.delete(key);
|
|
1368
1412
|
if (record.run.state !== "stopped") {
|
|
1369
1413
|
// Freeze reports at the settled boundary. A fast resume must not overwrite a queued
|
|
1370
|
-
// initial report, and multiple turns
|
|
1371
|
-
|
|
1414
|
+
// initial report, and multiple turns before delivery must each be delivered.
|
|
1415
|
+
queueEvent(`${key}:${record.run.resumes}`, { record, kind: "turn", label: `${record.run.name} turn ended`,
|
|
1416
|
+
content: settledRecord(record), detail: { key, turn: record.run.resumes } });
|
|
1372
1417
|
}
|
|
1373
1418
|
}
|
|
1374
|
-
if (settledQueue.size && !notifyTimer) {
|
|
1375
|
-
notifyTimer = setTimeout(flushSettled, NOTIFY_COALESCE_MS);
|
|
1376
|
-
notifyTimer.unref?.();
|
|
1377
|
-
}
|
|
1378
1419
|
});
|
|
1379
1420
|
|
|
1380
1421
|
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
@@ -1780,7 +1821,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1780
1821
|
flushUpdate();
|
|
1781
1822
|
toolReturned = true;
|
|
1782
1823
|
}
|
|
1783
|
-
|
|
1824
|
+
const outputLimit = Math.max(MIN_RUN_REPORT_CHARS, Math.floor(MAX_RESULT_REPORT_CHARS / Math.max(1, runs.length)));
|
|
1825
|
+
const keys = new Map(records.map(({ key, run }) => [run, key]));
|
|
1826
|
+
return { content: [{ type: "text", text: runs.map((run) => runReport(run, Date.now(), outputLimit, keys.get(run))).join("\n\n") }], details: details(),
|
|
1784
1827
|
...(runs.some((run) => run.state === "failed") ? { isError: true } : {}) };
|
|
1785
1828
|
} finally { releaseReservation(); }
|
|
1786
1829
|
},
|
|
@@ -1874,13 +1917,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1874
1917
|
const deliverControl = (record: SubagentRecord, operation: { id: string; action: string }, result: { content: Array<{ text: string }>; details?: unknown; isError?: boolean }) => {
|
|
1875
1918
|
// A session switch discards the old fleet. Never wake a new session with stale handoffs.
|
|
1876
1919
|
if (registry.get(record.key) !== record) return;
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
} catch (error) { console.error("pi-jar: could not deliver a control event", error); }
|
|
1920
|
+
const status = result.isError ? "failed" : "completed";
|
|
1921
|
+
// The stop handoff supersedes any queued turn report for the same agent.
|
|
1922
|
+
if (operation.action === "stop") deleteSettled(record.key);
|
|
1923
|
+
queueEvent(operation.id, { record, kind: "control", label: `${operation.action} ${record.run.name} ${status}`,
|
|
1924
|
+
content: `${operation.action} ${operation.id} ${status}:\n${result.content.map(part => part.text).join("\n")}`,
|
|
1925
|
+
detail: { operationId: operation.id, action: operation.action, key: record.key, status, report: result.details } });
|
|
1884
1926
|
};
|
|
1885
1927
|
|
|
1886
1928
|
pi.registerTool({
|
|
@@ -1889,7 +1931,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1889
1931
|
description: "Non-blocking moderator control for retained subagents. peek, steer, resume and discard return immediately. ask, pause and stop return an accepted operation receipt; the final answer or handoff arrives as a pi-jar.subagent event. Settled agents hibernate (process closed, context and workspace kept); resume and ask relaunch them transparently. Stop completion safely reconciles worktree changes; acceptance does not mean changes are applied yet. discard removes an unresolved worktree recovery workspace without applying it.",
|
|
1890
1932
|
promptSnippet: "Use jar_subagent as the moderator control plane. Prefer peek over rereading transcripts; reuse paused/idle/hibernated agents with resume; stop finished workers to reconcile their work.",
|
|
1891
1933
|
promptGuidelines: [
|
|
1892
|
-
"peek returns only task progress, plus workspace and changed files for unresolved worktree recovery records. Do not poll: initial/resumed turns and control completions send pi-jar.subagent events. Stop returns an accepted receipt first, then changed files and the full handoff in its completion event.",
|
|
1934
|
+
"peek returns only task progress, plus workspace and changed files for unresolved worktree recovery records. report returns an agent's latest full report when a result or event shows only part of it. Do not poll: initial/resumed turns and control completions send pi-jar.subagent events. Stop returns an accepted receipt first, then changed files and the full handoff in its completion event.",
|
|
1893
1935
|
"Use steer for active direction and ask for a brief BTW question. ask, pause and stop do not wait on the child; continue responding to the user until their completion event arrives.",
|
|
1894
1936
|
"Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement; a hibernated agent resumes exactly like an idle one.",
|
|
1895
1937
|
"Stop when an agent is no longer needed. Stop aborts any active turn, retires the process, reports progress/workspace/changes/remaining work, and reconciles safe worktree changes into /diff. On a failed/conflicted worktree recovery record, stop retries reconciliation; discard it only when its changes are not wanted."
|
|
@@ -1897,7 +1939,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1897
1939
|
parameters: Type.Object({
|
|
1898
1940
|
action: Type.Union([
|
|
1899
1941
|
Type.Literal("peek"), Type.Literal("steer"), Type.Literal("ask"),
|
|
1900
|
-
Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop"), Type.Literal("discard")
|
|
1942
|
+
Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop"), Type.Literal("discard"), Type.Literal("report")
|
|
1901
1943
|
]),
|
|
1902
1944
|
agent: Type.Optional(Type.String({ description: "Subagent key or unique name. Omit only for peek to show all retained agents." })),
|
|
1903
1945
|
message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
|
|
@@ -1914,6 +1956,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1914
1956
|
const agent = params.agent ?? "";
|
|
1915
1957
|
const record = registry.resolve(agent);
|
|
1916
1958
|
if (!record) return { content: [{ type: "text", text: "Subagent not found or name is ambiguous: " + agent }], isError: true };
|
|
1959
|
+
if (params.action === "report") return { content: [{ type: "text", text: runReport(record.run) }] };
|
|
1917
1960
|
const stopping = pendingControls.get(record.key + ":stop");
|
|
1918
1961
|
const controlling = pendingControls.get(record.key + ":control");
|
|
1919
1962
|
if (stopping || (controlling && params.action !== "stop")) {
|
package/src/goal-loop.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Goal, GoalStore } from "./goals.ts";
|
|
|
5
5
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
6
6
|
import { isSafePlanCommand } from "./plan-utils.ts";
|
|
7
7
|
import type { Todo, TodoStore } from "./tasks.ts";
|
|
8
|
+
import { projectWorkflowMessages } from "./workflow-context.ts";
|
|
8
9
|
|
|
9
10
|
export const GOAL_TOOL = "jar_goal";
|
|
10
11
|
export const DEFAULT_GOAL_ROUNDS = 8;
|
|
@@ -267,31 +268,16 @@ export class GoalLoop {
|
|
|
267
268
|
return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
|
|
268
269
|
});
|
|
269
270
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
271
|
+
// Never drop an already-sent goal prompt mid-history while the goal runs: that would re-bill the
|
|
272
|
+
// previous run uncached. Repeated identical context is sent once; a finished goal drops all.
|
|
272
273
|
this.pi.on("context", async (event) => {
|
|
273
274
|
const goal = this.goal();
|
|
274
275
|
const active = !!goal && goal.status !== "complete";
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (type === CONTEXT_TYPE) {
|
|
281
|
-
if (!active || latestContext >= 0) needsPrune = true;
|
|
282
|
-
else latestContext = index;
|
|
283
|
-
} else if (type === CONTINUATION_TYPE) {
|
|
284
|
-
if (!active || latestContinuation >= 0) needsPrune = true;
|
|
285
|
-
else latestContinuation = index;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
if (!needsPrune) return;
|
|
289
|
-
return { messages: event.messages.filter((raw, index) => {
|
|
290
|
-
const type = (raw as { customType?: string }).customType;
|
|
291
|
-
if (type === CONTEXT_TYPE) return active && index === latestContext;
|
|
292
|
-
if (type === CONTINUATION_TYPE) return active && index === latestContinuation;
|
|
293
|
-
return true;
|
|
294
|
-
}) };
|
|
276
|
+
const messages = projectWorkflowMessages(event.messages, {
|
|
277
|
+
[CONTEXT_TYPE]: { keep: active, dedupe: true },
|
|
278
|
+
[CONTINUATION_TYPE]: { keep: active }
|
|
279
|
+
});
|
|
280
|
+
if (messages) return { messages };
|
|
295
281
|
});
|
|
296
282
|
|
|
297
283
|
this.pi.on("agent_before_settle", async (event, ctx) => {
|
package/src/perf.ts
CHANGED
|
@@ -185,6 +185,9 @@ export class RenderScheduler {
|
|
|
185
185
|
|
|
186
186
|
/** Mid-turn context recomputation happens at most this often; boundaries refresh at once. */
|
|
187
187
|
export const CONTEXT_SAMPLE_MS = 750;
|
|
188
|
+
/** Costly samples widen the window to keep mid-turn sampling near a 1% duty cycle, up to this cap. */
|
|
189
|
+
export const CONTEXT_SAMPLE_MAX_MS = 5000;
|
|
190
|
+
const CONTEXT_SAMPLE_DUTY = 100;
|
|
188
191
|
export interface ContextUsageLike { percent?: number | null }
|
|
189
192
|
export interface ContextSamplingStats {
|
|
190
193
|
label: string;
|
|
@@ -197,6 +200,8 @@ export interface ContextSamplingStats {
|
|
|
197
200
|
/** Median over the latest samples. */
|
|
198
201
|
medianMs?: number;
|
|
199
202
|
lastSampleAgeMs?: number;
|
|
203
|
+
/** Current mid-turn throttle window, widened from the median sample cost. */
|
|
204
|
+
windowMs: number;
|
|
200
205
|
dirty: boolean;
|
|
201
206
|
pending: boolean;
|
|
202
207
|
}
|
|
@@ -253,20 +258,31 @@ export class ContextSampler {
|
|
|
253
258
|
const before = this.current;
|
|
254
259
|
this.sample();
|
|
255
260
|
if (this.current !== before) this.changed();
|
|
256
|
-
}, Math.max(0, (this.lastSampleAt ?? Number.NEGATIVE_INFINITY) + this.
|
|
261
|
+
}, Math.max(0, (this.lastSampleAt ?? Number.NEGATIVE_INFINITY) + this.windowMs() - this.now()));
|
|
257
262
|
this.timer.unref?.();
|
|
258
263
|
}
|
|
259
264
|
|
|
260
265
|
stats(): ContextSamplingStats {
|
|
261
|
-
const
|
|
266
|
+
const median = this.medianMs();
|
|
262
267
|
return {
|
|
263
268
|
label: this.current, ...this.counts,
|
|
264
|
-
...(
|
|
269
|
+
...(median !== undefined ? { medianMs: median } : {}),
|
|
265
270
|
...(this.lastSampleAt !== undefined ? { lastSampleAgeMs: this.now() - this.lastSampleAt } : {}),
|
|
266
|
-
dirty: this.stale, pending: this.timer !== undefined
|
|
271
|
+
windowMs: this.windowMs(), dirty: this.stale, pending: this.timer !== undefined
|
|
267
272
|
};
|
|
268
273
|
}
|
|
269
274
|
|
|
275
|
+
private medianMs(): number | undefined {
|
|
276
|
+
if (!this.durations.length) return undefined;
|
|
277
|
+
return [...this.durations].sort((a, b) => a - b)[Math.floor(this.durations.length / 2)]!;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Cost-adaptive: large sessions whose usage walk is slow sample less often mid-turn. */
|
|
281
|
+
private windowMs(): number {
|
|
282
|
+
const cost = (this.medianMs() ?? 0) * CONTEXT_SAMPLE_DUTY;
|
|
283
|
+
return Math.max(this.throttleMs, Math.min(CONTEXT_SAMPLE_MAX_MS, Math.round(cost)));
|
|
284
|
+
}
|
|
285
|
+
|
|
270
286
|
/** Session shutdown: no timer, and no reference to the old session's context. */
|
|
271
287
|
dispose(): void {
|
|
272
288
|
clearTimeout(this.timer);
|
|
@@ -386,6 +402,7 @@ export function formatPerf(snapshot: PerfSnapshot): string {
|
|
|
386
402
|
["dirty marks", count(sampling.marks)],
|
|
387
403
|
["total time", ms(sampling.totalMs)],
|
|
388
404
|
["max time", ms(sampling.maxMs) + (sampling.medianMs !== undefined ? ` (median ${ms(sampling.medianMs)})` : "")],
|
|
405
|
+
["window", ms(sampling.windowMs) + " mid-turn"],
|
|
389
406
|
["last sample age", sampling.lastSampleAgeMs !== undefined ? age(sampling.lastSampleAgeMs) : "never"],
|
|
390
407
|
["pending", sampling.pending ? "sample scheduled" : sampling.dirty ? "yes" : "no"]
|
|
391
408
|
]);
|
package/src/plan.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSte
|
|
|
11
11
|
import { cleanText } from "./status.ts";
|
|
12
12
|
import { sessionBranch } from "./session-branch.ts";
|
|
13
13
|
import { type TodoStore } from "./tasks.ts";
|
|
14
|
+
import { projectWorkflowMessages } from "./workflow-context.ts";
|
|
14
15
|
|
|
15
16
|
export const PLAN_ENTRY = "pi-jar.plan";
|
|
16
17
|
export const PLAN_SUBMIT_TOOL = "jar_plan_submit";
|
|
@@ -417,30 +418,14 @@ export class PlanMode {
|
|
|
417
418
|
return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
|
|
418
419
|
});
|
|
419
420
|
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
// rewrites apply to a single LLM call, so this runs on every call.
|
|
421
|
+
// Plan context is identical every turn: send its first copy only, keeping the cached prefix
|
|
422
|
+
// stable. Reminders stay in place while active; leaving plan mode drops all of them.
|
|
423
423
|
this.pi.on("context", async (event) => {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (type === "pi-jar.plan-context") {
|
|
430
|
-
if (!this.enabled || latestContext >= 0) needsPrune = true;
|
|
431
|
-
else latestContext = index;
|
|
432
|
-
} else if (type === "pi-jar.plan-reminder") {
|
|
433
|
-
if (!this.enabled || latestReminder >= 0) needsPrune = true;
|
|
434
|
-
else latestReminder = index;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
if (!needsPrune) return;
|
|
438
|
-
return { messages: event.messages.filter((raw, index) => {
|
|
439
|
-
const type = (raw as { customType?: string }).customType;
|
|
440
|
-
if (type === "pi-jar.plan-context") return this.enabled && index === latestContext;
|
|
441
|
-
if (type === "pi-jar.plan-reminder") return this.enabled && index === latestReminder;
|
|
442
|
-
return true;
|
|
443
|
-
}) };
|
|
424
|
+
const messages = projectWorkflowMessages(event.messages, {
|
|
425
|
+
"pi-jar.plan-context": { keep: this.enabled, dedupe: true },
|
|
426
|
+
"pi-jar.plan-reminder": { keep: this.enabled }
|
|
427
|
+
});
|
|
428
|
+
if (messages) return { messages };
|
|
444
429
|
});
|
|
445
430
|
|
|
446
431
|
this.pi.on("input", (event) => {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
3
|
+
|
|
4
|
+
export type ProfileAction = "create" | "switch";
|
|
5
|
+
|
|
6
|
+
export interface ProfileUiOptions {
|
|
7
|
+
store: ProfileStore;
|
|
8
|
+
themes: readonly string[];
|
|
9
|
+
/** Copies the active profile's settings and roles into a new profile. */
|
|
10
|
+
create(name: string, theme: string): JarProfile;
|
|
11
|
+
/** Why the current session cannot switch profiles, or undefined when it can. */
|
|
12
|
+
switchBlocked(): string | undefined;
|
|
13
|
+
select(name: string): Promise<void>;
|
|
14
|
+
openRoles(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CREATE = "+ Create profile…";
|
|
18
|
+
const FOLLOW = "Follow Pi";
|
|
19
|
+
|
|
20
|
+
/** Name and theme are the only creation questions; roles are configured afterwards in the roles UI. */
|
|
21
|
+
export async function promptNewProfile(ctx: ExtensionContext, options: Pick<ProfileUiOptions, "store" | "themes" | "create">): Promise<JarProfile | undefined> {
|
|
22
|
+
const name = await ctx.ui.input("New profile name (1–32 characters)", "");
|
|
23
|
+
if (name === undefined) return undefined;
|
|
24
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
|
|
25
|
+
const normalized = name.trim().replace(/\s+/g, " ");
|
|
26
|
+
if (!normalized || normalized.length > 32 || /[\\/\0]/.test(normalized)) {
|
|
27
|
+
ctx.ui.notify("Profile name must be 1–32 characters and cannot contain slashes", "warning"); return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (options.store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
|
|
30
|
+
ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
|
|
31
|
+
}
|
|
32
|
+
const selected = await ctx.ui.select("Choose a theme for " + normalized, [FOLLOW, ...options.themes.filter((item) => item !== "follow")]);
|
|
33
|
+
if (!selected) return undefined;
|
|
34
|
+
try { return options.create(normalized, selected === FOLLOW ? "follow" : selected); }
|
|
35
|
+
catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** /profiles and the settings Profiles tab: create a profile or switch to one, nothing else. */
|
|
39
|
+
export async function openProfiles(ctx: ExtensionContext, options: ProfileUiOptions, action?: ProfileAction): Promise<void> {
|
|
40
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
41
|
+
const active = options.store.activeName;
|
|
42
|
+
if (action !== "create") {
|
|
43
|
+
const labels = new Map(options.store.list().map((profile) => [profile.name === active ? `${profile.name} (active)` : profile.name, profile.name]));
|
|
44
|
+
const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, ...labels.keys()]);
|
|
45
|
+
if (!choice) return;
|
|
46
|
+
if (choice !== CREATE) { await options.select(labels.get(choice)!); return; }
|
|
47
|
+
}
|
|
48
|
+
const profile = await promptNewProfile(ctx, options);
|
|
49
|
+
if (!profile) return;
|
|
50
|
+
if (options.switchBlocked()) {
|
|
51
|
+
ctx.ui.notify(`Created profile ${profile.name} with ${active}'s model roles. Switch to it in a fresh session with /profiles, then set its roles with /roles.`, "info");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await options.select(profile.name);
|
|
55
|
+
if (options.store.activeName !== profile.name) return;
|
|
56
|
+
ctx.ui.notify(`Created profile ${profile.name}. Its model roles start as a copy of ${active}'s; set them now in Roles (later: /roles).`, "info");
|
|
57
|
+
await options.openRoles();
|
|
58
|
+
}
|
package/src/profiles.ts
CHANGED
|
@@ -16,7 +16,8 @@ export function conversationStarted(entries: readonly unknown[]): boolean {
|
|
|
16
16
|
|| (entry.type === "message" && isPlainObject(entry.message) && entry.message.role === "user")));
|
|
17
17
|
}
|
|
18
18
|
export function pinnedProfileId(entries: readonly unknown[]): string | undefined {
|
|
19
|
-
for (
|
|
19
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
20
|
+
const entry = entries[index];
|
|
20
21
|
if (isPlainObject(entry) && entry.type === "custom" && entry.customType === PROFILE_ENTRY
|
|
21
22
|
&& isPlainObject(entry.data) && entry.data.version === 1 && typeof entry.data.id === "string") return entry.data.id;
|
|
22
23
|
}
|
package/src/settings-ui.ts
CHANGED
|
@@ -3,9 +3,7 @@ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } fr
|
|
|
3
3
|
import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
|
|
4
4
|
import { ICON_SETS } from "./icons.ts";
|
|
5
5
|
import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
6
|
-
import {
|
|
7
|
-
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
8
|
-
import type { RoleConfig, ModelRoleManager } from "./model-roles.ts";
|
|
6
|
+
import type { ProfileAction } from "./profile-ui.ts";
|
|
9
7
|
|
|
10
8
|
const LABELS: Record<FooterField, string> = {
|
|
11
9
|
model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
|
|
@@ -29,13 +27,13 @@ export async function openJarSettings(
|
|
|
29
27
|
update: (settings: JarVisualSettings) => void,
|
|
30
28
|
availableAccents: readonly string[],
|
|
31
29
|
pi?: PiPreferences,
|
|
32
|
-
profiles?: {
|
|
30
|
+
profiles?: { activeName(): string; open(action: ProfileAction): Promise<void> }
|
|
33
31
|
): Promise<void> {
|
|
34
32
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
35
33
|
const PAGES: Page[] = profiles ? ["appearance", "footer", "pi", "profiles"] : ["appearance", "footer", "pi"];
|
|
36
34
|
let lastPage: Page = "appearance";
|
|
37
35
|
while (true) {
|
|
38
|
-
const action = await ctx.ui.custom<
|
|
36
|
+
const action = await ctx.ui.custom<ProfileAction | undefined>((tui, theme, _keys, done) => {
|
|
39
37
|
let page: Page = lastPage;
|
|
40
38
|
let selected = 0;
|
|
41
39
|
let width = 64;
|
|
@@ -43,7 +41,7 @@ export async function openJarSettings(
|
|
|
43
41
|
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
44
42
|
let choosing: "goalRounds" | "maxSubagents" | undefined;
|
|
45
43
|
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
|
|
46
|
-
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 7 : page === "profiles" ?
|
|
44
|
+
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 7 : page === "profiles" ? 2 : 7;
|
|
47
45
|
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
|
|
48
46
|
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
49
47
|
const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
|
|
@@ -58,7 +56,7 @@ export async function openJarSettings(
|
|
|
58
56
|
finishChoice();
|
|
59
57
|
} else if (page === "profiles") {
|
|
60
58
|
lastPage = page;
|
|
61
|
-
done(index === 0 ? "switch" :
|
|
59
|
+
done(index === 0 ? "switch" : "create");
|
|
62
60
|
return;
|
|
63
61
|
} else if (page === "footer") {
|
|
64
62
|
const field = FOOTER_FIELDS[index];
|
|
@@ -165,9 +163,8 @@ export async function openJarSettings(
|
|
|
165
163
|
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
|
|
166
164
|
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet)
|
|
167
165
|
] : page === "profiles" ? [
|
|
168
|
-
row(0, "
|
|
169
|
-
row(1, "
|
|
170
|
-
row(2, "Create profile…", "OPEN", true)
|
|
166
|
+
row(0, "Switch profile…", profiles!.activeName(), true),
|
|
167
|
+
row(1, "Create profile…", "OPEN", true)
|
|
171
168
|
] : [
|
|
172
169
|
row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
|
|
173
170
|
row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
|
|
@@ -186,7 +183,7 @@ export async function openJarSettings(
|
|
|
186
183
|
: choosing ? " Select a value; Enter/click to save, Esc to cancel"
|
|
187
184
|
: page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
188
185
|
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
|
|
189
|
-
: page === "profiles" ? " Switch only in a fresh, idle session" : " Accent cycles through loaded themes")),
|
|
186
|
+
: page === "profiles" ? " Switch only in a fresh, idle session; roles via /roles" : " Accent cycles through loaded themes")),
|
|
190
187
|
...rows,
|
|
191
188
|
line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
|
|
192
189
|
: " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
|
|
@@ -196,16 +193,9 @@ export async function openJarSettings(
|
|
|
196
193
|
};
|
|
197
194
|
});
|
|
198
195
|
if (!action || !profiles) return;
|
|
199
|
-
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const name = await ctx.ui.select("Choose a profile", profiles.store.list().map((profile) => profile.name));
|
|
204
|
-
if (name) await profiles.select(name);
|
|
205
|
-
} else {
|
|
206
|
-
const name = await ctx.ui.select("Profile theme", ["Follow Pi", ...profiles.themes.filter((name) => name !== "follow")]);
|
|
207
|
-
if (name) profiles.setTheme(name === "Follow Pi" ? "follow" : name);
|
|
208
|
-
}
|
|
209
|
-
} catch (error) { ctx.ui.notify("Could not change profile: " + (error as Error).message, "error"); }
|
|
196
|
+
try { await profiles.open(action); }
|
|
197
|
+
catch (error) { ctx.ui.notify("Could not change profile: " + (error as Error).message, "error"); }
|
|
198
|
+
// A new profile hands off to the roles UI; reopening settings over it would bury that step.
|
|
199
|
+
if (action === "create") return;
|
|
210
200
|
}
|
|
211
201
|
}
|
package/src/side-model.ts
CHANGED
|
@@ -26,6 +26,19 @@ export class SideUsage {
|
|
|
26
26
|
|
|
27
27
|
export interface SideAnswer { text: string; model: string }
|
|
28
28
|
|
|
29
|
+
/** Roles already told, this process, that they ran on the current (main) model. */
|
|
30
|
+
const unsetRoleNotices = new Set<string>();
|
|
31
|
+
|
|
32
|
+
/** An unassigned side role silently costs main-model prices; say so once per role. */
|
|
33
|
+
function noteCurrentModel(ctx: ExtensionContext, role: string, model: string): void {
|
|
34
|
+
if (unsetRoleNotices.has(role)) return;
|
|
35
|
+
unsetRoleNotices.add(role);
|
|
36
|
+
try {
|
|
37
|
+
ctx.ui?.notify(`${role} role is unset, so it used the current model (${model}). `
|
|
38
|
+
+ `Assign a cheaper one with /roles set ${role} PROVIDER/MODEL.`, "info");
|
|
39
|
+
} catch { /* the notice is advisory */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/**
|
|
30
43
|
* Ask the model assigned to `role` (or the current model) a one-shot question outside the
|
|
31
44
|
* conversation. Tries configured fallbacks in order on failure; cancellation never retries.
|
|
@@ -71,6 +84,7 @@ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, us
|
|
|
71
84
|
}
|
|
72
85
|
text = text.trim();
|
|
73
86
|
if (!text) throw new Error(`${name} returned an empty answer`);
|
|
87
|
+
if (!assigned) noteCurrentModel(ctx, role, name);
|
|
74
88
|
return { text, model: name };
|
|
75
89
|
} catch (error) {
|
|
76
90
|
signal?.throwIfAborted();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** How one hidden workflow message type is projected into provider context. */
|
|
2
|
+
export interface WorkflowMessageRule {
|
|
3
|
+
/** False drops every message of this type (its mode is off). */
|
|
4
|
+
keep: boolean;
|
|
5
|
+
/** Drop a message whose content repeats the previously kept one of the same type. */
|
|
6
|
+
dedupe?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Provider-context projection for hidden workflow prompts (plan/goal context, reminders,
|
|
11
|
+
* continuations). Pi caches the conversation prefix, so a message once sent must keep being sent:
|
|
12
|
+
* every keep/drop decision depends only on the messages before it, never on later ones. Dropping
|
|
13
|
+
* an older copy when a newer one arrives would change the prefix and re-bill the whole previous
|
|
14
|
+
* run uncached. Returns undefined when nothing changes.
|
|
15
|
+
*/
|
|
16
|
+
export function projectWorkflowMessages<T>(messages: T[], rules: Readonly<Record<string, WorkflowMessageRule>>): T[] | undefined {
|
|
17
|
+
const last = new Map<string, string>();
|
|
18
|
+
let projected: T[] | undefined;
|
|
19
|
+
for (let index = 0; index < messages.length; index++) {
|
|
20
|
+
const message = messages[index]!;
|
|
21
|
+
const type = (message as { customType?: string }).customType;
|
|
22
|
+
const rule = type === undefined ? undefined : rules[type];
|
|
23
|
+
let keep = true;
|
|
24
|
+
if (rule) {
|
|
25
|
+
if (!rule.keep) keep = false;
|
|
26
|
+
else if (rule.dedupe) {
|
|
27
|
+
const raw = (message as { content?: unknown }).content;
|
|
28
|
+
const content = typeof raw === "string" ? raw : JSON.stringify(raw ?? "");
|
|
29
|
+
if (last.get(type!) === content) keep = false;
|
|
30
|
+
else last.set(type!, content);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!keep) projected ??= messages.slice(0, index);
|
|
34
|
+
else projected?.push(message);
|
|
35
|
+
}
|
|
36
|
+
return projected;
|
|
37
|
+
}
|
package/src/profile-wizard.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { JarVisualSettings } from "./settings.ts";
|
|
3
|
-
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
4
|
-
import { isRoleName, normalizeSpec, type RoleConfig, type ModelRoleManager } from "./model-roles.ts";
|
|
5
|
-
|
|
6
|
-
export interface ProfileWizardOptions {
|
|
7
|
-
themes: readonly string[];
|
|
8
|
-
settings: JarVisualSettings;
|
|
9
|
-
roles: RoleConfig;
|
|
10
|
-
roleManager?: ModelRoleManager;
|
|
11
|
-
create(name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig): JarProfile;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Every step is cancellable; creation happens only after the final confirmation. */
|
|
15
|
-
export async function createProfileWizard(ctx: ExtensionContext, store: ProfileStore, options: ProfileWizardOptions): Promise<JarProfile | undefined> {
|
|
16
|
-
if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
|
|
17
|
-
const name = await ctx.ui.input("New profile name (1–32 characters)", "");
|
|
18
|
-
if (name === undefined) return undefined;
|
|
19
|
-
if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
|
|
20
|
-
const normalized = name.trim().replace(/\s+/g, " ");
|
|
21
|
-
if (!normalized || normalized.length > 32 || /[\\/\0]/.test(normalized)) {
|
|
22
|
-
ctx.ui.notify("Profile name must be 1–32 characters and cannot contain slashes", "warning"); return undefined;
|
|
23
|
-
}
|
|
24
|
-
if (store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
|
|
25
|
-
ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
|
|
26
|
-
}
|
|
27
|
-
if (!options.themes.length) { ctx.ui.notify("No themes are available for profiles", "warning"); return undefined; }
|
|
28
|
-
const selectedTheme = await ctx.ui.select("Choose a theme for " + normalized, ["Follow Pi", ...options.themes.filter((item) => item !== "follow")]);
|
|
29
|
-
if (!selectedTheme) return undefined;
|
|
30
|
-
const theme = selectedTheme === "Follow Pi" ? "follow" : selectedTheme;
|
|
31
|
-
const roles = structuredClone(options.roles);
|
|
32
|
-
const roleNames = options.roleManager?.list().map((role) => role.role) ?? [];
|
|
33
|
-
if (options.roleManager) {
|
|
34
|
-
for (const role of roleNames) {
|
|
35
|
-
const current = roles.roles[role];
|
|
36
|
-
const answer = await ctx.ui.input(`${role} role assignment (blank keeps current${current ? `: ${current}` : ""}; none clears)`, "");
|
|
37
|
-
if (answer === undefined) return undefined;
|
|
38
|
-
if (!answer.trim()) continue;
|
|
39
|
-
if (answer.trim().toLowerCase() === "none") { delete roles.roles[role]; continue; }
|
|
40
|
-
const model = normalizeSpec(answer.trim());
|
|
41
|
-
if (!model) { ctx.ui.notify("Enter a valid provider/model, @role, *, or none for " + role, "warning"); return undefined; }
|
|
42
|
-
roles.roles[role] = model;
|
|
43
|
-
}
|
|
44
|
-
} else {
|
|
45
|
-
const roleInput = await ctx.ui.input("Role assignments as role=provider/model, comma-separated (optional)", "");
|
|
46
|
-
if (roleInput === undefined) return undefined;
|
|
47
|
-
for (const assignment of roleInput.split(",").map((item) => item.trim()).filter(Boolean)) {
|
|
48
|
-
const split = assignment.indexOf("=");
|
|
49
|
-
if (split <= 0) { ctx.ui.notify("Use role=provider/model for each assignment", "warning"); return undefined; }
|
|
50
|
-
const role = assignment.slice(0, split).trim().toLowerCase();
|
|
51
|
-
const model = normalizeSpec(assignment.slice(split + 1).trim());
|
|
52
|
-
if (!isRoleName(role) || !model) { ctx.ui.notify("Invalid role assignment: " + assignment, "warning"); return undefined; }
|
|
53
|
-
roles.roles[role] = model;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const roleSummary = Object.entries(roles.roles).map(([role, target]) => `${role} → ${target}`).join("\n") || "No roles assigned";
|
|
57
|
-
const confirmed = await ctx.ui.confirm("Create profile", `${normalized}\nTheme: ${theme === "follow" ? "Follow Pi" : theme}\n${roleSummary}`);
|
|
58
|
-
if (!confirmed) return undefined;
|
|
59
|
-
try { return options.create(normalized, theme, options.settings, roles); }
|
|
60
|
-
catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
|
|
61
|
-
}
|