@oldflag2333333/pi-subagent 0.1.0 → 0.1.1
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 +11 -3
- package/README.zh-CN.md +12 -4
- package/docs/messaging.md +28 -0
- package/docs/releasing.md +46 -35
- package/docs/releasing.zh-CN.md +72 -0
- package/docs/third-party-notices.md +25 -0
- package/package.json +1 -1
- package/src/adapters/herdr.ts +4 -2
- package/src/commands/clean-sub.ts +27 -19
- package/src/inbox-state.ts +8 -2
- package/src/index.ts +1 -1
- package/src/parent-wake.ts +86 -0
- package/src/run-manager.ts +1 -1
- package/src/sessions.ts +11 -0
- package/src/talk-delivery.ts +13 -8
- package/src/tools/sub.ts +1 -1
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ Ask the reviewer profile to review src/auth for bugs and missing tests.
|
|
|
75
75
|
| Check progress | Ask Pi to list its Subs, or open the specialist's Herdr tab |
|
|
76
76
|
| Stop its current task without closing it | Ask Pi to interrupt the Sub |
|
|
77
77
|
| Finish with a specialist | Ask Pi to close the Sub, or close its Herdr tab |
|
|
78
|
-
| Remove communication files left by deleted
|
|
78
|
+
| Remove Sub sessions and communication files left by deleted Mains | `/clean-sub` |
|
|
79
79
|
|
|
80
80
|
Within the same Main session, repeated `/sub:reviewer` commands reuse that profile's open Sub. If it is busy, your next task is queued. Finishing a task does not automatically close the tab.
|
|
81
81
|
|
|
@@ -145,12 +145,20 @@ These instructions apply only to your main Pi, not to its Subs. Keep shared proj
|
|
|
145
145
|
|
|
146
146
|
## Clean up deleted sessions
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
Persistent Sub histories are grouped by their owning Main, using Pi's native `--session-dir`:
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
```text
|
|
151
|
+
~/.pi/agent/subagent/sessions/<main-session-id>/*.jsonl
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Run `/clean-sub` to immediately delete both the Sub session directory and runtime communication directory for any Main ID no longer found among saved Pi sessions. It checks Pi's default session store and the current session directory, always preserves the current Main, and creates no index. Unreadable session files or invalid headers stop cleanup instead of being treated as deleted.
|
|
155
|
+
|
|
156
|
+
Close any orphaned Sub tabs before cleaning; the command only deletes files. Profiles and global settings are untouched. Older Sub files outside the managed directory still need manual deletion. If you moved Main files or use other custom session directories, make them available in the scanned locations before cleaning.
|
|
151
157
|
|
|
152
158
|
## Things to know
|
|
153
159
|
|
|
160
|
+
Messages use custom inbox cards. An idle receiver also gets a short user-role wake prompt to preserve its profile instructions. See [messaging behavior and the Pi wake workaround](docs/messaging.md).
|
|
161
|
+
|
|
154
162
|
- **Herdr is required for Subs.** There is no headless fallback. Up to four Subs can be open at once, and Subs cannot delegate further.
|
|
155
163
|
- **Profiles are not a sandbox.** Subs run as your OS user. A read-only role is not a filesystem permission boundary.
|
|
156
164
|
- **MCP follows Pi's normal configuration.** Profiles select non-MCP tools; they do not isolate or filter MCP servers. Custom non-MCP tools must come from extensions already loaded in Main.
|
package/README.zh-CN.md
CHANGED
|
@@ -75,7 +75,7 @@ mkdir -p ~/.pi/agent/subagent/profiles/reviewer
|
|
|
75
75
|
| 查看进度 | 让 Pi 列出子助手,或打开助手的 Herdr 标签页 |
|
|
76
76
|
| 停止当前任务,但保留助手会话 | 让 Pi 中断该子助手 |
|
|
77
77
|
| 结束使用 | 让 Pi 关闭子助手,或手动关闭其 Herdr 标签页 |
|
|
78
|
-
| 清理已删除 Main
|
|
78
|
+
| 清理已删除 Main 遗留的 Sub 会话和通信文件 | `/clean-sub` |
|
|
79
79
|
|
|
80
80
|
在同一个主会话(Main)中,多次调用 `/sub:reviewer` 会复用该角色已打开的子助手(Sub)。如果它正在工作,新任务会排队。任务完成后,标签页不会自动关闭。
|
|
81
81
|
|
|
@@ -145,12 +145,20 @@ reviewer/
|
|
|
145
145
|
|
|
146
146
|
## 清理已删除会话的遗留文件
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
持久化 Sub 使用 Pi 原生的 `--session-dir`,按所属 Main 分目录保存:
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
```text
|
|
151
|
+
~/.pi/agent/subagent/sessions/<main-session-id>/*.jsonl
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
执行 `/clean-sub`,找不到对应 Main ID 时,立即删除它的 Sub 会话目录和运行时通信目录。命令检查 Pi 默认会话目录和当前会话目录,始终保留当前 Main,不新增索引。会话文件无法读取或文件头无效时会停止清理,不会将它们当作已删除。
|
|
155
|
+
|
|
156
|
+
清理前请关闭遗留的 Sub 标签页,命令只删除文件。角色配置和全局设置不受影响;以前散落在专用目录之外的 Sub 文件仍需手动删除。如果移动过 Main 文件或使用其他自定义会话目录,请先确保这些会话位于扫描范围内,再执行清理。
|
|
151
157
|
|
|
152
158
|
## 使用须知
|
|
153
159
|
|
|
160
|
+
助手间消息保留自定义收件卡片。接收方空闲时,会额外出现一条简短的用户角色唤醒消息,以确保角色指令正常加载。详见[消息机制与 Pi 唤醒问题的绕过方案](docs/messaging.md)。
|
|
161
|
+
|
|
154
162
|
- **子助手依赖 Herdr。** 不支持无界面后台运行;最多同时打开四个子助手,子助手不能继续委派。
|
|
155
163
|
- **角色配置不是沙箱。** 子助手以你的系统用户身份运行,配置“只读角色”不等于限制文件系统权限。
|
|
156
164
|
- **MCP 沿用 Pi 的常规配置。** 角色只选择非 MCP 工具,不隔离或筛选 MCP 服务。自定义的非 MCP 工具必须来自主 Pi 已加载的扩展。
|
|
@@ -169,4 +177,4 @@ npm run check
|
|
|
169
177
|
pi -e ./src/index.ts
|
|
170
178
|
```
|
|
171
179
|
|
|
172
|
-
维护者可参考[发布指南](docs/releasing.md)
|
|
180
|
+
维护者可参考[发布指南](docs/releasing.zh-CN.md),了解首次 npm 发布、Trusted Publisher 配置和后续自动发布流程。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Message delivery and rendering
|
|
2
|
+
|
|
3
|
+
Main ↔ Sub messages remain custom inbox cards. Delivery uses the same `parentWake` wrapper in both directions:
|
|
4
|
+
|
|
5
|
+
- **Busy receiver:** `pi.sendMessage()` with `deliverAs: "followUp"` and `triggerTurn: true`, using Pi's native queue.
|
|
6
|
+
- **Idle receiver:** append the card with `pi.sendMessage(..., { triggerTurn: false })`, then start the run with a short, fixed `pi.sendUserMessage(..., { deliverAs: "steer" })` prompt. This goes through normal input preparation and `before_agent_start`.
|
|
7
|
+
|
|
8
|
+
The wake is a real, visible user-role message added by the extension, not a duplicate of the peer's message. It only asks the receiving agent to process its inbox within its existing task and authority. Peer text stays in the custom card and is never submitted as executable slash-command input.
|
|
9
|
+
|
|
10
|
+
## Delivery guarantees
|
|
11
|
+
|
|
12
|
+
- `content` carries the peer message and routing instructions; `details` carries rendering and receipt metadata and is not sent to the model.
|
|
13
|
+
- Custom cards retain their title, collapsed/expanded rendering, and saved-session rendering.
|
|
14
|
+
- A message file is removed only after its receipt appears in the receiving session. Legacy user-message receipts remain recognized.
|
|
15
|
+
- An idle burst shares one wake while that wake is starting. Reservations survive `/reload`, are scoped to the receiving session, and clear on `agent_start` or non-reload shutdown.
|
|
16
|
+
- If a wake never starts, a later idle delivery can request another wake after ten seconds. The deadline does not create a timer or promise autonomous retry after an input hook handles the wake.
|
|
17
|
+
- A synchronous wake failure keeps the message file retryable; retrying does not append the custom card again. Pi's fire-and-forget extension API does not expose asynchronous prompt failures as delivery receipts.
|
|
18
|
+
|
|
19
|
+
## Why the workaround exists
|
|
20
|
+
|
|
21
|
+
Pi 1.0.4 and 1.1.0 have an idle custom-message wake path that bypasses `before_agent_start` and can drop profile prompt sections:
|
|
22
|
+
|
|
23
|
+
- [#5581: custom-message starts bypass run preparation](https://github.com/earendil-works/pi/issues/5581)
|
|
24
|
+
- [#10267: contributed prompt sections are dropped](https://github.com/earendil-works/pi/issues/10267)
|
|
25
|
+
|
|
26
|
+
The implementation adapts [nicobailon/pi-subagents' parentWake](https://github.com/nicobailon/pi-subagents/blob/0c33ec7cb26ed1db270d72e746c3c975be880aeb/src/shared/parent-wake.ts), with retries for synchronous wake failures. Attribution and the MIT license are in [third-party notices](third-party-notices.md).
|
|
27
|
+
|
|
28
|
+
Real Pi SDK tests cover the first idle wake, subsequent turns, busy follow-ups, and reload. The previous upstream TODO is now a required passing regression test. This workaround does not patch Pi internals or claim to fix unrelated retry/resume paths in Pi itself.
|
package/docs/releasing.md
CHANGED
|
@@ -1,61 +1,72 @@
|
|
|
1
1
|
# Releasing Pi Subagent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [简体中文](releasing.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
## CI
|
|
5
|
+
## What CI does
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`.github/workflows/release.yml` runs on PRs and pushes to `main`/`dev`, and through **Actions → CI and Release → Run workflow**.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- Type checks and tests on Node **22.19.0** and **24**.
|
|
10
|
+
- Package/lockfile validation and an npm packaging dry run.
|
|
11
|
+
- The Node 24 job uploads a `.tgz` as `npm-package-<commit SHA>` (retained for 14 days).
|
|
12
|
+
- Only a **version increase pushed to `main` in `oldflag2333333/pi-subagent`** triggers publication, after both check jobs pass. Ordinary pushes, PRs, manual CI runs, and tag pushes do not publish.
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
2. Reserve `v<version>` at the exact checked commit.
|
|
13
|
-
3. Publish the npm tarball through OIDC trusted publishing, with provenance.
|
|
14
|
-
4. Create the GitHub Release with automatically generated release notes.
|
|
14
|
+
Publishing uses npm OIDC with provenance, reserves `v<version>` at the checked commit, and creates a GitHub Release. Stable versions use `latest`; prereleases use `next`.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
## First npm release: 0.1.0
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
**npm requires the package to exist before a trusted publisher can be configured.** The first publish therefore needs your npm login, not OIDC. See [npm's prerequisites](https://docs.npmjs.com/cli/v11/commands/npm-trust/#prerequisites).
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
The npm package is **@oldflag2333333/pi-subagent**.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
npm run
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
```
|
|
22
|
+
1. Enable 2FA on your npm account.
|
|
23
|
+
2. Push the intended release commit to `main`. Wait for the entire **CI and Release** run to succeed.
|
|
24
|
+
3. Download that run's `npm-package-<commit SHA>` artifact and extract it. Use a trusted `main` run, not a PR artifact.
|
|
25
|
+
4. In the extracted directory, run:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx --yes npm@11.21.0 login --registry=https://registry.npmjs.org
|
|
29
|
+
npx --yes npm@11.21.0 publish ./oldflag2333333-pi-subagent-0.1.0.tgz --access public --ignore-scripts --registry=https://registry.npmjs.org
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Complete the browser/2FA prompts yourself. Do not send credentials or OTPs to an agent. The tarball is the exact package produced by CI; no need to repack it locally. This initial interactive publish does not have CI provenance and does not create a GitHub Release.
|
|
33
|
+
|
|
34
|
+
5. Verify:
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
```bash
|
|
37
|
+
npm view @oldflag2333333/pi-subagent version --registry=https://registry.npmjs.org
|
|
38
|
+
```
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
## Configure automatic publishing on npm
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
Open **npmjs.com → @oldflag2333333/pi-subagent → Settings → Trusted publishing → Add trusted publisher → GitHub Actions**:
|
|
43
|
+
|
|
44
|
+
| Field | Value |
|
|
34
45
|
| --- | --- |
|
|
35
46
|
| Organization or user | `oldflag2333333` |
|
|
36
47
|
| Repository | `pi-subagent` |
|
|
37
|
-
| Workflow filename | `release.yml`
|
|
38
|
-
| Environment | Leave
|
|
39
|
-
| Allowed actions |
|
|
48
|
+
| Workflow filename | `release.yml` — filename only |
|
|
49
|
+
| Environment name | Leave empty |
|
|
50
|
+
| Allowed actions | Enable direct `npm publish`, not just staged publishing |
|
|
51
|
+
|
|
52
|
+
No `NPM_TOKEN` or other npm secret is needed in GitHub. The workflow requests `id-token: write` and uses GitHub's built-in token for tags/releases.
|
|
40
53
|
|
|
41
|
-
|
|
54
|
+
A new trust configuration must complete a successful OIDC publish **within 2 days**, or it expires. Configure it when you are ready for the next version, or recreate it if it expires. After verifying OIDC publishing, npm recommends **Publishing access → Require two-factor authentication and disallow tokens**.
|
|
42
55
|
|
|
43
|
-
|
|
56
|
+
Source: [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
|
|
44
57
|
|
|
45
|
-
|
|
58
|
+
## Subsequent releases
|
|
46
59
|
|
|
47
|
-
|
|
60
|
+
With a clean working tree:
|
|
48
61
|
|
|
49
62
|
```bash
|
|
50
|
-
npm version
|
|
51
|
-
#
|
|
63
|
+
npm version patch --no-git-tag-version
|
|
64
|
+
# From 0.1.0, this produces 0.1.1.
|
|
52
65
|
git add package.json package-lock.json
|
|
53
|
-
git commit -m "chore: release v0.
|
|
66
|
+
git commit -m "chore: release v0.1.1"
|
|
67
|
+
git push origin main
|
|
54
68
|
```
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
If publishing fails, correct the external setup and **re-run the original failed workflow** on its original commit. An identical already-published artifact is not republished, so a failed GitHub Release step can be completed safely. An existing tag pointing elsewhere, an npm version with different contents, or a version that would move a dist-tag backwards causes an error instead of an overwrite. A failed npm publish may leave its reserved Git tag, but no GitHub Release is announced until publication succeeds. Publish one version at a time.
|
|
59
|
-
|
|
60
|
-
GitHub Releases created by `GITHUB_TOKEN` do not trigger a second release workflow, so npm publication and Release creation deliberately run in the same job.
|
|
70
|
+
Use a PR instead if required by branch policy. Once the version bump reaches `main`, CI publishes npm and creates the tag and GitHub Release automatically. Do not publish locally again or pre-create the tag.
|
|
61
71
|
|
|
72
|
+
If publication fails, fix the external configuration and **re-run the original failed workflow**. An identical already-published artifact is not republished; conflicting artifacts or tags fail rather than overwrite existing releases. Never try to reuse an npm version for different contents. Publish one version at a time.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Pi Subagent 发布指南
|
|
2
|
+
|
|
3
|
+
[English](releasing.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
## CI 已配置的内容
|
|
6
|
+
|
|
7
|
+
工作流位于 `.github/workflows/release.yml`:
|
|
8
|
+
|
|
9
|
+
- `main`、`dev` 的推送和 PR:在 Node **22.19.0 / 24** 上执行类型检查、测试、包信息校验和打包检查。
|
|
10
|
+
- Node 24 检查通过后上传 `.tgz` 安装包,名称为 `npm-package-<提交 SHA>`,保留 14 天。
|
|
11
|
+
- 可以在 **Actions → CI and Release → Run workflow** 手动运行检查,不会发布。
|
|
12
|
+
- 只有新仓库 `main` 上的**版本递增**才会在所有检查通过后自动发布 npm、创建版本标签和 GitHub Release。
|
|
13
|
+
|
|
14
|
+
普通代码提交、PR、手动运行 CI、推送标签都不会触发发布。正式版使用 npm 的 `latest`,预发布版使用 `next`。
|
|
15
|
+
|
|
16
|
+
## 首次发布 0.1.0
|
|
17
|
+
|
|
18
|
+
npm 包名为 **@oldflag2333333/pi-subagent**。
|
|
19
|
+
|
|
20
|
+
**npm 要求包已经存在,才能配置 Trusted Publisher。** 因此首次发布需要你登录 npm 手动完成,之后才使用 CI 自动发布。参见 [npm 官方说明](https://docs.npmjs.com/cli/v11/commands/npm-trust/#prerequisites)。
|
|
21
|
+
|
|
22
|
+
1. 为 npm 账号开启双因素认证(2FA)。
|
|
23
|
+
2. 将准备发布的代码推送到 `main`,等待整个 **CI and Release** 工作流通过。
|
|
24
|
+
3. 下载该次运行的 `npm-package-<提交 SHA>` artifact 并解压。使用确认过的 `main` 构建,不要使用 PR 的产物。
|
|
25
|
+
4. 在解压目录中执行:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx --yes npm@11.21.0 login --registry=https://registry.npmjs.org
|
|
29
|
+
npx --yes npm@11.21.0 publish ./oldflag2333333-pi-subagent-0.1.0.tgz --access public --ignore-scripts --registry=https://registry.npmjs.org
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
按提示完成浏览器登录和 2FA,不要把凭据或验证码发给助手。直接发布 CI 打好的包,不用在本地重新打包。首次交互式发布不带 CI provenance,也不会自动创建 GitHub Release。
|
|
33
|
+
|
|
34
|
+
5. 确认结果:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm view @oldflag2333333/pi-subagent version --registry=https://registry.npmjs.org
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## npm 网站上需要配置什么
|
|
41
|
+
|
|
42
|
+
进入 **@oldflag2333333/pi-subagent 包页面 → Settings → Trusted publishing → Add trusted publisher → GitHub Actions**:
|
|
43
|
+
|
|
44
|
+
| 字段 | 填写内容 |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| Organization or user | `oldflag2333333` |
|
|
47
|
+
| Repository | `pi-subagent` |
|
|
48
|
+
| Workflow filename | `release.yml`,不要填完整路径 |
|
|
49
|
+
| Environment name | 留空 |
|
|
50
|
+
| Allowed actions | 勾选允许直接 `npm publish`,不能只有 staged publishing |
|
|
51
|
+
|
|
52
|
+
**GitHub 不需要添加 `NPM_TOKEN`。** 工作流使用 OIDC 临时身份,标签和 Release 使用 GitHub 自动提供的 token。
|
|
53
|
+
|
|
54
|
+
新建的信任配置需要在 **2 天内成功完成一次 OIDC 发布**,否则会过期。准备发布下一版时再配置;过期后删除并重建即可。验证自动发布成功后,建议将 **Publishing access** 设置为 **Require two-factor authentication and disallow tokens**。
|
|
55
|
+
|
|
56
|
+
来源:[npm Trusted publishing 文档](https://docs.npmjs.com/trusted-publishers/)。
|
|
57
|
+
|
|
58
|
+
## 后续发布
|
|
59
|
+
|
|
60
|
+
在干净的工作区中执行:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm version patch --no-git-tag-version
|
|
64
|
+
# 0.1.0 → 0.1.1
|
|
65
|
+
git add package.json package-lock.json
|
|
66
|
+
git commit -m "chore: release v0.1.1"
|
|
67
|
+
git push origin main
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
如果仓库要求 PR,就通过 PR 将版本变更合入 `main`。之后 CI 自动完成 npm 发布、标签和 GitHub Release,不需要再手动 `npm publish`,也不要提前创建标签。
|
|
71
|
+
|
|
72
|
+
发布失败时,修正 npm 等外部配置后,重跑**原来失败的工作流**。相同产物不会重复发布;标签或包内容冲突会报错,不会覆盖。不要用同一个 npm 版本号发布不同内容,也不要并行发布多个版本。
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
`src/parent-wake.ts` adapts the parent wake workaround from [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents/blob/0c33ec7cb26ed1db270d72e746c3c975be880aeb/src/shared/parent-wake.ts).
|
|
4
|
+
|
|
5
|
+
## pi-subagents — MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Nico Bailon
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
package/package.json
CHANGED
package/src/adapters/herdr.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { subSessionName } from "../manual-context.js";
|
|
5
5
|
import { subCapabilityArgs } from "../profiles/launch-args.js";
|
|
6
|
+
import { subSessionDir } from "../sessions.js";
|
|
6
7
|
import type { SubAgentStatus, SubLaunchSpec, SubSurfaceAdapter, SurfaceHandle } from "../types.js";
|
|
7
8
|
|
|
8
9
|
function parseEnvelope(stdout: string): Record<string, unknown> {
|
|
@@ -39,8 +40,9 @@ function subArgs(spec: SubLaunchSpec): string[] {
|
|
|
39
40
|
"--no-extensions",
|
|
40
41
|
"-e", spec.entryPath,
|
|
41
42
|
...(fs.existsSync(herdrIntegration) ? ["-e", herdrIntegration] : []),
|
|
42
|
-
...(spec.profile.sessionPersistence === "persistent" || spec.resumeSessionId
|
|
43
|
-
|
|
43
|
+
...(spec.profile.sessionPersistence === "persistent" || spec.resumeSessionId
|
|
44
|
+
? ["--session-dir", subSessionDir(spec.mainSessionId)] : ["--no-session"]),
|
|
45
|
+
...(spec.resumeSessionId ? ["--session", spec.resumeSessionFile ?? spec.resumeSessionId] : []),
|
|
44
46
|
spec.projectTrusted ? "--approve" : "--no-approve",
|
|
45
47
|
"--name", subSessionName(safeLabel(spec.title), spec.origin),
|
|
46
48
|
...subCapabilityArgs(spec.profile, spec.entryPath),
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { channelPath, runtimeRoot } from "../channel.js";
|
|
5
|
+
import { subSessionDir, subSessionsRoot } from "../sessions.js";
|
|
5
6
|
|
|
6
7
|
function entries(directory: string): fs.Dirent[] {
|
|
7
8
|
try { return fs.readdirSync(directory, { withFileTypes: true }); }
|
|
@@ -26,11 +27,16 @@ function sessionId(file: string): string {
|
|
|
26
27
|
} finally { fs.closeSync(fd); }
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export function
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
export function cleanSubFiles(currentMainId: string, currentSessionDir: string): { removed: number; errors: string[] } {
|
|
31
|
+
const stores = [
|
|
32
|
+
{ root: runtimeRoot(), ownerDirectory: (id: string) => path.basename(path.dirname(channelPath(id, "unused"))) },
|
|
33
|
+
{ root: subSessionsRoot(), ownerDirectory: (id: string) => path.basename(subSessionDir(id)) },
|
|
34
|
+
].map((store) => {
|
|
35
|
+
const candidates = entries(store.root).filter((entry) => entry.isDirectory());
|
|
36
|
+
if (candidates.length && fs.lstatSync(store.root).isSymbolicLink()) throw new Error(`Refusing to clean symlinked directory: ${store.root}`);
|
|
37
|
+
return { ...store, candidates };
|
|
38
|
+
});
|
|
39
|
+
if (stores.every((store) => store.candidates.length === 0)) return { removed: 0, errors: [] };
|
|
34
40
|
|
|
35
41
|
const sessionsRoot = path.join(getAgentDir(), "sessions");
|
|
36
42
|
const directories = new Set([sessionsRoot]);
|
|
@@ -46,19 +52,21 @@ export function cleanSubChannels(currentMainId: string, currentSessionDir: strin
|
|
|
46
52
|
if (entry.name.endsWith(".jsonl")) live.add(sessionId(path.join(directory, entry.name)));
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
|
-
const liveDirectories = new Set([...live].map((id) => path.basename(path.dirname(channelPath(id, "unused")))));
|
|
50
55
|
let removed = 0;
|
|
51
56
|
const errors: string[] = [];
|
|
52
|
-
for (const
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
for (const store of stores) {
|
|
58
|
+
const liveDirectories = new Set([...live].map(store.ownerDirectory));
|
|
59
|
+
for (const entry of store.candidates) {
|
|
60
|
+
if (liveDirectories.has(entry.name)) continue;
|
|
61
|
+
const directory = path.join(store.root, entry.name);
|
|
62
|
+
try {
|
|
63
|
+
// Do not follow a directory replaced with a symlink during discovery.
|
|
64
|
+
if (!fs.lstatSync(directory).isDirectory()) continue;
|
|
65
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
66
|
+
removed++;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
errors.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`);
|
|
69
|
+
}
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
return { removed, errors };
|
|
@@ -66,12 +74,12 @@ export function cleanSubChannels(currentMainId: string, currentSessionDir: strin
|
|
|
66
74
|
|
|
67
75
|
export function registerCleanSubCommand(pi: ExtensionAPI): void {
|
|
68
76
|
pi.registerCommand("clean-sub", {
|
|
69
|
-
description: "Delete
|
|
77
|
+
description: "Delete managed Sub sessions and communication directories whose Main session can no longer be found.",
|
|
70
78
|
handler: async (_args, ctx) => {
|
|
71
79
|
try {
|
|
72
|
-
const result =
|
|
80
|
+
const result = cleanSubFiles(ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionDir());
|
|
73
81
|
ctx.ui.notify([
|
|
74
|
-
`Removed ${result.removed} orphaned
|
|
82
|
+
`Removed ${result.removed} orphaned Sub data directories (sessions and communication).`,
|
|
75
83
|
...result.errors,
|
|
76
84
|
].join("\n"), result.errors.length ? "warning" : "info");
|
|
77
85
|
} catch (error) {
|
package/src/inbox-state.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { MESSAGE_TYPE, type TalkDirection } from "./channel.js";
|
|
3
3
|
import type { DelegateManifest, TalkMessage } from "./types.js";
|
|
4
4
|
import { readTalkReceipt } from "./talk-message.js";
|
|
5
|
+
import { parentWakeFor } from "./parent-wake.js";
|
|
5
6
|
|
|
6
7
|
type ReadonlySessionManager = ExtensionContext["sessionManager"];
|
|
7
8
|
interface PendingTalk {
|
|
@@ -49,8 +50,8 @@ export function forgetTalk(ctx: ExtensionContext, direction: TalkDirection, runI
|
|
|
49
50
|
/** Queue behind streaming work, but never compete with manual compaction or prompt preflight. */
|
|
50
51
|
export function canUseNativeQueue(ctx: ExtensionContext): boolean {
|
|
51
52
|
const value = state(ctx);
|
|
52
|
-
//
|
|
53
|
-
//
|
|
53
|
+
// Serialize idle submissions until Pi starts the run or records its receipt.
|
|
54
|
+
// Once active, subsequent deliveries use Pi's native follow-up queue.
|
|
54
55
|
if (ctx.isIdle()) return ![...value.pending.values()].some((pending) => pending.queued && !pending.queuedWhileBusy);
|
|
55
56
|
return value.active || ctx.signal !== undefined;
|
|
56
57
|
}
|
|
@@ -68,6 +69,7 @@ export function bindInboxEvents(pi: ExtensionAPI, getContext: () => ExtensionCon
|
|
|
68
69
|
pi.on("agent_start", () => {
|
|
69
70
|
const ctx = getContext();
|
|
70
71
|
if (!ctx) return;
|
|
72
|
+
parentWakeFor(pi, ctx).agentStarted();
|
|
71
73
|
const value = state(ctx);
|
|
72
74
|
value.runSerial++;
|
|
73
75
|
value.aborted = ctx.signal?.aborted === true;
|
|
@@ -76,6 +78,10 @@ export function bindInboxEvents(pi: ExtensionAPI, getContext: () => ExtensionCon
|
|
|
76
78
|
ctx.signal?.addEventListener("abort", () => { if (value.runSerial === runSerial) value.aborted = true; }, { once: true });
|
|
77
79
|
wakePending();
|
|
78
80
|
});
|
|
81
|
+
pi.on("session_shutdown", (event, ctx) => {
|
|
82
|
+
// Use the event context: manager shutdown may already have cleared its active context.
|
|
83
|
+
parentWakeFor(pi, ctx).sessionShutdown(event.reason);
|
|
84
|
+
});
|
|
79
85
|
pi.on("agent_end", (event) => {
|
|
80
86
|
const ctx = getContext();
|
|
81
87
|
if (!ctx) return;
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ export default function piSubagent(pi: ExtensionAPI): void {
|
|
|
24
24
|
startupProfile.register();
|
|
25
25
|
new MainContextRuntime(pi).register();
|
|
26
26
|
|
|
27
|
-
//
|
|
27
|
+
// Render live deliveries and saved custom messages with the same inbox card.
|
|
28
28
|
pi.registerMessageRenderer(MESSAGE_TYPE, (message, options, theme) => {
|
|
29
29
|
const details = message.details as { title?: string; message?: string } | undefined;
|
|
30
30
|
const title = details?.title ?? "Sub";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Adapted from nicobailon/pi-subagents (MIT), src/shared/parent-wake.ts.
|
|
2
|
+
// Copyright (c) 2026 Nico Bailon. See docs/third-party-notices.md.
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
// Shared by Main and Sub. Peer content stays in the custom message, never in this prompt.
|
|
6
|
+
export const PARENT_WAKE_TEXT = "Process the new Pi Subagent inbox messages above and continue the task already authorized for this session. Follow your profile and communication protocol. Do not infer new user authorization.";
|
|
7
|
+
export const WAKE_PENDING_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
type WakeContext = Pick<ExtensionContext, "isIdle" | "sessionManager">;
|
|
10
|
+
type WakeReservation = { sessionId: string; sentAt?: number; failed?: boolean };
|
|
11
|
+
const reservationsSymbol = Symbol.for("pi-subagent.parent-wake-reservations.v1");
|
|
12
|
+
const wakeGlobal = globalThis as typeof globalThis & { [reservationsSymbol]?: WeakMap<object, WakeReservation> };
|
|
13
|
+
const reservations = wakeGlobal[reservationsSymbol] ?? (wakeGlobal[reservationsSymbol] = new WeakMap<object, WakeReservation>());
|
|
14
|
+
|
|
15
|
+
export interface ParentWake {
|
|
16
|
+
sendMessage(...args: Parameters<ExtensionAPI["sendMessage"]>): void;
|
|
17
|
+
/** A custom receipt alone must not acknowledge a synchronously failed wake. */
|
|
18
|
+
retryFailedWake(): boolean;
|
|
19
|
+
bindSession(ctx: WakeContext): void;
|
|
20
|
+
agentStarted(): void;
|
|
21
|
+
sessionShutdown(reason: string | undefined): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createParentWake(pi: Pick<ExtensionAPI, "sendMessage" | "sendUserMessage">, now: () => number = Date.now): ParentWake {
|
|
25
|
+
let ctx: WakeContext;
|
|
26
|
+
let reservation: WakeReservation = { sessionId: "" };
|
|
27
|
+
const wake = () => {
|
|
28
|
+
if (reservation.sentAt !== undefined && now() - reservation.sentAt < WAKE_PENDING_MS) return;
|
|
29
|
+
reservation.sentAt = now();
|
|
30
|
+
try {
|
|
31
|
+
// Steer safely queues the wake if another prompt starts a run first.
|
|
32
|
+
pi.sendUserMessage(PARENT_WAKE_TEXT, { deliverAs: "steer" });
|
|
33
|
+
reservation.failed = false;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
reservation.sentAt = undefined;
|
|
36
|
+
reservation.failed = true;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
sendMessage(message, options) {
|
|
42
|
+
if (options?.triggerTurn !== true || !ctx.isIdle()) {
|
|
43
|
+
pi.sendMessage(message, options);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Pi's idle custom-message wake bypasses before_agent_start (#5581/#10267).
|
|
47
|
+
// Retain custom rendering but use native user-input preparation to start the run.
|
|
48
|
+
pi.sendMessage(message, { triggerTurn: false });
|
|
49
|
+
wake();
|
|
50
|
+
},
|
|
51
|
+
retryFailedWake() {
|
|
52
|
+
if (!reservation.failed) return true;
|
|
53
|
+
if (!ctx.isIdle()) return false;
|
|
54
|
+
wake();
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
bindSession(context) {
|
|
58
|
+
ctx = context;
|
|
59
|
+
const sessionId = context.sessionManager.getSessionId();
|
|
60
|
+
const retained = reservations.get(context.sessionManager);
|
|
61
|
+
reservation = retained?.sessionId === sessionId ? retained : { sessionId };
|
|
62
|
+
reservations.set(context.sessionManager, reservation);
|
|
63
|
+
},
|
|
64
|
+
agentStarted() {
|
|
65
|
+
reservation.sentAt = undefined;
|
|
66
|
+
reservation.failed = false;
|
|
67
|
+
},
|
|
68
|
+
sessionShutdown(reason) {
|
|
69
|
+
if (reason !== "reload") {
|
|
70
|
+
reservation.sentAt = undefined;
|
|
71
|
+
reservation.failed = false;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const runtimes = new WeakMap<ExtensionAPI, ParentWake>();
|
|
78
|
+
export function parentWakeFor(pi: ExtensionAPI, ctx: WakeContext): ParentWake {
|
|
79
|
+
let wake = runtimes.get(pi);
|
|
80
|
+
if (!wake) {
|
|
81
|
+
wake = createParentWake(pi);
|
|
82
|
+
runtimes.set(pi, wake);
|
|
83
|
+
}
|
|
84
|
+
wake.bindSession(ctx);
|
|
85
|
+
return wake;
|
|
86
|
+
}
|
package/src/run-manager.ts
CHANGED
|
@@ -226,7 +226,7 @@ export class MainRunManager {
|
|
|
226
226
|
cwd,
|
|
227
227
|
projectTrusted,
|
|
228
228
|
...(input.origin ? { origin: input.origin } : {}),
|
|
229
|
-
...(resume ? { resumeSessionId: resume.sessionId } : {}),
|
|
229
|
+
...(resume ? { resumeSessionId: resume.sessionId, resumeSessionFile: resume.sessionFile } : {}),
|
|
230
230
|
profile: input.profile,
|
|
231
231
|
channelDir: channel.channelDir,
|
|
232
232
|
token: channel.token,
|
package/src/sessions.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import type { RunSnapshot } from "./types.js";
|
|
3
5
|
|
|
6
|
+
export function subSessionsRoot(): string {
|
|
7
|
+
return path.join(getAgentDir(), "subagent", "sessions");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function subSessionDir(mainSessionId: string): string {
|
|
11
|
+
if (!mainSessionId || mainSessionId === "." || mainSessionId === "..") throw new Error("Invalid Main session ID.");
|
|
12
|
+
return path.join(subSessionsRoot(), encodeURIComponent(mainSessionId));
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
export interface ResumableSubSession {
|
|
5
16
|
sessionId: string;
|
|
6
17
|
sessionFile: string;
|
package/src/talk-delivery.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { MESSAGE_TYPE, removeTalk, type TalkDirection } from "./channel.js";
|
|
3
3
|
import type { DelegateManifest, TalkMessage } from "./types.js";
|
|
4
|
-
import {
|
|
4
|
+
import { readTalkReceipt } from "./talk-message.js";
|
|
5
|
+
import { parentWakeFor } from "./parent-wake.js";
|
|
5
6
|
import { canUseNativeQueue, forgetTalk, markTalkQueued, rememberTalk } from "./inbox-state.js";
|
|
6
7
|
|
|
7
8
|
function hasReceipt(ctx: ExtensionContext, direction: TalkDirection, runId: string, messageId: string): boolean {
|
|
8
9
|
return ctx.sessionManager.getEntries().some((entry) => {
|
|
9
|
-
//
|
|
10
|
+
// Accept both custom receipts and user-message receipts from v0.1.0.
|
|
10
11
|
const details = entry.type === "message" ? readTalkReceipt(entry.message)
|
|
11
12
|
: entry.type === "custom_message" && entry.customType === MESSAGE_TYPE
|
|
12
13
|
? entry.details as { direction?: string; runId?: string; messageId?: string } | undefined
|
|
@@ -15,7 +16,7 @@ function hasReceipt(ctx: ExtensionContext, direction: TalkDirection, runId: stri
|
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/** pi.
|
|
19
|
+
/** pi.sendMessage is fire-and-forget; its return is not a session receipt. */
|
|
19
20
|
export function deliverTalk(
|
|
20
21
|
pi: ExtensionAPI,
|
|
21
22
|
ctx: ExtensionContext,
|
|
@@ -26,6 +27,7 @@ export function deliverTalk(
|
|
|
26
27
|
content: string,
|
|
27
28
|
): "acknowledged" | "waiting" | "sent" {
|
|
28
29
|
if (hasReceipt(ctx, direction, manifest.runId, message.id)) {
|
|
30
|
+
if (!parentWakeFor(pi, ctx).retryFailedWake()) return "waiting";
|
|
29
31
|
forgetTalk(ctx, direction, manifest.runId, message.id);
|
|
30
32
|
removeTalk(channelDir, direction, message.id);
|
|
31
33
|
return "acknowledged";
|
|
@@ -37,11 +39,14 @@ export function deliverTalk(
|
|
|
37
39
|
try {
|
|
38
40
|
// Pi owns waiting behind current work, not Pi Subagent. In-flight IDs prevent
|
|
39
41
|
// repeated file notifications from submitting the same follow-up twice.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
// Keep peer content in the custom inbox card. Idle runs use a separate,
|
|
43
|
+
// fixed user wake so Pi assembles the profile sections through its input pipeline.
|
|
44
|
+
parentWakeFor(pi, ctx).sendMessage({
|
|
45
|
+
customType: MESSAGE_TYPE,
|
|
46
|
+
content,
|
|
47
|
+
display: true,
|
|
48
|
+
details: { title: manifest.title, message: message.message, direction, runId: manifest.runId, messageId: message.id },
|
|
49
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
45
50
|
} catch (error) {
|
|
46
51
|
pending.queued = false;
|
|
47
52
|
throw error;
|
package/src/tools/sub.ts
CHANGED
|
@@ -154,7 +154,7 @@ export function registerSub(pi: ExtensionAPI): void {
|
|
|
154
154
|
return sections;
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
//
|
|
157
|
+
// Render live deliveries and saved custom messages with the same inbox card.
|
|
158
158
|
pi.registerMessageRenderer(MESSAGE_TYPE, (message, options, theme) => {
|
|
159
159
|
const details = message.details as { title?: string; message?: string } | undefined;
|
|
160
160
|
const title = details?.title ?? loaded.manifest.title;
|
package/src/types.ts
CHANGED
|
@@ -97,6 +97,8 @@ export interface SubLaunchSpec {
|
|
|
97
97
|
projectTrusted: boolean;
|
|
98
98
|
origin?: "manual";
|
|
99
99
|
resumeSessionId?: string;
|
|
100
|
+
/** Recorded path also supports sessions saved before per-Main directories. */
|
|
101
|
+
resumeSessionFile?: string;
|
|
100
102
|
profile: ResolvedProfile;
|
|
101
103
|
channelDir: string;
|
|
102
104
|
token: string;
|