@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
@@ -0,0 +1,69 @@
1
+ /**
2
+ * session-bus — coordinate parallel Pi sessions on one repository.
3
+ *
4
+ * A terminal session joins on `session_start`; RPC, JSON, print, and WeChat peer
5
+ * sessions join only when the tool or `/sessions` is used. The factory only
6
+ * registers; the runtime owns the heartbeat and removes the registry entry on
7
+ * shutdown. Nothing arms an inbox watch by itself, so delivery stays pull-based
8
+ * until a session asks: with the watch armed, new mail is reported at the next
9
+ * turn, or — with `wake` — raises one turn as soon as the agent is idle,
10
+ * including right after a running turn settles.
11
+ *
12
+ * Load with: pi --extension ./extensions/session-bus
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { isExtensionEnabled } from "../../lib/env.ts";
17
+ import { messageOf } from "../../lib/errors.ts";
18
+ import { createPendingReporter, registerPendingChannel } from "../../lib/report.ts";
19
+ import { registerCommands } from "./commands.ts";
20
+ import { createSessionBus, type SessionBus } from "./runtime.ts";
21
+ import { registerTools } from "./tools.ts";
22
+ import { mailNotice } from "./notice.ts";
23
+ import type { BusMessage } from "./types.ts";
24
+
25
+ export interface SessionBusDeps {
26
+ /** Test seam: a runtime with a temp dir and fixed clock. */
27
+ bus?: SessionBus;
28
+ }
29
+
30
+ export default function sessionBus(pi: ExtensionAPI, deps: SessionBusDeps = {}): void {
31
+ if (!isExtensionEnabled("session-bus")) return;
32
+ const bus = deps.bus ?? createSessionBus(pi);
33
+
34
+ const reporter = createPendingReporter<BusMessage>({
35
+ wake: (notice) => pi.sendMessage(notice, { triggerTurn: true }),
36
+ pending: () => bus.pending(),
37
+ markReported: (messages) => bus.markReported(messages.map((message) => message.id)),
38
+ buildNotice: mailNotice,
39
+ source: "session-bus",
40
+ wakeRequested: () => bus.wantsWake(),
41
+ });
42
+
43
+ registerTools(pi, bus);
44
+ registerCommands(pi, bus);
45
+
46
+ registerPendingChannel(pi, {
47
+ reporter,
48
+ runtime: {
49
+ // A start failure is reported once and leaves the runtime retryable: the next
50
+ // tool call starts it again, and a bus that never started simply never polls.
51
+ load: async (ctx) => {
52
+ try {
53
+ await bus.start(ctx);
54
+ } catch (error) {
55
+ ctx.ui.notify(`Session bus did not start: ${messageOf(error)}`, "warning");
56
+ }
57
+ },
58
+ shutdown: () => bus.stop(),
59
+ },
60
+ // A reload or a replaced session reuses the runtime, so the mail trigger is
61
+ // armed on every session start and cleared before the runtime releases.
62
+ onSessionStart: () => {
63
+ bus.onWake = () => reporter.report();
64
+ },
65
+ onSessionShutdown: () => {
66
+ bus.onWake = undefined;
67
+ },
68
+ });
69
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The mail notice: what the model reads when an armed session has new mail, and
3
+ * the rows bytefly draws from the same burst.
4
+ *
5
+ * The message bodies came from another session, so they are sanitized here before
6
+ * they reach either audience.
7
+ */
8
+
9
+ import { sanitizeLine } from "../../lib/format.ts";
10
+ import { burstText, noticeMessage, type NoticeItem, type NoticeMessage } from "../../lib/notice.ts";
11
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
12
+ import type { BusMessage } from "./types.ts";
13
+
14
+ /** Marker embedded in the injected mail context. */
15
+ const BUS_CONTEXT_MARKER = "[SESSION BUS]";
16
+
17
+ /** Messages previewed in the model-facing text. The renderer shows its own cap. */
18
+ const MAX_NOTICE_LINES = 5;
19
+
20
+ /** A message body is clipped to this many characters in a preview. */
21
+ const MAX_PREVIEW = 200;
22
+
23
+ /** One message as a preview line: sender, kind, and the head of its text. */
24
+ function previewOf(message: BusMessage): string {
25
+ const text = sanitizeLine(message.text);
26
+ const head = text.length > MAX_PREVIEW ? `${text.slice(0, MAX_PREVIEW)}…` : text;
27
+ const paths = message.paths?.length ? ` (${message.paths.map((path) => sanitizeLine(path)).join(", ")})` : "";
28
+ return `${sanitizeLine(message.fromLabel)}${SEPARATORS.item}${sanitizeLine(message.kind)}${SEPARATORS.item}${head}${paths}`;
29
+ }
30
+
31
+ /**
32
+ * The shared notice for pending mail: model text plus renderer details.
33
+ *
34
+ * The model reads five lines, so a burst stays bounded in its context; every
35
+ * message is passed to the renderer, whose own cap draws the `… N more` row. The
36
+ * two caps are deliberately separate — one is a context budget, the other a
37
+ * screen height.
38
+ */
39
+ export function mailNotice(messages: BusMessage[]): NoticeMessage {
40
+ const count = messages.length;
41
+ const shown = messages.slice(0, MAX_NOTICE_LINES);
42
+ const more = count - shown.length;
43
+ const items: NoticeItem[] = messages.map((message) => ({
44
+ label: `${sanitizeLine(message.fromLabel)}${SEPARATORS.item}${sanitizeLine(message.kind)}`,
45
+ detail: sanitizeLine(message.text).slice(0, MAX_PREVIEW),
46
+ severity: message.kind === "event" ? "warn" : "info",
47
+ }));
48
+ return noticeMessage({
49
+ content: burstText({
50
+ marker: BUS_CONTEXT_MARKER,
51
+ headline: `${count} new message${count === 1 ? "" : "s"}`,
52
+ lines: shown.map((message) => `- ${previewOf(message)}`),
53
+ ...(more > 0 ? { more: `… ${more} more` } : {}),
54
+ hint: "Run `session inbox` to read and consume them.",
55
+ }),
56
+ details: { source: "session-bus", title: `${count} new message${count === 1 ? "" : "s"}`, items },
57
+ });
58
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Pure protocol rules for the session bus.
3
+ *
4
+ * No filesystem or process access lives here: labels, targets, claim paths, and
5
+ * validation are testable without a runtime. `store.ts` owns the files.
6
+ */
7
+
8
+ import { isAbsolute, relative, resolve, sep } from "node:path";
9
+ import { sanitizeLine } from "../../lib/format.ts";
10
+
11
+ export const TOOL_NAME = "session";
12
+ export const COMMAND_NAME = "sessions";
13
+ export const MAX_TEXT = 4_000;
14
+ export const MAX_PATHS = 20;
15
+ export const MAX_LABEL = 40;
16
+ export const DEFAULT_LIMIT = 20;
17
+ export const MAX_LIMIT = 50;
18
+
19
+ export type SessionMode = "tui" | "rpc" | "json" | "print" | "unknown";
20
+ export type SessionAction = "status" | "send" | "inbox" | "claim" | "release" | "join" | "leave" | "watch" | "name";
21
+ export type MessageKind = "message" | "event" | "ask" | "handoff";
22
+
23
+ const ACTIONS: readonly SessionAction[] = [
24
+ "status",
25
+ "send",
26
+ "inbox",
27
+ "claim",
28
+ "release",
29
+ "join",
30
+ "leave",
31
+ "watch",
32
+ "name",
33
+ ];
34
+ const KINDS: readonly MessageKind[] = ["message", "event", "ask", "handoff"];
35
+
36
+ /** Only a terminal session is visible on the bus before it asks to join. */
37
+ export function isAutoJoinMode(mode: string | undefined): boolean {
38
+ return mode === "tui";
39
+ }
40
+
41
+ /** The session mode as the record stores it. */
42
+ export function sessionMode(mode: string | undefined): SessionMode {
43
+ return mode === "tui" || mode === "rpc" || mode === "json" || mode === "print" ? mode : "unknown";
44
+ }
45
+
46
+ /** A safe label, used both for display and as a target. */
47
+ export function normalizeLabel(raw: string | undefined, fallback: string): string {
48
+ const text = sanitizeLine(raw ?? "")
49
+ .replace(/\s+/g, " ")
50
+ .trim();
51
+ return (text || fallback).slice(0, MAX_LABEL);
52
+ }
53
+
54
+ /** A short, display-only suffix for a session id. */
55
+ export function shortId(id: string): string {
56
+ const clean = id.replace(/[^a-zA-Z0-9]/g, "");
57
+ return (clean || id).slice(-6);
58
+ }
59
+
60
+ /** The automatic label before uniqueness is applied. */
61
+ export function defaultLabel(sessionName: string | undefined, cwd: string, id: string): string {
62
+ const base = normalizeLabel(sessionName, normalizeLabel(cwd.split(/[\\/]/).filter(Boolean).at(-1), "session"));
63
+ return normalizeLabel(base, `session-${shortId(id)}`);
64
+ }
65
+
66
+ /** An action, or undefined when the model sent an unknown one. */
67
+ export function normalizeAction(raw: unknown): SessionAction | undefined {
68
+ return typeof raw === "string" && ACTIONS.includes(raw as SessionAction) ? (raw as SessionAction) : undefined;
69
+ }
70
+
71
+ /** A message kind; `message` is the default. */
72
+ export function normalizeKind(raw: unknown): MessageKind {
73
+ return typeof raw === "string" && KINDS.includes(raw as MessageKind) ? (raw as MessageKind) : "message";
74
+ }
75
+
76
+ /** Trimmed message text, or an error message. */
77
+ export function normalizeText(raw: unknown): { text: string } | { error: string } {
78
+ if (typeof raw !== "string" || raw.trim() === "") return { error: "text is required." };
79
+ const text = raw.trim();
80
+ if (text.length > MAX_TEXT) return { error: `text must be at most ${MAX_TEXT} characters.` };
81
+ return { text };
82
+ }
83
+
84
+ /** A shared inbox limit. */
85
+ export function normalizeLimit(raw: unknown): number {
86
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_LIMIT;
87
+ return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
88
+ }
89
+
90
+ /** Repository-relative, posix-normalized paths, or an error. */
91
+ export function normalizePaths(repoRoot: string, cwd: string, raw: unknown): { paths: string[] } | { error: string } {
92
+ if (!Array.isArray(raw) || raw.length === 0) return { error: "paths must contain at least one path." };
93
+ if (raw.length > MAX_PATHS) return { error: `at most ${MAX_PATHS} paths are allowed.` };
94
+ const paths: string[] = [];
95
+ for (const entry of raw) {
96
+ if (typeof entry !== "string" || entry.trim() === "") return { error: "every path must be a non-empty string." };
97
+ const absolute = resolve(cwd, entry.trim());
98
+ const rel = relative(repoRoot, absolute);
99
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
100
+ return { error: `path "${entry}" is outside the repository.` };
101
+ }
102
+ const normalized = rel.split(sep).join("/");
103
+ if (!paths.includes(normalized)) paths.push(normalized);
104
+ }
105
+ return { paths };
106
+ }
107
+
108
+ /**
109
+ * Whether one claim covers the other path in either direction.
110
+ *
111
+ * The predicate is `lib/claims/overlap.ts`, because plan approval validates a
112
+ * split with the same question; a second copy here is how the two answers drift.
113
+ */
114
+ export { claimsConflict } from "../../lib/claims/overlap.ts";
115
+
116
+ /** Resolve `to` to active sessions; `*` is broadcast and excludes the sender. */
117
+ export function resolveRecipients(
118
+ sessions: readonly { id: string; label: string }[],
119
+ to: string,
120
+ from: string,
121
+ ): { recipients: Array<{ id: string; label: string }> } | { error: string } {
122
+ const target = to.trim();
123
+ if (target === "") return { error: "to is required." };
124
+ if (target === "*") {
125
+ const recipients = sessions.filter((session) => session.id !== from);
126
+ return recipients.length === 0 ? { error: "there are no other active sessions." } : { recipients };
127
+ }
128
+ const exact = sessions.find((session) => session.id === target);
129
+ if (exact) return { recipients: [exact] };
130
+ const byLabel = sessions.filter((session) => session.label.toLowerCase() === target.toLowerCase());
131
+ if (byLabel.length === 1) return { recipients: byLabel };
132
+ if (byLabel.length > 1) return { error: `the label "${target}" is ambiguous; use a session id.` };
133
+ return { error: `no active session "${target}".` };
134
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The `session` tool's transcript block: the expanded rows one section per entity
3
+ * group, and the one collapsed line a reader sees without expanding.
4
+ *
5
+ * The row grammar and the sanitization live in `rows.ts`; this module only decides
6
+ * which rows an action shows and how the failed ones read.
7
+ */
8
+
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import type { Component } from "@earendil-works/pi-tui";
11
+ import { sanitizeLine } from "../../lib/format.ts";
12
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
13
+ import { TailResult, type TailResultInput, type TailSection } from "../../lib/tui/tool.ts";
14
+ import { paint } from "../../lib/tui/tokens.ts";
15
+ import { type BusRow, busRowLine, count, sessionRows } from "./rows.ts";
16
+ import type { SessionDetails } from "./types.ts";
17
+
18
+ /**
19
+ * The transcript block for an expanded `session` result: the shared tail block
20
+ * over the board's rows, one section per entity group.
21
+ */
22
+ export class SessionResult implements Component {
23
+ private readonly view: TailResult<BusRow>;
24
+
25
+ constructor(input: TailResultInput<BusRow>, theme: Theme) {
26
+ this.view = new TailResult(input, theme);
27
+ }
28
+
29
+ /** Update in place so the transcript can reuse this component across renders. */
30
+ setInput(input: TailResultInput<BusRow>, theme: Theme): void {
31
+ this.view.setInput(input, theme);
32
+ }
33
+
34
+ invalidate(): void {
35
+ this.view.invalidate();
36
+ }
37
+
38
+ render(width: number): string[] {
39
+ return this.view.render(width);
40
+ }
41
+ }
42
+
43
+ /** One section of the expanded result. */
44
+ function section(rows: BusRow[], label?: string): TailSection<BusRow> {
45
+ return { label, rows, more: 0, rowLine: busRowLine };
46
+ }
47
+
48
+ /**
49
+ * The expanded result rows for one action, in the order a reader expects: the
50
+ * board, the consumed mail, a conflict before the claim, released before
51
+ * not-owned. Returns `undefined` when the action has nothing to list.
52
+ */
53
+ export function resultSections(details: SessionDetails): TailResultInput<BusRow> | undefined {
54
+ switch (details.action) {
55
+ case "status": {
56
+ const board = sessionRows(details.status);
57
+ const claims = board.flatMap((row) => row.claims);
58
+ const sections: TailSection<BusRow>[] = [];
59
+ if (board.length > 0) sections.push(section(board.map((row) => ({ kind: "session", row }))));
60
+ if (claims.length > 0) {
61
+ sections.push(
62
+ section(
63
+ claims.map((claim) => ({ kind: "claim", claim })),
64
+ "Claims",
65
+ ),
66
+ );
67
+ }
68
+ return sections.length > 0 ? { kind: "sections", sections } : undefined;
69
+ }
70
+ case "inbox": {
71
+ const messages = details.messages ?? [];
72
+ if (messages.length === 0) return undefined;
73
+ return { kind: "sections", sections: [section(messages.map((message) => ({ kind: "message", message })))] };
74
+ }
75
+ case "claim": {
76
+ const conflicts = details.conflicts ?? [];
77
+ if (conflicts.length > 0) {
78
+ return {
79
+ kind: "sections",
80
+ sections: [
81
+ section(
82
+ conflicts.map((claim) => ({ kind: "claim", claim })),
83
+ "Conflicts",
84
+ ),
85
+ ],
86
+ };
87
+ }
88
+ const claimed = details.claimed ?? [];
89
+ if (claimed.length === 0) return undefined;
90
+ return {
91
+ kind: "sections",
92
+ sections: [
93
+ section(
94
+ claimed.map((label) => ({ kind: "result", label, tone: "ok" })),
95
+ "Claimed",
96
+ ),
97
+ ],
98
+ };
99
+ }
100
+ case "join": {
101
+ const conflicts = details.conflicts ?? [];
102
+ const claimed = details.claimed ?? [];
103
+ const rows: BusRow[] = [
104
+ ...conflicts.map((claim) => ({ kind: "claim" as const, claim })),
105
+ ...claimed.map((label) => ({ kind: "result" as const, label, tone: "ok" as const })),
106
+ ];
107
+ if (rows.length === 0) return undefined;
108
+ return {
109
+ kind: "sections",
110
+ sections: [section(rows, conflicts.length > 0 ? "Conflicts" : "Claimed")],
111
+ };
112
+ }
113
+ case "release":
114
+ case "leave": {
115
+ const released = details.released ?? [];
116
+ const notOwned = details.notOwned ?? [];
117
+ const sections: TailSection<BusRow>[] = [];
118
+ if (released.length > 0) {
119
+ sections.push(
120
+ section(
121
+ released.map((label) => ({ kind: "result", label, tone: "ok" })),
122
+ "Released",
123
+ ),
124
+ );
125
+ }
126
+ if (notOwned.length > 0) {
127
+ sections.push(
128
+ section(
129
+ notOwned.map((label) => ({ kind: "result", label, tone: "warn" })),
130
+ "Not yours",
131
+ ),
132
+ );
133
+ }
134
+ return sections.length > 0 ? { kind: "sections", sections } : undefined;
135
+ }
136
+ case "send": {
137
+ const delivered = details.delivered ?? [];
138
+ if (delivered.length === 0) return undefined;
139
+ return {
140
+ kind: "sections",
141
+ sections: [section(delivered.map((label) => ({ kind: "result", label, tone: "ok" })))],
142
+ };
143
+ }
144
+ case "watch":
145
+ return undefined;
146
+ case "name":
147
+ return undefined;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * The collapsed result line: the one thing the user needs without expanding.
153
+ *
154
+ * A conflict or a path this session does not own is worth the warning role; a
155
+ * successful claim, release, or send is routine, so those stay calm.
156
+ */
157
+ export function collapsedLine(theme: Theme, details: SessionDetails): string {
158
+ const sep = SEPARATORS.item;
159
+ switch (details.action) {
160
+ case "status": {
161
+ const board = sessionRows(details.status);
162
+ const unread = board.reduce((total, row) => total + row.session.unread, 0);
163
+ const claims = board.reduce((total, row) => total + row.claims.length, 0);
164
+ const cells = [count(board.length, "session")];
165
+ if (unread > 0) cells.push(`${unread} unread`);
166
+ cells.push(count(claims, "claim"));
167
+ const units = details.status?.units;
168
+ if (units) cells.push(`${units.joined}/${units.total} units`);
169
+ const watching = details.status?.watching;
170
+ if (watching) cells.push(watching.wake ? "watch wake" : "watch on");
171
+ return paint(theme, "meta", cells.join(sep));
172
+ }
173
+ case "send": {
174
+ const delivered = details.delivered ?? [];
175
+ if (delivered.length === 0) return paint(theme, "meta", "Delivered to nobody");
176
+ return paint(theme, "ok", `Delivered to ${delivered.map((label) => sanitizeLine(label)).join(", ")}`);
177
+ }
178
+ case "inbox": {
179
+ const messages = details.messages ?? [];
180
+ if (messages.length === 0) return paint(theme, "meta", "Inbox empty");
181
+ const senders = [...new Set(messages.map((message) => sanitizeLine(message.fromLabel)))];
182
+ return paint(theme, "meta", `${count(messages.length, "message")}${sep}${senders.join(", ")}`);
183
+ }
184
+ case "claim": {
185
+ const conflicts = details.conflicts ?? [];
186
+ if (conflicts.length > 0) {
187
+ const [first] = conflicts;
188
+ const more = conflicts.length - 1;
189
+ const path = first ? sanitizeLine(first.path) : "a path";
190
+ const owner = first ? sanitizeLine(first.ownerLabel) : "a peer";
191
+ return paint(
192
+ theme,
193
+ "warn",
194
+ `${count(conflicts.length, "conflict")}${sep}${path} held by ${owner}${more > 0 ? ` +${more} more` : ""}`,
195
+ );
196
+ }
197
+ const claimed = details.claimed ?? [];
198
+ if (claimed.length === 0) return paint(theme, "meta", "Claimed nothing");
199
+ return paint(theme, "ok", `Claimed ${claimed.map((path) => sanitizeLine(path)).join(", ")}`);
200
+ }
201
+ case "join": {
202
+ const conflicts = details.conflicts ?? [];
203
+ const where = [details.team, details.unit]
204
+ .filter(Boolean)
205
+ .map((value) => sanitizeLine(value as string))
206
+ .join("/");
207
+ if (conflicts.length > 0) {
208
+ const [first] = conflicts;
209
+ const owner = first ? sanitizeLine(first.ownerLabel) : "a peer";
210
+ return paint(
211
+ theme,
212
+ "warn",
213
+ `Could not join ${where}${sep}${count(conflicts.length, "conflict")} held by ${owner}`,
214
+ );
215
+ }
216
+ const claimed = details.claimed ?? [];
217
+ return paint(theme, "ok", `Joined ${where}${sep}claimed ${claimed.map((path) => sanitizeLine(path)).join(", ")}`);
218
+ }
219
+ case "release":
220
+ case "leave": {
221
+ const released = details.released ?? [];
222
+ const notOwned = details.notOwned ?? [];
223
+ const parts: string[] = [];
224
+ if (released.length > 0)
225
+ parts.push(paint(theme, "ok", `Released ${released.map((path) => sanitizeLine(path)).join(", ")}`));
226
+ if (notOwned.length > 0) {
227
+ parts.push(paint(theme, "warn", `Not yours: ${notOwned.map((path) => sanitizeLine(path)).join(", ")}`));
228
+ }
229
+ return parts.length > 0 ? parts.join(paint(theme, "faint", sep)) : paint(theme, "meta", "Nothing to release");
230
+ }
231
+ case "watch": {
232
+ const armed = details.watching?.armed === true;
233
+ if (!armed) return paint(theme, "meta", "Inbox watch off");
234
+ return paint(
235
+ theme,
236
+ "ok",
237
+ details.watching?.wake === true ? "Inbox watch on: new mail wakes a turn" : "Inbox watch on",
238
+ );
239
+ }
240
+ case "name":
241
+ return paint(theme, "ok", `Named this session "${sanitizeLine(details.label ?? "")}"`);
242
+ }
243
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The bus row grammar: the one place a session, a held path, or a message is
3
+ * turned into a line, shared by the `/sessions` board and the `session` tool
4
+ * block's expanded result.
5
+ *
6
+ * Every field came from another session's record, message, or claim, so it is
7
+ * sanitized here, at the boundary that reaches the terminal, and every row is
8
+ * clipped to the width it is given by `listRow`/`statusRow`.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { formatDuration, sanitizeLine } from "../../lib/format.ts";
13
+ import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
14
+ import { listRow, statusRow } from "../../lib/tui/rows.ts";
15
+ import type { TailRowLine } from "../../lib/tui/tool.ts";
16
+ import { paint } from "../../lib/tui/tokens.ts";
17
+ import { shortId } from "./format.ts";
18
+ import type { BusMessage, ClaimRecord, SessionRecord, SessionStatus } from "./types.ts";
19
+
20
+ /** Claim paths the focus block lists before it points at the pane. */
21
+ const BLOCK_CLAIMS = 2;
22
+
23
+ /** `1 claim`, `2 claims`. */
24
+ export function count(n: number, noun: string): string {
25
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
26
+ }
27
+
28
+ /** One session on the board, with the claims it holds. */
29
+ export interface SessionRow {
30
+ session: SessionRecord & { unread: number };
31
+ /** Whether this row is the session reading the board. */
32
+ self: boolean;
33
+ claims: ClaimRecord[];
34
+ }
35
+
36
+ /** A row of the transcript's expanded result: one entity per action. */
37
+ export type BusRow =
38
+ | { kind: "session"; row: SessionRow }
39
+ | { kind: "claim"; claim: Pick<ClaimRecord, "path" | "ownerLabel"> }
40
+ | { kind: "message"; message: BusMessage }
41
+ | { kind: "result"; label: string; tone: "ok" | "warn" };
42
+
43
+ /** Label order by code unit, so a live re-sort is deterministic. */
44
+ function byLabel(a: { label: string }, b: { label: string }): number {
45
+ return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
46
+ }
47
+
48
+ /**
49
+ * The board's rows: this session first, then peers by label, each with its own
50
+ * claims. A status without sessions (a failed read) is an empty board.
51
+ */
52
+ export function sessionRows(status: SessionStatus | undefined): SessionRow[] {
53
+ if (!status) return [];
54
+ const rows = status.sessions.map((session) => ({
55
+ session,
56
+ self: session.id === status.me.id,
57
+ claims: status.claims
58
+ .filter((claim) => claim.owner === session.id)
59
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
60
+ }));
61
+ return rows.sort((a, b) => (a.self === b.self ? byLabel(a.session, b.session) : a.self ? -1 : 1));
62
+ }
63
+
64
+ /** One session row: the label, a `you` tag on this session, and its counts. */
65
+ export function sessionRowLine(
66
+ row: SessionRow,
67
+ theme: Theme,
68
+ width: number,
69
+ options: { selected?: boolean; marker?: boolean } = {},
70
+ ): string {
71
+ const label = `${sanitizeLine(row.session.label)}${row.self ? " · you" : ""}`;
72
+ const counts = [`${row.session.unread} unread`];
73
+ if (row.claims.length > 0) counts.push(count(row.claims.length, "claim"));
74
+ return listRow(theme, width, {
75
+ selected: options.selected === true,
76
+ marker: options.marker !== false,
77
+ label,
78
+ labelRole: options.selected === true ? "accent" : "label",
79
+ trailing: counts.join(SEPARATORS.item),
80
+ });
81
+ }
82
+
83
+ /** One held path: the claim glyph, the path, and the session holding it. */
84
+ export function claimRowLine(
85
+ claim: Pick<ClaimRecord, "path" | "ownerLabel">,
86
+ theme: Theme,
87
+ width: number,
88
+ options: { selected?: boolean; marker?: boolean; self?: boolean } = {},
89
+ ): string {
90
+ const owner = `${sanitizeLine(claim.ownerLabel)}${options.self === true ? " · you" : ""}`;
91
+ return statusRow(theme, width, {
92
+ selected: options.selected === true,
93
+ marker: options.marker !== false,
94
+ glyph: GLYPHS.target,
95
+ glyphRole: "info",
96
+ label: sanitizeLine(claim.path),
97
+ labelRole: options.selected === true ? "accent" : "label",
98
+ trailing: owner,
99
+ });
100
+ }
101
+
102
+ /** One message row: the sender and kind as meta trailing the text. */
103
+ export function messageRowLine(
104
+ message: BusMessage,
105
+ theme: Theme,
106
+ width: number,
107
+ options: { selected?: boolean; marker?: boolean } = {},
108
+ ): string {
109
+ const glyph = message.kind === "event" ? GLYPHS.warn : message.kind === "ask" ? GLYPHS.unknown : GLYPHS.pending;
110
+ return statusRow(theme, width, {
111
+ selected: options.selected === true,
112
+ marker: options.marker !== false,
113
+ glyph,
114
+ glyphRole: message.kind === "event" ? "warn" : "info",
115
+ label: sanitizeLine(message.text),
116
+ labelRole: options.selected === true ? "accent" : "label",
117
+ trailing: `${sanitizeLine(message.fromLabel)}${SEPARATORS.item}${sanitizeLine(message.kind)}`,
118
+ });
119
+ }
120
+
121
+ /** The one row renderer the expanded transcript block uses. */
122
+ export const busRowLine: TailRowLine<BusRow> = (row, width, theme) => {
123
+ switch (row.kind) {
124
+ case "session":
125
+ return sessionRowLine(row.row, theme, width, { marker: false });
126
+ case "claim":
127
+ return claimRowLine(row.claim, theme, width, { marker: false });
128
+ case "message":
129
+ return messageRowLine(row.message, theme, width, { marker: false });
130
+ case "result":
131
+ return statusRow(theme, width, {
132
+ marker: false,
133
+ glyph: row.tone === "ok" ? GLYPHS.check : GLYPHS.cross,
134
+ glyphRole: row.tone,
135
+ label: sanitizeLine(row.label),
136
+ trailing: "",
137
+ });
138
+ }
139
+ };
140
+
141
+ /** The summary cells: the counts a reader scans before the rows. */
142
+ export function summaryCells(
143
+ theme: Theme,
144
+ rows: readonly SessionRow[],
145
+ watching: { armed: boolean; wake: boolean } | undefined,
146
+ ): string[] {
147
+ const unread = rows.reduce((total, row) => total + row.session.unread, 0);
148
+ const claims = rows.reduce((total, row) => total + row.claims.length, 0);
149
+ const cells = [paint(theme, "meta", count(rows.length, "session"))];
150
+ if (unread > 0) cells.push(paint(theme, "label", `${unread} unread`));
151
+ cells.push(paint(theme, "meta", count(claims, "claim")));
152
+ if (watching) cells.push(paint(theme, "ok", watching.wake ? "watch wake" : "watch on"));
153
+ return cells;
154
+ }
155
+
156
+ /** The focus block under the list: what the row does not already say. */
157
+ export function sessionDetailBlock(row: SessionRow, now: number): string[] {
158
+ const lines = [`cwd: ${sanitizeLine(row.session.cwd)}`];
159
+ if (row.claims.length > 0) {
160
+ const shown = row.claims.slice(0, BLOCK_CLAIMS).map((claim) => sanitizeLine(claim.path));
161
+ const hidden = row.claims.length - shown.length;
162
+ lines.push(`claims: ${shown.join(", ")}${hidden > 0 ? ` +${hidden} more` : ""}`);
163
+ }
164
+ lines.push(
165
+ `mode: ${sanitizeLine(row.session.mode)}${SEPARATORS.item}id: ${shortId(row.session.id)}` +
166
+ `${SEPARATORS.item}last seen: ${formatDuration(Math.max(0, now - row.session.lastSeen))} ago`,
167
+ );
168
+ return lines;
169
+ }