@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 +19 -12
- package/README.zh-CN.md +15 -9
- package/docs/extensions/README.md +2 -1
- package/docs/extensions/board.md +329 -151
- package/docs/extensions/bytefly.md +427 -273
- package/docs/extensions/file-browse.md +46 -44
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +86 -24
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +56 -28
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +48 -17
- package/extensions/board/format.ts +107 -9
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +202 -156
- package/extensions/board/result.ts +10 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +31 -10
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +7 -5
- package/extensions/board/tools.ts +211 -34
- package/extensions/board/transport/router.ts +27 -110
- package/extensions/board/transport/server.ts +76 -56
- package/extensions/board/types.ts +57 -0
- package/extensions/board/view/client.ts +24 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +6 -178
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +60 -89
- package/extensions/board/view/render.ts +228 -200
- package/extensions/board/view/styles.ts +72 -49
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -11
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +93 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -6
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +78 -46
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +23 -26
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +27 -22
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/runtime.ts +4 -4
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +88 -18
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +24 -2
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +43 -4
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +160 -0
- package/extensions/plan/web-chrome.ts +7 -5
- package/extensions/plan/web-client.ts +271 -325
- package/extensions/plan/web-collab.ts +7 -7
- package/extensions/plan/web-comments.ts +57 -16
- package/extensions/plan/web-composer.ts +91 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +258 -0
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +212 -0
- package/extensions/plan/web-page.ts +59 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +160 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +201 -0
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +44 -9
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/todo/tui.ts +21 -3
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +9 -7
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +111 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +268 -55
- package/lib/board/view.ts +114 -24
- package/lib/bus-request.ts +83 -0
- package/lib/format.ts +22 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -43
- package/lib/http/server.ts +3 -3
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +20 -13
- package/lib/policy.ts +14 -1
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-roster.ts +74 -3
- package/lib/session-tool.ts +37 -21
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +3 -6
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +149 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +106 -2
- package/lib/web/names.ts +42 -0
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
|
@@ -13,11 +13,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
13
13
|
import { Type, type Static } from "typebox";
|
|
14
14
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
15
15
|
import { errorResult, textResult } from "../../lib/tool-result.ts";
|
|
16
|
-
import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
|
|
16
|
+
import { callLine, detailsOrLine, isTailResult, TailResult } from "../../lib/tui/tool.ts";
|
|
17
17
|
import { formatClaim, formatInbox, formatJoin, formatRelease, formatSend, formatStatus } from "./format.ts";
|
|
18
18
|
import { normalizeAction, TOOL_NAME } from "./protocol.ts";
|
|
19
19
|
import type { SessionBus } from "./runtime.ts";
|
|
20
|
-
import { collapsedLine, resultSections
|
|
20
|
+
import { collapsedLine, resultSections } from "./result.ts";
|
|
21
|
+
import type { BusRow } from "./rows.ts";
|
|
21
22
|
import type { SessionDetails } from "./types.ts";
|
|
22
23
|
|
|
23
24
|
export { TOOL_NAME };
|
|
@@ -183,8 +184,8 @@ export function registerTools(pi: ExtensionAPI, bus: SessionBus): void {
|
|
|
183
184
|
text.setText(collapsedLine(theme, value));
|
|
184
185
|
return text;
|
|
185
186
|
}
|
|
186
|
-
const
|
|
187
|
-
|
|
187
|
+
const previous = context?.lastComponent;
|
|
188
|
+
const view = isTailResult<BusRow>(previous) ? previous : new TailResult<BusRow>(sections, theme);
|
|
188
189
|
view.setInput(sections, theme);
|
|
189
190
|
return view;
|
|
190
191
|
},
|
|
@@ -96,7 +96,7 @@ function boardKey(board: BoardSnapshot | undefined): string {
|
|
|
96
96
|
return [
|
|
97
97
|
...board.status.sessions.map(
|
|
98
98
|
(session) =>
|
|
99
|
-
`${session.id}:${session.label}:${session.unread}:${session.lastSeen}:${session.mode}:${session.cwd}`,
|
|
99
|
+
`${session.id}:${session.label}:${session.unread}:${session.lastSeen}:${session.mode}:${session.cwd}:${session.state}`,
|
|
100
100
|
),
|
|
101
101
|
...board.status.claims.map((claim) => `${claim.path}:${claim.owner}:${claim.ownerLabel}:${claim.at}`),
|
|
102
102
|
board.status.watching ? `${board.status.watching.armed}:${board.status.watching.wake}` : "",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* `store.ts` and the lifecycle in `runtime.ts`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { RosterState } from "../../lib/session-roster.ts";
|
|
8
9
|
import type { MessageKind, SessionMode } from "./protocol.ts";
|
|
9
10
|
|
|
10
11
|
/** One registered Pi session. */
|
|
@@ -12,6 +13,14 @@ export interface SessionRecord {
|
|
|
12
13
|
id: string;
|
|
13
14
|
label: string;
|
|
14
15
|
pid: number;
|
|
16
|
+
/**
|
|
17
|
+
* That process's start time, when the host can name one.
|
|
18
|
+
*
|
|
19
|
+
* It is what tells the record's process apart from an unrelated one that has since
|
|
20
|
+
* been given the same pid, so `gone` does not have to wait for the heartbeat to age.
|
|
21
|
+
* Absent on a platform without `ps`, and on a record written before this existed.
|
|
22
|
+
*/
|
|
23
|
+
startToken?: string;
|
|
15
24
|
/** Working directory of the session; the worktree root when there is one. */
|
|
16
25
|
cwd: string;
|
|
17
26
|
/** Repository root the session belongs to, for relative claims. */
|
|
@@ -57,7 +66,7 @@ export interface SessionStatus {
|
|
|
57
66
|
me: { id: string; label: string };
|
|
58
67
|
/** False while this session has only read the bus: it joins by naming itself. */
|
|
59
68
|
onBus: boolean;
|
|
60
|
-
sessions: Array<SessionRecord & { unread: number }>;
|
|
69
|
+
sessions: Array<SessionRecord & { unread: number; state: RosterState }>;
|
|
61
70
|
claims: ClaimRecord[];
|
|
62
71
|
/** Present only when this session armed its inbox watch. */
|
|
63
72
|
watching?: { armed: boolean; wake: boolean };
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { planModeRefusal } from "../../lib/plan-entry.ts";
|
|
10
11
|
import { isPlanModeEnabled } from "../../lib/plan-state.ts";
|
|
11
12
|
import { withRailsSuppressed } from "../../lib/rails.ts";
|
|
12
13
|
import type { BackgroundRuntime } from "./background.ts";
|
|
@@ -20,7 +21,7 @@ async function openAgents(
|
|
|
20
21
|
ctx: ExtensionCommandContext,
|
|
21
22
|
): Promise<void> {
|
|
22
23
|
if (isPlanModeEnabled(ctx.sessionManager.getBranch())) {
|
|
23
|
-
ctx.ui.notify("
|
|
24
|
+
ctx.ui.notify(planModeRefusal("managing background runs"), "warning");
|
|
24
25
|
return;
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The Team tab is the same round with a mouse; a lead reading a TUI should not have to
|
|
5
5
|
* open a browser to see what needs a decision, so the command prints the rollup the
|
|
6
|
-
* tool returns and one line saying who is live. It
|
|
7
|
-
* and writes nothing: acting on the round is
|
|
8
|
-
* `board`'s.
|
|
6
|
+
* tool returns, the work a dead owner left behind, and one line saying who is live. It
|
|
7
|
+
* reads the cards and the bus's records and writes nothing: acting on the round is
|
|
8
|
+
* `team`'s tool (`assign`, `reclaim`) or `board`'s.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import type
|
|
12
|
+
import { type Card, cardRef } from "../../lib/board/card.ts";
|
|
13
13
|
import { buildRound } from "../../lib/board/round.ts";
|
|
14
14
|
import { formatRound, plural } from "../../lib/board/round-text.ts";
|
|
15
15
|
import type { BoardSession } from "../../lib/board/session.ts";
|
|
@@ -40,7 +40,9 @@ export function registerCommands(pi: ExtensionAPI, session: BoardSession, deps:
|
|
|
40
40
|
const [word = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
41
41
|
if (word === "join") {
|
|
42
42
|
const label = rest.join(" ").trim();
|
|
43
|
-
|
|
43
|
+
// The one naming action: a typed label is also Pi's session name, which is what
|
|
44
|
+
// `/team join` is for. Joining without one leaves both to the bus.
|
|
45
|
+
const joined = await team.join(ctx, label === "" ? {} : { label, name: true });
|
|
44
46
|
ctx.ui.notify(joinedLine(joined), joined.ok ? "info" : "warning");
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
@@ -77,26 +79,58 @@ export function roundText(cards: readonly Card[], now: number, roster?: readonly
|
|
|
77
79
|
const round = formatRound(cards, buildRound(cards, now)).split("\n");
|
|
78
80
|
const shown = round.slice(0, MAX_ROUND_LINES);
|
|
79
81
|
if (round.length > MAX_ROUND_LINES) shown.push(`… ${plural(round.length - MAX_ROUND_LINES, "line")} more`);
|
|
82
|
+
for (const line of goneLines(cards, roster, now)) shown.push(line);
|
|
80
83
|
const who = presenceLine(roster, now);
|
|
81
84
|
if (who !== undefined) shown.push(who);
|
|
82
85
|
return shown.join("\n");
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
|
-
*
|
|
89
|
+
* The work a dead owner left behind, as the line that says who takes it.
|
|
87
90
|
*
|
|
88
|
-
*
|
|
91
|
+
* One line per gone owner that still holds cards, before the presence line: `gone: b` at
|
|
92
|
+
* the end of that line says who died, and this one says what to do about it. A card that
|
|
93
|
+
* waits on another is left to the board, whose rules would refuse the claim anyway.
|
|
94
|
+
*/
|
|
95
|
+
function goneLines(cards: readonly Card[], roster: readonly RosterSession[] | undefined, now: number): string[] {
|
|
96
|
+
if (roster === undefined) return [];
|
|
97
|
+
const gone = new Map<string, string>();
|
|
98
|
+
for (const session of roster) {
|
|
99
|
+
if (rosterStateOf(session, now) === "gone") gone.set(session.label.toLowerCase(), session.label);
|
|
100
|
+
}
|
|
101
|
+
if (gone.size === 0) return [];
|
|
102
|
+
const byOwner = new Map<string, string[]>();
|
|
103
|
+
for (const card of cards) {
|
|
104
|
+
if (card.owner === undefined || card.status === "done") continue;
|
|
105
|
+
const label = gone.get(card.owner.label.toLowerCase());
|
|
106
|
+
if (label === undefined) continue;
|
|
107
|
+
byOwner.set(label, [...(byOwner.get(label) ?? []), cardRef(card.id)]);
|
|
108
|
+
}
|
|
109
|
+
return [...byOwner.entries()].map(([label, refs]) => `reclaim ${refs.join(", ")} — ${label} is not running`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `live: a, b · not listening: b · quiet: c · gone: d`, or nothing when nobody is here.
|
|
114
|
+
*
|
|
115
|
+
* Four facts, and the middle one is the one a lead can act on: a live session that is
|
|
89
116
|
* not listening reads mail at its next turn rather than being woken for it. It is only
|
|
90
117
|
* named when its record says so — a session that never wrote the field is not accused of
|
|
91
|
-
* ignoring anything.
|
|
118
|
+
* ignoring anything. `gone` is the loudest and comes last, because it is the one a lead
|
|
119
|
+
* answers by reclaiming rather than by waiting.
|
|
92
120
|
*/
|
|
93
121
|
function presenceLine(roster: readonly RosterSession[] | undefined, now: number): string | undefined {
|
|
94
122
|
if (roster === undefined || roster.length === 0) return undefined;
|
|
95
123
|
const live: string[] = [];
|
|
96
124
|
const quiet: string[] = [];
|
|
125
|
+
const dead: string[] = [];
|
|
97
126
|
const silent: string[] = [];
|
|
98
127
|
for (const session of roster) {
|
|
99
|
-
|
|
128
|
+
const state = rosterStateOf(session, now);
|
|
129
|
+
if (state === "gone") {
|
|
130
|
+
dead.push(session.label);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (state !== "live") {
|
|
100
134
|
quiet.push(session.label);
|
|
101
135
|
continue;
|
|
102
136
|
}
|
|
@@ -107,6 +141,7 @@ function presenceLine(roster: readonly RosterSession[] | undefined, now: number)
|
|
|
107
141
|
live.length > 0 ? `live: ${live.join(", ")}` : "",
|
|
108
142
|
silent.length > 0 ? `not listening: ${silent.join(", ")}` : "",
|
|
109
143
|
quiet.length > 0 ? `quiet: ${quiet.join(", ")}` : "",
|
|
144
|
+
dead.length > 0 ? `gone: ${dead.join(", ")}` : "",
|
|
110
145
|
];
|
|
111
146
|
return parts.filter((part) => part !== "").join(" · ");
|
|
112
147
|
}
|
|
@@ -86,6 +86,20 @@ export function formatOfferFailure(card: Card, reason: string): string {
|
|
|
86
86
|
return `${cardRef(card.id)} was not offered: ${reason}\n The card is unchanged.`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The broadcast landed and recording the offer then lost a race.
|
|
91
|
+
*
|
|
92
|
+
* The peers have an event for a card somebody else has since claimed, moved, or taken
|
|
93
|
+
* over, so the correction says the offer stands as a message and the card is what the
|
|
94
|
+
* board now says — not that nothing happened.
|
|
95
|
+
*/
|
|
96
|
+
export function formatRacedOffer(card: Card, reason: string): string {
|
|
97
|
+
return (
|
|
98
|
+
`${cardRef(card.id)} was offered, but the card changed before the offer could be recorded: ${reason}\n` +
|
|
99
|
+
" The peers have the message; the card is as the board shows it now."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
/**
|
|
90
104
|
* The send landed and the assignment then lost a race.
|
|
91
105
|
*
|
package/extensions/team/index.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `board` owns the cards and never touches the session bus; `team` owns the two
|
|
5
5
|
* actions that need it. `assign` hands a card to a session that is already running
|
|
6
6
|
* and sends it a handoff — dispatch cannot start a peer's turn, and the page and the
|
|
7
|
-
* docs say so — and `reclaim` takes a card back when its owner has gone quiet.
|
|
7
|
+
* docs say so — and `reclaim` takes a card back when its owner is gone or has gone quiet.
|
|
8
|
+
* Its one
|
|
8
9
|
* command, `/team`, prints the round the board's Team tab draws, without a browser.
|
|
9
10
|
*
|
|
10
11
|
* Either extension works without the other: `board` is the work list any session
|
package/extensions/team/tools.ts
CHANGED
|
@@ -24,17 +24,26 @@ import { failedResult, type ToolFailure, textResult } from "../../lib/tool-resul
|
|
|
24
24
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
25
25
|
import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
|
|
26
26
|
import { plural } from "../../lib/board/round-text.ts";
|
|
27
|
+
import { strayField } from "../../lib/stray-fields.ts";
|
|
27
28
|
import { type Dispatch, handoffText, notListening, offerText, ownerFor, peerFor, sessionDispatch } from "./dispatch.ts";
|
|
28
29
|
import {
|
|
29
30
|
formatHandoff,
|
|
30
31
|
formatHandoffFailure,
|
|
31
32
|
formatOffer,
|
|
32
33
|
formatOfferFailure,
|
|
34
|
+
formatRacedOffer,
|
|
33
35
|
formatRacedAssignment,
|
|
34
36
|
formatReclaimed,
|
|
35
37
|
} from "./format.ts";
|
|
36
38
|
import { TeamParams } from "./schema.ts";
|
|
37
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
ACTION_FIELDS,
|
|
41
|
+
normalizeTeamAction,
|
|
42
|
+
TEAM_ACTIONS,
|
|
43
|
+
TEAM_TOOL,
|
|
44
|
+
type TeamAction,
|
|
45
|
+
type TeamDetails,
|
|
46
|
+
} from "./types.ts";
|
|
38
47
|
|
|
39
48
|
/** The parameters as the schema validates them. */
|
|
40
49
|
type TeamArgs = Static<typeof TeamParams>;
|
|
@@ -74,7 +83,8 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
|
|
|
74
83
|
"Open the board with `/board` or work a card first, so this session and its peers are on the team.",
|
|
75
84
|
"Offer a card nobody has taken; assign one you mean a particular peer to hold.",
|
|
76
85
|
"When a result says `Not listening`, say so: the message waits for that peer's next turn.",
|
|
77
|
-
"Reclaim a card whose owner has gone quiet rather than editing someone else's work."
|
|
86
|
+
"Reclaim a card whose owner is gone or has gone quiet rather than editing someone else's work. A " +
|
|
87
|
+
"`gone:` owner's process is not running, so what it held is anyone's to reclaim.",
|
|
78
88
|
],
|
|
79
89
|
parameters: TeamParams,
|
|
80
90
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
@@ -86,6 +96,10 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
|
|
|
86
96
|
if (!action) {
|
|
87
97
|
return refusal("assign", `unknown action "${String(args.action)}"; use ${TEAM_ACTIONS.join(" or ")}.`);
|
|
88
98
|
}
|
|
99
|
+
// Before the store and before the card: a refused call has done nothing, so it
|
|
100
|
+
// cannot have read a card or sent a message on its way to the refusal.
|
|
101
|
+
const stray = strayField(action, args, ACTION_FIELDS);
|
|
102
|
+
if (stray !== undefined) return refusal(action, stray);
|
|
89
103
|
const id = normalizeCardId(args.card);
|
|
90
104
|
if (id === undefined) return refusal(action, `"${String(args.card)}" is not a card id.`);
|
|
91
105
|
const me = session.who(ctx, ctx.cwd);
|
|
@@ -93,7 +107,7 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
|
|
|
93
107
|
const store = await session.store(ctx.cwd);
|
|
94
108
|
const now = session.now();
|
|
95
109
|
if (action === "reclaim") return await reclaimCard(store, me, id, now);
|
|
96
|
-
if (action === "offer") return await offerCard(store, id, args.note, now, dispatchFor(ctx));
|
|
110
|
+
if (action === "offer") return await offerCard(store, me, id, args.note, now, dispatchFor(ctx));
|
|
97
111
|
const to = typeof args.to === "string" ? args.to.trim() : "";
|
|
98
112
|
if (to === "") return refusal("assign", 'assign needs a peer: pass `to`, e.g. "billing-core".');
|
|
99
113
|
return await assignCard(store, me, id, to, args.note, now, dispatchFor(ctx));
|
|
@@ -180,14 +194,17 @@ async function assignCard(
|
|
|
180
194
|
const OFFER_CLAIMANT: Actor = { label: "*" };
|
|
181
195
|
|
|
182
196
|
/**
|
|
183
|
-
* `offer`: check the claim's preconditions, broadcast the card,
|
|
197
|
+
* `offer`: check the claim's preconditions, broadcast the card, then record the offer.
|
|
184
198
|
*
|
|
185
|
-
* The
|
|
186
|
-
*
|
|
187
|
-
*
|
|
199
|
+
* The broadcast comes first and the record second, because a record of an offer nobody
|
|
200
|
+
* received would be worse than an offer with no record: the card is unchanged by a failed
|
|
201
|
+
* send, and the first peer to claim is what makes an offer into work. What the record
|
|
202
|
+
* adds is the one thing an event cannot carry — when it was made — so a lead can tell an
|
|
203
|
+
* offer nobody took from a card nobody has looked at.
|
|
188
204
|
*/
|
|
189
205
|
async function offerCard(
|
|
190
206
|
store: BoardStore,
|
|
207
|
+
me: Actor,
|
|
191
208
|
id: string,
|
|
192
209
|
note: string | undefined,
|
|
193
210
|
now: number,
|
|
@@ -206,11 +223,15 @@ async function offerCard(
|
|
|
206
223
|
} catch (error) {
|
|
207
224
|
return refusal("offer", formatOfferFailure(card, messageOf(error)), card);
|
|
208
225
|
}
|
|
226
|
+
// The peers have the event, so a card that changed underneath is reported as raced
|
|
227
|
+
// rather than refused: the message is already in their inboxes.
|
|
228
|
+
const recorded = await store.mutate(id, { kind: "offer" }, me, now);
|
|
229
|
+
if (!recorded.ok) return refusal("offer", formatRacedOffer(recorded.card ?? card, recorded.error), recorded.card);
|
|
209
230
|
const peers = await dispatch.peers().catch(() => undefined);
|
|
210
231
|
const silent = notListening(peers, delivered);
|
|
211
|
-
return textResult(formatOffer(card, delivered, silent), {
|
|
232
|
+
return textResult(formatOffer(recorded.card, delivered, silent), {
|
|
212
233
|
action: "offer",
|
|
213
|
-
card,
|
|
234
|
+
card: recorded.card,
|
|
214
235
|
delivered,
|
|
215
236
|
...(silent.length === 0 ? {} : { notListening: silent }),
|
|
216
237
|
});
|
package/extensions/team/types.ts
CHANGED
|
@@ -42,6 +42,23 @@ export interface TeamDetails {
|
|
|
42
42
|
error?: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The fields each action acts on: what a call may carry, per action.
|
|
47
|
+
*
|
|
48
|
+
* The parameters are one flat object, so a field is published to every action at once,
|
|
49
|
+
* and only this table stops one reaching an action that would drop it. `to` is `assign`'s
|
|
50
|
+
* alone: an `offer` that named a peer was broadcast to every live session instead, and a
|
|
51
|
+
* `reclaim` that named one took the card back from whoever held it.
|
|
52
|
+
*
|
|
53
|
+
* A test holds the other direction — every published parameter but `action` is claimed
|
|
54
|
+
* here — so a field cannot be published without an action that acts on it.
|
|
55
|
+
*/
|
|
56
|
+
export const ACTION_FIELDS: Record<TeamAction, readonly string[]> = {
|
|
57
|
+
assign: ["card", "to", "note"],
|
|
58
|
+
offer: ["card", "note"],
|
|
59
|
+
reclaim: ["card"],
|
|
60
|
+
};
|
|
61
|
+
|
|
45
62
|
/** Whether a value is one of the actions, so an unknown one is a refusal and not a crash. */
|
|
46
63
|
export function normalizeTeamAction(raw: unknown): TeamAction | undefined {
|
|
47
64
|
return typeof raw === "string" && TEAM_ACTIONS.includes(raw as TeamAction) ? (raw as TeamAction) : undefined;
|
package/extensions/todo/index.ts
CHANGED
|
@@ -60,7 +60,10 @@ export default function todo(pi: ExtensionAPI) {
|
|
|
60
60
|
pi.on("tool_execution_end", (event) => {
|
|
61
61
|
if (event.isError) return;
|
|
62
62
|
const annotations = pi.getAllTools().find((tool) => tool.name === event.toolName)?.annotations;
|
|
63
|
-
|
|
63
|
+
// The result is the only place a call's own record of what it did survives to this
|
|
64
|
+
// event, which is why the board's read action is read from here.
|
|
65
|
+
const details = (event.result as { details?: unknown } | undefined)?.details;
|
|
66
|
+
if (!isMutatingTool(event.toolName, annotations, details)) return;
|
|
64
67
|
runtime.noteWork(isFileWrite(event.toolName, annotations));
|
|
65
68
|
});
|
|
66
69
|
|
package/extensions/todo/nudge.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import {
|
|
17
|
+
import { isBoardRead } from "../../lib/policy.ts";
|
|
18
|
+
import { BOARD_TOOL, TODO_TOOL } from "../../lib/tool-names.ts";
|
|
18
19
|
import { currentTodo, hasOpenTodos } from "./state.ts";
|
|
19
20
|
import type { Todo } from "./types.ts";
|
|
20
21
|
|
|
@@ -32,9 +33,14 @@ export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set(["write", "edit"
|
|
|
32
33
|
* Whether a finished tool call counts as work for lag detection. The `todo`
|
|
33
34
|
* tool never does (it is the update itself), a known mutator always does, and
|
|
34
35
|
* any other tool counts only when it explicitly declares `readOnlyHint: false`.
|
|
36
|
+
*
|
|
37
|
+
* `board` is the one mutator whose read face is provable from what the call
|
|
38
|
+
* returned: a result whose `details` say `action: "list"` read the cards and changed
|
|
39
|
+
* nothing, so it counts like every other reader.
|
|
35
40
|
*/
|
|
36
|
-
export function isMutatingTool(toolName: string, annotations?: ToolAnnotations): boolean {
|
|
41
|
+
export function isMutatingTool(toolName: string, annotations?: ToolAnnotations, details?: unknown): boolean {
|
|
37
42
|
if (toolName === TODO_TOOL) return false;
|
|
43
|
+
if (toolName === BOARD_TOOL && isBoardRead(details)) return false;
|
|
38
44
|
if (MUTATING_TOOL_NAMES.has(toolName)) return true;
|
|
39
45
|
return annotations?.readOnlyHint === false;
|
|
40
46
|
}
|
package/extensions/todo/tui.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tu
|
|
|
13
13
|
import { clip } from "../../lib/tui/columns.ts";
|
|
14
14
|
import { emptyState, listRow } from "../../lib/tui/rows.ts";
|
|
15
15
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
16
|
+
import { RenderCache } from "../../lib/tui/render-cache.ts";
|
|
16
17
|
import { titleRule } from "../../lib/tui/rules.ts";
|
|
17
18
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
18
19
|
import {
|
|
@@ -57,10 +58,19 @@ function todoLine(todos: Todo[], theme: Theme): string {
|
|
|
57
58
|
return `${head}${paint(theme, "faint", SEPARATORS.item)}${todoLabel(current, theme)}`;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Transcript result block: the indented glyph rail, wrapping at the render width.
|
|
63
|
+
*
|
|
64
|
+
* `render` runs on every frame Pi draws, for every block in the transcript, so the
|
|
65
|
+
* rail is memoized on the width (`RenderCache`) and rebuilt only when
|
|
66
|
+
* `setInput` hands over a new list or `invalidate` says the theme moved. The
|
|
67
|
+
* caller must go through `setInput`: mutating a list in place would leave the
|
|
68
|
+
* block showing what it drew last.
|
|
69
|
+
*/
|
|
61
70
|
export class TodoResult implements Component {
|
|
62
71
|
private input: TodoRailInput;
|
|
63
72
|
private theme: Theme;
|
|
73
|
+
private readonly cache = new RenderCache();
|
|
64
74
|
|
|
65
75
|
constructor(input: TodoRailInput, theme: Theme) {
|
|
66
76
|
this.input = input;
|
|
@@ -71,14 +81,22 @@ export class TodoResult implements Component {
|
|
|
71
81
|
setInput(input: TodoRailInput, theme: Theme): void {
|
|
72
82
|
this.input = input;
|
|
73
83
|
this.theme = theme;
|
|
84
|
+
this.cache.invalidate();
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
invalidate(): void {
|
|
87
|
+
invalidate(): void {
|
|
88
|
+
this.cache.invalidate();
|
|
89
|
+
}
|
|
77
90
|
|
|
78
91
|
render(width: number): string[] {
|
|
92
|
+
const w = Math.max(1, width);
|
|
93
|
+
return this.cache.lines(w, undefined, () => this.build(w));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private build(w: number): string[] {
|
|
79
97
|
// The leading blank line separates the rail from the call header, matching
|
|
80
98
|
// the built-ins' header/output gap.
|
|
81
|
-
return ["", ...todoRailLines(this.input, this.theme,
|
|
99
|
+
return ["", ...todoRailLines(this.input, this.theme, w)];
|
|
82
100
|
}
|
|
83
101
|
}
|
|
84
102
|
|