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.
Files changed (127) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +61 -21
  3. package/cli.js +1586 -0
  4. package/extensions/kanban.ts +277 -368
  5. package/extensions/tools/ask.ts +85 -0
  6. package/extensions/tools/board-api.ts +142 -0
  7. package/extensions/tools/cli-path.ts +34 -0
  8. package/extensions/tools/dispatch.ts +56 -0
  9. package/extensions/tools/inbox.ts +86 -0
  10. package/extensions/tools/kanban-command.ts +27 -0
  11. package/extensions/tools/scratchpad.ts +70 -0
  12. package/lib/agent-dir.js +20 -0
  13. package/lib/agent-log.js +64 -0
  14. package/lib/ask.js +252 -0
  15. package/lib/atomic-write.js +30 -0
  16. package/lib/board-dir.js +63 -0
  17. package/lib/claude-settings.js +21 -0
  18. package/lib/companion-host.js +142 -0
  19. package/lib/companions.js +136 -0
  20. package/lib/contain.js +78 -0
  21. package/lib/dispatch-reports.js +86 -0
  22. package/lib/file-url.js +38 -0
  23. package/lib/folder-dialog.js +91 -0
  24. package/lib/frame-policy.js +79 -0
  25. package/lib/git-branch.js +37 -102
  26. package/lib/http-error.js +7 -0
  27. package/lib/inline-assets.js +238 -0
  28. package/lib/linked-docs.js +56 -0
  29. package/lib/live-sessions.js +86 -0
  30. package/lib/model-windows.js +48 -0
  31. package/lib/net-guard.js +277 -0
  32. package/lib/one-line.js +6 -0
  33. package/lib/open-editor.js +173 -0
  34. package/lib/panes.js +213 -0
  35. package/lib/parsers.js +1434 -0
  36. package/lib/pi-agents.js +178 -0
  37. package/lib/pi-git-branch.js +112 -0
  38. package/lib/pi-models.js +34 -0
  39. package/lib/pi-sessions.js +240 -0
  40. package/lib/pi-transcript.js +354 -0
  41. package/lib/preview-action.js +55 -0
  42. package/lib/priority.js +45 -0
  43. package/lib/proc-stats.js +82 -0
  44. package/lib/retention.js +191 -0
  45. package/lib/scratch-files.js +47 -0
  46. package/lib/scratchpad-dir.js +95 -0
  47. package/lib/session-cache.js +32 -0
  48. package/lib/session-events.js +275 -0
  49. package/lib/session-pins.js +99 -0
  50. package/lib/show.js +279 -0
  51. package/lib/stamped-json-file.js +49 -0
  52. package/lib/task-counts.js +90 -0
  53. package/lib/task-dir.js +26 -0
  54. package/lib/task-store.js +44 -47
  55. package/lib/terminal-client.js +248 -0
  56. package/lib/terminal-host.js +113 -0
  57. package/lib/terminal.js +1116 -0
  58. package/lib/user-groups.js +384 -0
  59. package/lib/worktrees.js +132 -0
  60. package/package.json +37 -17
  61. package/public/app.js +14340 -3452
  62. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  63. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  64. package/public/fonts/fonts.css +244 -0
  65. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  66. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  67. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  68. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  69. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  70. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  71. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  72. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  73. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  74. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  75. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  76. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  77. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  78. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  79. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  80. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  81. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  82. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  83. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  84. package/public/group-tree.js +21 -0
  85. package/public/icons/icon-192.png +0 -0
  86. package/public/icons/icon-512.png +0 -0
  87. package/public/icons/icon-svg.svg +7 -5
  88. package/public/index.html +493 -335
  89. package/public/link-url.js +18 -0
  90. package/public/manifest.json +7 -7
  91. package/public/project-match.js +34 -0
  92. package/public/style.css +7689 -1727
  93. package/public/sw.js +4 -3
  94. package/public/terminal-frame.js +437 -0
  95. package/public/terminal.html +26 -0
  96. package/public/themes.css +876 -0
  97. package/public/tree-highlight.js +196 -0
  98. package/server.js +3362 -503
  99. package/skills/ask/SKILL.md +29 -0
  100. package/skills/ask/references/playground.md +25 -0
  101. package/skills/dispatch/SKILL.md +51 -0
  102. package/skills/dispatch/references/orchestration-patterns.md +50 -0
  103. package/skills/kanban/SKILL.md +46 -0
  104. package/skills/show/SKILL.md +41 -0
  105. package/skills/show/references/NOTICE.md +27 -0
  106. package/skills/show/references/chart.md +58 -0
  107. package/skills/show/references/diagram/architecture.md +18 -0
  108. package/skills/show/references/diagram/er.md +10 -0
  109. package/skills/show/references/diagram/flow.md +13 -0
  110. package/skills/show/references/diagram/layers.md +10 -0
  111. package/skills/show/references/diagram/sequence.md +19 -0
  112. package/skills/show/references/diagram/state.md +12 -0
  113. package/skills/show/references/diagram/swimlane.md +10 -0
  114. package/skills/show/references/diagram/timeline.md +9 -0
  115. package/skills/show/references/diagram/tree.md +10 -0
  116. package/skills/show/references/diagram.md +87 -0
  117. package/skills/show/references/explain.md +100 -0
  118. package/skills/show/references/html-contract.md +18 -0
  119. package/skills/show/references/mockup.md +30 -0
  120. package/skills/visualize/SKILL.md +73 -0
  121. package/skills/visualize/blocks.html +68 -0
  122. package/skills/visualize/template.html +179 -0
  123. package/lib/pi-parsers.js +0 -1155
  124. package/themes/kanban-dark-default.json +0 -21
  125. package/themes/kanban-default.json +0 -21
  126. package/themes/pi-dark.json +0 -21
  127. package/themes/pi-light.json +0 -21
package/cli.js ADDED
@@ -0,0 +1,1586 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { resolvePiAgentDir, boardDir, displayPath, SESSION_ID_ENV, boardTerminalId } = require('./lib/board-dir');
4
+ const { linkUrl } = require('./public/link-url');
5
+ const { sessionProjectKey } = require('./public/project-match');
6
+ const { dispatchLine, matchDispatches, reportText, WAIT_MAX_MS } = require('./lib/dispatch-reports');
7
+ const { THINKING_LEVELS } = require('./lib/pi-agents');
8
+ const { PANE_COMPANIONS } = require('./lib/companions');
9
+ // Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
10
+ const SESSION_FLAG = '--session <id>';
11
+ const SESSION_FLAG_HELP = 'Session, full id or unique prefix (default: $PREVIEW_SESSION, else $PI_KANBAN_SESSION_ID)';
12
+ const OR_SESSION_FLAG = '; `--session <id>` works too';
13
+ const SESSION_ID_HELP = `Full session id, or a unique prefix${OR_SESSION_FLAG}`;
14
+
15
+ const COMMANDS = {
16
+ doc: {
17
+ summary: 'Show documents to the user: link them to a session, or open the preview',
18
+ verbs: {
19
+ link: {
20
+ summary: 'Link a file or URL to a session, with no modal',
21
+ usage: 'pi-kanban doc link <file|url> [--session <id>]',
22
+ flags: {
23
+ '<file|url>': 'Any file type; one the preview cannot render shows a card with its path. An http(s) URL opens in a new tab.',
24
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
25
+ },
26
+ notes: 'The server keeps the link, so it shows when a browser tab opens later. Prefer this over `doc preview` while the user works.',
27
+ examples: [
28
+ 'pi-kanban doc link ./design.md',
29
+ 'pi-kanban doc link https://github.com/org/repo/pull/12',
30
+ ],
31
+ run: runDocLinkCli,
32
+ },
33
+ unlink: {
34
+ summary: 'Remove a linked file or URL from a session',
35
+ usage: 'pi-kanban doc unlink <file|url> [--session <id>]',
36
+ flags: {
37
+ '<file|url>': 'The linked path or URL; the file need not exist',
38
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
39
+ },
40
+ examples: ['pi-kanban doc unlink ./design.md'],
41
+ run: runDocLinkCli,
42
+ },
43
+ list: {
44
+ summary: 'Print the docs linked to a session',
45
+ usage: 'pi-kanban doc list [--session <id>] [--json]',
46
+ flags: {
47
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
48
+ '--json': 'Output JSON',
49
+ },
50
+ run: runDocListCli,
51
+ },
52
+ preview: {
53
+ summary: 'Open a markdown or HTML file in the preview modal on connected browser tabs',
54
+ usage: 'pi-kanban doc preview <file.md|file.html|url> [--session <id>]',
55
+ flags: {
56
+ [SESSION_FLAG]: 'Switch focused session in the browser (does not link the file; default: $PREVIEW_SESSION). For a URL, the session it links to (default: $PREVIEW_SESSION, else $PI_KANBAN_SESSION_ID).',
57
+ },
58
+ notes: 'The modal opens on the user\'s screen: the only command that does. HTML renders in a sandboxed iframe; local stylesheets, scripts and images are inlined. Relative paths resolve against the current dir. An http(s) URL is linked to the session instead, and the tab on screen shows an Open button.',
59
+ examples: [
60
+ 'pi-kanban doc preview ./notes.md --session $PI_KANBAN_SESSION_ID',
61
+ ],
62
+ run: runPreviewCli,
63
+ },
64
+ },
65
+ },
66
+ show: {
67
+ summary: 'Show the user a card: a markdown or HTML file you wrote, rendered on the board',
68
+ usage: 'pi-kanban show <file> [--title <text>] [--key <name>] [--base <dir>] [--session <id>]',
69
+ flags: {
70
+ '<file>': 'A markdown, HTML, image or text file (relative paths resolve against the current dir)',
71
+ '--title <text>': 'Card title (default: the file name)',
72
+ '--key <name>': 'Card name, e.g. latency-chart: a card with the same key is replaced in place',
73
+ '--base <dir>': 'Folder that the card\'s path:line links resolve against, usually the repo root (default: the session cwd)',
74
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
75
+ },
76
+ notes: 'From pi in the board terminal, the card opens in the overlay above the terminal. Elsewhere it opens as a background tab of the session, and --key and --base do nothing. The card refreshes on each save of the file. The show skill has the card guide.',
77
+ examples: [
78
+ 'pi-kanban show ./scratch/latency.html --title "p95 latency" --key latency-chart',
79
+ 'pi-kanban show ./scratch/plan.md',
80
+ ],
81
+ run: runShowCli,
82
+ },
83
+ pane: {
84
+ summary: 'Add live panes (a URL or a local file) to a session\'s view, without switching to them',
85
+ verbs: {
86
+ add: {
87
+ summary: 'Add a pane to a session; it opens as a tab next to Board, in the background',
88
+ usage: 'pi-kanban pane add <url|file> | --companion <id> [--title <text>] [--session <id>] [--json]',
89
+ flags: {
90
+ '<url|file>': 'An http(s) URL, or a local HTML, markdown, text or image file (relative paths resolve against the current dir)',
91
+ '--companion <id>': 'pi-cost (the session\'s cost, tokens and models) or pi-inspect (what pi loaded), in place of a target. The board starts the tool; needs the terminal token, like dispatch start.',
92
+ '--title <text>': 'Tab title (default: the host, file name or companion)',
93
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
94
+ '--json': 'Output JSON (the new pane, with frameable: true, false, or null when unknown)',
95
+ },
96
+ notes: 'The board does not switch to the new pane; the user opens it. Prints the pane id. The same target added again prints the pane it already has. A URL on the board\'s own origin is refused, and a file must be one the board can preview. A site whose headers refuse framing (X-Frame-Options, CSP frame-ancestors) is still added, but its pane shows an "Open in new tab" card, and a note line says so.',
97
+ examples: [
98
+ 'pi-kanban pane add http://localhost:5173',
99
+ 'pi-kanban pane add ./report.html --title Report',
100
+ 'pi-kanban pane add --companion pi-cost --session $PI_KANBAN_SESSION_ID',
101
+ ],
102
+ run: runPaneAddCli,
103
+ },
104
+ rm: {
105
+ summary: 'Remove a pane from a session',
106
+ usage: 'pi-kanban pane rm <pane-id> [--session <id>]',
107
+ flags: {
108
+ '<pane-id>': 'The id `pane add` or `pane list` printed',
109
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
110
+ },
111
+ examples: ['pi-kanban pane rm p3'],
112
+ run: runPaneRmCli,
113
+ },
114
+ list: {
115
+ summary: 'List the panes of a session, in tab order',
116
+ usage: 'pi-kanban pane list [--session <id>] [--json]',
117
+ flags: {
118
+ [SESSION_FLAG]: SESSION_FLAG_HELP,
119
+ '--json': 'Output JSON ({rev, panes: [{id, kind, target, title, addedAt}], updatedAt})',
120
+ },
121
+ run: runPaneListCli,
122
+ },
123
+ },
124
+ },
125
+ session: {
126
+ summary: 'List, search, open, inspect and group pi sessions',
127
+ verbs: {
128
+ list: {
129
+ summary: 'List the active sessions, as the sidebar\'s Active view shows them (pinned/sticky always included)',
130
+ usage: 'pi-kanban session list [--all] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
131
+ flags: {
132
+ '--all': 'Every session, active or not',
133
+ '--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
134
+ '--project <name>': 'Filter by project: an absolute path selects one project, other text matches a part of the path',
135
+ '--limit <n|all>': 'Max rows to display (default: every active row; 20 with --all). Pinned sessions are kept past it.',
136
+ '--no-pins': 'Disable always-include and sticky-first ordering for pinned sessions',
137
+ '--json': 'Output JSON instead of a table',
138
+ },
139
+ notes: 'Active: running in the terminal, or with messages and open tasks, live agents, a wait on the user or recent activity. STATUS is idle, active, busy (a task in progress) or wait. When --limit cuts the list, a last line says how many rows it shows of how many (on stderr with --json).',
140
+ examples: [
141
+ 'pi-kanban session list',
142
+ 'pi-kanban session list --all --days 0.5 --limit 20 --project my-repo',
143
+ ],
144
+ run: runSessionListCli,
145
+ },
146
+ search: {
147
+ summary: 'Find sessions whose name or id contains the text, from any transcript',
148
+ usage: 'pi-kanban session search <text> [--limit <n>] [--json]',
149
+ flags: {
150
+ '<text>': 'At least 3 characters; matched against the session name and id',
151
+ '--limit <n>': 'Max rows (default and max: 20)',
152
+ '--json': 'Output JSON instead of a table',
153
+ },
154
+ examples: [
155
+ 'pi-kanban session search login-redirect',
156
+ ],
157
+ run: runSessionSearchCli,
158
+ },
159
+ open: {
160
+ summary: 'Focus a session in the browser (Active tab)',
161
+ usage: 'pi-kanban session open <id>',
162
+ flags: {
163
+ '<id>': SESSION_ID_HELP,
164
+ },
165
+ examples: ['pi-kanban session open $PI_KANBAN_SESSION_ID'],
166
+ run: runSessionOpenCli,
167
+ },
168
+ view: {
169
+ summary: 'Show full session stats (metadata + context window + cost) and the transcript path',
170
+ usage: 'pi-kanban session view <id> [--json]',
171
+ flags: {
172
+ '<id>': SESSION_ID_HELP,
173
+ '--json': 'Output JSON instead of formatted sections',
174
+ },
175
+ notes: 'To learn what a session did, read its transcript (a .jsonl file, newest lines last).',
176
+ examples: ['pi-kanban session view $PI_KANBAN_SESSION_ID'],
177
+ run: runSessionViewCli,
178
+ },
179
+ agents: {
180
+ summary: 'List the subagents a session has run, and whether it waits for the user',
181
+ usage: 'pi-kanban session agents <id> [--json]',
182
+ flags: {
183
+ '<id>': SESSION_ID_HELP,
184
+ '--json': 'Output JSON ({agents, waitingForUser})',
185
+ },
186
+ notes: 'The pi-kanban pi package records the runs of pi-subagents. Without both packages in pi, the list is empty.',
187
+ examples: ['pi-kanban session agents $PI_KANBAN_SESSION_ID'],
188
+ run: runSessionAgentsCli,
189
+ },
190
+ ...Object.fromEntries(PANE_COMPANIONS.map((c) => [c.verb, {
191
+ summary: `Open ${c.id} for a session as a board tab: ${c.shows}`,
192
+ usage: `pi-kanban session ${c.verb} [<id>]`,
193
+ flags: { '<id>': `${SESSION_ID_HELP} (default: $PI_KANBAN_SESSION_ID)` },
194
+ notes: 'From pi in the board terminal, the board switches to the tab. Needs the terminal token, like dispatch start.',
195
+ companion: c.id,
196
+ run: runSessionCompanionCli,
197
+ }])),
198
+ pin: {
199
+ summary: 'Pin (or unpin) a session in the sidebar; every board on this config dir shows it',
200
+ usage: 'pi-kanban session pin <id> [--sticky] [--unpin]',
201
+ flags: {
202
+ '<id>': SESSION_ID_HELP,
203
+ '--sticky': 'Set sticky state (always shown in the list)',
204
+ '--unpin': 'Clear pin/sticky state',
205
+ },
206
+ examples: [
207
+ 'pi-kanban session pin $PI_KANBAN_SESSION_ID --sticky',
208
+ 'pi-kanban session pin $PI_KANBAN_SESSION_ID --unpin',
209
+ ],
210
+ run: runSessionPinCli,
211
+ },
212
+ group: {
213
+ summary: 'Move a session into a group in the sidebar',
214
+ usage: 'pi-kanban session group <id> (<group> | --with <session> | --parent <session>) [--no-parent] [--create] [--json]',
215
+ flags: {
216
+ '<id>': SESSION_ID_HELP,
217
+ '<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
218
+ '--with <session>': 'Join the group this other session shows in (id or unique prefix)',
219
+ '--parent <session>': 'Show the session under this one, indented, when both are in the same list; alone, it also joins its group',
220
+ '--no-parent': 'Show the session on its own again',
221
+ '--create': 'Make the group, and the missing parents of a path, when none matches; with --with, it holds both sessions',
222
+ '--json': 'Output JSON ({group, name, path, created, left, rev})',
223
+ },
224
+ notes: 'The session leaves the group it was in. In a group that holds its project, it shows in that project\'s block.',
225
+ examples: [
226
+ 'pi-kanban session group $PI_KANBAN_SESSION_ID "Auth refactor"',
227
+ 'pi-kanban session group 3fa9c1 auth-refactor/swarm-1 --create',
228
+ 'pi-kanban session group 3fa9c1 --with $PI_KANBAN_SESSION_ID --create',
229
+ 'pi-kanban session group 3fa9c1 --parent $PI_KANBAN_SESSION_ID',
230
+ ],
231
+ run: runSessionGroupCli,
232
+ },
233
+ ungroup: {
234
+ summary: 'Take a session out of its group',
235
+ usage: 'pi-kanban session ungroup <id> [--json]',
236
+ flags: {
237
+ '<id>': SESSION_ID_HELP,
238
+ '--json': 'Output JSON ({left, rev})',
239
+ },
240
+ notes: 'A session whose project is in a group still shows there; move the project on the board.',
241
+ examples: ['pi-kanban session ungroup $PI_KANBAN_SESSION_ID'],
242
+ run: runSessionUngroupCli,
243
+ },
244
+ },
245
+ },
246
+ group: {
247
+ summary: 'Read and change the session groups in the sidebar',
248
+ verbs: {
249
+ list: {
250
+ summary: 'List the groups in sidebar order, nested groups under their parent, with the members the sidebar\'s Active view shows (active, pinned or sticky)',
251
+ usage: 'pi-kanban group list [--all] [--json]',
252
+ flags: {
253
+ '--all': 'Every member, active or not',
254
+ '--json': 'Output JSON ({rev, groups: [{id, name, parent, transient?, visible, total, members}]}); a session member has title, branch, status, pinned and age, or missing: true; a project member has sessions (its shown sessions)',
255
+ },
256
+ notes: 'A project member shows when it has a session the Active view shows. visible/total counts the members shown. A group marked (transient) goes when the board\'s Active view shows none of its sessions; Keep or a rename on the board, or `group rename`, keeps it. `dispatch start --group` and `group create --transient` make transient groups. Pass a group\'s name, path (parent/child) or id to `session group`.',
257
+ examples: ['pi-kanban group list', 'pi-kanban group list --all --json'],
258
+ run: runGroupListCli,
259
+ },
260
+ create: {
261
+ summary: 'Make a group, and the missing parents of a path',
262
+ usage: 'pi-kanban group create <name|path> [--transient] [--json]',
263
+ flags: {
264
+ '<name|path>': 'Name of a new top-level group, or a path from the top (parent/child)',
265
+ '--transient': 'The groups it makes go when the Active view shows none of their sessions',
266
+ '--json': 'Output JSON ({group, name, path, rev})',
267
+ },
268
+ notes: 'Fails when the group exists.',
269
+ examples: ['pi-kanban group create "Auth refactor"', 'pi-kanban group create auth-refactor/swarm-1'],
270
+ run: runGroupCreateCli,
271
+ },
272
+ rename: {
273
+ summary: 'Rename a group; it keeps its place, parent and members',
274
+ usage: 'pi-kanban group rename <group> <new name> [--json]',
275
+ flags: {
276
+ '<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
277
+ '<new name>': 'The new name',
278
+ '--json': 'Output JSON ({group, name, path, rev})',
279
+ },
280
+ examples: ['pi-kanban group rename auth-refactor "Auth v2"'],
281
+ run: runGroupRenameCli,
282
+ },
283
+ rm: {
284
+ summary: 'Remove a group, as Delete on the board does',
285
+ usage: 'pi-kanban group rm <group> [--json]',
286
+ flags: {
287
+ '<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
288
+ '--json': 'Output JSON ({removed: {id, name, path}, ungrouped, rev})',
289
+ },
290
+ notes: 'Its members leave the group and show as with no group: in their project\'s group or the top level. Its child groups move up one level.',
291
+ examples: ['pi-kanban group rm presales-cloud'],
292
+ run: runGroupRmCli,
293
+ },
294
+ prune: {
295
+ summary: 'Remove every empty group',
296
+ usage: 'pi-kanban group prune [--dry-run] [--json]',
297
+ flags: {
298
+ '--dry-run': 'List the groups it would remove, and remove none',
299
+ '--json': 'Output JSON ({removed: [{id, name, path}], dryRun, rev})',
300
+ },
301
+ notes: 'Empty: no session whose transcript still exists, no project, and no child group that is not empty. It checks every member, not only the ones the Active view shows.',
302
+ examples: ['pi-kanban group prune --dry-run', 'pi-kanban group prune'],
303
+ run: runGroupPruneCli,
304
+ },
305
+ },
306
+ },
307
+ task: {
308
+ summary: 'Read the tasks on the board',
309
+ verbs: {
310
+ list: {
311
+ summary: 'List the tasks of a session, a project, or every session',
312
+ usage: 'pi-kanban task list (<session> | --project <path> | --all) [--status <s>] [--json]',
313
+ flags: {
314
+ '<session>': SESSION_ID_HELP,
315
+ '--project <path>': 'Every task of the sessions in this project (absolute path, as in `project list`)',
316
+ '--all': 'Every task on the board',
317
+ '--status <s>': 'Only tasks in this status: pending, in_progress or completed',
318
+ '--json': 'Output JSON instead of a table',
319
+ },
320
+ examples: [
321
+ 'pi-kanban task list $PI_KANBAN_SESSION_ID',
322
+ 'pi-kanban task list --all --status in_progress',
323
+ ],
324
+ run: runTaskListCli,
325
+ },
326
+ },
327
+ },
328
+ project: {
329
+ summary: 'Read the projects the board knows',
330
+ verbs: {
331
+ list: {
332
+ summary: 'List known project paths, newest activity first',
333
+ usage: 'pi-kanban project list [--json]',
334
+ flags: {
335
+ '--json': 'Output JSON instead of a table',
336
+ },
337
+ notes: 'These are the folders `dispatch start --cwd` accepts.',
338
+ run: runProjectListCli,
339
+ },
340
+ },
341
+ },
342
+ dispatch: {
343
+ summary: 'Start, wait for, list and end pi sessions for tasks in pi-kanban\'s terminal',
344
+ verbs: {
345
+ start: {
346
+ summary: 'Start pi in pi-kanban\'s terminal with a task; prints the session id',
347
+ usage: 'pi-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--model <m>] [--thinking <level>] [--json] [-- <pi args>...]',
348
+ flags: {
349
+ '--cwd <dir>': 'Folder to run in (any existing folder, default: current dir)',
350
+ '--spec <text>': 'The task, self-contained; sent as the first message',
351
+ '--spec-file <path>': 'Read the task from a file',
352
+ '--name <n>': 'Session name',
353
+ '--group <g>': 'Add it to this group: a name, path or id. The groups it makes are transient (see `help group list`)',
354
+ '--model <m>': 'pi model pattern, provider/id (see `pi --list-models`)',
355
+ '--thinking <level>': [...THINKING_LEVELS].join(', '),
356
+ '--json': 'Output JSON',
357
+ '-- <pi args>': 'Passed to pi as they are, e.g. --tools read,bash. No quotes, % or control characters; pi-kanban sets --session-id, --name, --model and --thinking',
358
+ },
359
+ notes: 'Needs the terminal token, so it runs on the machine of the pi-kanban server. The session gets the dispatch_report tool and reports through the board; read the report with `dispatch wait`. Run `pi-kanban skills get dispatch` for how to write the spec.',
360
+ examples: [
361
+ 'pi-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --thinking high',
362
+ 'pi-kanban dispatch start --cwd . --spec "Run the tests and report the failures" --model codemie/gpt-6-luna',
363
+ ],
364
+ run: runDispatchStartCli,
365
+ },
366
+ wait: {
367
+ summary: 'Wait for the reports of sessions this session started, and print them',
368
+ usage: 'pi-kanban dispatch wait <session id>...',
369
+ flags: { '<session id>': `The full id \`dispatch start\` printed; give several to wait for each in turn${OR_SESSION_FLAG}` },
370
+ notes: 'Prints a report at once when it is already in, and says so when the session\'s terminal ended without one.',
371
+ examples: ['pi-kanban dispatch wait 01a30000-0000-7000-8000-000000000004'],
372
+ run: runDispatchWaitCli,
373
+ },
374
+ list: {
375
+ summary: 'List sessions this session started that still run in pi-kanban\'s terminal',
376
+ usage: 'pi-kanban dispatch list [--all] [--json]',
377
+ flags: {
378
+ '--all': 'Every dispatch on this board',
379
+ '--json': 'Output JSON',
380
+ },
381
+ run: runDispatchListCli,
382
+ },
383
+ end: {
384
+ summary: 'End the terminal of a session this session started',
385
+ usage: 'pi-kanban dispatch end (<session id or prefix> | --name <n>) [--json]',
386
+ flags: {
387
+ '<session id or prefix>': `A session from \`dispatch list\`${OR_SESSION_FLAG}`,
388
+ '--name <n>': 'The session\'s --name from dispatch start',
389
+ '--json': 'Output JSON',
390
+ },
391
+ notes: 'Needs the terminal token, like dispatch start.',
392
+ examples: ['pi-kanban dispatch end --name api-worker'],
393
+ run: runDispatchEndCli,
394
+ },
395
+ },
396
+ },
397
+ terminal: {
398
+ summary: 'Read the terminals running in pi-kanban',
399
+ verbs: {
400
+ list: {
401
+ summary: 'List every terminal in pk: dispatched and opened by hand',
402
+ usage: 'pi-kanban terminal list [--json]',
403
+ flags: {
404
+ '--json': 'Output JSON',
405
+ },
406
+ notes: 'Read-only. `dispatch end` ends a terminal this session dispatched; the user ends the others from the board.',
407
+ run: runTerminalListCli,
408
+ },
409
+ },
410
+ },
411
+ skills: {
412
+ summary: 'Print a skill bundled with this version',
413
+ verbs: {
414
+ get: {
415
+ summary: 'Print the SKILL.md of a skill',
416
+ usage: 'pi-kanban skills get <name>',
417
+ flags: { '<name>': 'Skill name, e.g. dispatch' },
418
+ examples: ['pi-kanban skills get dispatch'],
419
+ run: runSkillsGetCli,
420
+ },
421
+ },
422
+ },
423
+ };
424
+
425
+ for (const [noun, cmd] of Object.entries(COMMANDS)) {
426
+ cmd.name = noun;
427
+ for (const [verb, v] of Object.entries(cmd.verbs || {})) v.name = `${noun} ${verb}`;
428
+ }
429
+ function runCli(argv) {
430
+ if (argv.includes('--version') || argv.includes('-v')) {
431
+ console.log(require('./package.json').version);
432
+ process.exit(0);
433
+ }
434
+ const cli = resolveCliCommand(argv);
435
+ if (cli.kind === 'server') return false;
436
+ if (cli.kind === 'help') {
437
+ const noun = cli.target && Object.hasOwn(COMMANDS, cli.target) ? COMMANDS[cli.target] : null;
438
+ const verb = noun?.verbs && cli.verb && Object.hasOwn(noun.verbs, cli.verb) ? noun.verbs[cli.verb] : null;
439
+ if (verb) printLeafHelp(verb);
440
+ else if (noun) printNounHelp(cli.target);
441
+ else printTopHelp();
442
+ process.exit(0);
443
+ }
444
+ if (cli.kind === 'unknown-noun') {
445
+ console.error(`Unknown command: ${cli.noun}\n`);
446
+ printTopHelp();
447
+ process.exit(1);
448
+ }
449
+ if (cli.kind === 'unknown-verb') {
450
+ console.error(`Unknown subcommand: ${cli.noun} ${cli.verb}\n`);
451
+ printNounHelp(cli.noun);
452
+ process.exit(1);
453
+ }
454
+ if (cli.kind === 'noun') {
455
+ printNounHelp(cli.noun);
456
+ process.exit(argv.includes('--help') || argv.includes('-h') ? 0 : 1);
457
+ }
458
+ if (cli.kind === 'leaf') {
459
+ if (cli.args.includes('--help') || cli.args.includes('-h')) {
460
+ printLeafHelp(cli.entry);
461
+ process.exit(0);
462
+ }
463
+ cli.entry.run(cli.args, cli.entry)
464
+ .then(code => { process.exitCode = code; })
465
+ .catch(e => { console.error(e.message); process.exitCode = 1; });
466
+ return true;
467
+ }
468
+ return true;
469
+ }
470
+
471
+ function resolveCliCommand(argv) {
472
+ const noun = argv[2] && !argv[2].startsWith('-') ? argv[2] : null;
473
+ const hasHelp = (a) => a.includes('--help') || a.includes('-h');
474
+ if (!noun) return hasHelp(argv) ? { kind: 'help' } : { kind: 'server' };
475
+ if (noun === 'help') return { kind: 'help', target: argv[3], verb: argv[4] };
476
+ if (!Object.hasOwn(COMMANDS, noun)) return { kind: 'unknown-noun', noun };
477
+ const entry = COMMANDS[noun];
478
+ if (!entry.verbs) return { kind: 'leaf', name: noun, entry, args: argv.slice(3) };
479
+ const verb = argv[3] && !argv[3].startsWith('-') ? argv[3] : null;
480
+ if (!verb) return { kind: 'noun', noun };
481
+ if (!Object.hasOwn(entry.verbs, verb)) return { kind: 'unknown-verb', noun, verb };
482
+ return { kind: 'leaf', name: `${noun} ${verb}`, entry: entry.verbs[verb], args: argv.slice(4) };
483
+ }
484
+
485
+ // The website's reference/cli.md has the same table; change both.
486
+ const SERVER_FLAGS = [
487
+ ['--port <n>', 'PORT', 'Port (default 3460)'],
488
+ ['--open', '', 'Open the board in the browser'],
489
+ ['--dir <path>', 'PI_CODING_AGENT_DIR', 'pi agent dir (default ~/.pi/agent)'],
490
+ ['--session-dir <dir>', 'PI_CODING_AGENT_SESSION_DIR', 'pi session dir (default: sessionDir in pi settings)'],
491
+ ['--no-terminal', '', 'Turn off the board terminal'],
492
+ ['--new-token', '', 'Replace the saved terminal token'],
493
+ ['--terminal-shell <shell>', 'PK_TERMINAL_SHELL', 'gitbash, a program on PATH, or a path'],
494
+ ['--host <addr>', 'HOST', 'Listen address (default 127.0.0.1)'],
495
+ ['--allowed-hosts <list>', 'ALLOWED_HOSTS', 'Extra Host header values, comma-separated'],
496
+ ];
497
+
498
+ function topHelp() {
499
+ const lines = ['Usage: pi-kanban <command> [args] [--flags]\n', 'Commands:'];
500
+ for (const [name, cmd] of Object.entries(COMMANDS)) {
501
+ const verbs = cmd.verbs ? ` (${Object.keys(cmd.verbs).join(', ')})` : '';
502
+ lines.push(` ${name.padEnd(20)}${cmd.summary}${verbs}`);
503
+ }
504
+ lines.push(` ${'help'.padEnd(20)}Show help for a command (pi-kanban help <command>)`);
505
+ lines.push('\nFlags:');
506
+ lines.push(' --help, -h Show help (top-level, noun-level, or leaf-level)');
507
+ lines.push(' --version, -v Print version and exit');
508
+ lines.push('\nServer (no command; a flag wins over its env var):');
509
+ const flagPad = Math.max(...SERVER_FLAGS.map(([flag]) => flag.length)) + 2;
510
+ const envPad = Math.max(...SERVER_FLAGS.map(([, env]) => env.length)) + 2;
511
+ for (const [flag, env, desc] of SERVER_FLAGS) lines.push(` ${flag.padEnd(flagPad)}${env.padEnd(envPad)}${desc}`);
512
+ lines.push('\nCommand env:');
513
+ lines.push(' PK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
514
+ lines.push(' PORT Server port (default: the one this config dir\'s server reports, else 3460)');
515
+ lines.push(' PI_CODING_AGENT_DIR pi agent dir whose board to use');
516
+ lines.push('\nRun `pi-kanban help <command>` for its subcommands, and `help <command> <subcommand>` for flags and examples.');
517
+ return lines.join('\n');
518
+ }
519
+
520
+ function printTopHelp() {
521
+ console.log(topHelp());
522
+ }
523
+
524
+ function printNounHelp(noun) {
525
+ const entry = COMMANDS[noun];
526
+ if (!entry.verbs) return printLeafHelp(entry);
527
+ console.log(`${entry.summary}\n`);
528
+ console.log(`Usage: pi-kanban ${noun} <subcommand> [args] [--flags]\n`);
529
+ console.log('Subcommands:');
530
+ for (const [vName, v] of Object.entries(entry.verbs)) {
531
+ console.log(` ${vName.padEnd(12)}${v.summary}`);
532
+ }
533
+ console.log(`\nRun \`pi-kanban help ${noun} <subcommand>\` for flags and examples.`);
534
+ }
535
+
536
+ function printLeafHelp(entry) {
537
+ console.log(`${entry.summary}\n`);
538
+ console.log(`Usage: ${entry.usage}`);
539
+ const flags = { ...entry.flags, '--help, -h': 'Show this help' };
540
+ const pad = Math.max(...Object.keys(flags).map(f => f.length));
541
+ console.log('\nFlags:');
542
+ for (const [flag, desc] of Object.entries(flags)) {
543
+ console.log(` ${flag.padEnd(pad + 2)}${desc}`);
544
+ }
545
+ if (entry.notes) console.log(`\n${entry.notes}`);
546
+ if (entry.examples?.length) {
547
+ console.log('\nExamples:');
548
+ for (const ex of entry.examples) console.log(` ${ex}`);
549
+ }
550
+ }
551
+
552
+ // For a bad value: the leaf help would bury the one line that says what is wrong.
553
+ function usageError(entry, message) {
554
+ console.error(message);
555
+ console.error(`Run \`pi-kanban help ${entry.name}\` for usage.`);
556
+ return 1;
557
+ }
558
+
559
+ function getArgValue(args, name) {
560
+ const idx = args.findIndex(a => a === `--${name}` || a.startsWith(`--${name}=`));
561
+ if (idx === -1) return null;
562
+ const arg = args[idx];
563
+ if (arg.includes('=')) return arg.split('=').slice(1).join('=');
564
+ return args[idx + 1] && !args[idx + 1].startsWith('--') ? args[idx + 1] : null;
565
+ }
566
+
567
+ // Each agent dir can run its own board on its own port, so 3460 can be another dir's board.
568
+ // The server's beacon in this agent dir names the right one; PORT still wins when set.
569
+ // A beacon left by a dead server means this dir's board is down: 3460 would be someone else's.
570
+ // Returns null in that case.
571
+ function beaconPort() {
572
+ const beacon = readCckJson('server.json');
573
+ if (!beacon) return 3460;
574
+ return beacon.port && beacon.pid && isPidAlive(beacon.pid) ? beacon.port : null;
575
+ }
576
+
577
+ let cachedPort;
578
+ function cliPort() {
579
+ if (process.env.PORT) return process.env.PORT;
580
+ if (cachedPort === undefined) cachedPort = beaconPort();
581
+ return cachedPort;
582
+ }
583
+
584
+ function cliBaseUrl() {
585
+ if (process.env.PK_URL) return process.env.PK_URL.replace(/\/+$/, '');
586
+ const port = cliPort();
587
+ return port === null ? null : `http://127.0.0.1:${port}`;
588
+ }
589
+
590
+ function cliTargetPort() {
591
+ if (!process.env.PK_URL) return cliPort();
592
+ try { return new URL(process.env.PK_URL).port; } catch (_) { return ''; }
593
+ }
594
+
595
+ function readCckJson(name) {
596
+ try { return JSON.parse(fs.readFileSync(path.join(boardDir(resolvePiAgentDir()), name), 'utf8')); } catch (_) { return null; }
597
+ }
598
+
599
+ // EPERM means the process exists but belongs to someone else.
600
+ function isPidAlive(pid) {
601
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
602
+ }
603
+ function unreachable() {
604
+ const dir = displayPath(resolvePiAgentDir());
605
+ const start = 'Start it first with "pi-kanban".';
606
+ if (process.env.PK_URL) return `Cannot reach pi-kanban server for ${dir} at ${cliBaseUrl()} (PK_URL). ${start}`;
607
+ const port = cliPort();
608
+ if (port === null) return `Cannot reach pi-kanban server for ${dir}: its server.json names a server that is no longer running. ${start}`;
609
+ return `Cannot reach pi-kanban server for ${dir} on port ${port}. ${start}`;
610
+ }
611
+
612
+ class CliUnreachable extends Error { constructor() { super(unreachable()); this.code = 'unreachable'; } }
613
+
614
+ // Windows sometimes fails a loopback connect with ETIMEDOUT after ~300 ms when many
615
+ // connects run at once. Nothing reached the server, so even a POST is safe to send again.
616
+ const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
617
+ const isConnectTimeout = (e) => e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
618
+ // A loopback connection can also be reset after it opens. A GET is safe to send again; a write may have landed.
619
+ const isRetryable = (e, init) => isConnectTimeout(e) || (!init?.method && e.cause?.code === 'ECONNRESET');
620
+
621
+ async function cliFetch(urlPath, init) {
622
+ const base = cliBaseUrl();
623
+ if (!base) throw new CliUnreachable();
624
+ for (let i = 0; ; i++) {
625
+ try {
626
+ return await fetch(`${base}${urlPath}`, init);
627
+ } catch (e) {
628
+ if (isRetryable(e, init) && i < CONNECT_RETRY_MS.length) {
629
+ await new Promise((r) => setTimeout(r, CONNECT_RETRY_MS[i]));
630
+ continue;
631
+ }
632
+ if (e.cause?.code === 'ECONNREFUSED' || /fetch failed/i.test(e.message)) throw new CliUnreachable();
633
+ throw e;
634
+ }
635
+ }
636
+ }
637
+
638
+ const cliPostJson = (urlPath, body, label, headers) => cliSendJson('POST', urlPath, body, label, headers);
639
+
640
+ // Every write verb sends JSON and reports failure the same way; `label` names the verb
641
+ // in the error line. Returns the parsed response body ({} when empty), or null after
642
+ // printing the failure, so callers just return 1.
643
+ async function cliSendJson(method, urlPath, body, label, headers = {}) {
644
+ const res = await cliFetch(urlPath, {
645
+ method,
646
+ headers: { 'Content-Type': 'application/json', ...headers },
647
+ body: JSON.stringify(body)
648
+ });
649
+ const text = await res.text();
650
+ let parsed = {};
651
+ try { parsed = text ? JSON.parse(text) : {}; } catch (_) { /* not JSON */ }
652
+ if (res.ok) return parsed;
653
+ console.error(`${label} failed (${res.status}): ${parsed.error || text}`);
654
+ return null;
655
+ }
656
+
657
+ function reportCliError(e) {
658
+ console.error(e.code === 'unreachable' ? e.message : (e.message || String(e)));
659
+ }
660
+
661
+ async function runPreviewCli(args) {
662
+ const [filePathArg] = positionals(args, ['--session']);
663
+ if (!filePathArg) {
664
+ printLeafHelp(COMMANDS.doc.verbs.preview);
665
+ return 1;
666
+ }
667
+ const url = linkUrl(filePathArg);
668
+ if (url) return previewUrlCli(url, args);
669
+ // No $PI_KANBAN_SESSION_ID fallback: a session here switches the board's focus, and the
670
+ // agent must not move the board unless asked.
671
+ const sessionArg = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
672
+ const resolved = sessionArg && await resolveSessionByIdOrPrefix(sessionArg);
673
+ if (sessionArg && !resolved) return 1;
674
+ const sessionId = resolved?.id || null;
675
+ const abs = cliPath(filePathArg);
676
+ try {
677
+ if (!await cliPostJson('/api/preview', { path: abs, sessionId }, 'Preview')) return 1;
678
+ console.log(`Preview opened: ${abs}${sessionId ? ` (session ${sessionId.slice(0, 8)})` : ''}`);
679
+ return 0;
680
+ } catch (e) { reportCliError(e); return 1; }
681
+ }
682
+
683
+ // The modal cannot show a web page, so a URL is linked instead and the tab on screen
684
+ // offers to open it.
685
+ async function previewUrlCli(url, args) {
686
+ const resolved = await resolveSessionArg(COMMANDS.doc.verbs.preview, args, 'a URL is linked to the session.');
687
+ return resolved ? postDocLink(url, resolved.id, { open: true }) : 1;
688
+ }
689
+
690
+ async function postDocLink(target, sessionId, { unlink = false, open = false } = {}) {
691
+ try {
692
+ const out = await cliPostJson('/api/document/link', { path: target, sessionId, unlink, open }, 'Link');
693
+ if (!out) return 1;
694
+ const what = linkUrl(target) ? 'URL' : 'Document';
695
+ console.log(`${what} ${unlink ? 'unlinked from' : 'linked to'} session ${sessionId.slice(0, 8)}: ${out.path}`);
696
+ if (!unlink && out.tabs === 0) console.log('No browser tab is open; the board shows it when one opens.');
697
+ return 0;
698
+ } catch (e) { reportCliError(e); return 1; }
699
+ }
700
+
701
+ // Resolved to the full id because the browser keys linked docs by it, so a prefix won't match.
702
+ async function resolveSessionArg(entry, args, why) {
703
+ const sessionArg = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || process.env[SESSION_ID_ENV];
704
+ if (!sessionArg) {
705
+ usageError(entry, `--session is required: ${why}`);
706
+ return null;
707
+ }
708
+ return resolveSessionByIdOrPrefix(sessionArg);
709
+ }
710
+
711
+ async function runDocLinkCli(args, entry) {
712
+ const [filePathArg] = positionals(args, ['--session']);
713
+ if (!filePathArg) {
714
+ printLeafHelp(entry);
715
+ return 1;
716
+ }
717
+ const resolved = await resolveSessionArg(entry, args, 'linked docs are stored per session.');
718
+ if (!resolved) return 1;
719
+ const unlink = entry === COMMANDS.doc.verbs.unlink;
720
+ return postDocLink(cliTarget(filePathArg), resolved.id, { unlink });
721
+ }
722
+
723
+ async function runDocListCli(args, entry) {
724
+ const resolved = await resolveSessionArg(entry, args, 'linked docs are stored per session.');
725
+ return resolved ? printLinkedDocs(resolved.id, args.includes('--json')) : 1;
726
+ }
727
+
728
+ // The server resolves a path against its own cwd, so a relative one is made absolute here.
729
+ const cliTarget = (arg) => linkUrl(arg) || cliPath(arg);
730
+
731
+ const PANES_WHY = 'panes are stored per session.';
732
+
733
+ const panesPath = (sessionId, paneId) =>
734
+ `/api/panes/${encodeURIComponent(sessionId)}${paneId ? `/${encodeURIComponent(paneId)}` : ''}`;
735
+
736
+ async function runPaneAddCli(args, entry) {
737
+ const [target] = positionals(args, ['--session', '--title', '--companion']);
738
+ const companion = getArgValue(args, 'companion');
739
+ if (!target === !companion) {
740
+ printLeafHelp(entry);
741
+ return 1;
742
+ }
743
+ const token = companion && readTerminalToken();
744
+ if (companion && !token) return 1;
745
+ const resolved = await resolveSessionArg(entry, args, PANES_WHY);
746
+ if (!resolved) return 1;
747
+ try {
748
+ const title = getArgValue(args, 'title') || '';
749
+ const out = companion
750
+ ? await postCompanion(resolved.id, companion, title, token)
751
+ : await cliPostJson(panesPath(resolved.id), { target: cliTarget(target), title }, 'Pane add');
752
+ if (!out) return 1;
753
+ const { pane } = out;
754
+ let frameable;
755
+ if (pane.kind === 'url' && !pane.companion) {
756
+ try {
757
+ ({ frameable } = await cliGetJson(`${panesPath(resolved.id, pane.id)}/framing`, 'Pane framing'));
758
+ } catch {}
759
+ }
760
+ if (args.includes('--json')) console.log(JSON.stringify({ ...pane, frameable }, null, 2));
761
+ else console.log(paneLine(out));
762
+ if (frameable === false && !args.includes('--json')) {
763
+ console.log(`note: ${new URL(pane.target).host} refuses framing, so the pane shows an "Open in new tab" card. Look for a URL of the same content that allows framing.`);
764
+ }
765
+ return 0;
766
+ } catch (e) { reportCliError(e); return 1; }
767
+ }
768
+
769
+ async function runPaneRmCli(args, entry) {
770
+ const [paneId] = positionals(args, ['--session']);
771
+ if (!paneId) {
772
+ printLeafHelp(entry);
773
+ return 1;
774
+ }
775
+ const resolved = await resolveSessionArg(entry, args, PANES_WHY);
776
+ if (!resolved) return 1;
777
+ try {
778
+ if (!await cliSendJson('DELETE', panesPath(resolved.id, paneId), undefined, 'Pane rm')) return 1;
779
+ console.log(`Removed pane ${paneId} from session ${resolved.id.slice(0, 8)}`);
780
+ return 0;
781
+ } catch (e) { reportCliError(e); return 1; }
782
+ }
783
+
784
+ async function runPaneListCli(args, entry) {
785
+ const resolved = await resolveSessionArg(entry, args, PANES_WHY);
786
+ if (!resolved) return 1;
787
+ try {
788
+ const layout = await cliGetJson(panesPath(resolved.id), 'Pane list');
789
+ if (args.includes('--json')) console.log(JSON.stringify(layout, null, 2));
790
+ else if (!layout.panes.length) console.log(`No panes for session ${resolved.id.slice(0, 8)}.`);
791
+ else printTable(['ID', 'KIND', 'TITLE', 'TARGET'], layout.panes.map((p) => [p.id, p.kind, p.title, p.target]));
792
+ return 0;
793
+ } catch (e) { reportCliError(e); return 1; }
794
+ }
795
+
796
+ const paneLine = (out) => `${out.pane.id} ${out.pane.title}${out.added ? '' : ' (already there)'}`;
797
+
798
+ // With a board terminal id, the board tab that shows that terminal switches to the pane.
799
+ const postCompanion = (sessionId, companion, title, token) =>
800
+ cliPostJson(panesPath(sessionId), { companion, title, terminal: boardTerminalId() || undefined }, 'Companion', { 'x-terminal-token': token });
801
+
802
+ async function runShowCli(args, entry) {
803
+ const [file] = positionals(args, ['--session', '--title', '--key', '--base']);
804
+ if (!file) {
805
+ printLeafHelp(entry);
806
+ return 1;
807
+ }
808
+ const terminal = boardTerminalId();
809
+ const token = terminal && readTerminalToken();
810
+ if (terminal && !token) return 1;
811
+ const resolved = await resolveSessionArg(entry, args, 'a card belongs to a session.');
812
+ if (!resolved) return 1;
813
+ const abs = cliPath(file);
814
+ const title = getArgValue(args, 'title') || path.basename(abs);
815
+ const base = getArgValue(args, 'base');
816
+ const body = { title, file: abs, key: getArgValue(args, 'key'), base: base && cliPath(base), terminal: terminal || undefined };
817
+ try {
818
+ const out = await cliPostJson(`/api/sessions/${encodeURIComponent(resolved.id)}/show`, body, 'Show', token ? { 'x-terminal-token': token } : {});
819
+ if (!out) return 1;
820
+ if (out.shown === 'overlay') {
821
+ console.log(`Shown "${out.title}" (${out.key ? `${out.key}, ` : ''}${out.index}/${out.count}). The user sees the rendered card and you do not: in your reply, say what the card shows, and take the render as given.`);
822
+ } else {
823
+ console.log(out.added
824
+ ? `Added "${title}" as a background tab of session ${resolved.id.slice(0, 8)} on the board. In your reply, say what the card shows and that it waits there.`
825
+ : 'The board already has a tab for this file; it refreshes on each save.');
826
+ }
827
+ return 0;
828
+ } catch (e) { reportCliError(e); return 1; }
829
+ }
830
+
831
+ async function runSessionCompanionCli(args, entry) {
832
+ const idArg = sessionPositionals(args)[0] || process.env[SESSION_ID_ENV];
833
+ if (!idArg) return usageError(entry, '<id> is required outside a pi session.');
834
+ const token = readTerminalToken();
835
+ if (!token) return 1;
836
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
837
+ if (!resolved) return 1;
838
+ try {
839
+ const out = await postCompanion(resolved.id, entry.companion, undefined, token);
840
+ if (!out) return 1;
841
+ console.log(paneLine(out));
842
+ return 0;
843
+ } catch (e) { reportCliError(e); return 1; }
844
+ }
845
+
846
+ async function printLinkedDocs(sessionId, asJson) {
847
+ try {
848
+ const links = await cliGetJson(`/api/document/links?session=${encodeURIComponent(sessionId)}`, 'Linked docs');
849
+ const paths = links[sessionId] || [];
850
+ if (asJson) console.log(JSON.stringify(paths, null, 2));
851
+ else if (!paths.length) console.log(`No linked docs for session ${sessionId.slice(0, 8)}.`);
852
+ else for (const p of paths) console.log(p);
853
+ return 0;
854
+ } catch (e) { reportCliError(e); return 1; }
855
+ }
856
+
857
+ function sessionTitle(s) {
858
+ return s.customTitle || s.name || '';
859
+ }
860
+
861
+ // The board's badge rule (docs/session-states.md). `active` is a wider rule, for which rows show.
862
+ function sessionStatus(s) {
863
+ if (!(s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser)) return 'idle';
864
+ if (s.hasWaitingForUser) return 'wait';
865
+ if (s.inProgress > 0) return 'busy';
866
+ return 'active';
867
+ }
868
+
869
+ function parseLimit(args, { fallback, allowAll = false }) {
870
+ const raw = getArgValue(args, 'limit');
871
+ if (raw === null) return { ok: true, limit: fallback };
872
+ if (allowAll && raw === 'all') return { ok: true, limit: null };
873
+ const n = parseInt(raw, 10);
874
+ if (Number.isNaN(n) || n <= 0) return { ok: false, error: `Invalid --limit value: ${raw}` };
875
+ return { ok: true, limit: n };
876
+ }
877
+
878
+ function sessionsListPath({ limit, noPins = false, include = [], project = null, activeOnly = false }) {
879
+ const q = limit === null ? 'all' : String(limit);
880
+ const pinsQ = noPins ? '&pins=off' : '';
881
+ const includeQ = include.length ? `&include=${include.map(encodeURIComponent).join(',')}` : '';
882
+ const projectQ = project ? `&project=${encodeURIComponent(project)}` : '';
883
+ const filterQ = activeOnly ? '&filter=active' : '';
884
+ return `/api/sessions?limit=${q}${pinsQ}${includeQ}${projectQ}${filterQ}`;
885
+ }
886
+
887
+ const fetchSessionsList = (opts, label = 'Session list') => cliGetJson(sessionsListPath(opts), label);
888
+
889
+ const uniqueById = (list) => [...new Map(list.map((s) => [s.id, s])).values()];
890
+
891
+ // The server refuses a request line above about 16 KB (431), about 430 ids, so the ids go in batches.
892
+ const IDS_PER_REQUEST = 100;
893
+
894
+ async function fetchSessionsByIds(ids, label) {
895
+ const batches = [];
896
+ for (let i = 0; i < ids.length; i += IDS_PER_REQUEST) batches.push(ids.slice(i, i + IDS_PER_REQUEST));
897
+ const lists = await Promise.all(batches.map((include) => fetchSessionsList({ limit: 1, include }, label)));
898
+ return uniqueById(lists.flat());
899
+ }
900
+
901
+ async function resolveSessionByIdOrPrefix(idArg) {
902
+ let res;
903
+ try {
904
+ res = await cliFetch(`/api/session/resolve?id=${encodeURIComponent(idArg)}`);
905
+ } catch (e) {
906
+ reportCliError(e);
907
+ return null;
908
+ }
909
+ if (res.status === 404) {
910
+ console.error(`No session matches: ${idArg}`);
911
+ return null;
912
+ }
913
+ if (res.status === 409) {
914
+ const { matches = [] } = await res.json().catch(() => ({}));
915
+ console.error(`Ambiguous prefix "${idArg}" matches ${matches.length} sessions:`);
916
+ for (const m of matches.slice(0, 10)) console.error(` ${m.id} ${m.customTitle || ''}`);
917
+ return null;
918
+ }
919
+ if (!res.ok) {
920
+ console.error(`Resolve failed (${res.status}): ${await res.text()}`);
921
+ return null;
922
+ }
923
+ return res.json();
924
+ }
925
+
926
+ async function runSessionListCli(args) {
927
+ const activeOnly = !args.includes('--all');
928
+ const noPins = args.includes('--no-pins');
929
+ const projectFilter = getArgValue(args, 'project');
930
+ const daysArg = getArgValue(args, 'days');
931
+ const days = daysArg !== null ? parseFloat(daysArg) : null;
932
+ if (daysArg !== null && (Number.isNaN(days) || days <= 0)) {
933
+ return usageError(COMMANDS.session.verbs.list, `Invalid --days value: ${daysArg}`);
934
+ }
935
+ // The Active view is short, so it has no cap. The server reads a whole transcript for some
936
+ // fields of each row it sends, so --all keeps one.
937
+ const parsed = parseLimit(args, { fallback: activeOnly ? null : 20, allowAll: true });
938
+ if (!parsed.ok) return usageError(COMMANDS.session.verbs.list, parsed.error);
939
+ const limit = parsed.limit;
940
+ const asJson = args.includes('--json');
941
+ let list;
942
+ let total;
943
+ try {
944
+ const res = await cliGet(sessionsListPath({ limit: days !== null ? null : limit, noPins, project: projectFilter, activeOnly }), 'Session list');
945
+ list = await res.json();
946
+ total = Number(res.headers.get('x-total-count')) || list.length;
947
+ } catch (e) {
948
+ reportCliError(e);
949
+ return 1;
950
+ }
951
+ if (days !== null) {
952
+ const cutoff = Date.now() - days * 86_400_000;
953
+ list = list.filter(s => s.pin || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
954
+ total = list.length;
955
+ }
956
+ const pinRank = s => s.pin === 'sticky' ? 0 : s.pin === 'pinned' ? 1 : 2;
957
+ list.sort((a, b) => {
958
+ const r = pinRank(a) - pinRank(b);
959
+ if (r !== 0) return r;
960
+ return new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0);
961
+ });
962
+ if (limit !== null && list.length > limit) {
963
+ const top = list.slice(0, limit);
964
+ const topIds = new Set(top.map(s => s.id));
965
+ const extraPinned = list.filter(s => s.pin && !topIds.has(s.id));
966
+ list = [...top, ...extraPinned];
967
+ }
968
+ const cut = list.length < total ? `${list.length} of ${total} shown; --limit all shows every row` : null;
969
+ if (asJson) {
970
+ console.log(JSON.stringify(list.map(({ pin, ...s }) => ({ ...s, pinState: pin || null })), null, 2));
971
+ if (cut) console.error(cut);
972
+ return 0;
973
+ }
974
+ if (!list.length) {
975
+ console.log('No sessions match.');
976
+ return 0;
977
+ }
978
+ const rows = list.map(s => ({
979
+ id: s.id.slice(0, 8),
980
+ pin: s.pin || '',
981
+ status: sessionStatus(s),
982
+ age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
983
+ tasks: `${s.completed}/${s.taskCount}`,
984
+ project: path.basename(s.project || ''),
985
+ title: sessionTitle(s),
986
+ }));
987
+ const w = {
988
+ id: 8,
989
+ pin: Math.max(3, ...rows.map(r => r.pin.length)),
990
+ status: Math.max(6, ...rows.map(r => r.status.length)),
991
+ age: Math.max(3, ...rows.map(r => r.age.length)),
992
+ tasks: Math.max(5, ...rows.map(r => r.tasks.length)),
993
+ project: Math.max(7, ...rows.map(r => r.project.length)),
994
+ };
995
+ console.log(`${'ID'.padEnd(w.id)} ${'PIN'.padEnd(w.pin)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
996
+ for (const r of rows) {
997
+ console.log(`${r.id.padEnd(w.id)} ${r.pin.padEnd(w.pin)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
998
+ }
999
+ if (cut) console.log(cut);
1000
+ return 0;
1001
+ }
1002
+
1003
+ function formatAge(ms) {
1004
+ if (ms < 0) return '0s';
1005
+ const s = Math.floor(ms / 1000);
1006
+ if (s < 60) return `${s}s`;
1007
+ const m = Math.floor(s / 60);
1008
+ if (m < 60) return `${m}m`;
1009
+ const h = Math.floor(m / 60);
1010
+ if (h < 24) return `${h}h`;
1011
+ const d = Math.floor(h / 24);
1012
+ return `${d}d`;
1013
+ }
1014
+
1015
+ async function runSessionOpenCli(args) {
1016
+ const [idArg] = sessionPositionals(args);
1017
+ if (!idArg) {
1018
+ printLeafHelp(COMMANDS.session.verbs.open);
1019
+ return 1;
1020
+ }
1021
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
1022
+ if (!resolved) return 1;
1023
+ try {
1024
+ if (!await cliPostJson('/api/session/open', { id: resolved.id }, 'Open')) return 1;
1025
+ console.log(`Session opened: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
1026
+ return 0;
1027
+ } catch (e) { reportCliError(e); return 1; }
1028
+ }
1029
+
1030
+ async function runSessionPinCli(args) {
1031
+ const [idArg] = sessionPositionals(args);
1032
+ if (!idArg) {
1033
+ printLeafHelp(COMMANDS.session.verbs.pin);
1034
+ return 1;
1035
+ }
1036
+ const state = args.includes('--unpin') ? 'none' : args.includes('--sticky') ? 'sticky' : 'pinned';
1037
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
1038
+ if (!resolved) return 1;
1039
+ try {
1040
+ if (!await cliPostJson('/api/session/pin', { ids: [resolved.id], state }, 'Pin')) return 1;
1041
+ const label = state === 'none' ? 'unpinned' : state;
1042
+ console.log(`Session ${label}: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
1043
+ return 0;
1044
+ } catch (e) { reportCliError(e); return 1; }
1045
+ }
1046
+
1047
+ const postSessionGroup = (id, body, label) => cliPostJson(`/api/sessions/${encodeURIComponent(id)}/group`, body, label);
1048
+
1049
+ async function runSessionGroupCli(args, entry) {
1050
+ const [idArg, groupArg] = sessionPositionals(args, ['--with', '--parent']);
1051
+ const parentArg = getArgValue(args, 'parent');
1052
+ const withFlag = getArgValue(args, 'with');
1053
+ const noParent = args.includes('--no-parent');
1054
+ if (!idArg || (!groupArg && !withFlag && !parentArg)) {
1055
+ printLeafHelp(entry);
1056
+ return 1;
1057
+ }
1058
+ if (groupArg && withFlag) return usageError(entry, 'Give one of <group> or --with.');
1059
+ if (parentArg && noParent) return usageError(entry, 'Give one of --parent or --no-parent.');
1060
+ const [resolved, withPeer, parent] = await Promise.all([idArg, withFlag, parentArg].map((a) => a && resolveSessionByIdOrPrefix(a)));
1061
+ if (!resolved || (withFlag && !withPeer) || (parentArg && !parent)) return 1;
1062
+ // With no group, --parent also names the group to join.
1063
+ const peer = withPeer || (!groupArg && parent);
1064
+ const create = args.includes('--create');
1065
+ const body = peer ? { with: peer.id, create } : { group: groupArg, create };
1066
+ if (parent) body.parent = parent.id;
1067
+ else if (noParent) body.parent = null;
1068
+ try {
1069
+ const out = await postSessionGroup(resolved.id, body, 'Group');
1070
+ if (!out) return 1;
1071
+ const { group, name, path, created, left, rev } = out;
1072
+ if (args.includes('--json')) console.log(JSON.stringify({ group, name, path, created, left, rev }, null, 2));
1073
+ else console.log(`Session ${resolved.id.slice(0, 8)} moved to ${created ? 'new ' : ''}group "${path}" (${group})`);
1074
+ return 0;
1075
+ } catch (e) { reportCliError(e); return 1; }
1076
+ }
1077
+
1078
+ async function runSessionUngroupCli(args, entry) {
1079
+ const [idArg] = sessionPositionals(args);
1080
+ if (!idArg) {
1081
+ printLeafHelp(entry);
1082
+ return 1;
1083
+ }
1084
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
1085
+ if (!resolved) return 1;
1086
+ try {
1087
+ const out = await postSessionGroup(resolved.id, { group: null }, 'Ungroup');
1088
+ if (!out) return 1;
1089
+ const { left, rev } = out;
1090
+ const short = resolved.id.slice(0, 8);
1091
+ if (args.includes('--json')) console.log(JSON.stringify({ left, rev }, null, 2));
1092
+ else console.log(left ? `Session ${short} left group "${left}"` : `Session ${short} was in no group`);
1093
+ return 0;
1094
+ } catch (e) { reportCliError(e); return 1; }
1095
+ }
1096
+
1097
+ async function groupVerb(args, urlPath, body, label, line) {
1098
+ try {
1099
+ const out = await cliPostJson(urlPath, body, label);
1100
+ if (!out) return 1;
1101
+ console.log(args.includes('--json') ? JSON.stringify(out, null, 2) : line(out));
1102
+ return 0;
1103
+ } catch (e) { reportCliError(e); return 1; }
1104
+ }
1105
+
1106
+ async function runGroupCreateCli(args, entry) {
1107
+ const [pathArg] = positionals(args, []);
1108
+ if (!pathArg) {
1109
+ printLeafHelp(entry);
1110
+ return 1;
1111
+ }
1112
+ const transient = args.includes('--transient');
1113
+ return groupVerb(args, '/api/groups/path', { path: pathArg, transient }, 'Create', (r) => `Created group "${r.path}" (${r.group})${transient ? ', transient' : ''}`);
1114
+ }
1115
+
1116
+ async function runGroupRenameCli(args, entry) {
1117
+ const [groupArg, nameArg] = positionals(args, []);
1118
+ if (!groupArg || !nameArg) {
1119
+ printLeafHelp(entry);
1120
+ return 1;
1121
+ }
1122
+ return groupVerb(args, '/api/groups/rename', { group: groupArg, name: nameArg }, 'Rename', (r) => `Renamed group to "${r.path}" (${r.group})`);
1123
+ }
1124
+
1125
+ async function runGroupRmCli(args, entry) {
1126
+ const [groupArg] = positionals(args, []);
1127
+ if (!groupArg) {
1128
+ printLeafHelp(entry);
1129
+ return 1;
1130
+ }
1131
+ return groupVerb(args, '/api/groups/remove', { group: groupArg }, 'Remove', (r) =>
1132
+ `Removed group "${r.removed.path}" (${r.removed.id})${r.ungrouped ? `; ${r.ungrouped} member${r.ungrouped === 1 ? '' : 's'} left it` : ''}`);
1133
+ }
1134
+
1135
+ async function runGroupPruneCli(args) {
1136
+ const dryRun = args.includes('--dry-run');
1137
+ return groupVerb(args, '/api/groups/prune', { dryRun }, 'Prune', (r) => {
1138
+ if (!r.removed.length) return 'No empty groups.';
1139
+ return r.removed.map((g) => `${dryRun ? 'Would remove' : 'Removed'} "${g.path}" (${g.id})`).join('\n');
1140
+ });
1141
+ }
1142
+
1143
+ // A session member carries the session's metadata; a project member carries the count of its
1144
+ // sessions that are shown. With activeOnly, a member is shown when the sidebar's Active view
1145
+ // shows it: an active or pinned session, a project with one. A project counts the sessions the
1146
+ // sidebar puts under it: keyed as the board keys them, without those a group holds by id.
1147
+ function describeGroupMembers(groups, sessions, activeOnly) {
1148
+ const byId = new Map(sessions.map((s) => [s.id, s]));
1149
+ const shown = (s) => !activeOnly || s.active || !!s.pin;
1150
+ const heldById = new Set(groups.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref)));
1151
+ const shownByProject = new Map();
1152
+ for (const s of sessions) {
1153
+ const key = sessionProjectKey(s);
1154
+ if (!shown(s) || !key || heldById.has(s.id)) continue;
1155
+ shownByProject.set(key, (shownByProject.get(key) || 0) + 1);
1156
+ }
1157
+ const describe = (m) => {
1158
+ if (m.type === 'project') {
1159
+ const count = shownByProject.get(m.ref) || 0;
1160
+ return [!activeOnly || count > 0, { ...m, sessions: count }];
1161
+ }
1162
+ const s = byId.get(m.ref);
1163
+ if (!s) return [!activeOnly, { ...m, missing: true }];
1164
+ return [shown(s), {
1165
+ ...m,
1166
+ title: sessionTitle(s),
1167
+ branch: s.gitBranch || null,
1168
+ status: sessionStatus(s),
1169
+ pinned: s.pin || null,
1170
+ age: ageOf(s.modifiedAt),
1171
+ }];
1172
+ };
1173
+ return groups.map((g) => {
1174
+ const members = g.members.map(describe).filter(([visible]) => visible).map(([, m]) => m);
1175
+ return { ...g, visible: members.length, total: g.members.length, members };
1176
+ });
1177
+ }
1178
+
1179
+ async function runGroupListCli(args) {
1180
+ const activeOnly = !args.includes('--all');
1181
+ let rev;
1182
+ let groups;
1183
+ try {
1184
+ ({ rev, groups } = await cliGetJson('/api/groups', 'Group list'));
1185
+ const sessionRefs = [...new Set(groups.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref)))];
1186
+ // An inactive session held by id is still listed, so only the active list needs the id lookups.
1187
+ const lists = await Promise.all([
1188
+ fetchSessionsList({ limit: null, activeOnly }, 'Group list'),
1189
+ activeOnly ? fetchSessionsByIds(sessionRefs, 'Group list') : [],
1190
+ ]);
1191
+ const sessions = uniqueById(lists.flat());
1192
+ groups = describeGroupMembers(groups, sessions, activeOnly);
1193
+ } catch (e) { reportCliError(e); return 1; }
1194
+ if (args.includes('--json')) {
1195
+ console.log(JSON.stringify({ rev, groups }, null, 2));
1196
+ return 0;
1197
+ }
1198
+ if (!groups.length) {
1199
+ console.log('No groups.');
1200
+ return 0;
1201
+ }
1202
+ const rows = [];
1203
+ const add = (parent, depth) => {
1204
+ const pad = ' '.repeat(depth);
1205
+ for (const g of groups.filter((x) => x.parent === parent)) {
1206
+ rows.push([g.id, '', '', '', `${pad}${g.name}${g.transient ? ' (transient)' : ''} (${g.visible}/${g.total})`]);
1207
+ for (const m of g.members) {
1208
+ if (m.type === 'project') rows.push(['project', '', '', '', `${pad} ${m.ref} (${m.sessions} sessions)`]);
1209
+ else if (m.missing) rows.push([m.ref.slice(0, 8), 'gone', '-', '', `${pad} (no such session)`]);
1210
+ else rows.push([m.ref.slice(0, 8), m.status, m.age, m.pinned || '', `${pad} ${m.title}`]);
1211
+ }
1212
+ add(g.id, depth + 1);
1213
+ }
1214
+ };
1215
+ add(null, 0);
1216
+ printTable(['ID', 'STATUS', 'AGE', 'PIN', 'NAME'], rows);
1217
+ return 0;
1218
+ }
1219
+
1220
+ async function runSessionViewCli(args) {
1221
+ const [idArg] = sessionPositionals(args);
1222
+ if (!idArg) {
1223
+ printLeafHelp(COMMANDS.session.verbs.view);
1224
+ return 1;
1225
+ }
1226
+ const asJson = args.includes('--json');
1227
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
1228
+ if (!resolved) return 1;
1229
+ let list;
1230
+ try {
1231
+ list = await fetchSessionsByIds([resolved.id], 'Session view');
1232
+ } catch (e) { reportCliError(e); return 1; }
1233
+ const s = list.find(x => x.id === resolved.id);
1234
+ if (!s) {
1235
+ console.error(`Session ${resolved.id} not found in /api/sessions response.`);
1236
+ return 1;
1237
+ }
1238
+ if (asJson) {
1239
+ console.log(JSON.stringify(s, null, 2));
1240
+ return 0;
1241
+ }
1242
+ const status = sessionStatus(s);
1243
+ const title = sessionTitle(s);
1244
+ const age = s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-';
1245
+ const fmtTok = (n) => typeof n === 'number' ? (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)) : '-';
1246
+ const fmtCost = (n) => typeof n === 'number' ? (n > 0 && n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`) : '-';
1247
+ const lines = [];
1248
+ lines.push(`${s.id.slice(0, 8)} — ${title} [${status}]`);
1249
+ if (s.project) lines.push(` ${path.basename(s.project)}${s.gitBranch ? ` · ${s.gitBranch}` : ''} · modified ${age} ago`);
1250
+ lines.push(` Tasks: ${s.completed}/${s.taskCount}${s.inProgress ? ` (${s.inProgress} in progress)` : ''}${s.pending ? ` · ${s.pending} pending` : ''}`);
1251
+ if (s.cache?.modelName) lines.push(` Model: ${s.cache.modelName}`);
1252
+ const t = s.totals;
1253
+ if (t) {
1254
+ lines.push(` Tokens: ${fmtTok(t.input)} in / ${fmtTok(t.output)} out · cache ${fmtTok(s.cache?.usage?.cache_read_input_tokens)} read`);
1255
+ lines.push(` Cost: ${fmtCost(t.costUsd)}${t.durationMs != null ? ` · ${formatAge(t.durationMs)} total` : ''}`);
1256
+ }
1257
+ if (s.jsonlPath) lines.push(` Transcript: ${s.jsonlPath}`);
1258
+ console.log(lines.join('\n'));
1259
+ return 0;
1260
+ }
1261
+
1262
+ function printTable(header, rows) {
1263
+ const last = header.length - 1;
1264
+ const width = header.map((h, i) => Math.max(h.length, ...rows.map(r => String(r[i]).length)));
1265
+ const line = cells => cells.map((c, i) => (i === last ? String(c) : String(c).padEnd(width[i]))).join(' ');
1266
+ console.log(line(header));
1267
+ for (const r of rows) console.log(line(r));
1268
+ }
1269
+
1270
+ const ageOf = (iso) => (iso ? formatAge(Date.now() - new Date(iso).getTime()) : '-');
1271
+
1272
+ async function cliGet(urlPath, label) {
1273
+ const res = await cliFetch(urlPath);
1274
+ if (!res.ok) throw new Error(`${label} failed (${res.status}): ${await res.text()}`);
1275
+ return res;
1276
+ }
1277
+
1278
+ const cliGetJson = async (urlPath, label) => (await cliGet(urlPath, label)).json();
1279
+
1280
+ async function runSessionSearchCli(args) {
1281
+ const entry = COMMANDS.session.verbs.search;
1282
+ const text = positionals(args, ['--limit']).join(' ').trim();
1283
+ if (!text) {
1284
+ printLeafHelp(entry);
1285
+ return 1;
1286
+ }
1287
+ if (text.length < 3) return usageError(entry, 'Search text needs at least 3 characters.');
1288
+ const parsed = parseLimit(args, { fallback: 20 });
1289
+ if (!parsed.ok) return usageError(entry, parsed.error);
1290
+ try {
1291
+ const ids = (await cliGetJson(`/api/sessions/search?q=${encodeURIComponent(text)}`, 'Search')).slice(0, parsed.limit);
1292
+ const list = ids.length ? await fetchSessionsByIds(ids, 'Search') : [];
1293
+ const byId = new Map(list.map(s => [s.id, s]));
1294
+ const rows = ids.map(id => byId.get(id) || { id });
1295
+ if (args.includes('--json')) {
1296
+ console.log(JSON.stringify(rows, null, 2));
1297
+ return 0;
1298
+ }
1299
+ if (!rows.length) {
1300
+ console.log(`No sessions match "${text}".`);
1301
+ return 0;
1302
+ }
1303
+ printTable(['ID', 'STATUS', 'AGE', 'PROJECT', 'TITLE'], rows.map(s => [
1304
+ s.id.slice(0, 8),
1305
+ byId.has(s.id) ? sessionStatus(s) : '-',
1306
+ ageOf(s.modifiedAt),
1307
+ path.basename(s.project || ''),
1308
+ sessionTitle(s),
1309
+ ]));
1310
+ return 0;
1311
+ } catch (e) { reportCliError(e); return 1; }
1312
+ }
1313
+
1314
+ async function runSessionAgentsCli(args) {
1315
+ const [idArg] = sessionPositionals(args);
1316
+ if (!idArg) {
1317
+ printLeafHelp(COMMANDS.session.verbs.agents);
1318
+ return 1;
1319
+ }
1320
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
1321
+ if (!resolved) return 1;
1322
+ try {
1323
+ const out = await cliGetJson(`/api/sessions/${resolved.id}/agents`, 'Agents');
1324
+ if (args.includes('--json')) {
1325
+ console.log(JSON.stringify(out, null, 2));
1326
+ return 0;
1327
+ }
1328
+ const agents = out.agents || [];
1329
+ if (!agents.length) console.log(`No agents for session ${resolved.id.slice(0, 8)}.`);
1330
+ else {
1331
+ printTable(['AGENT', 'STATUS', 'AGE', 'TYPE', 'DESCRIPTION'], agents.map(a => [
1332
+ String(a.agentId || '').slice(0, 8),
1333
+ a.status || '-',
1334
+ ageOf(a.updatedAt || a.startedAt),
1335
+ a.type || a.agentType || '',
1336
+ (a.description || a.name || '').replace(/\s+/g, ' ').trim(),
1337
+ ]));
1338
+ }
1339
+ if (out.waitingForUser) console.log('Waiting for the user.');
1340
+ return 0;
1341
+ } catch (e) { reportCliError(e); return 1; }
1342
+ }
1343
+
1344
+ async function runTaskListCli(args) {
1345
+ const entry = COMMANDS.task.verbs.list;
1346
+ const [sessionArg] = sessionPositionals(args, ['--project', '--status']);
1347
+ const project = getArgValue(args, 'project');
1348
+ const all = args.includes('--all');
1349
+ const status = getArgValue(args, 'status');
1350
+ const sources = [sessionArg, project, all || null].filter(Boolean).length;
1351
+ if (sources === 0) {
1352
+ printLeafHelp(entry);
1353
+ return 1;
1354
+ }
1355
+ if (sources > 1) return usageError(entry, 'Give one of <session>, --project or --all.');
1356
+ if (args.includes('--status') && !status) return usageError(entry, '--status needs a value, e.g. in_progress');
1357
+ let tasks;
1358
+ let resolved = null;
1359
+ try {
1360
+ if (sessionArg) {
1361
+ resolved = await resolveSessionByIdOrPrefix(sessionArg);
1362
+ if (!resolved) return 1;
1363
+ tasks = await cliGetJson(`/api/sessions/${resolved.id}`, 'Task list');
1364
+ } else if (project) {
1365
+ const encoded = Buffer.from(canonicalDir(project), 'utf8').toString('base64');
1366
+ tasks = await cliGetJson(`/api/projects/${encodeURIComponent(encoded)}/tasks`, 'Task list');
1367
+ } else {
1368
+ tasks = await cliGetJson('/api/tasks/all', 'Task list');
1369
+ }
1370
+ } catch (e) { reportCliError(e); return 1; }
1371
+ if (status) tasks = tasks.filter(t => t.status === status);
1372
+ if (args.includes('--json')) {
1373
+ console.log(JSON.stringify(tasks, null, 2));
1374
+ return 0;
1375
+ }
1376
+ if (!tasks.length) {
1377
+ console.log('No tasks match.');
1378
+ return 0;
1379
+ }
1380
+ const showSource = !resolved;
1381
+ const header = ['ID', 'STATUS', ...(showSource ? ['SESSION'] : []), 'SUBJECT'];
1382
+ printTable(header, tasks.map(t => [
1383
+ t.id,
1384
+ t.status || '-',
1385
+ ...(showSource ? [String(t.sessionId || t._taskDir || '').slice(0, 8)] : []),
1386
+ `${t.subject || ''}${t.blockedBy?.length ? ` (blocked by ${t.blockedBy.join(', ')})` : ''}`,
1387
+ ]));
1388
+ return 0;
1389
+ }
1390
+
1391
+ async function runProjectListCli(args) {
1392
+ let projects;
1393
+ try {
1394
+ projects = await cliGetJson('/api/projects', 'Project list');
1395
+ } catch (e) { reportCliError(e); return 1; }
1396
+ projects.sort((a, b) => new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0));
1397
+ if (args.includes('--json')) {
1398
+ console.log(JSON.stringify(projects, null, 2));
1399
+ return 0;
1400
+ }
1401
+ if (!projects.length) {
1402
+ console.log('No projects.');
1403
+ return 0;
1404
+ }
1405
+ printTable(['AGE', 'PATH'], projects.map(p => [ageOf(p.modifiedAt), `${p.path}${p.temp ? ' (temp)' : ''}`]));
1406
+ return 0;
1407
+ }
1408
+
1409
+ function positionals(args, valueFlags) {
1410
+ return args.filter((a, i) => !a.startsWith('--') && !valueFlags.includes(args[i - 1]));
1411
+ }
1412
+
1413
+ // Every verb takes its session as --session <id>; a verb on sessions also takes it as the first positional.
1414
+ function sessionPositionals(args, valueFlags = []) {
1415
+ const rest = positionals(args, ['--session', ...valueFlags]);
1416
+ const flag = getArgValue(args, 'session');
1417
+ return flag ? [flag, ...rest] : rest;
1418
+ }
1419
+
1420
+ // The server matches the folder against known project paths by string, so an 8.3 short name
1421
+ // or a differently cased drive letter from the shell must become the long, canonical form.
1422
+ function canonicalDir(dir) {
1423
+ try { return fs.realpathSync.native(cliPath(dir)); } catch (_) { return cliPath(dir); }
1424
+ }
1425
+
1426
+ // Every path argument goes through here. Git Bash hands a native node `/c/x` for `C:\x`.
1427
+ function cliPath(arg) {
1428
+ return path.resolve(process.platform === 'win32' ? arg.replace(/^\/([a-zA-Z])(?=\/|$)/, '$1:/') : arg);
1429
+ }
1430
+
1431
+ function textArg(args, name) {
1432
+ const file = getArgValue(args, `${name}-file`);
1433
+ return file ? fs.readFileSync(cliPath(file), 'utf8') : getArgValue(args, name);
1434
+ }
1435
+
1436
+ function readTerminalToken() {
1437
+ const port = cliTargetPort();
1438
+ if (port === null) {
1439
+ console.error(unreachable());
1440
+ return null;
1441
+ }
1442
+ const token = port && readCckJson(`terminal-tokens/${port}.json`)?.token;
1443
+ if (!token) {
1444
+ console.error(`No terminal token for ${displayPath(resolvePiAgentDir())} at ${cliBaseUrl()}. The pi-kanban server must be running with the terminal enabled.`);
1445
+ return null;
1446
+ }
1447
+ return token;
1448
+ }
1449
+
1450
+ async function runDispatchStartCli(argv) {
1451
+ const sep = argv.indexOf('--');
1452
+ const args = sep === -1 ? argv : argv.slice(0, sep);
1453
+ const extraArgs = sep === -1 ? [] : argv.slice(sep + 1);
1454
+ const token = readTerminalToken();
1455
+ if (!token) return 1;
1456
+ let spec;
1457
+ try { spec = textArg(args, 'spec'); } catch (e) { console.error(e.message); return 1; }
1458
+ if (!spec) {
1459
+ printLeafHelp(COMMANDS.dispatch.verbs.start);
1460
+ return 1;
1461
+ }
1462
+ const hasGroup = args.some(a => a === '--group' || a.startsWith('--group='));
1463
+ const group = hasGroup ? getArgValue(args, 'group') || '' : null;
1464
+ if (hasGroup && !group.trim()) return usageError(COMMANDS.dispatch.verbs.start, '--group needs a group name, path or id');
1465
+ const body = {
1466
+ cwd: canonicalDir(getArgValue(args, 'cwd') || '.'),
1467
+ spec,
1468
+ name: getArgValue(args, 'name'),
1469
+ model: getArgValue(args, 'model'),
1470
+ thinking: getArgValue(args, 'thinking'),
1471
+ group,
1472
+ extraArgs,
1473
+ parent: process.env[SESSION_ID_ENV] || null,
1474
+ };
1475
+ try {
1476
+ const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
1477
+ if (!out) return 1;
1478
+ if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
1479
+ else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}${out.transient ? ', transient' : ''}]` : ''}`);
1480
+ if (out.groupError) console.error(`Not grouped: ${out.groupError}`);
1481
+ return 0;
1482
+ } catch (e) { reportCliError(e); return 1; }
1483
+ }
1484
+
1485
+ async function runDispatchWaitCli(args, entry) {
1486
+ const ids = sessionPositionals(args);
1487
+ if (!ids.length) {
1488
+ printLeafHelp(entry);
1489
+ return 1;
1490
+ }
1491
+ try {
1492
+ for (const id of ids) {
1493
+ let got;
1494
+ do got = await cliGetJson(`/api/dispatch/${encodeURIComponent(id)}/report?wait=${WAIT_MAX_MS}`, 'Dispatch wait');
1495
+ while (got.status === 'pending');
1496
+ console.log(reportText(id, got));
1497
+ }
1498
+ return 0;
1499
+ } catch (e) { reportCliError(e); return 1; }
1500
+ }
1501
+
1502
+ async function fetchDispatches(all) {
1503
+ const q = new URLSearchParams();
1504
+ if (!all && process.env[SESSION_ID_ENV]) q.set('parent', process.env[SESSION_ID_ENV]);
1505
+ return (await cliGetJson(`/api/dispatch?${q}`, 'Dispatch list')).running.sort((a, b) => b.startedAt - a.startedAt);
1506
+ }
1507
+
1508
+ async function runDispatchListCli(args) {
1509
+ try {
1510
+ const rows = await fetchDispatches(args.includes('--all'));
1511
+ if (args.includes('--json')) console.log(JSON.stringify(rows, null, 2));
1512
+ else if (!rows.length) console.log('No dispatches.');
1513
+ else for (const r of rows) console.log(dispatchLine(r));
1514
+ return 0;
1515
+ } catch (e) { reportCliError(e); return 1; }
1516
+ }
1517
+
1518
+ async function runDispatchEndCli(args) {
1519
+ const entry = COMMANDS.dispatch.verbs.end;
1520
+ const name = getArgValue(args, 'name');
1521
+ const id = sessionPositionals(args, ['--name'])[0];
1522
+ if (!id && !name) {
1523
+ printLeafHelp(entry);
1524
+ return 1;
1525
+ }
1526
+ if (id && name) return usageError(entry, 'Give one of <session id or prefix> or --name.');
1527
+ const token = readTerminalToken();
1528
+ if (!token) return 1;
1529
+ try {
1530
+ const matches = matchDispatches(await fetchDispatches(false), name || id);
1531
+ if (!matches.length) {
1532
+ console.error(`No running dispatch matches "${name || id}". Run "pi-kanban dispatch list".`);
1533
+ return 1;
1534
+ }
1535
+ if (matches.length > 1) {
1536
+ console.error(`"${name || id}" matches ${matches.length} dispatches:`);
1537
+ for (const r of matches) console.error(` ${dispatchLine(r)}`);
1538
+ return 1;
1539
+ }
1540
+ const { session } = matches[0];
1541
+ if (!await cliSendJson('DELETE', `/api/terminals/${session}?by=dispatch`, undefined, 'Dispatch end', { 'x-terminal-token': token })) return 1;
1542
+ if (args.includes('--json')) console.log(JSON.stringify({ session }, null, 2));
1543
+ else console.log(`Ended session ${session}`);
1544
+ return 0;
1545
+ } catch (e) { reportCliError(e); return 1; }
1546
+ }
1547
+
1548
+ function terminalKind(t, self) {
1549
+ if (t.dispatched) return self && t.dispatched.parent === self ? 'dispatch (yours)' : 'dispatch';
1550
+ return t.mode === 'shell' ? 'shell' : 'pi';
1551
+ }
1552
+
1553
+ async function runTerminalListCli(args) {
1554
+ let rows;
1555
+ try {
1556
+ rows = (await cliGetJson('/api/terminals', 'Terminal list')).sessions.sort((a, b) => b.startedAt - a.startedAt);
1557
+ } catch (e) { reportCliError(e); return 1; }
1558
+ if (args.includes('--json')) {
1559
+ console.log(JSON.stringify(rows, null, 2));
1560
+ return 0;
1561
+ }
1562
+ if (!rows.length) {
1563
+ console.log('No terminals.');
1564
+ return 0;
1565
+ }
1566
+ const self = process.env[SESSION_ID_ENV];
1567
+ printTable(['SESSION', 'AGE', 'KIND', 'NAME', 'CWD'], rows.map(t => [t.id, ageOf(t.startedAt), terminalKind(t, self), t.name || '-', t.cwd || '-']));
1568
+ if (rows.some(t => self && t.dispatched?.parent === self)) {
1569
+ console.log('\nEnd one of yours with `pi-kanban dispatch end <session>`. The user ends the others from the board.');
1570
+ }
1571
+ return 0;
1572
+ }
1573
+
1574
+ async function runSkillsGetCli(args) {
1575
+ const [name] = positionals(args, []);
1576
+ const skills = path.join(__dirname, 'skills');
1577
+ const file = name && /^[a-z][a-z-]*$/.test(name) ? path.join(skills, name, 'SKILL.md') : null;
1578
+ if (!file || !fs.existsSync(file)) {
1579
+ const known = fs.readdirSync(skills).filter(d => fs.existsSync(path.join(skills, d, 'SKILL.md')));
1580
+ return usageError(COMMANDS.skills.verbs.get, `Unknown skill: ${name || '(none)'}. Known: ${known.join(', ')}`);
1581
+ }
1582
+ process.stdout.write(fs.readFileSync(file, 'utf8'));
1583
+ return 0;
1584
+ }
1585
+
1586
+ module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS };