@tt-a1i/openpi 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -389
  3. package/SETUP.md +24 -22
  4. package/THIRD_PARTY_NOTICES.md +3 -4
  5. package/assets/readme-hero-mobile.svg +2 -2
  6. package/assets/readme-hero.svg +10 -10
  7. package/extensions/ask-user/handoff.ts +5 -1
  8. package/extensions/ask-user/index.ts +44 -0
  9. package/extensions/background-terminals/index.ts +118 -29
  10. package/extensions/background-terminals/src/domain.ts +5 -1
  11. package/extensions/background-terminals/src/manager.ts +2 -1
  12. package/extensions/background-terminals/src/prompt.ts +35 -0
  13. package/extensions/background-terminals/src/result-delivery.ts +76 -3
  14. package/extensions/background-terminals/src/ui/tool-result.ts +52 -1
  15. package/extensions/capabilities/index.ts +198 -0
  16. package/extensions/context-pivot/index.ts +21 -0
  17. package/extensions/cron/index.ts +42 -15
  18. package/extensions/execution-convergence/active-evidence.ts +129 -0
  19. package/extensions/execution-convergence/index.ts +442 -0
  20. package/extensions/execution-convergence/workspace-provenance.ts +338 -0
  21. package/extensions/file-search/index.ts +8 -1
  22. package/extensions/file-search/src/binaries.ts +2 -1
  23. package/extensions/git-info/src/runtime.ts +1 -1
  24. package/extensions/goal/controller.ts +2 -1
  25. package/extensions/goal/index.ts +20 -1
  26. package/extensions/plan-mode/index.ts +12 -0
  27. package/extensions/setup/index.ts +241 -45
  28. package/extensions/setup/intercom-fs-helper.cjs +130 -0
  29. package/extensions/setup/intercom.ts +603 -0
  30. package/extensions/shared/child-session.ts +42 -5
  31. package/extensions/shared/setup-config.ts +27 -1
  32. package/extensions/shared/setup-episode-state.ts +7 -0
  33. package/extensions/shared/tool-surface.ts +435 -0
  34. package/extensions/subagents/index.ts +16 -1
  35. package/extensions/subagents/src/manager.ts +13 -11
  36. package/extensions/subagents/src/prompt.ts +1 -1
  37. package/extensions/tasks/index.ts +39 -12
  38. package/extensions/ui-customization/footer.ts +6 -1
  39. package/extensions/workflows/artifacts.ts +6 -1
  40. package/extensions/workflows/dashboard.ts +138 -27
  41. package/extensions/workflows/graph-projection.ts +240 -0
  42. package/extensions/workflows/handoff.ts +194 -0
  43. package/extensions/workflows/index.ts +258 -56
  44. package/extensions/workflows/invocation-ledger.ts +368 -0
  45. package/extensions/workflows/model.ts +57 -1
  46. package/extensions/workflows/operator.ts +131 -0
  47. package/extensions/workflows/prompt.ts +10 -38
  48. package/extensions/workflows/replay-safety.ts +9 -8
  49. package/extensions/workflows/runner.ts +10 -2
  50. package/extensions/workflows/sandbox.ts +5 -0
  51. package/package.json +15 -15
  52. package/skills/subagents/SKILL.md +6 -0
  53. package/skills/workflows/EXAMPLES.md +58 -0
  54. package/skills/workflows/REFERENCE.md +44 -0
  55. package/skills/workflows/SKILL.md +39 -0
package/SETUP.md CHANGED
@@ -9,14 +9,14 @@ pi install npm:@tt-a1i/openpi
9
9
  To inspect the current source before loading it, install directly from GitHub instead:
10
10
 
11
11
  ```sh
12
- pi install git:github.com/tt-a1i/my-pi-setup
12
+ pi install git:github.com/tt-a1i/openpi
13
13
  ```
14
14
 
15
15
  Pi installs the package dependencies automatically. Restart Pi or run `/reload` after installation.
16
16
 
17
17
  ## fd and rg tools
18
18
 
19
- The `file-search` extension registers `fd` and `rg` as model tools. No setup is normally needed: at startup it silently uses a system-installed `fd` (or `fdfind` on Debian/Ubuntu) and `rg` when available, or an existing fallback binary in `~/.pi/agent/bin/`. Only when neither exists does it download an official release binary (macOS/Linux, arm64/x64, over HTTPS) into `~/.pi/agent/bin/` and show a one-time notification. If your platform is unsupported, install `fd` and `rg` with your package manager and restart pi.
19
+ The `file-search` extension registers `fd` and `rg` as model tools. They stay outside an ordinary parent turn until the user explicitly asks to use `fd`/`rg` or structured file search, or the model loads the `search` group through `openpi_load_tools`. The gateway is shown after an explicit OpenPI-capability request, or remains visible when the user opts into adaptive discovery; child sessions may still receive `fd` and `rg` through the reviewed child-safe allowlist. No setup is normally needed: at startup it silently uses a system-installed `fd` (or `fdfind` on Debian/Ubuntu) and `rg` when available, or an existing fallback in this package's private `bin/` directory. Only when neither exists does it download an official release binary (macOS/Linux, arm64/x64, over HTTPS) into that package-local directory and show a one-time notification. If your platform is unsupported, install `fd` and `rg` with your package manager and restart Pi.
20
20
 
21
21
  ## Theme
22
22
 
@@ -28,31 +28,33 @@ Add the included theme to `~/.pi/agent/settings.json` while keeping your existin
28
28
  }
29
29
  ```
30
30
 
31
- Pi will load the extensions, skills, and theme from their directories the next time it starts.
31
+ Pi will load the extensions and theme the next time it starts. OpenPI's Background, Subagent, and Workflow Skill files remain in the package, but ordinary turns do not advertise them in the system prompt; the matching path is disclosed only after explicit capability intent or after the model loads that group through the opt-in adaptive gateway.
32
32
 
33
33
  ## Configure this package
34
34
 
35
- Use the single package-owned command. With no arguments, the current model explains the configurable areas and their impact, then uses `ask_user`: first run initializes them; later runs explain the saved state and ask whether to keep it, change one area, or review everything. With arguments, it treats the rest as a targeted natural-language request:
35
+ Use the single canonical package-owned command. `/my-pi-setup` remains a compatibility alias. With no arguments, when optional pi-intercom is absent, the interactive TUI first offers a reviewed global installation; declining changes nothing. Acceptance installs the fixed `npm:pi-intercom` source through Pi's package manager. A new private config receives `confirmSend: true` and `inboundTrigger: "replies"`; an existing preference file is never rewritten and must already define both fields. Package download failure writes no config, while uncertain activation retains the safe new config. Setup asks for `/reload` instead of loading a new broker into the running Session. The current model then explains the remaining configurable areas and uses `ask_user`: first run initializes them; later runs explain the saved state and ask whether to keep it, change one area, or review everything. With arguments, it treats the rest as a targeted natural-language request. Persist still goes through the typed `configure_my_pi_setup` tool, which is exposed only while that `/openpi-setup` episode is in flight and is hidden again afterward. One successful apply completes the episode; a later configuration change starts a new one with `/openpi-setup <request>` rather than reusing the hidden tool:
36
36
 
37
37
  ```text
38
- /my-pi-setup
39
- /my-pi-setup 开启下一步预测,使用 seal/deepseek-v4-flash,关闭推理
40
- /my-pi-setup 关闭下一步预测
41
- /my-pi-setup workflow 同时跑 16 个 agent,总任务最多 256 个
42
- /my-pi-setup 显示大标题
43
- /my-pi-setup 切换 Footer powerline
44
- /my-pi-setup 用 mono powerline Footer
45
- /my-pi-setup Footer compact
46
- /my-pi-setup Footer 两行:cwd flex model / context cost flex git
47
- /my-pi-setup Footer 只显示 model、thinking、context、cache 和 git
48
- /my-pi-setup 关闭自定义状态栏
49
- /my-pi-setup 编辑后自动跑 npm run format
50
- /my-pi-setup 关闭 post-edit 命令
51
- /my-pi-setup explorer 指定当前 Registry 中可用的模型
52
- /my-pi-setup 清除 explorer 的模型,让它继承父模型
38
+ /openpi-setup
39
+ /openpi-setup 让模型在合适时自主发现并采用 OpenPI 能力
40
+ /openpi-setup 只在我明确要求时加载 OpenPI 能力
41
+ /openpi-setup 开启下一步预测,使用 seal/deepseek-v4-flash,关闭推理
42
+ /openpi-setup 关闭下一步预测
43
+ /openpi-setup workflow 同时跑 16 个 agent,总任务最多 256 个
44
+ /openpi-setup 显示大标题
45
+ /openpi-setup 切换 Footer powerline
46
+ /openpi-setup mono powerline Footer
47
+ /openpi-setup Footer compact
48
+ /openpi-setup Footer 两行:cwd flex model / context cost flex git
49
+ /openpi-setup Footer 只显示 model、thinking、context、cache 和 git
50
+ /openpi-setup 关闭自定义状态栏
51
+ /openpi-setup 编辑后自动跑 npm run format
52
+ /openpi-setup 关闭 post-edit 命令
53
+ /openpi-setup 给 explorer 指定当前 Registry 中可用的模型
54
+ /openpi-setup 清除 explorer 的模型,让它继承父模型
53
55
  ```
54
56
 
55
- Next-action suggestions default to off. Run `/my-pi-setup` to explicitly choose an available model and reasoning level. After a fully settled main-agent run, one suggestion may appear as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. `Right` accepts it into the editor without submitting, while any other editor input dismisses it. Suggestions are ephemeral and never enter session history or model context. Workflows default to 8 concurrent agents and 128 total agent calls per run; configurable hard maxima are 64 and 1024. The large decorative header defaults off and the custom dashboard footer defaults on with a one-line Powerline layout (`cwd model thinking context cache cost throughput |flex| git pr`). Footer presets are `powerline`, `powerline-mono`, and `compact`; style can also be set independently to `plain`, `powerline`, or `powerline-mono`. Custom layouts use a 2D `footerLines` array with at most one `flex` per row for left/right alignment. Nerd Font only affects powerline separator glyphs (``); metric text stays readable without it. Footer changes apply immediately in the active TUI session. Subagent results default to the existing full display; users who do not usually inspect implementation detail can select compact previews. Bash defaults to a folded one-line command with bounded output and a hidden-line count. Write/Edit defaults to an extra-short folded preview capped at three rendered lines including the operation header; its hidden-line hint remains inside the operation's status background. Select full independently for any category to keep it expanded. Compact views temporarily expand with `app.tools.expand` (`Ctrl+O` by default). An optional post-edit command is off by default: set one (for example `npm run format`, maximum 500 characters) and it runs once in the background after each interactive-TUI turn with successful Write/Edit operations, with failures reported as a notification. It deliberately does not guess whether arbitrary Bash commands changed files. Built-in Agent roles `explorer`, `implementer`, `reviewer`, and `advisor` are shared by `subagent_spawn.agent_type` and Workflow `agent(..., { agent_type })`; all inherit the parent model by default. `/my-pi-setup` may assign a currently available Registry model to any subset; clearing one returns it to inheritance and omitted roles stay unchanged. Model precedence is explicit call > selected role-file model > setup assignment > parent inheritance; effort is explicit call > selected role > parent. A trusted project `.pi/agents/<role>.md` overrides global `~/.pi/agent/agents/<role>.md`, which overrides the complete built-in role definition; overrides are diagnosed. Role-model changes apply to the next spawn or Workflow agent call without reload. Configuration is stored privately at `~/.pi/agent/my-pi-setup.json`.
57
+ Capability discovery defaults to `explicit`, preserving the zero-resident OpenPI tool surface until the user asks for a capability. `adaptive` is an explicit opt-in that keeps only `openpi_load_tools` visible and allows the model to load a useful group on its own; because this can start Subagents, Workflows, or background processes, normal permission and configured concurrency/call limits still apply. Changing the setting updates the current Session immediately, while already loaded groups remain stable for that Session. Next-action suggestions default to off. Run `/openpi-setup` to explicitly choose an available model and reasoning level. After a fully settled main-agent run, one suggestion may appear as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. `Right` accepts it into the editor without submitting, while any other editor input dismisses it. Suggestions are ephemeral and never enter session history or model context. Workflows default to 8 concurrent agents and 128 total agent calls per run; configurable hard maxima are 64 and 1024. The large decorative header defaults off and the custom dashboard footer defaults on with a one-line Powerline layout (`cwd model thinking context cache cost throughput |flex| git pr`). Footer presets are `powerline`, `powerline-mono`, and `compact`; style can also be set independently to `plain`, `powerline`, or `powerline-mono`. Custom layouts use a 2D `footerLines` array with at most one `flex` per row for left/right alignment. Nerd Font only affects powerline separator glyphs (``); metric text stays readable without it. Footer changes apply immediately in the active TUI session. Subagent results default to the existing full display; users who do not usually inspect implementation detail can select compact previews. Bash defaults to a folded one-line command with bounded output and a hidden-line count. Write/Edit defaults to an extra-short folded preview capped at three rendered lines including the operation header; its hidden-line hint remains inside the operation's status background. Select full independently for any category to keep it expanded. Compact views temporarily expand with `app.tools.expand` (`Ctrl+O` by default). An optional post-edit command is off by default: set one (for example `npm run format`, maximum 500 characters) and it runs once in the background after each interactive-TUI turn with successful Write/Edit operations, with failures reported as a notification. It deliberately does not guess whether arbitrary Bash commands changed files. Built-in Agent roles `explorer`, `implementer`, `reviewer`, and `advisor` are shared by `subagent_spawn.agent_type` and Workflow `agent(..., { agent_type })`; all inherit the parent model by default. `/openpi-setup` may assign a currently available Registry model to any subset; clearing one returns it to inheritance and omitted roles stay unchanged. Model precedence is explicit call > selected role-file model > setup assignment > parent inheritance; effort is explicit call > selected role > parent. A trusted project `.pi/agents/<role>.md` overrides global `~/.pi/agent/agents/<role>.md`, which overrides the complete built-in role definition; overrides are diagnosed. Role-model changes apply to the next spawn or Workflow agent call without reload. Configuration is stored privately at `~/.pi/agent/my-pi-setup.json`.
56
58
 
57
59
  ## Session Goal and Tasks
58
60
 
@@ -62,7 +64,7 @@ Model callers use `get_goal`, `create_goal`, and `update_goal`. `create_goal` is
62
64
 
63
65
  There are no normal user-facing Turn, no-progress, or wall-clock caps; a hidden 1000-continuation circuit breaker exists only to stop runaway automation. An optional `token_budget` must only be positive. Goal non-cached Assistant input-plus-output Token and elapsed-time usage are persisted; crossing the budget marks `budget_limited` and queues one wrap-up Turn. Active goals continue after reload/resume. Fork and tree navigation defer inherited active continuation until the first explicit user input; paused, blocked, and usage-limited goals remain stopped and can prompt for Resume. A v1 active/waiting goal migrates once to paused. Assistant aborts pause an active goal and Assistant errors block it. Print/json automation is inert. Footer text mirrors Codex (`Pursuing goal (…)`, resume hints, `Goal unmet`, `Goal achieved`) without showing the objective or legacy Turn counts. An achieved Footer remains visible until the next explicit interactive/RPC input, then a branch-persisted acknowledgement hides only the Footer while `/goal` retains the completed record.
64
66
 
65
- Session Tasks remain advisory multi-item work intent and do not determine Goal completion. They are scoped to the current request batch: once every item is done or dropped, the batch closes and the next `tasks_add` starts again at T1. Active items persist in a polished Claude Code-style panel above the editor; `Ctrl+Shift+T` or `/tasks hide|show|toggle` controls visibility, while `/tasks` opens the full list. No `/my-pi-setup` setting or secondary judge model is required.
67
+ Session Tasks remain advisory multi-item work intent and do not determine Goal completion. They are scoped to the current request batch: once every item is done or dropped, the batch closes and the next `tasks_add` starts again at T1. The model marks a tracked item `in_progress` before starting it, records `done`, `blocked`, or `dropped` immediately after that item reaches a real outcome, and reconciles touched items before its final answer. Every add/update result returns the complete bounded current snapshot so the next item is explicit and the panel refreshes on each persisted transition. Commit, test, and authorization signals are only task-scoped evidence candidates; OpenPI never infers completion or mutates a task from those signals. Active items persist in a polished Claude Code-style panel above the editor; `Ctrl+Shift+T` or `/tasks hide|show|toggle` controls visibility, while `/tasks` opens the full list. No `/openpi-setup` setting or secondary judge model is required.
66
68
 
67
69
  ## Other commands added by this fork
68
70
 
@@ -70,5 +72,5 @@ Session Tasks remain advisory multi-item work intent and do not determine Goal c
70
72
  - `/tasks` inspects branch-scoped advisory work items.
71
73
  - `/goal ...` controls the persistent autonomous session objective.
72
74
  - `/context-pivot <next phase>` deliberately compacts a long current session into a next-phase brief. It requires at least 30,000 context tokens and is rejected below that; use the separate `/handoff` skill when work should move to a genuinely new session.
73
- - `/cron every <5m> <prompt>`, `/cron in <30s> <prompt>`, `/cron list`, and `/cron remove <id>` schedule a prompt for this session. Jobs are in-memory and session-scoped (cleared on shutdown), fire only while the session is idle, and use a duration grammar (`30s`/`5m`/`2h`, minimum 30s) rather than crontab fields, because the scheduler polls about every 30 seconds.
75
+ - `/cron every <5m> <prompt>`, `/cron in <30s> <prompt>`, `/cron list`, and `/cron remove <id>` schedule a prompt for this session. Jobs are in-memory and session-scoped (cleared on shutdown), fire only while the session is idle, and use a duration grammar (`30s`/`5m`/`2h`, minimum 30s) rather than crontab fields, because the scheduler polls about every 30 seconds. Jobs due in the same poll are delivered as one triggered turn while retaining each job's id and recurrence metadata; if that atomic delivery fails, every due job remains pending for retry.
74
76
  - `/plan [objective]` explores read-only before changing anything: while armed it blocks `edit`, `write`, mutating Bash, `subagent_send`, `workflow`, and `bg_start`, while read/grep/find/ls/fd/rg and verified read-only Git/GitHub commands stay available. It permits `subagent_spawn`, but the harness narrows every newly spawned planning child to investigation-only tools; agent types can narrow that list further, never widen it. The model must submit the complete plan through parent-only `plan_ready`; the write gate stays closed until `/plan` prepares an editable implementation prompt for the current or a fresh Session. `/plan off` cancels.
@@ -10,7 +10,6 @@ The upstream project is distributed under the MIT License. Its notice states
10
10
  `Copyright (c) 2026`; the complete license text is included at
11
11
  [`extensions/sessions/LICENSE.upstream`](extensions/sessions/LICENSE.upstream).
12
12
 
13
- The remainder of this repository retains its upstream licensing status; no
14
- project-wide license is asserted here. The npm package therefore declares
15
- `UNLICENSED`; publication does not grant permission to copy, modify, or
16
- redistribute code beyond rights supplied by an applicable upstream license.
13
+ OpenPI is distributed under the MIT License; see [`LICENSE`](LICENSE). Portions
14
+ identified in this notice retain their original copyright notices and license
15
+ terms. The project-wide MIT license does not replace or remove those notices.
@@ -1,6 +1,6 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" width="720" height="900" viewBox="0 0 720 900" role="img" aria-labelledby="title desc">
2
2
  <title id="title">OpenPI</title>
3
- <desc id="desc">A mobile-readable overview of the Pi-native multi-agent runtime.</desc>
3
+ <desc id="desc">OpenPI small harness, deep extensions, clean context.</desc>
4
4
  <defs>
5
5
  <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
6
6
  <stop stop-color="#0d1117"/>
@@ -21,7 +21,7 @@
21
21
  <text x="42" y="28" fill="#a5d6ff" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="700" letter-spacing="1">PI NATIVE</text>
22
22
 
23
23
  <text x="0" y="122" fill="#f0f6fc" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="66" font-weight="760" letter-spacing="-2">OpenPI</text>
24
- <text x="0" y="168" fill="#8b949e" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="24">Small harness. Real runtime.</text>
24
+ <text x="0" y="168" fill="#8b949e" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="24">Small harness. Deep extensions. Clean context.</text>
25
25
  </g>
26
26
 
27
27
  <g transform="translate(48 250)" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="23" font-weight="600">
@@ -1,6 +1,6 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500" role="img" aria-labelledby="title desc">
2
2
  <title id="title">OpenPI</title>
3
- <desc id="desc">A Pi-native runtime layer for background terminals, isolated subagents, dynamic workflows, and clean context.</desc>
3
+ <desc id="desc">OpenPI small harness, deep extensions, clean context.</desc>
4
4
 
5
5
  <defs>
6
6
  <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
@@ -34,11 +34,11 @@
34
34
  <g>
35
35
  <rect width="112" height="34" rx="17" fill="#12233d" stroke="#2f81f7"/>
36
36
  <circle cx="18" cy="17" r="5" fill="#3fb950"/>
37
- <text x="33" y="22" fill="#a5d6ff" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="13" font-weight="700" letter-spacing="1">PI NATIVE</text>
37
+ <text x="33" y="22" fill="#a5d6ff" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="14" font-weight="700" letter-spacing="0.7">PI NATIVE</text>
38
38
  </g>
39
39
 
40
40
  <text x="0" y="112" fill="#f0f6fc" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="64" font-weight="760" letter-spacing="-2">OpenPI</text>
41
- <text x="0" y="155" fill="#8b949e" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="21">Keep the harness small. Give it a real runtime.</text>
41
+ <text x="0" y="155" fill="#8b949e" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="20">Small harness. Deep extensions. Clean context.</text>
42
42
 
43
43
  <g transform="translate(0 198)" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="16">
44
44
  <path d="M5 9h14M12 2l7 7-7 7" fill="none" stroke="#58a6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -62,14 +62,14 @@
62
62
  <circle cx="22" cy="22" r="5" fill="#f85149"/>
63
63
  <circle cx="40" cy="22" r="5" fill="#d29922"/>
64
64
  <circle cx="58" cy="22" r="5" fill="#3fb950"/>
65
- <text x="229" y="27" text-anchor="middle" fill="#7d8590" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="13">pi · main</text>
65
+ <text x="229" y="27" text-anchor="middle" fill="#7d8590" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="15">pi · main</text>
66
66
 
67
- <g font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="14">
67
+ <g font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="16">
68
68
  <text x="24" y="78" fill="#7d8590">$ pi</text>
69
69
  <rect x="24" y="92" width="410" height="44" rx="9" fill="#0d1117" stroke="#21262d"/>
70
70
  <circle cx="44" cy="114" r="5" fill="#58a6ff"/>
71
71
  <text x="59" y="119" fill="#f0f6fc">main agent</text>
72
- <text x="320" y="119" fill="#7d8590">orchestrating</text>
72
+ <text x="320" y="119" fill="#7d8590" font-size="14">orchestrating</text>
73
73
 
74
74
  <path d="M47 136V155M47 155H379" fill="none" stroke="#30363d" stroke-width="2"/>
75
75
  <path d="M90 155V169M229 155V169M379 155V169" stroke="#30363d" stroke-width="2"/>
@@ -91,18 +91,18 @@
91
91
 
92
92
  <rect x="24" y="256" width="410" height="34" rx="8" fill="#0d1117" stroke="#21262d"/>
93
93
  <text x="38" y="278" fill="#7d8590">context</text>
94
- <rect x="104" y="268" width="166" height="10" rx="5" fill="#21262d"/>
95
- <rect x="104" y="268" width="61" height="10" rx="5" fill="#2f81f7"/>
94
+ <rect x="115" y="268" width="155" height="10" rx="5" fill="#21262d"/>
95
+ <rect x="115" y="268" width="57" height="10" rx="5" fill="#2f81f7"/>
96
96
  <text x="285" y="278" fill="#c9d1d9">37%</text>
97
97
  <text x="338" y="278" fill="#3fb950">settled</text>
98
98
 
99
99
  <path d="M24 308H434" stroke="#21262d"/>
100
100
  <rect x="24" y="318" width="410" height="18" rx="4" fill="#0b1720"/>
101
- <text x="34" y="332" fill="#39c5cf" font-size="13">run tests and open the PR</text>
101
+ <text x="34" y="332" fill="#39c5cf" font-size="15">run tests and open the PR</text>
102
102
  </g>
103
103
  </g>
104
104
 
105
- <g transform="translate(70 444)" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="13" font-weight="600" letter-spacing="0.4">
105
+ <g transform="translate(70 444)" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="14" font-weight="600" letter-spacing="0.25">
106
106
  <text fill="#58a6ff">BACKGROUND</text>
107
107
  <circle cx="110" cy="-4" r="2" fill="#30363d"/>
108
108
  <text x="126" fill="#3fb950">SUBAGENTS</text>
@@ -33,7 +33,11 @@ const HumanHandoffParams = Type.Object({
33
33
 
34
34
  export type HumanHandoffInput = Static<typeof HumanHandoffParams>;
35
35
  export type HumanHandoffStatus =
36
- "completed" | "unable" | "dismissed" | "cancelled" | "unavailable";
36
+ | "completed"
37
+ | "unable"
38
+ | "dismissed"
39
+ | "cancelled"
40
+ | "unavailable";
37
41
 
38
42
  export interface HumanHandoffDetails {
39
43
  status: HumanHandoffStatus;
@@ -25,6 +25,18 @@ import {
25
25
  import { Cause, Effect, Exit } from "effect";
26
26
  import { Type, type Static } from "typebox";
27
27
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
28
+ import {
29
+ PLAN_MODE_CHANNEL,
30
+ type PlanModeState,
31
+ } from "../shared/plan-mode-state.ts";
32
+ import {
33
+ OPENPI_SETUP_EPISODE_CHANNEL,
34
+ type OpenPiSetupEpisodeState,
35
+ } from "../shared/setup-episode-state.ts";
36
+ import {
37
+ OPENPI_TOOL_SURFACE,
38
+ patchOwnedTools,
39
+ } from "../shared/tool-surface.ts";
28
40
  import { createHumanHandoffToolDefinition } from "./handoff.ts";
29
41
  import {
30
42
  BRACKETED_PASTE_END,
@@ -355,6 +367,38 @@ export async function showQuestionsWithDialogs(
355
367
  }
356
368
 
357
369
  export default function askUser(pi: ExtensionAPI) {
370
+ let planning = false;
371
+ let setupActive = false;
372
+ const syncInteractionTools = () =>
373
+ patchOwnedTools(pi, "interaction", {
374
+ ...(planning || setupActive
375
+ ? { enable: OPENPI_TOOL_SURFACE.interaction.deferred }
376
+ : { disable: OPENPI_TOOL_SURFACE.interaction.deferred }),
377
+ });
378
+
379
+ pi.events.on(PLAN_MODE_CHANNEL, (state: unknown) => {
380
+ planning =
381
+ typeof state === "object" &&
382
+ state !== null &&
383
+ (state as PlanModeState).planning === true;
384
+ syncInteractionTools();
385
+ });
386
+ pi.events.on(OPENPI_SETUP_EPISODE_CHANNEL, (state: unknown) => {
387
+ setupActive =
388
+ typeof state === "object" &&
389
+ state !== null &&
390
+ (state as OpenPiSetupEpisodeState).active === true;
391
+ syncInteractionTools();
392
+ });
393
+ pi.on("session_shutdown", () => {
394
+ planning = false;
395
+ setupActive = false;
396
+ syncInteractionTools();
397
+ });
398
+ pi.on("session_start", () => {
399
+ syncInteractionTools();
400
+ });
401
+
358
402
  pi.registerTool({
359
403
  name: "ask_user",
360
404
  label: "Ask User",
@@ -28,8 +28,16 @@ import type {
28
28
  import { Type } from "typebox";
29
29
  import { loadSetupConfig } from "../shared/setup-config.ts";
30
30
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
31
+ import {
32
+ OPENPI_TOOL_SURFACE,
33
+ patchOwnedTools,
34
+ } from "../shared/tool-surface.ts";
31
35
  import type { TerminalSnapshot } from "./src/domain.ts";
32
- import { TerminalManager, type TerminalManagerShape } from "./src/manager.ts";
36
+ import {
37
+ MAX_RUNNING,
38
+ TerminalManager,
39
+ type TerminalManagerShape,
40
+ } from "./src/manager.ts";
33
41
  import {
34
42
  BG_KILL_PARAMETER_DESCRIPTIONS,
35
43
  BG_KILL_TOOL_DESCRIPTION,
@@ -45,6 +53,7 @@ import {
45
53
  buildKillReport,
46
54
  buildStartResult,
47
55
  buildStatusResult,
56
+ buildTerminalBatchResultMessage,
48
57
  buildTerminalResultMessage,
49
58
  buildWatchArmedResult,
50
59
  buildWatchMatchMessage,
@@ -52,23 +61,29 @@ import {
52
61
  } from "./src/prompt.ts";
53
62
  import {
54
63
  createDeferredResultDelivery,
64
+ createIdleResultBatcher,
65
+ hasTerminalCapacity,
55
66
  resultDeliveryOptions,
56
67
  } from "./src/result-delivery.ts";
57
- import {
58
- assertWatchableOutput,
59
- compileWatchPattern,
60
- createChunkMatcher,
61
- matchCapturedOutput,
62
- } from "./src/watch.ts";
63
68
  import {
64
69
  createTerminalRuntime,
65
70
  runTool,
66
71
  type TerminalRuntime,
67
72
  } from "./src/runtime.ts";
68
73
  import { openTerminalPicker } from "./src/ui/ps.ts";
69
- import { renderTerminalResult } from "./src/ui/tool-result.ts";
74
+ import {
75
+ renderTerminalBatchResult,
76
+ renderTerminalResult,
77
+ } from "./src/ui/tool-result.ts";
78
+ import {
79
+ assertWatchableOutput,
80
+ compileWatchPattern,
81
+ createChunkMatcher,
82
+ matchCapturedOutput,
83
+ } from "./src/watch.ts";
70
84
 
71
85
  const WIDGET_KEY = "background-terminals";
86
+ const IDLE_RESULT_BATCH_MS = 200;
72
87
 
73
88
  interface WatchToolDetails {
74
89
  id: string;
@@ -83,7 +98,16 @@ export default function (pi: ExtensionAPI) {
83
98
  let sessionContext: ExtensionContext | undefined;
84
99
  let ui: ExtensionUIContext | undefined;
85
100
  let unsubStatus: (() => void) | undefined;
101
+ let startReservations = 0;
86
102
  const resultDelivery = createDeferredResultDelivery<TerminalSnapshot>();
103
+ const hideLifecycleTools = () =>
104
+ patchOwnedTools(pi, "background", {
105
+ disable: OPENPI_TOOL_SURFACE.background.deferred,
106
+ });
107
+ const showLifecycleTools = () =>
108
+ patchOwnedTools(pi, "background", {
109
+ enable: OPENPI_TOOL_SURFACE.background.deferred,
110
+ });
87
111
  /** Active bg_watch disarm callbacks, keyed by terminal id (one per id). */
88
112
  const watchers = new Map<string, () => void>();
89
113
 
@@ -160,7 +184,9 @@ export default function (pi: ExtensionAPI) {
160
184
  customType: "background-terminal-result",
161
185
  // One message per flush, not per terminal: five processes exiting
162
186
  // together are one event to react to, not five.
163
- content: snaps.map(buildTerminalResultMessage).join("\n\n"),
187
+ content: buildTerminalBatchResultMessage(
188
+ snaps.map(buildTerminalResultMessage),
189
+ ),
164
190
  display: true,
165
191
  details:
166
192
  snaps.length === 1
@@ -198,12 +224,18 @@ export default function (pi: ExtensionAPI) {
198
224
  };
199
225
 
200
226
  const flushResults = (wake: boolean) => {
201
- const snaps = resultDelivery.drain();
202
- if (!deliverResults(snaps, wake)) {
203
- for (const snap of snaps) resultDelivery.defer(snap);
204
- }
227
+ const snaps = resultDelivery.drain(MAX_RUNNING);
228
+ if (!deliverResults(snaps, wake)) resultDelivery.restore(snaps);
205
229
  };
206
230
 
231
+ const idleResultBatcher = createIdleResultBatcher({
232
+ delayMs: IDLE_RESULT_BATCH_MS,
233
+ isIdle: () => sessionContext?.isIdle() === true,
234
+ flush: flushResults,
235
+ startTimer: (callback, delayMs) => setTimeout(callback, delayMs),
236
+ clearTimer: (timer) => clearTimeout(timer),
237
+ });
238
+
207
239
  const onSettled = (snap: TerminalSnapshot, consumed: boolean) => {
208
240
  // A settled terminal has delivered its final result and will emit no more
209
241
  // output, so any watch armed on it can never match — disarm it now instead
@@ -216,17 +248,27 @@ export default function (pi: ExtensionAPI) {
216
248
  }
217
249
  // Defer a deep-enough copy: the live snapshot's output views keep
218
250
  // mutating (late flushes) after settle.
219
- resultDelivery.defer({
251
+ const pending = resultDelivery.defer({
220
252
  ...snap,
221
253
  stdout: { ...snap.stdout },
222
254
  stderr: { ...snap.stderr },
223
255
  });
224
- // Settled while the model sits idle: it has nothing else in flight, so
225
- // this is the result it is waiting on wake it.
226
- if (sessionContext?.isIdle()) flushResults(true);
256
+ // Pending settlements remain retractable while busy, so bg_status/bg_kill
257
+ // can consume them before they are committed to context. bg_start applies
258
+ // backpressure across running + pending + reserved work, keeping this map
259
+ // bounded without dropping or prematurely queueing any result.
260
+ if (pending >= MAX_RUNNING && sessionContext?.isIdle()) {
261
+ idleResultBatcher.flushNow();
262
+ return;
263
+ }
264
+ // Give near-simultaneous idle settlements one fixed, bounded window to
265
+ // join this result. This costs one model turn for a batch instead of one
266
+ // turn per process, while preserving the immediate path after 200 ms.
267
+ if (sessionContext?.isIdle()) idleResultBatcher.schedule();
227
268
  };
228
269
 
229
270
  pi.on("session_start", (_event, ctx) => {
271
+ hideLifecycleTools();
230
272
  sessionContext = ctx;
231
273
  if (ctx.hasUI) ui = ctx.ui;
232
274
  });
@@ -236,7 +278,14 @@ export default function (pi: ExtensionAPI) {
236
278
  // double delivery is structurally impossible — whoever drains first wins.
237
279
  // These finished while the model was working on something else, so they go
238
280
  // into context without forcing a turn per stale process.
239
- pi.on("agent_settled", () => flushResults(false));
281
+ pi.on("agent_settled", () => {
282
+ idleResultBatcher.flushWithoutWake();
283
+ // A failed send is restored exactly. Drain it in bounded chunks on later
284
+ // settled turns rather than growing a single unbounded message.
285
+ if (sessionContext?.isIdle() && resultDelivery.size() > 0) {
286
+ idleResultBatcher.schedule();
287
+ }
288
+ });
240
289
 
241
290
  // /new, /resume, /fork, /reload, and quit all emit session_shutdown for
242
291
  // the old extension instance. Processes never survive a session
@@ -245,6 +294,7 @@ export default function (pi: ExtensionAPI) {
245
294
  // bounded so a wedged process cannot hang shutdown.
246
295
  pi.on("session_shutdown", async () => {
247
296
  sessionContext = undefined;
297
+ idleResultBatcher.clear();
248
298
  resultDelivery.clear();
249
299
  for (const disarm of [...watchers.values()]) disarm();
250
300
  watchers.clear();
@@ -256,6 +306,7 @@ export default function (pi: ExtensionAPI) {
256
306
  // UI may already be gone.
257
307
  }
258
308
  widgetRunning = 0;
309
+ startReservations = 0;
259
310
  ui = undefined;
260
311
  const closing = runtime;
261
312
  runtime = undefined;
@@ -309,15 +360,38 @@ export default function (pi: ExtensionAPI) {
309
360
  .replace(/\s+/g, " ")
310
361
  .trim()
311
362
  .slice(0, 80) || "terminal";
312
- const snap = await runTool(
313
- getRuntime(),
314
- manager.start({
315
- command,
316
- title,
317
- cwd,
318
- timeoutSeconds: params.timeout_seconds,
319
- }),
320
- );
363
+ const running = manager.view
364
+ .list()
365
+ .filter((entry) => entry.status === "running").length;
366
+ if (
367
+ !hasTerminalCapacity({
368
+ running,
369
+ pending: resultDelivery.size(),
370
+ reserved: startReservations,
371
+ maximum: MAX_RUNNING,
372
+ })
373
+ ) {
374
+ throw new Error(
375
+ `Max ${MAX_RUNNING} background terminals may be running or awaiting delivery. Let the current turn settle, or inspect a finished terminal with bg_status before starting another.`,
376
+ );
377
+ }
378
+ startReservations++;
379
+ let snap: TerminalSnapshot;
380
+ try {
381
+ snap = await runTool(
382
+ getRuntime(),
383
+ manager.start({
384
+ command,
385
+ title,
386
+ cwd,
387
+ timeoutSeconds: params.timeout_seconds,
388
+ }),
389
+ );
390
+ } finally {
391
+ startReservations--;
392
+ }
393
+
394
+ showLifecycleTools();
321
395
 
322
396
  return {
323
397
  content: [{ type: "text", text: buildStartResult(snap) }],
@@ -591,7 +665,24 @@ export default function (pi: ExtensionAPI) {
591
665
  status?: string;
592
666
  exitCode?: number;
593
667
  signal?: string;
668
+ results?: Array<{
669
+ id: string;
670
+ title: string;
671
+ status: string;
672
+ exitCode?: number;
673
+ signal?: string;
674
+ }>;
594
675
  };
676
+ const content =
677
+ typeof message.content === "string" ? message.content : "";
678
+ if (details.results && details.results.length > 1) {
679
+ return renderTerminalBatchResult(
680
+ content,
681
+ expanded || loadSetupConfig().ui.bashToolDisplay === "full",
682
+ theme,
683
+ details.results,
684
+ );
685
+ }
595
686
  const failed = details.status === "failed";
596
687
  const killed = details.status === "killed";
597
688
  const timedOut = details.status === "timed_out";
@@ -611,8 +702,6 @@ export default function (pi: ExtensionAPI) {
611
702
  theme.fg("accent", theme.bold(`terminal ${details.id ?? "?"}`)) +
612
703
  theme.fg("muted", ` · ${details.title ?? ""} · ${how}`);
613
704
 
614
- const content =
615
- typeof message.content === "string" ? message.content : "";
616
705
  return renderTerminalResult(
617
706
  content,
618
707
  expanded || loadSetupConfig().ui.bashToolDisplay === "full",
@@ -9,7 +9,11 @@
9
9
  import { Data } from "effect";
10
10
 
11
11
  export type TerminalStatus =
12
- "running" | "done" | "failed" | "killed" | "timed_out";
12
+ | "running"
13
+ | "done"
14
+ | "failed"
15
+ | "killed"
16
+ | "timed_out";
13
17
  // "done" = exited with code 0
14
18
  // "failed" = exited non-zero, or a spawn-level runtime error after start
15
19
  // "killed" = terminated by bg_kill, the /ps UI, or session teardown
@@ -313,7 +313,8 @@ const makeManager = Effect.gen(function* () {
313
313
  let disposed = false;
314
314
  let spillDir: string | undefined | null;
315
315
  let onSettled:
316
- ((snap: TerminalSnapshot, consumed: boolean) => void) | undefined;
316
+ | ((snap: TerminalSnapshot, consumed: boolean) => void)
317
+ | undefined;
317
318
 
318
319
  const notify = (id?: string) => {
319
320
  for (const listener of [...listeners]) {
@@ -22,6 +22,8 @@ export const STATUS_STDERR_MAX = 8 * 1024;
22
22
  export const RESULT_STDOUT_MAX = 8 * 1024;
23
23
  /** Completion follow-up stderr tail. Keep this concise; /ps has the detailed view. */
24
24
  export const RESULT_STDERR_MAX = 4 * 1024;
25
+ /** Global model-facing cap for one batched completion message. */
26
+ export const RESULT_BATCH_MAX = 48 * 1024;
25
27
  const STATUS_STDOUT_MAX_LINES = 400;
26
28
  const STATUS_STDERR_MAX_LINES = 200;
27
29
  const RESULT_STDOUT_MAX_LINES = 40;
@@ -179,6 +181,39 @@ export function buildTerminalResultMessage(snap: TerminalSnapshot) {
179
181
  return text;
180
182
  }
181
183
 
184
+ /** Preserve every retained terminal identity while globally bounding batch logs. */
185
+ export function buildTerminalBatchResultMessage(
186
+ messages: readonly string[],
187
+ omitted = 0,
188
+ ) {
189
+ if (messages.length === 1 && omitted === 0) return messages[0]!;
190
+ const summaries = messages.map(
191
+ (message) => message.split("\n", 1)[0] || "Background terminal result",
192
+ );
193
+ const header = [
194
+ `${messages.length} background terminal result${messages.length === 1 ? "" : "s"}:`,
195
+ ...summaries.map((summary) => `- ${summary}`),
196
+ omitted > 0
197
+ ? `- ${omitted} older result${omitted === 1 ? "" : "s"} omitted from this bounded batch; use bg_list/bg_status for retained details.`
198
+ : "",
199
+ ]
200
+ .filter(Boolean)
201
+ .join("\n");
202
+ const logsHeader = "\n\nLogs (newest tail):\n";
203
+ const truncationMarker =
204
+ "\n[batch logs truncated at the 48 KiB model-context limit; use bg_status or /ps for retained details.]";
205
+ const fixedBytes = Buffer.byteLength(
206
+ `${header}${logsHeader}${truncationMarker}`,
207
+ "utf8",
208
+ );
209
+ const logs = truncateTail(messages.join("\n\n"), {
210
+ maxBytes: Math.max(1, RESULT_BATCH_MAX - fixedBytes),
211
+ maxLines: DEFAULT_MAX_LINES,
212
+ });
213
+ const text = `${header}${logsHeader}${logs.content}${logs.truncated ? truncationMarker : ""}`;
214
+ return text;
215
+ }
216
+
182
217
  export function buildKillReport(results: ReadonlyArray<KillResult>) {
183
218
  return results
184
219
  .map((entry) => {