@gavin-hu/my-pi-agent 0.16.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.
- package/README.md +29 -15
- package/README.zh-CN.md +27 -15
- package/extensions/bytefly/README.md +195 -115
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +29 -39
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +89 -22
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +74 -459
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +26 -152
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +27 -6
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +10 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +35 -52
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/README.md +3 -3
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +333 -84
- package/extensions/plan/commands.ts +73 -7
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +184 -58
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +95 -95
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +123 -107
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +17 -12
- package/extensions/plan/tools/exit.ts +148 -59
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +29 -31
- package/extensions/plan/tools.ts +6 -3
- package/extensions/plan/tui.ts +60 -15
- package/extensions/plan/types.ts +45 -3
- package/extensions/plan/web-chrome.ts +102 -0
- package/extensions/plan/web-client.ts +177 -446
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +131 -19
- package/extensions/plan/web-router.ts +148 -32
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +26 -13
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +25 -44
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +45 -36
- package/extensions/wechat/bridge.ts +46 -48
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +3 -3
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +54 -18
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +9 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +53 -23
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -40
- package/lib/README.md +12 -7
- package/lib/claims/overlap.ts +43 -0
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +28 -1
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +117 -25
- package/lib/requirements.ts +1 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +30 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +91 -19
- package/lib/tui/gauge.ts +9 -5
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +5 -2
- package/skills/changelog/SKILL.md +57 -45
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +209 -0
- package/skills/parallel-dev/SKILL.md +150 -0
- 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
|
|
|
@@ -152,6 +152,8 @@ export abstract class ChecklistScreen<T> implements Component {
|
|
|
152
152
|
const rows = this.rows();
|
|
153
153
|
if (rows.length === 0 && this.options.emptyLabel !== undefined) {
|
|
154
154
|
return dockListFrame({
|
|
155
|
+
theme: this.theme,
|
|
156
|
+
width: w,
|
|
155
157
|
header,
|
|
156
158
|
body: emptyState(this.theme, w, { label: this.options.emptyLabel }),
|
|
157
159
|
hint: this.hint(w),
|
|
@@ -172,6 +174,8 @@ export abstract class ChecklistScreen<T> implements Component {
|
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
return dockListFrame({
|
|
177
|
+
theme: this.theme,
|
|
178
|
+
width: w,
|
|
175
179
|
header,
|
|
176
180
|
summary: clip(` ${paint(this.theme, "meta", this.summaryText())}`, w),
|
|
177
181
|
body,
|
|
@@ -28,7 +28,7 @@ import { SELECTION_MARKS, type PathChange, type ReviewEntry } from "./paths.ts";
|
|
|
28
28
|
|
|
29
29
|
/** Rows a diff screen shows when the terminal height is unknown. */
|
|
30
30
|
const DIFF_DEFAULT_ITEMS = 20;
|
|
31
|
-
/** Header, summary, blank, blank, hint, and
|
|
31
|
+
/** Header, summary, blank, blank, hint, and closing-rule rows around the diff. */
|
|
32
32
|
const DIFF_CHROME_ROWS = 6;
|
|
33
33
|
|
|
34
34
|
/** Lines one wheel notch moves in the diff pane. */
|
|
@@ -294,6 +294,8 @@ export class ReviewDiffComponent implements Component {
|
|
|
294
294
|
if (body.length === 0) body.push(...emptyState(this.theme, w, { label: "No textual diff." }));
|
|
295
295
|
|
|
296
296
|
return dockListFrame({
|
|
297
|
+
theme: this.theme,
|
|
298
|
+
width: w,
|
|
297
299
|
header,
|
|
298
300
|
summary,
|
|
299
301
|
body,
|
|
@@ -79,6 +79,8 @@ export class RewindListComponent implements Component {
|
|
|
79
79
|
|
|
80
80
|
if (this.points.length === 0) {
|
|
81
81
|
return dockListFrame({
|
|
82
|
+
theme: this.theme,
|
|
83
|
+
width: w,
|
|
82
84
|
header,
|
|
83
85
|
body: emptyState(this.theme, w, { label: "No prompts on this branch yet." }),
|
|
84
86
|
hint: this.hint(w),
|
|
@@ -122,6 +124,8 @@ export class RewindListComponent implements Component {
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
return dockListFrame({
|
|
127
|
+
theme: this.theme,
|
|
128
|
+
width: w,
|
|
125
129
|
header,
|
|
126
130
|
summary: clip(` ${paint(this.theme, "meta", `${count}${SEPARATORS.item}newest first`)}`, w),
|
|
127
131
|
body,
|
|
@@ -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
|
+
}
|