pi-kanban 0.3.1 → 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 +330 -367
- package/extensions/package.json +1 -0
- 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/extensions/tsconfig.json +16 -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 +39 -15
- package/public/app.js +14349 -3448
- 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 -328
- package/public/link-url.js +18 -0
- package/public/manifest.json +7 -7
- package/public/project-match.js +34 -0
- package/public/style.css +8301 -2251
- 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 +3368 -467
- 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 -1000
- 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,184 +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
|
-
|
|
16
|
-
|
|
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
|
+
};
|
|
17
39
|
};
|
|
18
40
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
};
|
|
22
45
|
|
|
23
|
-
const
|
|
24
|
-
const SETTINGS_PATH = joinPath(KANBAN_DIR, "settings.json");
|
|
25
|
-
const DEFAULT_THEME_DIR = joinPath(KANBAN_DIR, "themes");
|
|
46
|
+
const { SESSION_ID_ENV } = lib("board-dir.js") as { SESSION_ID_ENV: string };
|
|
26
47
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
}
|
|
76
|
+
|
|
77
|
+
function splitArgs(args: string): string[] {
|
|
78
|
+
return args.trim().split(/\s+/).filter(Boolean);
|
|
79
|
+
}
|
|
34
80
|
|
|
35
|
-
|
|
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()!;
|
|
36
87
|
try {
|
|
37
|
-
return
|
|
38
|
-
} catch
|
|
39
|
-
|
|
40
|
-
return {};
|
|
88
|
+
return `${url}/#t=${terminalToken()}`;
|
|
89
|
+
} catch {
|
|
90
|
+
return url;
|
|
41
91
|
}
|
|
42
92
|
}
|
|
43
93
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (process.env.
|
|
50
|
-
|
|
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}`));
|
|
51
117
|
}
|
|
52
|
-
|
|
53
|
-
|
|
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));
|
|
54
123
|
}
|
|
55
|
-
return env;
|
|
56
|
-
}
|
|
57
124
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"open",
|
|
64
|
-
"web",
|
|
65
|
-
"app",
|
|
66
|
-
"pin",
|
|
67
|
-
"sticky-pin",
|
|
68
|
-
"unpin",
|
|
69
|
-
"preview",
|
|
70
|
-
"link",
|
|
71
|
-
] as const;
|
|
72
|
-
type Sub = (typeof SUBCOMMANDS)[number];
|
|
73
|
-
|
|
74
|
-
function probePort(p: number, timeoutMs = 250): Promise<boolean> {
|
|
75
|
-
return new Promise((resolve) => {
|
|
76
|
-
const sock = createConnection({ port: p, host: "127.0.0.1" });
|
|
77
|
-
const done = (ok: boolean) => {
|
|
78
|
-
sock.destroy();
|
|
79
|
-
resolve(ok);
|
|
80
|
-
};
|
|
81
|
-
sock.setTimeout(timeoutMs);
|
|
82
|
-
sock.once("connect", () => done(true));
|
|
83
|
-
sock.once("timeout", () => done(false));
|
|
84
|
-
sock.once("error", () => done(false));
|
|
85
|
-
});
|
|
86
|
-
}
|
|
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
|
+
}
|
|
87
130
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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));
|
|
93
142
|
}
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
143
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}
|
|
151
|
+
|
|
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");
|
|
162
|
+
});
|
|
100
163
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
body: JSON.stringify({ id, state }),
|
|
164
|
+
pi.on("session_info_changed", async (event) => {
|
|
165
|
+
if (!session) return;
|
|
166
|
+
session.name = event.name ?? null;
|
|
167
|
+
publish();
|
|
106
168
|
});
|
|
107
|
-
}
|
|
108
169
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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));
|
|
114
176
|
});
|
|
115
|
-
}
|
|
116
177
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
178
|
+
pi.on("agent_start", async () => {
|
|
179
|
+
if (!session) return;
|
|
180
|
+
running = true;
|
|
181
|
+
inActivityDir("stop marker", agentLog.clearStopped);
|
|
182
|
+
publish("busy");
|
|
122
183
|
});
|
|
123
|
-
}
|
|
124
184
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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");
|
|
130
190
|
});
|
|
131
|
-
}
|
|
132
191
|
|
|
133
|
-
|
|
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");
|
|
198
|
+
}
|
|
134
199
|
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
200
|
+
function closePrompt() {
|
|
201
|
+
if (!session || openPrompts === 0 || --openPrompts > 0) return;
|
|
202
|
+
inActivityDir("waiting", agentLog.clearWaiting);
|
|
203
|
+
publish(running ? "busy" : "idle");
|
|
204
|
+
}
|
|
139
205
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
206
|
+
pi.on("ui_prompt_start", async (event) => openPrompt(event, [...toolsInFlight.values()].at(-1)));
|
|
207
|
+
pi.on("ui_prompt_end", async () => closePrompt());
|
|
143
208
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
for (const line of r.stdout.split(/\r?\n/)) {
|
|
150
|
-
const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
|
|
151
|
-
if (m && Number(m[1]) === p) pids.add(Number(m[2]));
|
|
152
|
-
}
|
|
153
|
-
return [...pids];
|
|
154
|
-
}
|
|
155
|
-
const r = spawnSync("lsof", ["-tiTCP:" + p, "-sTCP:LISTEN"], { encoding: "utf8" });
|
|
156
|
-
if (r.status !== 0) return [];
|
|
157
|
-
return r.stdout
|
|
158
|
-
.split(/\s+/)
|
|
159
|
-
.map((s) => Number(s))
|
|
160
|
-
.filter((n) => Number.isFinite(n) && n > 0);
|
|
161
|
-
}
|
|
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());
|
|
162
214
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
});
|
|
171
238
|
}
|
|
172
239
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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));
|
|
177
247
|
}
|
|
178
248
|
|
|
179
249
|
export default function kanbanExtension(pi: ExtensionAPI) {
|
|
180
|
-
|
|
181
|
-
|
|
250
|
+
addCliToPath();
|
|
251
|
+
registerLiveState(pi);
|
|
252
|
+
registerModelWindows(pi);
|
|
253
|
+
registerScratchpad(pi);
|
|
254
|
+
registerAsk(pi);
|
|
255
|
+
registerDispatch(pi);
|
|
256
|
+
registerInbox(pi);
|
|
182
257
|
|
|
183
258
|
pi.on("tool_result", async (event, ctx) => {
|
|
184
259
|
if (event.toolName !== "todo") return;
|
|
@@ -186,8 +261,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
186
261
|
const tasks = (event.details as any)?.tasks;
|
|
187
262
|
if (sessionId && Array.isArray(tasks)) {
|
|
188
263
|
try {
|
|
189
|
-
taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
190
|
-
taskStore.cleanupIfAllCompleted(sessionId);
|
|
264
|
+
await taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
191
265
|
} catch (e: any) { console.warn(`pi-kanban: reconcile failed: ${e?.message ?? e}`); }
|
|
192
266
|
}
|
|
193
267
|
});
|
|
@@ -195,6 +269,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
195
269
|
pi.on("session_start", async (_event, ctx) => {
|
|
196
270
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
197
271
|
if (!sessionId) return;
|
|
272
|
+
process.env[SESSION_ID_ENV] = sessionId;
|
|
198
273
|
const branch = ctx.sessionManager.getBranch();
|
|
199
274
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
200
275
|
const entry = branch[i];
|
|
@@ -204,262 +279,150 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
204
279
|
const tasks = msg?.details?.tasks;
|
|
205
280
|
if (!Array.isArray(tasks)) continue;
|
|
206
281
|
try {
|
|
207
|
-
taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
208
|
-
taskStore.cleanupIfAllCompleted(sessionId);
|
|
282
|
+
await taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
209
283
|
} catch (e: any) { console.warn(`pi-kanban: backfill failed: ${e?.message ?? e}`); }
|
|
210
284
|
return;
|
|
211
285
|
}
|
|
212
286
|
});
|
|
213
287
|
|
|
214
|
-
pi.registerTool({
|
|
215
|
-
name: "bind_plan",
|
|
216
|
-
label: "Bind Plan",
|
|
217
|
-
description:
|
|
218
|
-
"Attach a markdown plan file (e.g. plan.md) to the current session so the dashboard can render it.",
|
|
219
|
-
promptSnippet:
|
|
220
|
-
"Bind a markdown plan file to the current session — call after writing plan file",
|
|
221
|
-
promptGuidelines: [
|
|
222
|
-
"Use bind_plan once a planning markdown file exists so it surfaces in pi-kanban.",
|
|
223
|
-
],
|
|
224
|
-
parameters: Type.Object({
|
|
225
|
-
path: Type.String({
|
|
226
|
-
description: "Path to the plan markdown file. Absolute, or relative to the session cwd.",
|
|
227
|
-
}),
|
|
228
|
-
}),
|
|
229
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
230
|
-
const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
|
|
231
|
-
const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd();
|
|
232
|
-
const inputPath = String(params.path ?? "").trim();
|
|
233
|
-
if (!inputPath) {
|
|
234
|
-
return { content: [{ type: "text", text: "Error: path is required." }], isError: true };
|
|
235
|
-
}
|
|
236
|
-
const absPath = isAbsolute(inputPath) ? inputPath : resolvePath(cwd, inputPath);
|
|
237
|
-
|
|
238
|
-
let title: string | null = null;
|
|
239
|
-
try {
|
|
240
|
-
const stat = statSync(absPath);
|
|
241
|
-
if (!stat.isFile()) {
|
|
242
|
-
return { content: [{ type: "text", text: `Error: ${absPath} is not a file.` }], isError: true };
|
|
243
|
-
}
|
|
244
|
-
const buf = readFileSync(absPath, { encoding: "utf8" });
|
|
245
|
-
const head = buf.slice(0, PLAN_MAX_BYTES);
|
|
246
|
-
title = extractFirstHeading(head);
|
|
247
|
-
} catch (e: any) {
|
|
248
|
-
return {
|
|
249
|
-
content: [{ type: "text", text: `Error reading ${absPath}: ${e?.message ?? e}` }],
|
|
250
|
-
isError: true,
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (!sessionId) {
|
|
255
|
-
return {
|
|
256
|
-
content: [{ type: "text", text: "Error: no active session id; cannot bind plan." }],
|
|
257
|
-
isError: true,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
const res = await postPlan(sessionId, absPath, title);
|
|
262
|
-
if (!res.ok) {
|
|
263
|
-
const msg = `pi-kanban server rejected bind_plan: HTTP ${res.status}. Is /kanban running?`;
|
|
264
|
-
console.warn(msg);
|
|
265
|
-
return { content: [{ type: "text", text: msg }], isError: true };
|
|
266
|
-
}
|
|
267
|
-
} catch (e: any) {
|
|
268
|
-
const msg = `pi-kanban: bind_plan POST failed (server may be stopped): ${e?.message ?? e}`;
|
|
269
|
-
console.warn(msg);
|
|
270
|
-
return { content: [{ type: "text", text: msg }], isError: true };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const summary = title ? `Bound plan "${title}" → ${absPath}` : `Bound plan → ${absPath}`;
|
|
274
|
-
return {
|
|
275
|
-
content: [{ type: "text", text: summary }],
|
|
276
|
-
details: { path: absPath, title, sessionId },
|
|
277
|
-
};
|
|
278
|
-
},
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
type Notify = (m: string, l?: "info" | "error") => void;
|
|
282
|
-
|
|
283
|
-
async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
|
284
|
-
if (await probePort(port)) {
|
|
285
|
-
notify(`pi-kanban already listening on ${url} — run /kanban open or /kanban web`);
|
|
286
|
-
return true;
|
|
287
|
-
}
|
|
288
|
-
lastStderr = "";
|
|
289
|
-
child = spawn(process.execPath, [serverPath], {
|
|
290
|
-
env: buildServerEnv(),
|
|
291
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
292
|
-
detached: true,
|
|
293
|
-
windowsHide: true,
|
|
294
|
-
});
|
|
295
|
-
child.stderr?.on("data", (b) => {
|
|
296
|
-
lastStderr += b.toString();
|
|
297
|
-
});
|
|
298
|
-
child.on("exit", () => {
|
|
299
|
-
child = null;
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
if (!(await waitForPort(port))) {
|
|
303
|
-
notify(
|
|
304
|
-
`pi-kanban failed to start.\n${lastStderr.slice(-500) || "(no stderr)"}`,
|
|
305
|
-
"error",
|
|
306
|
-
);
|
|
307
|
-
return false;
|
|
308
|
-
}
|
|
309
|
-
// Detach: stop holding the child to the parent's lifetime so /kanban survives
|
|
310
|
-
// when the pi process that ran /kanban start exits.
|
|
311
|
-
child.stderr?.removeAllListeners("data");
|
|
312
|
-
child.stderr?.resume();
|
|
313
|
-
child.unref();
|
|
314
|
-
if (!opts.silentSuccess) {
|
|
315
|
-
notify(`pi-kanban started → ${url} — run /kanban open or /kanban web to launch it`);
|
|
316
|
-
}
|
|
317
|
-
return true;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
async function stopServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
|
321
|
-
if (child) child.kill("SIGINT");
|
|
322
|
-
child = null;
|
|
323
|
-
|
|
324
|
-
if (await probePort(port)) {
|
|
325
|
-
const pids = findPidsOnPort(port);
|
|
326
|
-
for (const pid of pids) killPid(pid);
|
|
327
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
328
|
-
const stillUp = await probePort(port);
|
|
329
|
-
if (stillUp) {
|
|
330
|
-
notify(
|
|
331
|
-
`port ${port} still in use after killing pids ${pids.join(",") || "?"}`,
|
|
332
|
-
"error",
|
|
333
|
-
);
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
if (!opts.silentSuccess) {
|
|
337
|
-
notify(`pi-kanban stopped (killed orphan pid${pids.length > 1 ? "s" : ""} ${pids.join(",")})`);
|
|
338
|
-
}
|
|
339
|
-
return true;
|
|
340
|
-
}
|
|
341
|
-
if (!opts.silentSuccess) notify("pi-kanban stopped");
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
288
|
pi.registerCommand("kanban", {
|
|
346
|
-
description:
|
|
347
|
-
|
|
348
|
-
getArgumentCompletions: (prefix) =>
|
|
349
|
-
SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),
|
|
289
|
+
description: `pi-kanban board: ${CAN_OPEN ? "open | " : ""}session <verb> | ${COMPANION_VERBS.join(" | ")}`,
|
|
290
|
+
getArgumentCompletions: completionsFor,
|
|
350
291
|
handler: async (args, ctx) => {
|
|
351
292
|
const tokens = splitArgs(args);
|
|
352
|
-
const sub =
|
|
293
|
+
const sub = tokens[0] || (CAN_OPEN ? "open" : "help");
|
|
353
294
|
const rest = tokens.slice(1);
|
|
354
295
|
const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
|
|
355
296
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if (sub === "status") {
|
|
374
|
-
const up = await probePort(port);
|
|
375
|
-
const owned = child ? ` (pid ${child.pid})` : " (external)";
|
|
376
|
-
notify(up ? `running on ${url}${owned}` : "not running");
|
|
377
|
-
return;
|
|
297
|
+
function showHelp() {
|
|
298
|
+
const lines = [
|
|
299
|
+
"Usage: /kanban <command>",
|
|
300
|
+
"",
|
|
301
|
+
...(CAN_OPEN ? [" open Open the board in the browser, with the terminal token", ""] : []),
|
|
302
|
+
"Session commands (default to current session):",
|
|
303
|
+
" session open [<id>]",
|
|
304
|
+
" session pin [<id>]",
|
|
305
|
+
" session sticky-pin [<id>]",
|
|
306
|
+
" session unpin [<id>]",
|
|
307
|
+
" session view-doc <file> [<id>]",
|
|
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})`),
|
|
310
|
+
];
|
|
311
|
+
notify(lines.join("\n"));
|
|
378
312
|
}
|
|
379
313
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const id =
|
|
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();
|
|
383
317
|
if (!id) {
|
|
384
|
-
notify(
|
|
318
|
+
notify(`Usage: /kanban ${verb} <session-id> (no current session to default to)`, "error");
|
|
385
319
|
return;
|
|
386
320
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
notify(
|
|
390
|
-
|
|
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");
|
|
391
330
|
}
|
|
392
|
-
const usedCurrent = !rest[0];
|
|
393
|
-
notify(`opened: ${id}${usedCurrent ? " (current)" : ""}`);
|
|
394
|
-
return;
|
|
395
331
|
}
|
|
396
332
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
await
|
|
333
|
+
const subCompanion = companionOf(sub);
|
|
334
|
+
if (subCompanion) {
|
|
335
|
+
await companionCommand(subCompanion, rest[0]);
|
|
400
336
|
return;
|
|
401
337
|
}
|
|
402
338
|
|
|
403
|
-
if (sub === "
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
} catch {}
|
|
339
|
+
if (sub === "open" && CAN_OPEN) {
|
|
340
|
+
try {
|
|
341
|
+
const { default: open } = await import("open");
|
|
342
|
+
await open(await boardPageUrl());
|
|
343
|
+
} catch (e: any) {
|
|
344
|
+
notify(`open failed: ${e?.message ?? e}`, "error");
|
|
410
345
|
}
|
|
411
|
-
notify("Could not find Chrome/Edge for PWA window mode", "error");
|
|
412
346
|
return;
|
|
413
347
|
}
|
|
414
348
|
|
|
415
|
-
if (sub === "
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
349
|
+
if (sub === "session") {
|
|
350
|
+
const verb = rest[0];
|
|
351
|
+
const verbRest = rest.slice(1);
|
|
352
|
+
|
|
353
|
+
if (!verb) {
|
|
354
|
+
showHelp();
|
|
420
355
|
return;
|
|
421
356
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!res.ok) {
|
|
426
|
-
notify(`${sub} failed (${res.status}): ${await res.text()}`, "error");
|
|
357
|
+
|
|
358
|
+
if (!SESSION_SUBCOMMANDS.includes(verb)) {
|
|
359
|
+
notify(`Unknown session verb: ${verb}. Try: ${SESSION_SUBCOMMANDS.join(", ")}`, "error");
|
|
427
360
|
return;
|
|
428
361
|
}
|
|
429
|
-
const usedCurrent = !rest[0];
|
|
430
|
-
const verb = sub === "unpin" ? "unpinned" : state;
|
|
431
|
-
notify(`${verb}: ${id}${usedCurrent ? " (current)" : ""}`);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
362
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
363
|
+
if (verb === "open") {
|
|
364
|
+
const id = verbRest[0] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
365
|
+
if (!id) {
|
|
366
|
+
notify("Usage: /kanban session open <session-id> (no current session to default to)", "error");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
await okJson(postJson("/api/session/open", { id }));
|
|
371
|
+
} catch (e: any) {
|
|
372
|
+
notify(`open failed: ${e?.message ?? e}`, "error");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
notify(`opened: ${id}${!verbRest[0] ? " (current)" : ""}`);
|
|
440
376
|
return;
|
|
441
377
|
}
|
|
442
|
-
|
|
443
|
-
if (
|
|
444
|
-
|
|
378
|
+
|
|
379
|
+
if (verb === "pin" || verb === "sticky-pin" || verb === "unpin") {
|
|
380
|
+
const id = verbRest[0] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
381
|
+
if (!id) {
|
|
382
|
+
notify(`Usage: /kanban session ${verb} <session-id> (no current session to default to)`, "error");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const PIN_STATE = { "sticky-pin": "sticky", "unpin": "none", "pin": "pinned" } as const;
|
|
386
|
+
const state = PIN_STATE[verb];
|
|
387
|
+
try {
|
|
388
|
+
await okJson(postJson("/api/session/pin", { ids: [id], state }));
|
|
389
|
+
} catch (e: any) {
|
|
390
|
+
notify(`${verb} failed: ${e?.message ?? e}`, "error");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const pinVerb = verb === "unpin" ? "unpinned" : state;
|
|
394
|
+
notify(`${pinVerb}: ${id}${!verbRest[0] ? " (current)" : ""}`);
|
|
445
395
|
return;
|
|
446
396
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
397
|
+
|
|
398
|
+
if (verb === "view-doc" || verb === "link-doc") {
|
|
399
|
+
const file = verbRest[0];
|
|
400
|
+
if (!file) {
|
|
401
|
+
notify(`Usage: /kanban session ${verb} <file.md> [session-id]`, "error");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const sessionId = verbRest[1] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
405
|
+
if (verb === "link-doc" && !sessionId) {
|
|
406
|
+
notify("Usage: /kanban session link-doc <file.md> <session-id> (no current session to default to)", "error");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
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");
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const verbCompanion = companionOf(verb);
|
|
419
|
+
if (verbCompanion) {
|
|
420
|
+
await companionCommand(verbCompanion, verbRest[0]);
|
|
455
421
|
return;
|
|
456
422
|
}
|
|
457
|
-
const usedCurrent = !rest[1] && sessionId;
|
|
458
|
-
notify(`${sub}: ${abs}${sessionId ? ` → session ${sessionId}${usedCurrent ? " (current)" : ""}` : ""}`);
|
|
459
|
-
return;
|
|
460
423
|
}
|
|
461
424
|
|
|
462
|
-
|
|
425
|
+
showHelp();
|
|
463
426
|
},
|
|
464
427
|
});
|
|
465
428
|
}
|