pi-kanban 1.0.0 → 2.0.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/CHANGELOG.md +30 -0
- package/README.md +61 -21
- package/cli.js +1586 -0
- package/extensions/kanban.ts +277 -368
- package/extensions/tools/ask.ts +85 -0
- package/extensions/tools/board-api.ts +142 -0
- package/extensions/tools/cli-path.ts +34 -0
- package/extensions/tools/dispatch.ts +56 -0
- package/extensions/tools/inbox.ts +86 -0
- package/extensions/tools/kanban-command.ts +27 -0
- package/extensions/tools/scratchpad.ts +70 -0
- package/lib/agent-dir.js +20 -0
- package/lib/agent-log.js +64 -0
- package/lib/ask.js +252 -0
- package/lib/atomic-write.js +30 -0
- package/lib/board-dir.js +63 -0
- package/lib/claude-settings.js +21 -0
- package/lib/companion-host.js +142 -0
- package/lib/companions.js +136 -0
- package/lib/contain.js +78 -0
- package/lib/dispatch-reports.js +86 -0
- package/lib/file-url.js +38 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/frame-policy.js +79 -0
- package/lib/git-branch.js +37 -102
- package/lib/http-error.js +7 -0
- package/lib/inline-assets.js +238 -0
- package/lib/linked-docs.js +56 -0
- package/lib/live-sessions.js +86 -0
- package/lib/model-windows.js +48 -0
- package/lib/net-guard.js +277 -0
- package/lib/one-line.js +6 -0
- package/lib/open-editor.js +173 -0
- package/lib/panes.js +213 -0
- package/lib/parsers.js +1434 -0
- package/lib/pi-agents.js +178 -0
- package/lib/pi-git-branch.js +112 -0
- package/lib/pi-models.js +34 -0
- package/lib/pi-sessions.js +240 -0
- package/lib/pi-transcript.js +354 -0
- package/lib/preview-action.js +55 -0
- package/lib/priority.js +45 -0
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +191 -0
- package/lib/scratch-files.js +47 -0
- package/lib/scratchpad-dir.js +95 -0
- package/lib/session-cache.js +32 -0
- package/lib/session-events.js +275 -0
- package/lib/session-pins.js +99 -0
- package/lib/show.js +279 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/task-counts.js +90 -0
- package/lib/task-dir.js +26 -0
- package/lib/task-store.js +44 -47
- package/lib/terminal-client.js +248 -0
- package/lib/terminal-host.js +113 -0
- package/lib/terminal.js +1116 -0
- package/lib/user-groups.js +384 -0
- package/lib/worktrees.js +132 -0
- package/package.json +37 -17
- package/public/app.js +14340 -3452
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/group-tree.js +21 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +7 -5
- package/public/index.html +493 -335
- package/public/link-url.js +18 -0
- package/public/manifest.json +7 -7
- package/public/project-match.js +34 -0
- package/public/style.css +7689 -1727
- package/public/sw.js +4 -3
- package/public/terminal-frame.js +437 -0
- package/public/terminal.html +26 -0
- package/public/themes.css +876 -0
- package/public/tree-highlight.js +196 -0
- package/server.js +3362 -503
- package/skills/ask/SKILL.md +29 -0
- package/skills/ask/references/playground.md +25 -0
- package/skills/dispatch/SKILL.md +51 -0
- package/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/skills/kanban/SKILL.md +46 -0
- package/skills/show/SKILL.md +41 -0
- package/skills/show/references/NOTICE.md +27 -0
- package/skills/show/references/chart.md +58 -0
- package/skills/show/references/diagram/architecture.md +18 -0
- package/skills/show/references/diagram/er.md +10 -0
- package/skills/show/references/diagram/flow.md +13 -0
- package/skills/show/references/diagram/layers.md +10 -0
- package/skills/show/references/diagram/sequence.md +19 -0
- package/skills/show/references/diagram/state.md +12 -0
- package/skills/show/references/diagram/swimlane.md +10 -0
- package/skills/show/references/diagram/timeline.md +9 -0
- package/skills/show/references/diagram/tree.md +10 -0
- package/skills/show/references/diagram.md +87 -0
- package/skills/show/references/explain.md +100 -0
- package/skills/show/references/html-contract.md +18 -0
- package/skills/show/references/mockup.md +30 -0
- package/skills/visualize/SKILL.md +73 -0
- package/skills/visualize/blocks.html +68 -0
- package/skills/visualize/template.html +179 -0
- package/lib/pi-parsers.js +0 -1155
- package/themes/kanban-dark-default.json +0 -21
- package/themes/kanban-default.json +0 -21
- package/themes/pi-dark.json +0 -21
- package/themes/pi-light.json +0 -21
package/extensions/kanban.ts
CHANGED
|
@@ -1,189 +1,259 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import type
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
) as {
|
|
16
|
-
reconcileFromSnapshot: (sessionId: string, snapshot: unknown[]) => void
|
|
17
|
-
|
|
1
|
+
import { dirname, join as joinPath } from "node:path";
|
|
2
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { boardFetch, boardTerminalId, boardUrl, errorText, kanbanDir, lib, okJson, postJson, resolveTarget, terminalToken } from "./tools/board-api.js";
|
|
4
|
+
import { linkDoc, openCompanion, previewDoc } from "./tools/kanban-command.js";
|
|
5
|
+
import { addCliToPath } from "./tools/cli-path.js";
|
|
6
|
+
import { registerDispatch } from "./tools/dispatch.js";
|
|
7
|
+
import { registerInbox } from "./tools/inbox.js";
|
|
8
|
+
import { registerScratchpad } from "./tools/scratchpad.js";
|
|
9
|
+
import { ASK_CLOSE, ASK_OPEN, ASK_TOOL, type AskOpen, registerAsk } from "./tools/ask.js";
|
|
10
|
+
|
|
11
|
+
type Line = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
type SessionLines = { sessionId: string; lines: Line[] } | null;
|
|
14
|
+
|
|
15
|
+
const taskStore = lib("task-store.js") as {
|
|
16
|
+
reconcileFromSnapshot: (sessionId: string, snapshot: unknown[]) => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
const liveSessions = lib("live-sessions.js") as {
|
|
19
|
+
writeLiveSession: (dir: string, entry: Line) => Promise<void>;
|
|
20
|
+
removeLiveSession: (dir: string, pid: number) => Promise<void>;
|
|
21
|
+
removeLiveSessionSync: (dir: string, pid: number) => void;
|
|
22
|
+
sweepDeadLiveSessions: (dir: string) => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
const agentLog = lib("agent-log.js") as {
|
|
25
|
+
appendAgentLines: (dir: string, records: Line[]) => Promise<void>;
|
|
26
|
+
promptMarker: (prompt: { kind: string; title?: string }, tool: { toolName: string; args: unknown } | undefined, now: string) => Line;
|
|
27
|
+
writeWaiting: (dir: string, marker: Line) => Promise<void>;
|
|
28
|
+
clearWaiting: (dir: string) => Promise<void>;
|
|
29
|
+
markStopped: (dir: string) => Promise<void>;
|
|
30
|
+
clearStopped: (dir: string) => Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
const piAgents = lib("pi-agents.js") as {
|
|
33
|
+
createSubagentTracker: () => {
|
|
34
|
+
start: (sessionId: string, toolCallId: string, args: unknown, now: string) => Line[];
|
|
35
|
+
update: (toolCallId: string, partial: unknown, now: string) => SessionLines;
|
|
36
|
+
end: (toolCallId: string, result: unknown, isError: boolean, now: string) => SessionLines;
|
|
37
|
+
asyncComplete: (payload: unknown, now: string) => Promise<SessionLines>;
|
|
38
|
+
};
|
|
18
39
|
};
|
|
19
40
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
const modelWindows = lib("model-windows.js") as {
|
|
42
|
+
MODEL_WINDOWS_FILE: string;
|
|
43
|
+
recordModelWindows: (file: string, models: { provider: string; id: string; contextWindow: number }[]) => Promise<boolean>;
|
|
44
|
+
};
|
|
23
45
|
|
|
24
|
-
const
|
|
25
|
-
const SETTINGS_PATH = joinPath(KANBAN_DIR, "settings.json");
|
|
26
|
-
const DEFAULT_THEME_DIR = joinPath(KANBAN_DIR, "themes");
|
|
46
|
+
const { SESSION_ID_ENV } = lib("board-dir.js") as { SESSION_ID_ENV: string };
|
|
27
47
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
const LIVE_DIR = joinPath(kanbanDir(), "live");
|
|
49
|
+
const MODEL_WINDOWS_FILE = joinPath(kanbanDir(), modelWindows.MODEL_WINDOWS_FILE);
|
|
50
|
+
const ACTIVITY_DIR = joinPath(kanbanDir(), "agent-activity");
|
|
51
|
+
|
|
52
|
+
// pi's default dir is <agentDir>/sessions/<encoded cwd>, which the board scans already. Only a
|
|
53
|
+
// dir pi was told to use (--session-dir, env, settings) goes in the live entry.
|
|
54
|
+
function ownSessionDir(dir: string): string | null {
|
|
55
|
+
return dir && dirname(dir) !== joinPath(getAgentDir(), "sessions") ? dir : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A pi in the board terminal already runs inside the board page.
|
|
59
|
+
const CAN_OPEN = !boardTerminalId();
|
|
60
|
+
const { PANE_COMPANIONS } = lib("companions.js") as { PANE_COMPANIONS: { id: string; verb: string; shows: string }[] };
|
|
61
|
+
const companionOf = (verb: string) => PANE_COMPANIONS.find((c) => c.verb === verb);
|
|
62
|
+
const COMPANION_VERBS = PANE_COMPANIONS.map((c) => c.verb);
|
|
63
|
+
const SUBCOMMANDS = [...(CAN_OPEN ? ["open"] : []), "session", ...COMPANION_VERBS];
|
|
64
|
+
|
|
65
|
+
const SESSION_SUBCOMMANDS = ["open", "pin", "sticky-pin", "unpin", "view-doc", "link-doc", ...COMPANION_VERBS];
|
|
66
|
+
|
|
67
|
+
// Whole commands, never an argument: Enter on a completion must not replace a typed path.
|
|
68
|
+
const COMPLETIONS = [...SUBCOMMANDS, ...SESSION_SUBCOMMANDS.map((s) => `session ${s}`)];
|
|
69
|
+
|
|
70
|
+
// pi's first Enter takes an offered completion, so a command typed in full offers none and runs.
|
|
71
|
+
export function completionsFor(prefix: string) {
|
|
72
|
+
const typed = prefix.trimStart();
|
|
73
|
+
const items = COMPLETIONS.filter((c) => c.startsWith(typed) && c !== typed).map((c) => ({ value: c, label: c }));
|
|
74
|
+
return items.length ? items : null;
|
|
75
|
+
}
|
|
35
76
|
|
|
36
|
-
function
|
|
77
|
+
function splitArgs(args: string): string[] {
|
|
78
|
+
return args.trim().split(/\s+/).filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The board reads the token from the hash and drops it from the address bar. A board started
|
|
82
|
+
// without the terminal has no token, and opens read-only.
|
|
83
|
+
async function boardPageUrl(): Promise<string> {
|
|
84
|
+
const res = await boardFetch("/", { method: "HEAD" });
|
|
85
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
86
|
+
const url = boardUrl()!;
|
|
37
87
|
try {
|
|
38
|
-
return
|
|
39
|
-
} catch
|
|
40
|
-
|
|
41
|
-
return {};
|
|
88
|
+
return `${url}/#t=${terminalToken()}`;
|
|
89
|
+
} catch {
|
|
90
|
+
return url;
|
|
42
91
|
}
|
|
43
92
|
}
|
|
44
93
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (process.env.
|
|
51
|
-
|
|
94
|
+
const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000);
|
|
95
|
+
const EXIT_GUARD = Symbol.for("pi-kanban.live-exit-guard");
|
|
96
|
+
|
|
97
|
+
// A subagent child is shown on its parent's card, so it does not register as a live session.
|
|
98
|
+
function registerLiveState(pi: ExtensionAPI) {
|
|
99
|
+
if (process.env.PI_SUBAGENT_CHILD) return;
|
|
100
|
+
|
|
101
|
+
let session: { sessionId: string; cwd: string; sessionDir: string | null; name: string | null } | null = null;
|
|
102
|
+
let status: "busy" | "idle" | "waiting" = "idle";
|
|
103
|
+
let statusUpdatedAt = Date.now();
|
|
104
|
+
let running = false;
|
|
105
|
+
let openPrompts = 0;
|
|
106
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
107
|
+
let published = "";
|
|
108
|
+
const toolsInFlight = new Map<string, { toolName: string; args: unknown }>();
|
|
109
|
+
const subagents = piAgents.createSubagentTracker();
|
|
110
|
+
|
|
111
|
+
const now = () => new Date().toISOString();
|
|
112
|
+
const activityDir = (sessionId: string) => joinPath(ACTIVITY_DIR, sessionId);
|
|
113
|
+
|
|
114
|
+
// Writes run one at a time, so the files change in the order the events came.
|
|
115
|
+
function enqueue(label: string, job: () => Promise<unknown>) {
|
|
116
|
+
queue = queue.then(job).catch((e: any) => console.warn(`pi-kanban: ${label} failed: ${e?.message ?? e}`));
|
|
52
117
|
}
|
|
53
|
-
|
|
54
|
-
|
|
118
|
+
|
|
119
|
+
function inActivityDir(label: string, job: (dir: string) => Promise<unknown>) {
|
|
120
|
+
if (!session) return;
|
|
121
|
+
const dir = activityDir(session.sessionId);
|
|
122
|
+
enqueue(label, () => job(dir));
|
|
55
123
|
}
|
|
56
|
-
return env;
|
|
57
|
-
}
|
|
58
124
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"open",
|
|
65
|
-
"session",
|
|
66
|
-
] as const;
|
|
67
|
-
type Sub = (typeof SUBCOMMANDS)[number];
|
|
68
|
-
|
|
69
|
-
const SESSION_SUBCOMMANDS = [
|
|
70
|
-
"open",
|
|
71
|
-
"pin",
|
|
72
|
-
"sticky-pin",
|
|
73
|
-
"unpin",
|
|
74
|
-
"view-doc",
|
|
75
|
-
"link-doc",
|
|
76
|
-
] as const;
|
|
77
|
-
type SessionSub = (typeof SESSION_SUBCOMMANDS)[number];
|
|
78
|
-
|
|
79
|
-
function probePort(p: number, timeoutMs = 250): Promise<boolean> {
|
|
80
|
-
return new Promise((resolve) => {
|
|
81
|
-
const sock = createConnection({ port: p, host: "127.0.0.1" });
|
|
82
|
-
const done = (ok: boolean) => {
|
|
83
|
-
sock.destroy();
|
|
84
|
-
resolve(ok);
|
|
85
|
-
};
|
|
86
|
-
sock.setTimeout(timeoutMs);
|
|
87
|
-
sock.once("connect", () => done(true));
|
|
88
|
-
sock.once("timeout", () => done(false));
|
|
89
|
-
sock.once("error", () => done(false));
|
|
90
|
-
});
|
|
91
|
-
}
|
|
125
|
+
function appendLines(batch: SessionLines) {
|
|
126
|
+
if (!batch?.lines.length) return;
|
|
127
|
+
const dir = activityDir(batch.sessionId);
|
|
128
|
+
enqueue("agent activity", () => agentLog.appendAgentLines(dir, batch.lines));
|
|
129
|
+
}
|
|
92
130
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
131
|
+
function publish(next?: typeof status) {
|
|
132
|
+
if (next && next !== status) {
|
|
133
|
+
status = next;
|
|
134
|
+
statusUpdatedAt = Date.now();
|
|
135
|
+
}
|
|
136
|
+
if (!session) return;
|
|
137
|
+
const entry = { sessionId: session.sessionId, pid: process.pid, cwd: session.cwd, sessionDir: session.sessionDir, startedAt: PROCESS_STARTED_AT, status, statusUpdatedAt, name: session.name };
|
|
138
|
+
const text = JSON.stringify(entry);
|
|
139
|
+
if (text === published) return;
|
|
140
|
+
published = text;
|
|
141
|
+
enqueue("live session", () => liveSessions.writeLiveSession(LIVE_DIR, entry));
|
|
98
142
|
}
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
143
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
144
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
145
|
+
if (!g[EXIT_GUARD]) {
|
|
146
|
+
g[EXIT_GUARD] = true;
|
|
147
|
+
process.once("exit", () => {
|
|
148
|
+
try { liveSessions.removeLiveSessionSync(LIVE_DIR, process.pid); } catch {}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
105
151
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
152
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
153
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
154
|
+
if (!sessionId) return;
|
|
155
|
+
session = { sessionId, cwd: ctx.sessionManager.getCwd(), sessionDir: ownSessionDir(ctx.sessionManager.getSessionDir()), name: ctx.sessionManager.getSessionName() ?? null };
|
|
156
|
+
running = false;
|
|
157
|
+
openPrompts = 0;
|
|
158
|
+
toolsInFlight.clear();
|
|
159
|
+
enqueue("sweep", () => liveSessions.sweepDeadLiveSessions(LIVE_DIR));
|
|
160
|
+
inActivityDir("waiting", agentLog.clearWaiting);
|
|
161
|
+
publish("idle");
|
|
111
162
|
});
|
|
112
|
-
}
|
|
113
163
|
|
|
114
|
-
async
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
body: JSON.stringify({ id }),
|
|
164
|
+
pi.on("session_info_changed", async (event) => {
|
|
165
|
+
if (!session) return;
|
|
166
|
+
session.name = event.name ?? null;
|
|
167
|
+
publish();
|
|
119
168
|
});
|
|
120
|
-
}
|
|
121
169
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
170
|
+
// /new, /resume and /fork keep the process, and the next session_start rewrites its file.
|
|
171
|
+
pi.on("session_shutdown", async (event) => {
|
|
172
|
+
if (event.reason !== "quit" || !session) return;
|
|
173
|
+
session = null;
|
|
174
|
+
published = "";
|
|
175
|
+
enqueue("live session", () => liveSessions.removeLiveSession(LIVE_DIR, process.pid));
|
|
127
176
|
});
|
|
128
|
-
}
|
|
129
177
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
178
|
+
pi.on("agent_start", async () => {
|
|
179
|
+
if (!session) return;
|
|
180
|
+
running = true;
|
|
181
|
+
inActivityDir("stop marker", agentLog.clearStopped);
|
|
182
|
+
publish("busy");
|
|
135
183
|
});
|
|
136
|
-
}
|
|
137
184
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function splitArgs(args: string): string[] {
|
|
146
|
-
return args.trim().split(/\s+/).filter(Boolean);
|
|
147
|
-
}
|
|
185
|
+
pi.on("agent_settled", async (event) => {
|
|
186
|
+
if (!session) return;
|
|
187
|
+
running = false;
|
|
188
|
+
if (!event.aborted) inActivityDir("stop marker", agentLog.markStopped);
|
|
189
|
+
publish(openPrompts ? "waiting" : "idle");
|
|
190
|
+
});
|
|
148
191
|
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
|
|
156
|
-
if (m && Number(m[1]) === p) pids.add(Number(m[2]));
|
|
157
|
-
}
|
|
158
|
-
return [...pids];
|
|
192
|
+
function openPrompt(prompt: { kind: string; title?: string }, tool?: { toolName: string; args: unknown }) {
|
|
193
|
+
if (!session) return;
|
|
194
|
+
openPrompts++;
|
|
195
|
+
const marker = agentLog.promptMarker(prompt, tool, now());
|
|
196
|
+
inActivityDir("waiting", (dir) => agentLog.writeWaiting(dir, marker));
|
|
197
|
+
publish("waiting");
|
|
159
198
|
}
|
|
160
|
-
const r = spawnSync("lsof", ["-tiTCP:" + p, "-sTCP:LISTEN"], { encoding: "utf8" });
|
|
161
|
-
if (r.status !== 0) return [];
|
|
162
|
-
return r.stdout
|
|
163
|
-
.split(/\s+/)
|
|
164
|
-
.map((s) => Number(s))
|
|
165
|
-
.filter((n) => Number.isFinite(n) && n > 0);
|
|
166
|
-
}
|
|
167
199
|
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
process.kill(pid, "SIGKILL");
|
|
174
|
-
} catch {}
|
|
200
|
+
function closePrompt() {
|
|
201
|
+
if (!session || openPrompts === 0 || --openPrompts > 0) return;
|
|
202
|
+
inActivityDir("waiting", agentLog.clearWaiting);
|
|
203
|
+
publish(running ? "busy" : "idle");
|
|
175
204
|
}
|
|
205
|
+
|
|
206
|
+
pi.on("ui_prompt_start", async (event) => openPrompt(event, [...toolsInFlight.values()].at(-1)));
|
|
207
|
+
pi.on("ui_prompt_end", async () => closePrompt());
|
|
208
|
+
|
|
209
|
+
pi.events.on(ASK_OPEN, (data) => {
|
|
210
|
+
const { title, args } = data as AskOpen;
|
|
211
|
+
openPrompt({ kind: "input", title }, { toolName: ASK_TOOL, args });
|
|
212
|
+
});
|
|
213
|
+
pi.events.on(ASK_CLOSE, () => closePrompt());
|
|
214
|
+
|
|
215
|
+
pi.on("tool_execution_start", async (event) => {
|
|
216
|
+
toolsInFlight.set(event.toolCallId, { toolName: event.toolName, args: event.args });
|
|
217
|
+
if (!session || event.toolName !== "subagent") return;
|
|
218
|
+
const { sessionId } = session;
|
|
219
|
+
appendLines({ sessionId, lines: subagents.start(sessionId, event.toolCallId, event.args, now()) });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
pi.on("tool_execution_update", async (event) => {
|
|
223
|
+
appendLines(subagents.update(event.toolCallId, event.partialResult, now()));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
pi.on("tool_execution_end", async (event) => {
|
|
227
|
+
toolsInFlight.delete(event.toolCallId);
|
|
228
|
+
appendLines(subagents.end(event.toolCallId, event.result, event.isError, now()));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// pi-subagents emits this in the parent process when an async run's result file appears.
|
|
232
|
+
pi.events.on("subagent:async-complete", (data: unknown) => {
|
|
233
|
+
enqueue("agent activity", async () => {
|
|
234
|
+
const batch = await subagents.asyncComplete(data, now());
|
|
235
|
+
if (batch?.lines.length) await agentLog.appendAgentLines(activityDir(batch.sessionId), batch.lines);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
176
238
|
}
|
|
177
239
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
240
|
+
function registerModelWindows(pi: ExtensionAPI) {
|
|
241
|
+
const record = (ctx: ExtensionContext) => {
|
|
242
|
+
modelWindows.recordModelWindows(MODEL_WINDOWS_FILE, ctx.modelRegistry.getAvailable())
|
|
243
|
+
.catch((e: any) => console.warn(`pi-kanban: model windows failed: ${e?.message ?? e}`));
|
|
244
|
+
};
|
|
245
|
+
pi.on("session_start", async (_event, ctx) => record(ctx));
|
|
246
|
+
pi.on("model_select", async (_event, ctx) => record(ctx));
|
|
182
247
|
}
|
|
183
248
|
|
|
184
249
|
export default function kanbanExtension(pi: ExtensionAPI) {
|
|
185
|
-
|
|
186
|
-
|
|
250
|
+
addCliToPath();
|
|
251
|
+
registerLiveState(pi);
|
|
252
|
+
registerModelWindows(pi);
|
|
253
|
+
registerScratchpad(pi);
|
|
254
|
+
registerAsk(pi);
|
|
255
|
+
registerDispatch(pi);
|
|
256
|
+
registerInbox(pi);
|
|
187
257
|
|
|
188
258
|
pi.on("tool_result", async (event, ctx) => {
|
|
189
259
|
if (event.toolName !== "todo") return;
|
|
@@ -191,8 +261,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
191
261
|
const tasks = (event.details as any)?.tasks;
|
|
192
262
|
if (sessionId && Array.isArray(tasks)) {
|
|
193
263
|
try {
|
|
194
|
-
taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
195
|
-
taskStore.cleanupIfAllCompleted(sessionId);
|
|
264
|
+
await taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
196
265
|
} catch (e: any) { console.warn(`pi-kanban: reconcile failed: ${e?.message ?? e}`); }
|
|
197
266
|
}
|
|
198
267
|
});
|
|
@@ -200,6 +269,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
200
269
|
pi.on("session_start", async (_event, ctx) => {
|
|
201
270
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
202
271
|
if (!sessionId) return;
|
|
272
|
+
process.env[SESSION_ID_ENV] = sessionId;
|
|
203
273
|
const branch = ctx.sessionManager.getBranch();
|
|
204
274
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
205
275
|
const entry = branch[i];
|
|
@@ -209,162 +279,18 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
209
279
|
const tasks = msg?.details?.tasks;
|
|
210
280
|
if (!Array.isArray(tasks)) continue;
|
|
211
281
|
try {
|
|
212
|
-
taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
213
|
-
taskStore.cleanupIfAllCompleted(sessionId);
|
|
282
|
+
await taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
214
283
|
} catch (e: any) { console.warn(`pi-kanban: backfill failed: ${e?.message ?? e}`); }
|
|
215
284
|
return;
|
|
216
285
|
}
|
|
217
286
|
});
|
|
218
287
|
|
|
219
|
-
pi.registerTool({
|
|
220
|
-
name: "bind_plan",
|
|
221
|
-
label: "Bind Plan",
|
|
222
|
-
description:
|
|
223
|
-
"Attach a markdown plan file (e.g. plan.md) to the current session so the dashboard can render it.",
|
|
224
|
-
promptSnippet:
|
|
225
|
-
"Bind a markdown plan file to the current session — call after writing plan file",
|
|
226
|
-
promptGuidelines: [
|
|
227
|
-
"Use bind_plan once a planning markdown file exists so it surfaces in pi-kanban.",
|
|
228
|
-
],
|
|
229
|
-
parameters: Type.Object({
|
|
230
|
-
path: Type.String({
|
|
231
|
-
description: "Path to the plan markdown file. Absolute, or relative to the session cwd.",
|
|
232
|
-
}),
|
|
233
|
-
}),
|
|
234
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
235
|
-
const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
|
|
236
|
-
const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd();
|
|
237
|
-
const inputPath = String(params.path ?? "").trim();
|
|
238
|
-
if (!inputPath) {
|
|
239
|
-
return { content: [{ type: "text", text: "Error: path is required." }], isError: true, details: { path: inputPath, title: null, sessionId } };
|
|
240
|
-
}
|
|
241
|
-
const absPath = isAbsolute(inputPath) ? inputPath : resolvePath(cwd, inputPath);
|
|
242
|
-
|
|
243
|
-
let title: string | null = null;
|
|
244
|
-
try {
|
|
245
|
-
const stat = statSync(absPath);
|
|
246
|
-
if (!stat.isFile()) {
|
|
247
|
-
return { content: [{ type: "text", text: `Error: ${absPath} is not a file.` }], isError: true, details: { path: absPath, title: null, sessionId } };
|
|
248
|
-
}
|
|
249
|
-
const buf = readFileSync(absPath, { encoding: "utf8" });
|
|
250
|
-
const head = buf.slice(0, PLAN_MAX_BYTES);
|
|
251
|
-
title = extractFirstHeading(head);
|
|
252
|
-
} catch (e: any) {
|
|
253
|
-
return {
|
|
254
|
-
content: [{ type: "text", text: `Error reading ${absPath}: ${e?.message ?? e}` }],
|
|
255
|
-
isError: true,
|
|
256
|
-
details: { path: absPath, title: null, sessionId },
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (!sessionId) {
|
|
261
|
-
return {
|
|
262
|
-
content: [{ type: "text", text: "Error: no active session id; cannot bind plan." }],
|
|
263
|
-
isError: true,
|
|
264
|
-
details: { path: absPath, title: title ?? null, sessionId },
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
try {
|
|
268
|
-
const res = await postPlan(sessionId, absPath, title);
|
|
269
|
-
if (!res.ok) {
|
|
270
|
-
const msg = `pi-kanban server rejected bind_plan: HTTP ${res.status}. Is /kanban running?`;
|
|
271
|
-
console.warn(msg);
|
|
272
|
-
return { content: [{ type: "text", text: msg }], isError: true, details: { path: absPath, title, sessionId } };
|
|
273
|
-
}
|
|
274
|
-
} catch (e: any) {
|
|
275
|
-
const msg = `pi-kanban: bind_plan POST failed (server may be stopped): ${e?.message ?? e}`;
|
|
276
|
-
console.warn(msg);
|
|
277
|
-
return { content: [{ type: "text", text: msg }], isError: true, details: { path: absPath, title: title ?? null, sessionId } };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const summary = title ? `Bound plan "${title}" → ${absPath}` : `Bound plan → ${absPath}`;
|
|
281
|
-
return {
|
|
282
|
-
content: [{ type: "text", text: summary }],
|
|
283
|
-
details: { path: absPath, title, sessionId },
|
|
284
|
-
};
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
type Notify = (m: string, l?: "info" | "error") => void;
|
|
289
|
-
|
|
290
|
-
async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
|
291
|
-
if (await probePort(port)) {
|
|
292
|
-
notify(`pi-kanban already listening on ${url} — run /kanban open web to launch it`);
|
|
293
|
-
return true;
|
|
294
|
-
}
|
|
295
|
-
lastStderr = "";
|
|
296
|
-
child = spawn(process.execPath, [serverPath], {
|
|
297
|
-
env: buildServerEnv(),
|
|
298
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
299
|
-
detached: true,
|
|
300
|
-
windowsHide: true,
|
|
301
|
-
});
|
|
302
|
-
child.stderr?.on("data", (b) => {
|
|
303
|
-
lastStderr += b.toString();
|
|
304
|
-
});
|
|
305
|
-
child.on("exit", () => {
|
|
306
|
-
child = null;
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
if (!(await waitForPort(port))) {
|
|
310
|
-
notify(
|
|
311
|
-
`pi-kanban failed to start.\n${lastStderr.slice(-500) || "(no stderr)"}`,
|
|
312
|
-
"error",
|
|
313
|
-
);
|
|
314
|
-
return false;
|
|
315
|
-
}
|
|
316
|
-
// Detach: stop holding the child to the parent's lifetime so /kanban survives
|
|
317
|
-
// when the pi process that ran /kanban start exits.
|
|
318
|
-
child.stderr?.removeAllListeners("data");
|
|
319
|
-
child.stderr?.resume();
|
|
320
|
-
child.unref();
|
|
321
|
-
if (!opts.silentSuccess) {
|
|
322
|
-
notify(`pi-kanban started → ${url} — run /kanban open web to launch it`);
|
|
323
|
-
}
|
|
324
|
-
return true;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function stopServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
|
328
|
-
if (child) child.kill("SIGINT");
|
|
329
|
-
child = null;
|
|
330
|
-
|
|
331
|
-
if (await probePort(port)) {
|
|
332
|
-
const pids = findPidsOnPort(port);
|
|
333
|
-
for (const pid of pids) killPid(pid);
|
|
334
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
335
|
-
const stillUp = await probePort(port);
|
|
336
|
-
if (stillUp) {
|
|
337
|
-
notify(
|
|
338
|
-
`port ${port} still in use after killing pids ${pids.join(",") || "?"}`,
|
|
339
|
-
"error",
|
|
340
|
-
);
|
|
341
|
-
return false;
|
|
342
|
-
}
|
|
343
|
-
if (!opts.silentSuccess) {
|
|
344
|
-
notify(`pi-kanban stopped (killed orphan pid${pids.length > 1 ? "s" : ""} ${pids.join(",")})`);
|
|
345
|
-
}
|
|
346
|
-
return true;
|
|
347
|
-
}
|
|
348
|
-
if (!opts.silentSuccess) notify("pi-kanban stopped");
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
288
|
pi.registerCommand("kanban", {
|
|
353
|
-
description:
|
|
354
|
-
|
|
355
|
-
getArgumentCompletions: (prefix) => {
|
|
356
|
-
const tokens = splitArgs(prefix);
|
|
357
|
-
if (tokens.length >= 1 && tokens[0] === "session") {
|
|
358
|
-
const verbPrefix = tokens[1] ?? "";
|
|
359
|
-
return SESSION_SUBCOMMANDS
|
|
360
|
-
.filter((s) => s.startsWith(verbPrefix))
|
|
361
|
-
.map((s) => ({ value: `session ${s}`, label: s }));
|
|
362
|
-
}
|
|
363
|
-
return SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
364
|
-
},
|
|
289
|
+
description: `pi-kanban board: ${CAN_OPEN ? "open | " : ""}session <verb> | ${COMPANION_VERBS.join(" | ")}`,
|
|
290
|
+
getArgumentCompletions: completionsFor,
|
|
365
291
|
handler: async (args, ctx) => {
|
|
366
292
|
const tokens = splitArgs(args);
|
|
367
|
-
const sub =
|
|
293
|
+
const sub = tokens[0] || (CAN_OPEN ? "open" : "help");
|
|
368
294
|
const rest = tokens.slice(1);
|
|
369
295
|
const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
|
|
370
296
|
|
|
@@ -372,14 +298,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
372
298
|
const lines = [
|
|
373
299
|
"Usage: /kanban <command>",
|
|
374
300
|
"",
|
|
375
|
-
"
|
|
376
|
-
" start Start the dashboard server",
|
|
377
|
-
" stop Stop the dashboard server",
|
|
378
|
-
" restart Restart the dashboard server",
|
|
379
|
-
" status Show server status",
|
|
380
|
-
" open web Open dashboard in browser",
|
|
381
|
-
" open app Open dashboard as PWA",
|
|
382
|
-
"",
|
|
301
|
+
...(CAN_OPEN ? [" open Open the board in the browser, with the terminal token", ""] : []),
|
|
383
302
|
"Session commands (default to current session):",
|
|
384
303
|
" session open [<id>]",
|
|
385
304
|
" session pin [<id>]",
|
|
@@ -387,58 +306,48 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
387
306
|
" session unpin [<id>]",
|
|
388
307
|
" session view-doc <file> [<id>]",
|
|
389
308
|
" session link-doc <file> [<id>]",
|
|
309
|
+
...PANE_COMPANIONS.map((c) => ` ${`${c.verb} [<id>]`.padEnd(17)} Open ${c.id}: ${c.shows} (also session ${c.verb})`),
|
|
390
310
|
];
|
|
391
311
|
notify(lines.join("\n"));
|
|
392
312
|
}
|
|
393
313
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
314
|
+
// In the board terminal the board is on screen, so the pane opens there; elsewhere it waits as a tab.
|
|
315
|
+
async function companionCommand({ verb, id: companion }: { verb: string; id: string }, idArg: string | undefined) {
|
|
316
|
+
const id = idArg ?? ctx.sessionManager.getSessionId();
|
|
317
|
+
if (!id) {
|
|
318
|
+
notify(`Usage: /kanban ${verb} <session-id> (no current session to default to)`, "error");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const { pane, added } = await openCompanion(id, companion, boardTerminalId() ?? undefined);
|
|
323
|
+
notify(
|
|
324
|
+
CAN_OPEN
|
|
325
|
+
? `${added ? "Added" : "Already there"}: ${pane.id} ${pane.title}. Open its tab on the board.`
|
|
326
|
+
: `Opened ${pane.title} on the board.`,
|
|
327
|
+
);
|
|
328
|
+
} catch (e: any) {
|
|
329
|
+
notify(`${verb} failed: ${e?.message ?? e}`, "error");
|
|
330
|
+
}
|
|
409
331
|
}
|
|
410
332
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
notify(up ? `running on ${url}${owned}` : "not running");
|
|
333
|
+
const subCompanion = companionOf(sub);
|
|
334
|
+
if (subCompanion) {
|
|
335
|
+
await companionCommand(subCompanion, rest[0]);
|
|
415
336
|
return;
|
|
416
337
|
}
|
|
417
338
|
|
|
418
|
-
if (sub === "open") {
|
|
419
|
-
|
|
420
|
-
if (verb === "web") {
|
|
339
|
+
if (sub === "open" && CAN_OPEN) {
|
|
340
|
+
try {
|
|
421
341
|
const { default: open } = await import("open");
|
|
422
|
-
await open(
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (verb === "app") {
|
|
426
|
-
const { default: open, apps } = await import("open");
|
|
427
|
-
for (const name of [apps.chrome, apps.edge, apps.browser]) {
|
|
428
|
-
try {
|
|
429
|
-
await open(url, { app: { name, arguments: [`--app=${url}`] } });
|
|
430
|
-
return;
|
|
431
|
-
} catch {}
|
|
432
|
-
}
|
|
433
|
-
notify("Could not find Chrome/Edge for PWA window mode", "error");
|
|
434
|
-
return;
|
|
342
|
+
await open(await boardPageUrl());
|
|
343
|
+
} catch (e: any) {
|
|
344
|
+
notify(`open failed: ${e?.message ?? e}`, "error");
|
|
435
345
|
}
|
|
436
|
-
notify(`Usage: /kanban open web|app${verb ? ` (unknown: ${verb})` : ""}`, "error");
|
|
437
346
|
return;
|
|
438
347
|
}
|
|
439
348
|
|
|
440
349
|
if (sub === "session") {
|
|
441
|
-
const verb = rest[0]
|
|
350
|
+
const verb = rest[0];
|
|
442
351
|
const verbRest = rest.slice(1);
|
|
443
352
|
|
|
444
353
|
if (!verb) {
|
|
@@ -446,22 +355,21 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
446
355
|
return;
|
|
447
356
|
}
|
|
448
357
|
|
|
449
|
-
if (!
|
|
358
|
+
if (!SESSION_SUBCOMMANDS.includes(verb)) {
|
|
450
359
|
notify(`Unknown session verb: ${verb}. Try: ${SESSION_SUBCOMMANDS.join(", ")}`, "error");
|
|
451
360
|
return;
|
|
452
361
|
}
|
|
453
362
|
|
|
454
|
-
if (!(await ensureRunning(notify))) return;
|
|
455
|
-
|
|
456
363
|
if (verb === "open") {
|
|
457
364
|
const id = verbRest[0] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
458
365
|
if (!id) {
|
|
459
366
|
notify("Usage: /kanban session open <session-id> (no current session to default to)", "error");
|
|
460
367
|
return;
|
|
461
368
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
369
|
+
try {
|
|
370
|
+
await okJson(postJson("/api/session/open", { id }));
|
|
371
|
+
} catch (e: any) {
|
|
372
|
+
notify(`open failed: ${e?.message ?? e}`, "error");
|
|
465
373
|
return;
|
|
466
374
|
}
|
|
467
375
|
notify(`opened: ${id}${!verbRest[0] ? " (current)" : ""}`);
|
|
@@ -476,9 +384,10 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
476
384
|
}
|
|
477
385
|
const PIN_STATE = { "sticky-pin": "sticky", "unpin": "none", "pin": "pinned" } as const;
|
|
478
386
|
const state = PIN_STATE[verb];
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
387
|
+
try {
|
|
388
|
+
await okJson(postJson("/api/session/pin", { ids: [id], state }));
|
|
389
|
+
} catch (e: any) {
|
|
390
|
+
notify(`${verb} failed: ${e?.message ?? e}`, "error");
|
|
482
391
|
return;
|
|
483
392
|
}
|
|
484
393
|
const pinVerb = verb === "unpin" ? "unpinned" : state;
|
|
@@ -497,18 +406,18 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
497
406
|
notify("Usage: /kanban session link-doc <file.md> <session-id> (no current session to default to)", "error");
|
|
498
407
|
return;
|
|
499
408
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const res = await postPreview(abs, sessionId, verb === "link-doc");
|
|
506
|
-
if (!res.ok) {
|
|
507
|
-
notify(`${verb} failed (${res.status}): ${await res.text()}`, "error");
|
|
508
|
-
return;
|
|
409
|
+
try {
|
|
410
|
+
const target = resolveTarget(ctx.cwd, file);
|
|
411
|
+
notify(verb === "link-doc" ? await linkDoc(sessionId!, target) : await previewDoc(target, sessionId));
|
|
412
|
+
} catch (e: any) {
|
|
413
|
+
notify(`${verb} failed: ${e?.message ?? e}`, "error");
|
|
509
414
|
}
|
|
510
|
-
|
|
511
|
-
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const verbCompanion = companionOf(verb);
|
|
419
|
+
if (verbCompanion) {
|
|
420
|
+
await companionCommand(verbCompanion, verbRest[0]);
|
|
512
421
|
return;
|
|
513
422
|
}
|
|
514
423
|
}
|