@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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|