@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
package/lib/http/sink.ts
CHANGED
|
@@ -22,12 +22,23 @@ export interface EventSink {
|
|
|
22
22
|
end(frame?: string): void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Why a server went idle: no stream ever opened, or the last one left. */
|
|
26
|
+
export type IdleReason = "first" | "last";
|
|
27
|
+
|
|
25
28
|
/** The idle policy a server was given, or none. */
|
|
26
29
|
export interface IdleGrace {
|
|
27
30
|
/** How long the last stream's departure is held, in ms. */
|
|
28
31
|
ms: number;
|
|
29
|
-
/**
|
|
30
|
-
|
|
32
|
+
/**
|
|
33
|
+
* How long a server may stand with no stream ever opened, in ms.
|
|
34
|
+
*
|
|
35
|
+
* A page opened in a browser that never came up would otherwise hold its port for the
|
|
36
|
+
* rest of the session. Omitted, the grace still waits for the first stream, which is
|
|
37
|
+
* what a server whose page is opened by hand wants.
|
|
38
|
+
*/
|
|
39
|
+
firstMs?: number;
|
|
40
|
+
/** Called once no stream has been open for the grace that applied. */
|
|
41
|
+
onIdle: (reason: IdleReason) => void;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
/** What the registry needs besides the streams themselves. */
|
|
@@ -44,6 +55,8 @@ export interface SinkRegistry {
|
|
|
44
55
|
open(response: ServerResponse): EventSink;
|
|
45
56
|
/** How many streams are open. */
|
|
46
57
|
size(): number;
|
|
58
|
+
/** Arm the grace for a server nobody may ever read. Does nothing without `firstMs`. */
|
|
59
|
+
start(): void;
|
|
47
60
|
/** End every open stream, because the server is closing. */
|
|
48
61
|
endAll(): void;
|
|
49
62
|
}
|
|
@@ -52,20 +65,24 @@ export function createSinks(deps: SinkDeps = {}): SinkRegistry {
|
|
|
52
65
|
const streams = new Set<EventSink>();
|
|
53
66
|
const timer = deps.timer ?? defaultTimer;
|
|
54
67
|
let cancelIdle: (() => void) | undefined;
|
|
55
|
-
/** A
|
|
68
|
+
/** A stream has attached at least once, so a departure is one to wait on. */
|
|
56
69
|
let sawStream = false;
|
|
70
|
+
/** The server asked for the first grace, so it may go idle without a reader. */
|
|
71
|
+
let started = false;
|
|
57
72
|
|
|
58
73
|
const standDown = (): void => {
|
|
59
74
|
cancelIdle?.();
|
|
60
75
|
cancelIdle = undefined;
|
|
61
76
|
};
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (idle === undefined || !sawStream || streams.size > 0 || cancelIdle !== undefined) return;
|
|
66
|
-
cancelIdle = timer(
|
|
78
|
+
/** Arm `ms` from now, unless a stream is open or a grace already runs. */
|
|
79
|
+
const arm = (ms: number, reason: IdleReason): void => {
|
|
80
|
+
if (deps.idle === undefined || (!started && !sawStream) || streams.size > 0 || cancelIdle !== undefined) return;
|
|
81
|
+
cancelIdle = timer(ms, () => {
|
|
67
82
|
cancelIdle = undefined;
|
|
68
|
-
|
|
83
|
+
// A tab that attached while the grace ran has already stood it down, but a
|
|
84
|
+
// second look costs nothing and keeps a race from closing a page being read.
|
|
85
|
+
if (streams.size === 0) deps.idle?.onIdle(reason);
|
|
69
86
|
});
|
|
70
87
|
};
|
|
71
88
|
|
|
@@ -74,12 +91,20 @@ export function createSinks(deps: SinkDeps = {}): SinkRegistry {
|
|
|
74
91
|
standDown();
|
|
75
92
|
sawStream = true;
|
|
76
93
|
const sink = createSink(response, () => {
|
|
77
|
-
if (streams.delete(sink))
|
|
94
|
+
if (!streams.delete(sink)) return;
|
|
95
|
+
const idle = deps.idle;
|
|
96
|
+
if (idle !== undefined) arm(idle.ms, "last");
|
|
78
97
|
});
|
|
79
98
|
streams.add(sink);
|
|
80
99
|
return sink;
|
|
81
100
|
},
|
|
82
101
|
size: () => streams.size,
|
|
102
|
+
start() {
|
|
103
|
+
const idle = deps.idle;
|
|
104
|
+
if (idle?.firstMs === undefined || started || sawStream) return;
|
|
105
|
+
started = true;
|
|
106
|
+
arm(idle.firstMs, "first");
|
|
107
|
+
},
|
|
83
108
|
endAll() {
|
|
84
109
|
standDown();
|
|
85
110
|
const open = [...streams];
|
package/lib/plan-entry.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { type BusAnswer, requestOverBus } from "./bus-request.ts";
|
|
19
20
|
import { isPlanModeEnabled } from "./plan-state.ts";
|
|
20
21
|
import { PLAN_ENTER_TOOL } from "./tool-names.ts";
|
|
21
22
|
|
|
@@ -41,12 +42,23 @@ export const PLAN_CONSENT_SHORT =
|
|
|
41
42
|
"Enter read-only plan mode? Writes and mutating tools are disabled until you approve a plan.";
|
|
42
43
|
export const PLAN_CONSENT_SHORT_ZH = "进入只读 plan 模式?批准计划前会禁用写入和修改。";
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The refusal a command shows when plan mode forbids what it does.
|
|
47
|
+
*
|
|
48
|
+
* Plan mode is read-only, so every extension whose command changes something says so the
|
|
49
|
+
* same way and names the action it is not doing: one sentence in `lib/`, because five
|
|
50
|
+
* copies is how the guarantee stops reading as one guarantee.
|
|
51
|
+
*/
|
|
52
|
+
export function planModeRefusal(action: string): string {
|
|
53
|
+
return `Plan mode is read-only. Approve the plan before ${action}.`;
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
/** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
|
|
45
57
|
export interface PlanEnterRequest {
|
|
46
58
|
/** The caller's context; `plan` uses it for the footer status. */
|
|
47
59
|
ctx: ExtensionContext;
|
|
48
|
-
/**
|
|
49
|
-
respond(entered: boolean): void;
|
|
60
|
+
/** Claimed once, with whether plan mode is on; the caller settles on that claim. */
|
|
61
|
+
respond(entered: BusAnswer<boolean>): void;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/**
|
|
@@ -54,8 +66,8 @@ export interface PlanEnterRequest {
|
|
|
54
66
|
*
|
|
55
67
|
* Resolves `true` without asking when the branch is already planning, and
|
|
56
68
|
* `false` when the `plan` tool is not registered — so `goal` loaded alone keeps
|
|
57
|
-
* working and nothing waits on a listener that is not there. The listener
|
|
58
|
-
* synchronously; the
|
|
69
|
+
* working and nothing waits on a listener that is not there. The listener claims
|
|
70
|
+
* synchronously; the shared handshake is what guarantees a resolution.
|
|
59
71
|
*/
|
|
60
72
|
export function requestPlanMode(
|
|
61
73
|
pi: Pick<ExtensionAPI, "events" | "getAllTools">,
|
|
@@ -63,14 +75,9 @@ export function requestPlanMode(
|
|
|
63
75
|
): Promise<boolean> {
|
|
64
76
|
if (isPlanModeEnabled(ctx.sessionManager.getBranch())) return Promise.resolve(true);
|
|
65
77
|
if (!pi.getAllTools().some((tool) => tool.name === PLAN_ENTER_TOOL)) return Promise.resolve(false);
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
settled = true;
|
|
71
|
-
resolve(entered);
|
|
72
|
-
};
|
|
73
|
-
pi.events.emit(PLAN_ENTER_REQUEST, { ctx, respond } satisfies PlanEnterRequest);
|
|
74
|
-
queueMicrotask(() => respond(false));
|
|
78
|
+
return requestOverBus<boolean, PlanEnterRequest>(pi, {
|
|
79
|
+
channel: PLAN_ENTER_REQUEST,
|
|
80
|
+
payload: (respond) => ({ ctx, respond }),
|
|
81
|
+
unclaimed: () => false,
|
|
75
82
|
});
|
|
76
83
|
}
|
package/lib/policy.ts
CHANGED
|
@@ -15,7 +15,20 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { READ_DOC_TOOL } from "./tool-names.ts";
|
|
18
|
+
import { BOARD_READ_ACTION, READ_DOC_TOOL } from "./tool-names.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a call to the `board` tool only reads it.
|
|
22
|
+
*
|
|
23
|
+
* The board is one tool over ten actions, so its `readOnlyHint` says "may write" and
|
|
24
|
+
* cannot answer this. The action can, and it travels in the call's input — and in the
|
|
25
|
+
* result's `details`, which is all a `tool_execution_end` event carries. Plan mode
|
|
26
|
+
* asks before the call and the todo nudge after it, so the answer lives here once.
|
|
27
|
+
*/
|
|
28
|
+
export function isBoardRead(call: unknown): boolean {
|
|
29
|
+
if (typeof call !== "object" || call === null) return false;
|
|
30
|
+
return (call as { action?: unknown }).action === BOARD_READ_ACTION;
|
|
31
|
+
}
|
|
19
32
|
|
|
20
33
|
/**
|
|
21
34
|
* Tools that only read the workspace: the built-in structured readers and the
|
package/lib/requirements.ts
CHANGED
|
@@ -208,6 +208,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
|
|
|
208
208
|
},
|
|
209
209
|
todo: {},
|
|
210
210
|
goal: {},
|
|
211
|
+
serve: { notes: ["Without it `/board`, `/browse`, and `/plan review` refuse, naming it."] },
|
|
211
212
|
board: {},
|
|
212
213
|
team: { notes: ["`assign` needs `session-bus` loaded to deliver a handoff; without it the card is untouched."] },
|
|
213
214
|
check: {
|
|
@@ -256,8 +257,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
|
|
|
256
257
|
"file-open": {
|
|
257
258
|
notes: [
|
|
258
259
|
"The `/open` command and its notification need an interactive UI; the `open_path` tool works without one.",
|
|
259
|
-
"Launching the OS handler is best-effort
|
|
260
|
-
"The tool result names the target even when the launch fails.",
|
|
260
|
+
"Launching the OS handler is best-effort — a launcher that fails to start, or exits non-zero on macOS and Linux, is reported, Windows `explorer` can exit non-zero after a successful open, and the tool result names the target either way.",
|
|
261
261
|
],
|
|
262
262
|
},
|
|
263
263
|
memory: {
|
package/lib/session-label.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The session label: the one name another session addresses this one by.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Two vocabularies share this module, and neither copies the other. `session-bus`
|
|
5
|
+
* settles its own label for a session that did not ask for one — the Pi session name,
|
|
6
|
+
* else the working directory's basename, else the id. The board names a session for
|
|
7
|
+
* the team instead: the page's seat, or `team-agent-` and the id's tail, which is the
|
|
8
|
+
* same name on every surface and cannot drift when a user renames a session. The rules
|
|
9
|
+
* are here rather than in either extension because a label is a shared vocabulary: it
|
|
10
|
+
* is displayed, it is a send target, and it is stored in a card file that outlives one
|
|
11
|
+
* session.
|
|
10
12
|
*
|
|
11
13
|
* Value-only: no state, no I/O.
|
|
12
14
|
*/
|
|
@@ -42,12 +44,26 @@ export function shortId(id: string): string {
|
|
|
42
44
|
* The bus appends a suffix when two live sessions share one; that decision belongs
|
|
43
45
|
* to the registry, so this is only the name a session would pick for itself.
|
|
44
46
|
*/
|
|
45
|
-
export function defaultLabel(sessionName: string | undefined, cwd: string, id
|
|
47
|
+
export function defaultLabel(sessionName: string | undefined, cwd: string, id = "session"): string {
|
|
46
48
|
const directory = cwd.split(/[\\/]/).filter(Boolean).at(-1);
|
|
47
49
|
const base = normalizeLabel(sessionName, normalizeLabel(directory, "session"));
|
|
48
50
|
return normalizeLabel(base, `session-${shortId(id)}`);
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/** The prefix of the team's name for a session that is not the page's seat. */
|
|
54
|
+
export const TEAM_AGENT_PREFIX = "team-agent-";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The team's name for a session: the prefix and the id's tail.
|
|
58
|
+
*
|
|
59
|
+
* A team is addressed by id, not by a name a user chose: an id is unique, so two
|
|
60
|
+
* sessions cannot settle on one name, and a rename cannot strand the card that
|
|
61
|
+
* recorded it. The seat is the one name that is a role instead.
|
|
62
|
+
*/
|
|
63
|
+
export function agentLabel(id: string): string {
|
|
64
|
+
return normalizeLabel(`${TEAM_AGENT_PREFIX}${shortId(id)}`, "team-agent");
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
/**
|
|
52
68
|
* Pi's session name, which is the first choice for a label.
|
|
53
69
|
*
|
package/lib/session-roster.ts
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* here once, and `session-bus` imports them, so a layout change cannot land in one
|
|
10
10
|
* copy and not the other.
|
|
11
11
|
*
|
|
12
|
+
* The rule answers three things, not two: a record whose process is gone is `gone` at
|
|
13
|
+
* once, rather than aging into `quiet` for ten minutes while the paths it claimed stay
|
|
14
|
+
* held and the cards it held read `idle`.
|
|
15
|
+
*
|
|
12
16
|
* A missing directory is an answer, not an error: it means no session has ever
|
|
13
17
|
* joined this repository, and a caller renders that differently from an empty bus.
|
|
14
18
|
*
|
|
@@ -18,6 +22,7 @@
|
|
|
18
22
|
|
|
19
23
|
import { readdir, readFile } from "node:fs/promises";
|
|
20
24
|
import { join } from "node:path";
|
|
25
|
+
import { isPidAlive, type LivenessFn, startTokenOf, type StartTokenFn } from "./spawn.ts";
|
|
21
26
|
|
|
22
27
|
/** The bus directory under the agent dir, `session-bus`. */
|
|
23
28
|
export const BUS_DIR_NAME = "session-bus";
|
|
@@ -37,6 +42,16 @@ export interface RosterSession {
|
|
|
37
42
|
lastSeen: number;
|
|
38
43
|
/** Messages waiting in its inbox. */
|
|
39
44
|
mail: number;
|
|
45
|
+
/**
|
|
46
|
+
* The process that owns the record, when the record names one.
|
|
47
|
+
*
|
|
48
|
+
* Absent is "cannot tell": a record written before this existed, or by a mode that
|
|
49
|
+
* is not a local process, is judged by its heartbeat alone rather than being called
|
|
50
|
+
* dead on no evidence.
|
|
51
|
+
*/
|
|
52
|
+
pid?: number;
|
|
53
|
+
/** That process's start time, as the bus wrote it, when the record carries it. */
|
|
54
|
+
startToken?: string;
|
|
40
55
|
/**
|
|
41
56
|
* Whether the session is listening for mail, when its record says.
|
|
42
57
|
*
|
|
@@ -46,9 +61,56 @@ export interface RosterSession {
|
|
|
46
61
|
watching?: { armed: boolean; wake: boolean };
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
/**
|
|
50
|
-
export
|
|
51
|
-
|
|
64
|
+
/** What a record's heartbeat and its process say together. */
|
|
65
|
+
export type RosterState = "live" | "quiet" | "gone";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What a record's heartbeat and its process say together.
|
|
69
|
+
*
|
|
70
|
+
* The fields the rule reads, rather than the whole roster row: `session-bus` judges its
|
|
71
|
+
* own records by the same rule without carrying a `mail` count it never shows.
|
|
72
|
+
*/
|
|
73
|
+
export type RosterFacts = Pick<RosterSession, "lastSeen" | "pid" | "startToken">;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The host facts {@link rosterStateOf} reads, injectable so every branch is testable.
|
|
77
|
+
*
|
|
78
|
+
* The token spawns a process, so it is asked for only where liveness cannot decide.
|
|
79
|
+
*/
|
|
80
|
+
export interface RosterSeams {
|
|
81
|
+
alive?: LivenessFn;
|
|
82
|
+
token?: StartTokenFn;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What one record says about its session: alive, aged out, or attached to nothing.
|
|
87
|
+
*
|
|
88
|
+
* Three answers, in the order they are decided:
|
|
89
|
+
*
|
|
90
|
+
* - `gone` — the record names a process and that process is not running. A heartbeat
|
|
91
|
+
* written ten seconds before a crash is not evidence the process exists, so death
|
|
92
|
+
* outranks a fresh one.
|
|
93
|
+
* - `live` — the heartbeat is fresh. When the pid is alive too, the start token has not
|
|
94
|
+
* been asked for yet: a session that wrote ten seconds ago is not the interesting
|
|
95
|
+
* case for pid reuse, and `ps` costs a process.
|
|
96
|
+
* - `quiet` — the heartbeat has aged. If the pid is alive but its start time is not the
|
|
97
|
+
* one the record wrote, the process is somebody else's and the session is `gone`
|
|
98
|
+
* after all; a platform that cannot answer the start time leaves it at `quiet`.
|
|
99
|
+
*/
|
|
100
|
+
export function rosterStateOf(
|
|
101
|
+
session: RosterFacts,
|
|
102
|
+
now: number,
|
|
103
|
+
ttlMs = SESSION_TTL_MS,
|
|
104
|
+
seams: RosterSeams = {},
|
|
105
|
+
): RosterState {
|
|
106
|
+
const alive = seams.alive ?? isPidAlive;
|
|
107
|
+
if (session.pid !== undefined && !alive(session.pid)) return "gone";
|
|
108
|
+
if (now - session.lastSeen <= ttlMs) return "live";
|
|
109
|
+
if (session.pid !== undefined && session.startToken !== undefined) {
|
|
110
|
+
const current = (seams.token ?? startTokenOf)(session.pid);
|
|
111
|
+
if (current !== undefined && current !== session.startToken) return "gone";
|
|
112
|
+
}
|
|
113
|
+
return "quiet";
|
|
52
114
|
}
|
|
53
115
|
|
|
54
116
|
/** The fields this reader trusts; anything else in the file is the bus's business. */
|
|
@@ -59,6 +121,11 @@ interface SessionPayload {
|
|
|
59
121
|
lastSeen: number;
|
|
60
122
|
}
|
|
61
123
|
|
|
124
|
+
/** A record's pid, read only when it is the shape a pid has. */
|
|
125
|
+
function pidOf(value: unknown): number | undefined {
|
|
126
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
62
129
|
function isSessionPayload(value: unknown): value is SessionPayload {
|
|
63
130
|
if (typeof value !== "object" || value === null) return false;
|
|
64
131
|
const { id, label, mode, lastSeen } = value as Record<string, unknown>;
|
|
@@ -117,12 +184,16 @@ export async function readRoster(dir: string): Promise<RosterSession[] | undefin
|
|
|
117
184
|
if (!isSessionPayload(parsed)) continue;
|
|
118
185
|
const { id, label, mode, lastSeen } = parsed;
|
|
119
186
|
const watching = watchingOf((parsed as { watching?: unknown }).watching);
|
|
187
|
+
const pid = pidOf((parsed as { pid?: unknown }).pid);
|
|
188
|
+
const rawToken = (parsed as { startToken?: unknown }).startToken;
|
|
120
189
|
sessions.push({
|
|
121
190
|
id,
|
|
122
191
|
label,
|
|
123
192
|
mode,
|
|
124
193
|
lastSeen,
|
|
125
194
|
mail: await mailCount(dir, name.slice(0, -".json".length)),
|
|
195
|
+
...(pid === undefined ? {} : { pid }),
|
|
196
|
+
...(typeof rawToken === "string" && rawToken !== "" ? { startToken: rawToken } : {}),
|
|
126
197
|
...(watching === undefined ? {} : { watching }),
|
|
127
198
|
});
|
|
128
199
|
}
|
package/lib/session-tool.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { ExtensionAPI, ExtensionContext, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { type BusAnswer, requestOverBus } from "./bus-request.ts";
|
|
25
26
|
import { messageOf } from "./errors.ts";
|
|
26
27
|
import { watchingOf } from "./session-roster.ts";
|
|
27
28
|
import { SESSION_TOOL } from "./tool-names.ts";
|
|
@@ -54,14 +55,29 @@ export type JoinResult = { ok: true; label: string; watching: Watching } | { ok:
|
|
|
54
55
|
/** EventBus channel on which a caller asks `session-bus` to join the team for it. */
|
|
55
56
|
export const TEAM_JOIN_REQUEST = "my-pi-agent/team-join/request";
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* What a join asks for.
|
|
60
|
+
*
|
|
61
|
+
* `label` is the name to be on the team as; without one the bus settles its own. `name`
|
|
62
|
+
* is the separate question of whether that label also becomes Pi's session name — the
|
|
63
|
+
* footer and `/resume` — and it is opt-in, because a join is not a rename: only
|
|
64
|
+
* `/team join <label>` means "call me this from now on".
|
|
65
|
+
*/
|
|
66
|
+
export interface TeamJoinInput {
|
|
67
|
+
label?: string;
|
|
68
|
+
wake?: boolean;
|
|
69
|
+
listen?: boolean;
|
|
70
|
+
name?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
/** What a caller sends on {@link TEAM_JOIN_REQUEST}. */
|
|
58
74
|
export interface TeamJoinRequest {
|
|
59
75
|
/** The caller's context; the bus starts and names through it. */
|
|
60
76
|
ctx: ExtensionContext;
|
|
61
77
|
/** What to join as and how to listen. */
|
|
62
|
-
input:
|
|
63
|
-
/**
|
|
64
|
-
respond(result: JoinResult): void;
|
|
78
|
+
input: TeamJoinInput;
|
|
79
|
+
/** Claimed once, with what the bus answered; the caller settles on that claim. */
|
|
80
|
+
respond(result: BusAnswer<JoinResult>): void;
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
/** What a send did. */
|
|
@@ -96,49 +112,49 @@ function nestedMessage(result: { content?: Array<{ type: string; text?: string }
|
|
|
96
112
|
/**
|
|
97
113
|
* How an extension joins the team, whatever kind of context it is holding.
|
|
98
114
|
*
|
|
99
|
-
* Two extensions ask — `board` when the page opens or a card is
|
|
115
|
+
* Two extensions ask — `board` when the page opens or a card is claimed, `team` for
|
|
100
116
|
* `/team join` — and both hold a command or a tool context, so the request is the one
|
|
101
117
|
* transport that works from either.
|
|
102
118
|
*/
|
|
103
119
|
export interface TeamJoin {
|
|
104
|
-
/** Join the team
|
|
105
|
-
join(ctx: ExtensionContext,
|
|
120
|
+
/** Join the team as `input.label` when given. Never throws. */
|
|
121
|
+
join(ctx: ExtensionContext, input?: TeamJoinInput): Promise<JoinResult>;
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
/** The production join, bound to one extension's `pi`. */
|
|
109
125
|
export function createTeamJoin(pi: Pick<ExtensionAPI, "events" | "getAllTools">): TeamJoin {
|
|
110
126
|
return {
|
|
111
|
-
join: (ctx,
|
|
127
|
+
join: (ctx, input) => join(pi, ctx, input ?? {}),
|
|
112
128
|
};
|
|
113
129
|
}
|
|
114
130
|
|
|
131
|
+
/** What a caller is told when there is no bus to join: one sentence on both paths. */
|
|
132
|
+
const NO_BUS = "the session bus is not loaded";
|
|
133
|
+
|
|
115
134
|
/**
|
|
116
135
|
* Join the team: on the bus, named when a label is given, and listening for mail.
|
|
117
136
|
*
|
|
118
137
|
* One call for all three, because that is one act — see `join` in
|
|
119
138
|
* `extensions/session-bus` — and one transport for both kinds of caller: the request
|
|
120
|
-
* travels on `pi.events` and the bus answers it.
|
|
139
|
+
* travels on `pi.events` and the bus answers it. Pi's session name is not one of the
|
|
140
|
+
* three: that is `input.name`, which only a caller meaning to rename asks for.
|
|
121
141
|
*
|
|
122
142
|
* With no bus loaded there is no listener, so the answer is the sentence the caller
|
|
123
143
|
* reports rather than a promise that never settles: the `session` tool's presence is
|
|
124
|
-
* the same
|
|
144
|
+
* checked here, and the shared handshake refuses the same way if a bus is loaded but
|
|
145
|
+
* listening for nothing.
|
|
125
146
|
*/
|
|
126
147
|
export async function join(
|
|
127
148
|
pi: Pick<ExtensionAPI, "events" | "getAllTools">,
|
|
128
149
|
ctx: ExtensionContext,
|
|
129
|
-
input:
|
|
150
|
+
input: TeamJoinInput = {},
|
|
130
151
|
): Promise<JoinResult> {
|
|
131
|
-
if (!pi.getAllTools().some((tool) => tool.name === SESSION_TOOL)) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (settled) return;
|
|
138
|
-
settled = true;
|
|
139
|
-
resolve(result);
|
|
140
|
-
};
|
|
141
|
-
pi.events.emit(TEAM_JOIN_REQUEST, { ctx, input, respond } satisfies TeamJoinRequest);
|
|
152
|
+
if (!pi.getAllTools().some((tool) => tool.name === SESSION_TOOL)) return { ok: false, error: NO_BUS };
|
|
153
|
+
return requestOverBus<JoinResult, TeamJoinRequest>(pi, {
|
|
154
|
+
channel: TEAM_JOIN_REQUEST,
|
|
155
|
+
payload: (respond) => ({ ctx, input, respond }),
|
|
156
|
+
unclaimed: () => ({ ok: false, error: NO_BUS }),
|
|
157
|
+
failed: (error) => ({ ok: false, error: messageOf(error) }),
|
|
142
158
|
});
|
|
143
159
|
}
|
|
144
160
|
|
package/lib/spawn.ts
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
*
|
|
9
9
|
* IO is split from the decision: {@link spawnOptions} and {@link killPlan} are
|
|
10
10
|
* pure and testable on every platform, while {@link defaultSpawn} and
|
|
11
|
-
* {@link defaultKillTree} execute them.
|
|
12
|
-
*
|
|
11
|
+
* {@link defaultKillTree} execute them. The liveness pair — {@link isPidAlive}
|
|
12
|
+
* and {@link startTokenOf} — is here too, because asking whether a process is
|
|
13
|
+
* still the one a record named is the same question as killing it: `job` guards
|
|
14
|
+
* a reattached pid with it, and so does the session roster. Value-only, like
|
|
15
|
+
* every `lib/` module: no module state, and each caller keeps its own children.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
|
-
import { spawn as nodeSpawn, spawnSync } from "node:child_process";
|
|
18
|
+
import { execFileSync, spawn as nodeSpawn, spawnSync } from "node:child_process";
|
|
16
19
|
import type { Readable } from "node:stream";
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -124,6 +127,37 @@ export function isPidAlive(pid: number, probe: KillProbe = process.kill): boolea
|
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
/** Whether a pid is still alive. */
|
|
131
|
+
export type LivenessFn = (pid: number) => boolean;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Best-effort OS start-time token for a pid, used to detect pid reuse.
|
|
135
|
+
*
|
|
136
|
+
* Returns undefined when the platform cannot provide one (for example Windows, or a
|
|
137
|
+
* process that is already gone), in which case callers fall back to liveness alone.
|
|
138
|
+
*/
|
|
139
|
+
export type StartTokenFn = (pid: number) => string | undefined;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Default start-time token via `ps`. Undefined where `ps` is unavailable.
|
|
143
|
+
*
|
|
144
|
+
* It spawns a process, so a caller asks for it only where liveness alone cannot
|
|
145
|
+
* decide: a pid that is alive may be a different program than the one a record
|
|
146
|
+
* named, and nothing cheaper than the start time can tell them apart.
|
|
147
|
+
*/
|
|
148
|
+
export const startTokenOf: StartTokenFn = (pid) => {
|
|
149
|
+
try {
|
|
150
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
151
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
152
|
+
timeout: 2000,
|
|
153
|
+
});
|
|
154
|
+
const text = out.toString().trim();
|
|
155
|
+
return text || undefined;
|
|
156
|
+
} catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
127
161
|
/** The host seams {@link applyKillPlan} runs through. */
|
|
128
162
|
export interface KillSeams {
|
|
129
163
|
/** Signal one pid; throws when the pid is gone or not ours. */
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A field a tool call carries but its action does not act on.
|
|
3
|
+
*
|
|
4
|
+
* An action-shaped tool publishes one flat parameter object, so every field is
|
|
5
|
+
* offered to every action at once; that is harmless only while each action reads
|
|
6
|
+
* its own. A field that reaches an action which never reads it used to be dropped
|
|
7
|
+
* in silence — `board add` with `dependsOn` wrote the card without the link and
|
|
8
|
+
* answered success, and `team offer` with `to` broadcast to every live session
|
|
9
|
+
* while the caller believed it had named one. The caller could not tell what was
|
|
10
|
+
* recorded from what was ignored, and nothing in the result said which.
|
|
11
|
+
*
|
|
12
|
+
* So the table of what each action reads lives with the tool (`ACTION_FIELDS`),
|
|
13
|
+
* and this is the sentence a call is refused with: it names every stray field and
|
|
14
|
+
* the action that does take it, which makes the retry one call away. The table is
|
|
15
|
+
* keyed in the order the actions are documented, because that order is how the
|
|
16
|
+
* message lists the verbs that take a field.
|
|
17
|
+
*
|
|
18
|
+
* Value-only, as `lib/` must be: the tables are the callers'.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The fields each action reads, keyed in the order the actions are documented. */
|
|
22
|
+
export type ActionFields = Readonly<Record<string, readonly string[]>>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Why a call carries a field its action does not act on, or nothing when it is clean.
|
|
26
|
+
*
|
|
27
|
+
* `undefined` is not a stray: an optional field the caller left out was never passed,
|
|
28
|
+
* and a schema makes every unset one `undefined`.
|
|
29
|
+
*/
|
|
30
|
+
export function strayField(action: string, args: Record<string, unknown>, fields: ActionFields): string | undefined {
|
|
31
|
+
const mine = fields[action] ?? [];
|
|
32
|
+
const stray = Object.entries(args)
|
|
33
|
+
.filter(([field, value]) => value !== undefined && field !== "action" && !mine.includes(field))
|
|
34
|
+
.map(([field]) => field);
|
|
35
|
+
if (stray.length === 0) return undefined;
|
|
36
|
+
// One stray reads as "takes it"; several need their names, or the sentence stops
|
|
37
|
+
// saying which field goes where.
|
|
38
|
+
const named = (field: string): string => {
|
|
39
|
+
const taking = Object.keys(fields).filter((candidate) => fields[candidate]?.includes(field));
|
|
40
|
+
if (taking.length === 0) return `${field} belongs to no action`;
|
|
41
|
+
const verbs = taking.map((one) => `\`${one}\``).join(" and ");
|
|
42
|
+
return `${verbs} ${taking.length === 1 ? "takes" : "take"} ${stray.length === 1 ? "it" : field}`;
|
|
43
|
+
};
|
|
44
|
+
return `${action} does not act on ${stray.join(", ")}: ${stray.map(named).join("; ")}.`;
|
|
45
|
+
}
|
package/lib/tool-names.ts
CHANGED
|
@@ -45,6 +45,19 @@ export const SESSION_TOOL = "session";
|
|
|
45
45
|
*/
|
|
46
46
|
export const SUBAGENT_READ_ONLY_PARAM = "readOnly";
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The `board` tool: the shared feature list. Plan mode keeps it active because its
|
|
50
|
+
* `list` action is a read of the repository's cards, so the name is shared while the
|
|
51
|
+
* write actions stay the board's to refuse.
|
|
52
|
+
*/
|
|
53
|
+
export const BOARD_TOOL = "board";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The one `board` action that reads. Plan mode admits it and refuses the rest, which
|
|
57
|
+
* is the distinction the tool's `readOnlyHint` cannot carry: the same tool writes.
|
|
58
|
+
*/
|
|
59
|
+
export const BOARD_READ_ACTION = "list";
|
|
60
|
+
|
|
48
61
|
/**
|
|
49
62
|
* The `read_doc` tool. `file-read` registers it, and the plan, rewind, and
|
|
50
63
|
* subagent reader lists carry the name, so the document reader is one reader
|
package/lib/tui/README.md
CHANGED
|
@@ -160,9 +160,12 @@ hold themed strings in state unless `invalidate()` rebuilds them. A widget is
|
|
|
160
160
|
derived from state, never authoritative.
|
|
161
161
|
|
|
162
162
|
Pi renders the whole tree every frame, so a component that derives its lines from
|
|
163
|
-
another one memoizes them with `RenderCache` (keyed on width and the
|
|
164
|
-
lines' identity
|
|
165
|
-
|
|
163
|
+
another one memoizes them with `RenderCache` — `lines()` keyed on width and the
|
|
164
|
+
source lines' identity, or `keyed()` for a component with no inner component (a
|
|
165
|
+
header's view, a footer's snapshot), keyed on the parts it read: primitives by
|
|
166
|
+
value, an object such as the theme by identity — and drops the memo in
|
|
167
|
+
`invalidate()`. Re-deriving every line per frame is what makes a long transcript
|
|
168
|
+
feel slow.
|
|
166
169
|
|
|
167
170
|
## Focus
|
|
168
171
|
|
package/lib/tui/glyphs.ts
CHANGED
|
@@ -29,8 +29,7 @@ export const GLYPHS = {
|
|
|
29
29
|
jobsRunning: "▸",
|
|
30
30
|
jobsFailure: "✗",
|
|
31
31
|
stopped: "⊘",
|
|
32
|
-
|
|
33
|
-
board: "▤",
|
|
32
|
+
http: "◉",
|
|
34
33
|
delegation: "↳",
|
|
35
34
|
wechat: "✆",
|
|
36
35
|
gaugeFull: "▰",
|
|
@@ -57,10 +56,9 @@ export const GLYPHS = {
|
|
|
57
56
|
/** Columns between a rail body glyph and its text. */
|
|
58
57
|
export const GLYPH_GAP = 1;
|
|
59
58
|
|
|
60
|
-
/** Padding used to join
|
|
59
|
+
/** Padding used to join UI segments. */
|
|
61
60
|
export const SEPARATORS = {
|
|
62
61
|
item: " · ",
|
|
63
|
-
group: ` ${GLYPHS.divider} `,
|
|
64
62
|
} as const;
|
|
65
63
|
|
|
66
64
|
/** `ctx.ui.setStatus` keys; one per extension that publishes a chip. */
|
|
@@ -71,8 +69,7 @@ export const STATUS_KEYS = {
|
|
|
71
69
|
jobsFailure: "jobs-failure",
|
|
72
70
|
watch: "watch",
|
|
73
71
|
watchMatches: "watch-matches",
|
|
74
|
-
|
|
75
|
-
board: "board",
|
|
72
|
+
http: "http",
|
|
76
73
|
rewind: "rewind",
|
|
77
74
|
agents: "agents",
|
|
78
75
|
wechat: "wechat",
|