@gavin-hu/my-pi-agent 0.17.0 → 0.18.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 (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. package/extensions/wechat/qrcode.ts +0 -46
@@ -6,13 +6,15 @@
6
6
  * fake and reusable by any host.
7
7
  */
8
8
 
9
- import { OTHER_LABEL, cancelledResult, completedResult } from "./answers.ts";
9
+ import { OTHER_LABEL, cancelledResult, collectAnswers, completedResult } from "./answers.ts";
10
10
  import type { Answer, AskResult, Question, Selection } from "./types.ts";
11
11
 
12
12
  export interface QuestionUI {
13
13
  select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined>;
14
14
  input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined>;
15
15
  editor(title: string, prefill?: string): Promise<string | undefined>;
16
+ /** Prompt language, when the host knows it. */
17
+ language?(): "zh" | "en";
16
18
  }
17
19
 
18
20
  const MULTI_INSTRUCTIONS = "Enter one or more option numbers or labels, separated by commas.";
@@ -56,8 +58,96 @@ export function parseMultiSelect(question: Question, text: string): Selection |
56
58
  return { type: "options", values, labels, indices };
57
59
  }
58
60
 
61
+ /** The one-message prompt for a multi-question ask. */
62
+ export function batchPrompt(questions: Question[], language: "zh" | "en" = "en"): string {
63
+ const zh = language === "zh";
64
+ const lines = [zh ? "请一次性回答所有问题:" : "Answer all questions in one message:", ""];
65
+ questions.forEach((question, index) => {
66
+ lines.push(`${index + 1}. ${question.question}`);
67
+ if (question.options.length === 0) {
68
+ lines.push(zh ? " 文字" : " text");
69
+ return;
70
+ }
71
+ const options = question.options.map((option, optionIndex) => `${optionIndex + 1}. ${option.label}`).join(" ");
72
+ lines.push(` ${options}${question.multiSelect ? (zh ? "(可多选)" : " (multi)") : ""}`);
73
+ });
74
+ lines.push("", zh ? "格式:1: 2;2: A, B;3: 文字" : "Format: 1: 2; 2: A, B; 3: text");
75
+ return lines.join("\n");
76
+ }
77
+
78
+ /**
79
+ * Parse one batch reply. Each line/semicolon chunk starts with the question
80
+ * number; a chunk with no prefix makes the whole reply ambiguous.
81
+ */
82
+ export function parseBatchAnswers(questions: Question[], text: string): Map<string, Selection> | undefined {
83
+ const entries = new Map<number, string>();
84
+ for (const chunk of text.split(/[\n;]+/)) {
85
+ const match = /^\s*(\d+)\s*[:.)-]\s*(.*)$/.exec(chunk);
86
+ if (!match) {
87
+ if (chunk.trim() !== "") return undefined;
88
+ continue;
89
+ }
90
+ const index = Number.parseInt(match[1] ?? "", 10);
91
+ if (index < 1 || index > questions.length) return undefined;
92
+ entries.set(index, (match[2] ?? "").trim());
93
+ }
94
+ const selections = new Map<string, Selection>();
95
+ for (const [index, question] of questions.entries()) {
96
+ const raw = entries.get(index + 1);
97
+ if (raw === undefined || raw === "") return undefined;
98
+ if (question.options.length === 0) {
99
+ selections.set(question.id, { type: "custom", text: raw });
100
+ continue;
101
+ }
102
+ if (question.multiSelect) {
103
+ const selection = parseMultiSelect(question, raw);
104
+ if (!selection) return undefined;
105
+ selections.set(question.id, selection);
106
+ continue;
107
+ }
108
+ const asNumber = Number.parseInt(raw, 10);
109
+ const optionIndex =
110
+ String(asNumber) === raw && asNumber >= 1 && asNumber <= question.options.length
111
+ ? asNumber - 1
112
+ : question.options.findIndex((option) => option.label.toLowerCase() === raw.toLowerCase());
113
+ if (optionIndex >= 0) {
114
+ const option = question.options[optionIndex];
115
+ if (!option) return undefined;
116
+ selections.set(question.id, {
117
+ type: "options",
118
+ values: [option.label],
119
+ labels: [option.label],
120
+ indices: [optionIndex + 1],
121
+ });
122
+ continue;
123
+ }
124
+ if (!question.allowOther) return undefined;
125
+ selections.set(question.id, { type: "custom", text: raw });
126
+ }
127
+ return selections;
128
+ }
129
+
130
+ /** One batch editor, with one retry only when the format was not understood. */
131
+ async function askViaBatch(ui: QuestionUI, questions: Question[], signal?: AbortSignal): Promise<AskResult> {
132
+ const language = ui.language?.() ?? "en";
133
+ const prompt = batchPrompt(questions, language);
134
+ const first = await ui.editor(prompt, "");
135
+ if (first === undefined) return cancelledResult(questions);
136
+ const parsed = parseBatchAnswers(questions, first);
137
+ if (parsed) return completedResult(questions, parsed);
138
+ if (signal?.aborted) return cancelledResult(questions);
139
+ const retry = await ui.editor(
140
+ `${prompt}\n\n${language === "zh" ? "格式不对,请按“1: 答案”重发。" : "Please reply again as “1: answer”."}`,
141
+ "",
142
+ );
143
+ if (retry === undefined) return cancelledResult(questions);
144
+ const again = parseBatchAnswers(questions, retry);
145
+ return again ? completedResult(questions, again) : cancelledResult(questions, collectAnswers(questions, new Map()));
146
+ }
147
+
59
148
  /** Ask every question through forwarded dialogs, stopping on cancel or abort. */
60
149
  export async function askViaDialogs(ui: QuestionUI, questions: Question[], signal?: AbortSignal): Promise<AskResult> {
150
+ if (questions.length > 1) return askViaBatch(ui, questions, signal);
61
151
  const selections = new Map<string, Selection>();
62
152
  const opts = { signal };
63
153
 
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
- import { askHuman } from "../../lib/interaction.ts";
6
+ import { askHuman, remoteLanguage } from "../../lib/interaction.ts";
7
7
  import { sanitizeLine } from "../../lib/format.ts";
8
8
  import { withRailsSuppressed } from "../../lib/rails.ts";
9
9
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
@@ -25,6 +25,7 @@ function dialogUI(ctx: ExtensionContext): QuestionUI {
25
25
  select: (title, options, opts) => ui.select(title, options, opts),
26
26
  input: (title, placeholder, opts) => ui.input(title, placeholder, opts),
27
27
  editor: (title, prefill) => ui.editor(title, prefill),
28
+ language: () => remoteLanguage(ctx) ?? "en",
28
29
  };
29
30
  }
30
31
 
@@ -0,0 +1,247 @@
1
+ # session-bus — coordinate parallel Pi sessions
2
+
3
+ A local coordination bus for Pi sessions on the same machine and repository.
4
+ Sessions discover each other, exchange short messages and events, consume an
5
+ inbox, and claim paths before editing. Delivery is pull-based, and a session can
6
+ arm an inbox watch to be told about new mail: a report at its next turn, or —
7
+ with `wake` — one raised turn as soon as the agent is idle, including right after
8
+ a running turn settles. No network service, and no hidden context.
9
+
10
+ <img src="../../assets/session.png" alt="The /sessions screen: a counts summary, session rows with unread and claim counts, and the focused session's cwd, claims, and identity" width="100%">
11
+
12
+ ```bash
13
+ pi --extension ./extensions/session-bus # load just this extension
14
+ pi -e . # load the whole package
15
+ ```
16
+
17
+ ## What it does
18
+
19
+ - Registers one `session` tool and the `/sessions` command; in the TUI the command
20
+ opens a live board with Sessions, Inbox, and Claims scopes.
21
+ - Stores one record per session, one file per message, and one file per claim
22
+ under `<agent-dir>/session-bus/<repo-key>/`; the key is the repository’s common
23
+ git directory, so worktrees meet on one bus.
24
+ - A TUI session joins on `session_start`; RPC, JSON, print, and WeChat peer
25
+ sessions join only when the tool or `/sessions` is used. A subagent or a phone
26
+ peer does not appear merely because the package is loaded.
27
+ - A heartbeat keeps a joined session visible; a stale session is pruned after ten
28
+ minutes, its claims are released, and inbox messages expire after 24 hours.
29
+ - An armed inbox watch polls this session’s own inbox directory and queues what
30
+ arrived. It announces; `inbox` still consumes.
31
+ - Writes are atomic and private to the OS user; there is no server, no network,
32
+ and no cross-user authorization.
33
+
34
+ ## Commands
35
+
36
+ | Command | Effect |
37
+ |---|---|
38
+ | `/sessions` | Open the board: `Tab` cycles Sessions, Inbox, and Claims. `Enter` opens the focused row, `w` arms or disarms this session's inbox watch, and in Claims `x` releases a claim you hold after a y/N confirmation. A non-TUI caller prints the listing and joins. |
39
+ | `/sessions name <label>` | Name this session for the bus and for Pi. The label is unique among active sessions, and it becomes the session name the footer shows and `/resume` lists. |
40
+ | `/sessions watch` | Report new mail at this session’s next turn. |
41
+ | `/sessions watch wake` | Report new mail, and raise one turn as soon as the agent is idle — including right after a running turn settles. |
42
+ | `/sessions watch off` | Stop the inbox watch. |
43
+ | `/sessions help` | Show the command usage. |
44
+
45
+ Labels are how another session addresses you. Name one by the outcome it owns
46
+ (`billing-core`, `integrator`, `verify-billing`), not by a layer or a role;
47
+ duplicate labels get a short id suffix automatically.
48
+
49
+ Naming also sets Pi's session name, so the footer's `[label]` and `/resume` agree
50
+ with the bus. Because the bus derives a joining session's label from that name, a
51
+ named session comes back under the same label after a resume.
52
+
53
+ The tool's `name` action is the same rename from the model's side, so a lead can
54
+ name itself once the roster card has told it how — the one line of the card it
55
+ cannot get any other way.
56
+
57
+ ## Tools
58
+
59
+ | Action | Inputs | Result |
60
+ |---|---|---|
61
+ | `status` | — | Active sessions with unread counts, claims, and this session’s identity, plus `units n/m` for the newest split in this repository. Prunes stale records. |
62
+ | `send` | `to`, `text`, optional `kind`, optional `paths` | Writes one message per recipient. `to: "*"` is only valid with `kind: "event"`. |
63
+ | `inbox` | optional `limit` | Returns and deletes up to `limit` messages, oldest first (default 20, max 50). |
64
+ | `claim` | `paths` | Claims repository-relative files or directory prefixes; returns conflicts with their owner. |
65
+ | `release` | `paths` | Removes claims owned by this session; reports paths owned by someone else. |
66
+ | `join` | `team`, `unit` | Claims every path of one unit in an approved split. Needs the plan's `## Team` section, so it refuses a plan that was never split, and needs `plan.split` on. Idempotent: joining twice is the same claims. |
67
+ | `leave` | optional `team`, `unit` | Releases that unit's paths, or every claim this session holds when neither is given. Never gated by the switch. |
68
+ | `watch` | optional `wake`, optional `stop` | Arms this session’s inbox watch, or turns it off with `stop`. `wake: true` raises one turn per burst once the agent is idle; `wake: false` reports at the next turn; omitting it keeps an armed watch’s mode. |
69
+ | `name` | `label` | Renames this session on the bus and in Pi — the same path as `/sessions name`, with the same uniqueness rule, so the footer and `/resume` agree with the record peers address. An empty label is refused rather than keeping the old one, and a label a live session holds is refused with its owner named. The result reports the label the bus settled on. |
70
+
71
+ Message kinds are `message` (default), `event`, `ask`, and `handoff`. An `ask` is
72
+ still a message: it does not block or wait for a reply. Text is capped at 4,000
73
+ characters. Paths resolve against the caller’s working directory and are stored
74
+ relative to its repository root; a path outside the repository is refused.
75
+
76
+ A claim is all-or-nothing: if one path conflicts, nothing is claimed. A claim on
77
+ a directory conflicts with claims below it and above it, so `src/api` and
78
+ `src/api/routes.ts` cannot be held by different sessions. There is no `force`
79
+ takeover; wait for the owner, ask it to release, or let the session expire.
80
+
81
+ `wake` is the mode, not an arm-time switch: `/sessions watch wake` raises a turn
82
+ for a watch that was reporting at the next turn, and `/sessions watch` — or
83
+ `wake: false` — sets it back. The board’s `w` only arms and disarms, and it arms
84
+ a plain watch; set `wake` with the command or the tool. Nothing arms on
85
+ `session_start`; a session that never asks stays pull-only.
86
+
87
+ ## Parallel workflow
88
+
89
+ 1. The user names each session (`/sessions name`), and the contract (OpenAPI,
90
+ schema, or types) is written before parallel work starts.
91
+ 2. When the plan was approved with a split, each session `join`s its unit once —
92
+ that claims every path the unit owns, so nobody types a path list by hand.
93
+ Otherwise each session claims its paths. Then it implements its side.
94
+ 3. A contract, build, or test change goes out as an event:
95
+ `session({action:"send", to:"*", kind:"event", text:"contract v2: ..."})`.
96
+ 4. Other sessions consume `inbox` at the start of their next turn, or arm
97
+ `session({action:"watch", wake:true})` so a message raises their turn as soon
98
+ as they are idle; a wake that arrives mid-turn is raised once that run
99
+ settles, never pushed into the running turn.
100
+ 5. The lead merges the worktrees and runs the project’s full check.
101
+
102
+ The bus carries coordination, not authority. The plan, todo list, contract, and
103
+ git branch remain the source of truth.
104
+
105
+ ### Joining a split
106
+
107
+ A plan approval can write a `## Team` table into the plan file, which is where a
108
+ joining session reads its unit from:
109
+
110
+ ```text
111
+ session({action:"join", team:"2026-11-04-1802-billing", unit:"core"})
112
+ → Joined 2026-11-04-1802-billing/core: claimed src/billing/core.
113
+ ```
114
+
115
+ `team` is the plan file's stem, so two plans in one repository never share a team.
116
+ The unit's paths come from the table the same module wrote, so a hand-edited or
117
+ stale section is refused rather than half-read; an unknown unit names the units
118
+ that exist; a taken unit names its holder; and with `plan.split` off `join`
119
+ refuses and names the settings row. `status` reports `units 1/2 joined` with the
120
+ holder of each unit, which is how a lead answers "has everyone started?" without
121
+ messaging anyone.
122
+
123
+ A claim belongs to the live session, not to a branch: a session that exits
124
+ releases its unit, so a restarted owner joins again (the join is idempotent), and
125
+ a crashed one frees the unit when its record goes stale. That is why the roster
126
+ card asks you to name each session — an unnamed one joins under its working
127
+ directory's name, which says nothing about the unit it holds.
128
+
129
+ ## Behaviour by mode
130
+
131
+ | Mode | Behaviour |
132
+ |---|---|
133
+ | `tui` | Joins automatically on `session_start`. `/sessions` opens the board: sessions, this session's unread mail, and every claim, with `Tab` between them. |
134
+ | `rpc`, `json`, `print` | Does not join automatically. The first `session` call or `/sessions` joins; this is how a subagent or an RPC-driven session opts in. |
135
+ | WeChat peer | Same as `rpc`: the peer is a separate AgentSession in the bridge process and joins only on explicit use. Messages are read by that peer’s model; the bridge does not forward them to the phone. |
136
+
137
+ Every mode can arm the inbox watch; the notice is a visible transcript message, so
138
+ a `wake` raises that session’s own turn, not a phone notification.
139
+
140
+ The TUI opens the board; RPC, JSON, and print print the compact listing. The board
141
+ changes only this session's own state: `w` toggles its watch and `x` releases a
142
+ claim it holds. Mail is previewed, not consumed — `session inbox` consumes it —
143
+ and no view shows another session's inbox or releases its claims.
144
+
145
+ ## Configuration
146
+
147
+ None in v1. The heartbeat (10 s), watch poll (2 s), session TTL (10 min), message
148
+ TTL (24 h), text cap, inbox limit, and preview cap are constants in the extension.
149
+ The bus lives under the agent directory, not the project, so it is never committed.
150
+
151
+ ## Limitations
152
+
153
+ - **Same machine, same OS user.** Any process with that user’s file access can
154
+ read or write the bus; there is no authentication between sessions.
155
+ - **Pull-based by default.** A message does not wake a session that did not arm
156
+ its inbox watch. Agents must call `inbox` at coordination points.
157
+ - **Arming is per session and session-only.** It survives neither a resume nor a
158
+ new process; unread mail is previewed once more after `/resume`.
159
+ - **The board acts on your own state only.** `w` arms or disarms this session's
160
+ inbox watch and `x` releases a claim it holds. Mail is a preview, not a consume:
161
+ the board never deletes a message, never marks one reported, and never touches a
162
+ peer's inbox or claim.
163
+ - **Two armed peers can ping-pong.** Each reply wakes the other as soon as it is
164
+ idle. A wake raises one turn per burst, and `inbox` consumes, so the loop needs
165
+ both sides to keep replying; the answer is to disarm one of them.
166
+ - **No broadcast history.** A broadcast reaches the sessions active at send time;
167
+ a session that joins later does not receive it.
168
+ - **No forced claims.** A crashed owner blocks its paths until the session TTL
169
+ expires. This is deliberate; stealing a claim is worse than waiting.
170
+ - **Bounded by TTL.** An idle session with a stopped heartbeat is pruned after
171
+ ten minutes; its record, its claims, and an inbox directory left empty go with
172
+ it, and its unread mail expires after 24 hours.
173
+ - **A message file that does not parse is dropped.** The board's read never
174
+ consumes mail, but it does delete a file it cannot validate, so a truncated
175
+ message cannot linger on the bus.
176
+ - **No network.** It is not a distributed bus and does not synchronize machines.
177
+
178
+ ## Design notes
179
+
180
+ - **Common git dir as the key.** `git rev-parse --git-common-dir` gives all
181
+ worktrees of one repository the same bus; `--show-toplevel` normalizes claim
182
+ paths within each worktree. Non-repository sessions fall back to their cwd.
183
+ - **One path, one claim.** The cwd is canonicalized at join and every claim or
184
+ release is re-keyed by its real target, so a working directory reached through
185
+ a symlink still resolves inside its own repository, a link inside the repository
186
+ and the file it names are the same claim, and a link that leaves the repository
187
+ is refused. The automatic label is the real basename of the cwd, so a session
188
+ started through a link named differently is named after the directory it is
189
+ actually in. An absolute path argument that sits outside the repository is
190
+ refused before it is re-keyed, and a relative one whose target leaves the
191
+ repository is refused by the re-keying itself.
192
+ - **One file per item.** Sessions never append to a shared registry: each session
193
+ writes its own record, each message is its own file, and a claim is a temp file
194
+ hard-linked into place — an exclusive create no reader can see half-written.
195
+ This avoids a cross-process lock for the common path.
196
+ - **Lazy join.** Loading a package extension must not make every subagent and
197
+ WeChat peer a team member. Auto-join only in `tui`; every other mode opts in by
198
+ using the tool or command.
199
+ - **Sanitized at the boundary.** Messages are model-authored and are sanitized
200
+ before rendering, like other untrusted text in the transcript.
201
+ - **Poll, not `fs.watch`.** The inbox watch reads this session's inbox directory
202
+ with one `readdir` every 2 s, the same trade `watch` makes for a file tail, so
203
+ it does not depend on a platform's event API or a filename in the event. Only
204
+ an armed session polls.
205
+ - **Read the board, prune on the next call.** The open board polls a read-only
206
+ `snapshot()` and `peek()` that hide a session past the TTL, a claim held by one,
207
+ and consume nothing. The physical prune, and the claim release that goes with
208
+ it, happen on the next `status` or `claim` call — which is the call that reader
209
+ is about to make.
210
+ - **Release by the stored path.** A claim is stored relative to the repo root of
211
+ the session that took it, so releasing one resolves nothing: re-resolving it
212
+ against a cwd below that root would target `src/src/api`. The screen refuses a
213
+ peer's claim before asking the store, which checks the owner again.
214
+ - **A pane is a log or prose.** `DetailContents.text` wraps at the render width
215
+ and `tail: false` opens it at the top, so a message reads as prose while a job
216
+ log keeps following its tail.
217
+ - **The notice reuses the shared report channel.** `lib/report.ts` already owns
218
+ whether an outcome may raise a turn; the bus supplies the pending mail and the
219
+ text, and bytefly renders the notice like a `job` or `watch` one.
220
+
221
+ ## Pi integration
222
+
223
+ | Point | Detail |
224
+ |---|---|
225
+ | Tools | `session` — one action-shaped tool; `readOnlyHint: false`, `destructiveHint: true`, `openWorldHint: false`, `executionMode: "sequential"`. |
226
+ | Commands | `/sessions` (board in `tui`, listing elsewhere), `/sessions name <label>`, `/sessions watch [off\|wake]`, `/sessions help`. |
227
+ | UI | `tui` mounts `SessionListComponent` through `withPolledScreen` (rails suppressed, a 1 s read-only poll of `snapshot()` + `peek()`); every other mode prints through `ctx.ui.notify`. No status chip, so the watch never changes the footer. |
228
+ | Events | `session_start` resolves the repository and joins a TUI session and arms the mail trigger; `before_agent_start` reports queued non-wake mail at the next turn, `agent_settled` retries a wake the run suppressed, and `context` prunes stale notices; `session_shutdown` clears the heartbeat, the watch ticker, and the record. The seven hooks are `lib/report.ts`'s `registerPendingChannel`, not a local copy. |
229
+ | State | Files under `<agent-dir>/session-bus/<repo-key>/`; nothing in the session branch. The armed watch is session-only. `/resume` re-registers the same session id. |
230
+ | Lifecycle | The factory only registers. The runtime starts on `session_start`; heartbeat, watch ticker, and cleanup belong to it. Disabling `session-bus` through `PI_DISABLED_EXTENSIONS` registers nothing. |
231
+ | Mode | Uses `ctx.mode` to decide auto-join and whether `/sessions` mounts the board; `askHuman` is not involved and no status chip is published. |
232
+
233
+ ## File map
234
+
235
+ | File | Responsibility |
236
+ |---|---|
237
+ | `index.ts` | Disable guard, registration, the pending-report channel, and the session hooks. |
238
+ | `runtime.ts` | Lifecycle, repo key, lazy join, heartbeat, inbox watch and its poll, operations. |
239
+ | `store.ts` | Atomic JSON files, inbox, peek, claims, pruning. |
240
+ | `protocol.ts` | Pure labels, targets, paths, validation, conflict rules. |
241
+ | `tui.ts` | The `/sessions` board, its scopes, and the pane over one row; the row grammar is in `rows.ts`. |
242
+ | `rows.ts` | The session/claim/message row builders and summaries shared by the board and the transcript. |
243
+ | `result.ts` | The `session` tool's expanded result block and its collapsed line. |
244
+ | `notice.ts` | The mail notice's model text and renderer details. |
245
+ | `tools.ts` | The `session` tool and its renderer. |
246
+ | `commands.ts` | `/sessions`. |
247
+ | `format.ts` | Model-facing listing and result text. |
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The `/sessions` command.
3
+ *
4
+ * In the TUI, bare `/sessions` opens the live board: `Tab` cycles Sessions,
5
+ * Inbox, and Claims, and `w`/`x` act on this session's own watch and claims.
6
+ * Every other mode prints the compact listing, because there is no dock to mount
7
+ * the screen in. `name <label>` gives this session a stable target and `watch
8
+ * [off|wake]` controls the inbox watch from either surface.
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { messageOf } from "../../lib/errors.ts";
13
+ import { sanitizeLine } from "../../lib/format.ts";
14
+ import { withPolledScreen } from "../../lib/screen-session.ts";
15
+ import { formatStatus, formatWatch } from "./format.ts";
16
+ import { COMMAND_NAME } from "./protocol.ts";
17
+ import type { SessionBus } from "./runtime.ts";
18
+ import { SessionListComponent } from "./tui.ts";
19
+
20
+ const USAGE = "/sessions [name <label>|watch [off|wake]]";
21
+ const WATCH_MODES = ["off", "wake"];
22
+
23
+ /** How often the open board re-reads the bus. */
24
+ const BOARD_POLL_MS = 1_000;
25
+
26
+ /**
27
+ * One board read at a time, shared by the open screen's poll and its watch verb.
28
+ *
29
+ * A read that is still running is skipped rather than queued, and a failed read
30
+ * leaves the last board on screen; the bus's own heartbeat keeps this session's
31
+ * record fresh, so a poll never has to write. Both reads are read-only: the
32
+ * snapshot neither prunes nor refreshes, and the peek neither consumes nor marks.
33
+ */
34
+ function createBoardReader(bus: SessionBus): (screen: SessionListComponent) => Promise<void> {
35
+ let busy = false;
36
+ return async (screen) => {
37
+ if (busy) return;
38
+ busy = true;
39
+ try {
40
+ const [status, messages] = await Promise.all([bus.snapshot(), bus.peek()]);
41
+ screen.setBoard({ status, messages });
42
+ } catch {
43
+ // Best-effort: the next tick tries again, and the board keeps its last read.
44
+ } finally {
45
+ busy = false;
46
+ }
47
+ };
48
+ }
49
+
50
+ /** Open the TUI board, and resolve when it closes. */
51
+ async function openBoard(pi: ExtensionAPI, ctx: ExtensionCommandContext, bus: SessionBus): Promise<void> {
52
+ await bus.start(ctx);
53
+ const [status, messages] = await Promise.all([bus.snapshot(), bus.peek()]);
54
+ const first = { status, messages };
55
+ let read: ((screen: SessionListComponent) => Promise<void>) | undefined;
56
+ await withPolledScreen(
57
+ pi,
58
+ ctx,
59
+ (screen) => {
60
+ const reader = createBoardReader(bus);
61
+ read = reader;
62
+ // The verbs repaint the board they belong to; their callbacks only run after
63
+ // construction, so a holder keeps the wiring one-directional.
64
+ const holder: { screen?: SessionListComponent } = {};
65
+ const component = new SessionListComponent({
66
+ latest: () => first,
67
+ theme: screen.theme,
68
+ callbacks: {
69
+ toggleWatch: async (armed) => {
70
+ const watching = await bus.watch({ stop: armed });
71
+ if (holder.screen) await reader(holder.screen);
72
+ return formatWatch(watching);
73
+ },
74
+ releaseClaim: async (path) => {
75
+ const released = await bus.releaseStored(path);
76
+ if (holder.screen) await reader(holder.screen);
77
+ const name = sanitizeLine(path);
78
+ return released ? `Released ${name}.` : `${name} is no longer yours.`;
79
+ },
80
+ },
81
+ onClose: screen.close,
82
+ requestRender: screen.requestRender,
83
+ viewportRowsSource: screen.viewportRows,
84
+ });
85
+ holder.screen = component;
86
+ return component;
87
+ },
88
+ { pollMs: BOARD_POLL_MS, poll: (component) => void read?.(component) },
89
+ );
90
+ }
91
+
92
+ async function open(ctx: ExtensionCommandContext, pi: ExtensionAPI, bus: SessionBus): Promise<void> {
93
+ try {
94
+ if (ctx.mode === "tui") {
95
+ await openBoard(pi, ctx, bus);
96
+ return;
97
+ }
98
+ await bus.start(ctx);
99
+ ctx.ui.notify(formatStatus(await bus.status()), "info");
100
+ } catch (error) {
101
+ ctx.ui.notify(`Session bus: ${messageOf(error)}`, "warning");
102
+ }
103
+ }
104
+
105
+ export function registerCommands(pi: ExtensionAPI, bus: SessionBus): void {
106
+ pi.registerCommand(COMMAND_NAME, {
107
+ description: "List parallel Pi sessions, their unread counts and claims; name this session",
108
+ getArgumentCompletions: (prefix) => {
109
+ const text = prefix.trimStart();
110
+ if (text.includes(" ")) return null;
111
+ const options = ["name", "help", "watch"].filter((value) => value.startsWith(text));
112
+ return options.length > 0 ? options.map((value) => ({ value, label: value })) : null;
113
+ },
114
+ handler: async (args, ctx) => {
115
+ const [sub = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
116
+ if (sub === "help") {
117
+ ctx.ui.notify(
118
+ `/sessions — open the bus board\n/sessions name <label> — name this session\n` +
119
+ `/sessions watch [off|wake] — report new mail at the next turn, or raise one\n\n${USAGE}`,
120
+ "info",
121
+ );
122
+ return;
123
+ }
124
+ if (sub === "watch") {
125
+ const mode = rest[0] ?? "";
126
+ if (mode !== "" && !WATCH_MODES.includes(mode)) {
127
+ ctx.ui.notify(`Unknown watch mode "${mode}". Usage: /sessions watch [off|wake]`, "warning");
128
+ return;
129
+ }
130
+ try {
131
+ await bus.start(ctx);
132
+ const status = await bus.status();
133
+ const watching = await bus.watch({ stop: mode === "off", wake: mode === "wake" });
134
+ ctx.ui.notify(formatWatch(watching, status.me.label), "info");
135
+ } catch (error) {
136
+ ctx.ui.notify(`Session bus: ${messageOf(error)}`, "warning");
137
+ }
138
+ return;
139
+ }
140
+ if (sub === "name") {
141
+ const label = rest.join(" ").trim();
142
+ if (!label) {
143
+ ctx.ui.notify("Usage: /sessions name <label>", "warning");
144
+ return;
145
+ }
146
+ try {
147
+ await bus.start(ctx);
148
+ await bus.status();
149
+ const renamed = await bus.rename(label);
150
+ // The bus validates first, so a rejected label leaves both names alone.
151
+ pi.setSessionName(renamed);
152
+ ctx.ui.notify(`This session is now named "${renamed}".`, "info");
153
+ } catch (error) {
154
+ ctx.ui.notify(`Session bus: ${messageOf(error)}`, "warning");
155
+ }
156
+ return;
157
+ }
158
+ if (sub !== "") {
159
+ ctx.ui.notify(`Unknown sessions command "${sub}". Try ${USAGE}.`, "warning");
160
+ return;
161
+ }
162
+ await open(ctx, pi, bus);
163
+ },
164
+ });
165
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Model-facing text for the session bus.
3
+ *
4
+ * Everything that came from another session is untrusted text: it is sanitized
5
+ * before it reaches the transcript, and paths/labels are rendered as one line.
6
+ */
7
+
8
+ import { sanitizeLine } from "../../lib/format.ts";
9
+ import type { BusMessage, ClaimConflict, ClaimRecord, SessionStatus } from "./types.ts";
10
+ /** A short id suffix, neutralized like every other peer-supplied field. */
11
+ export function shortId(id: string): string {
12
+ return sanitizeLine(id.slice(-6));
13
+ }
14
+
15
+ function claimLine(claim: ClaimRecord): string {
16
+ return `${sanitizeLine(claim.path)} · ${sanitizeLine(claim.ownerLabel)}`;
17
+ }
18
+
19
+ /** The status listing: sessions, unread counts, claims, and this session's alerts. */
20
+ export function formatStatus(status: SessionStatus): string {
21
+ const sessions = status.sessions.map((session) => {
22
+ const unread = session.unread > 0 ? ` · ${session.unread} unread` : "";
23
+ const label = sanitizeLine(session.label);
24
+ const cwd = sanitizeLine(session.cwd);
25
+ return `- ${label} (${shortId(session.id)}) · ${cwd} · ${sanitizeLine(session.mode)}${unread}`;
26
+ });
27
+ const claims = status.claims.map((claim) => `- ${claimLine(claim)}`);
28
+ const alerts = status.watching ? [`Alerts: inbox watch on${status.watching.wake ? " (wake)" : ""}`] : [];
29
+ const units = status.units ? [unitLine(status.units)] : [];
30
+ return [
31
+ `Sessions (${status.sessions.length})`,
32
+ ...(sessions.length > 0 ? sessions : ["- none"]),
33
+ "",
34
+ `Claims (${status.claims.length})`,
35
+ ...(claims.length > 0 ? claims : ["- none"]),
36
+ ...(units.length > 0 ? ["", ...units] : []),
37
+ ...(alerts.length > 0 ? ["", ...alerts] : []),
38
+ "",
39
+ `You: ${sanitizeLine(status.me.label)} (${shortId(status.me.id)})`,
40
+ ].join("\n");
41
+ }
42
+
43
+ /**
44
+ * The newest split in this repository: joined over total, then each unit with
45
+ * its holder. Peer-supplied labels are sanitized like every other field.
46
+ */
47
+ function unitLine(units: NonNullable<SessionStatus["units"]>): string {
48
+ const rows = units.rows.map(
49
+ (row) => `${sanitizeLine(row.unit)}${row.lead ? " (lead)" : ""} · ${row.holder ? sanitizeLine(row.holder) : "—"}`,
50
+ );
51
+ return [
52
+ `Units ${units.joined}/${units.total} joined · ${sanitizeLine(units.team)}`,
53
+ ...rows.map((row) => `- ${row}`),
54
+ ].join("\n");
55
+ }
56
+
57
+ /** The rename result: the label the bus settled on, which peers now address. */
58
+ export function formatName(label: string): string {
59
+ return `This session is now named "${sanitizeLine(label)}".`;
60
+ }
61
+
62
+ /** The inbox-watch result. */
63
+ export function formatWatch(watching: { armed: boolean; wake: boolean }, name?: string): string {
64
+ const owner = name ? ` for "${sanitizeLine(name)}"` : "";
65
+ if (!watching.armed) return `Inbox watch off${owner}.`;
66
+ return watching.wake
67
+ ? `Inbox watch on${owner}: a new message raises one turn.`
68
+ : `Inbox watch on${owner}: new messages appear at your next turn.`;
69
+ }
70
+
71
+ /** One consumed inbox, oldest first. */
72
+ export function formatInbox(messages: readonly BusMessage[]): string {
73
+ if (messages.length === 0) return "Inbox empty.";
74
+ return messages
75
+ .map((message, index) => {
76
+ const paths = message.paths?.length
77
+ ? `\n paths: ${message.paths.map((path) => sanitizeLine(path)).join(", ")}`
78
+ : "";
79
+ const from = sanitizeLine(message.fromLabel);
80
+ return `${index + 1}. ${from} · ${sanitizeLine(message.kind)} · ${sanitizeLine(message.text)}${paths}`;
81
+ })
82
+ .join("\n");
83
+ }
84
+
85
+ /** Confirmation for a send. */
86
+ export function formatSend(delivered: readonly string[], messageId: string): string {
87
+ if (delivered.length === 0) return "Delivered to 0 sessions.";
88
+ const names = delivered.map((label) => sanitizeLine(label)).join(", ");
89
+ return `Delivered to ${names} (${sanitizeLine(messageId)}).`;
90
+ }
91
+
92
+ /** Claim result, including the owner that blocked it. */
93
+ export function formatClaim(claimed: readonly string[], conflicts: readonly ClaimConflict[]): string {
94
+ const lines: string[] = [];
95
+ if (claimed.length > 0) lines.push(`Claimed: ${claimed.map((path) => sanitizeLine(path)).join(", ")}.`);
96
+ for (const conflict of conflicts) {
97
+ const owner = sanitizeLine(conflict.ownerLabel);
98
+ lines.push(`${sanitizeLine(conflict.path)} is owned by ${owner} (${shortId(conflict.owner)}).`);
99
+ }
100
+ return lines.length > 0 ? lines.join("\n") : "Nothing claimed.";
101
+ }
102
+
103
+ /** Join result: which unit, and what it claimed or could not. */
104
+ export function formatJoin(
105
+ team: string,
106
+ unit: string,
107
+ claimed: readonly string[],
108
+ conflicts: readonly ClaimConflict[],
109
+ ): string {
110
+ const where = `${sanitizeLine(team)}/${sanitizeLine(unit)}`;
111
+ if (conflicts.length > 0) {
112
+ return [`Could not join ${where}.`, ...formatClaim([], conflicts).split("\n").filter(Boolean)].join("\n");
113
+ }
114
+ const paths = claimed.map((path) => sanitizeLine(path)).join(", ");
115
+ return claimed.length > 0 ? `Joined ${where}: claimed ${paths}.` : `Joined ${where}: nothing to claim.`;
116
+ }
117
+
118
+ /** Release result. */
119
+ export function formatRelease(released: readonly string[], notOwned: readonly string[]): string {
120
+ const lines: string[] = [];
121
+ if (released.length > 0) lines.push(`Released: ${released.map((path) => sanitizeLine(path)).join(", ")}.`);
122
+ if (notOwned.length > 0) lines.push(`Not yours: ${notOwned.map((path) => sanitizeLine(path)).join(", ")}.`);
123
+ return lines.length > 0 ? lines.join("\n") : "Nothing to release.";
124
+ }