@vercel/board 0.0.0-stage → 0.15.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 (126) hide show
  1. package/README.md +179 -2
  2. package/dist/a2-5EG8FkZ8.d.mts +3359 -0
  3. package/dist/a2-DYGYankn.mjs +3045 -0
  4. package/dist/access-CJU2-vaa.mjs +226 -0
  5. package/dist/access-CV96SYgr.d.mts +32 -0
  6. package/dist/access.d.mts +29 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-rdN4dDTU.mjs +223 -0
  9. package/dist/attempts-BQUp9MKO.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-wHFcAte3.d.mts +203 -0
  12. package/dist/backend-a2/blob-vercel.d.mts +8 -0
  13. package/dist/backend-a2/blob-vercel.mjs +81 -0
  14. package/dist/backend-a2/contract.d.mts +18 -0
  15. package/dist/backend-a2/contract.mjs +5 -0
  16. package/dist/backend-a2/scheduler-local.d.mts +11 -0
  17. package/dist/backend-a2/scheduler-local.mjs +2 -0
  18. package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
  19. package/dist/backend-a2/scheduler-qstash.mjs +2 -0
  20. package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
  21. package/dist/backend-a2/scheduler-vercel.mjs +2 -0
  22. package/dist/backend-a2/store-memory.d.mts +11 -0
  23. package/dist/backend-a2/store-memory.mjs +19 -0
  24. package/dist/backend-a2/store-postgres.d.mts +38 -0
  25. package/dist/backend-a2/store-postgres.mjs +110 -0
  26. package/dist/backend-a2/store-redis-http.d.mts +17 -0
  27. package/dist/backend-a2/store-redis-http.mjs +32 -0
  28. package/dist/backend-a2/store-redis.d.mts +23 -0
  29. package/dist/backend-a2/store-redis.mjs +39 -0
  30. package/dist/backend-a2/store-sqlite.d.mts +12 -0
  31. package/dist/backend-a2/store-sqlite.mjs +31 -0
  32. package/dist/backend-a2.d.mts +4 -0
  33. package/dist/backend-a2.mjs +2 -0
  34. package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
  35. package/dist/backend-http-BsDSgmeo.mjs +613 -0
  36. package/dist/backend-http-Cv6xbEy9.mjs +469 -0
  37. package/dist/backend-http.d.mts +10 -0
  38. package/dist/backend-http.mjs +2 -0
  39. package/dist/backend-http.node.d.mts +10 -0
  40. package/dist/backend-http.node.mjs +2 -0
  41. package/dist/blob-C0J3wbGv.d.mts +39 -0
  42. package/dist/bots-CGkO76qH.mjs +67 -0
  43. package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
  44. package/dist/chat-provider-jFSeyNmB.mjs +389 -0
  45. package/dist/cli.d.mts +1 -0
  46. package/dist/cli.mjs +2252 -0
  47. package/dist/client-DGDPaN7U.mjs +380 -0
  48. package/dist/conformance-DRR23YNy.mjs +512 -0
  49. package/dist/conformance.d.mts +30 -0
  50. package/dist/conformance.mjs +2 -0
  51. package/dist/contract-CuJmlrhw.mjs +566 -0
  52. package/dist/emoji-CTLpvH-A.mjs +445 -0
  53. package/dist/engine-3EvQ8idk.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-BpgyEdh6.d.mts +4 -0
  56. package/dist/exports-CyY3_FjZ.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-BZ3lItjH.mjs +12 -0
  60. package/dist/index.mjs +19 -0
  61. package/dist/index.node.d.mts +14 -0
  62. package/dist/index.node.mjs +5 -0
  63. package/dist/inspect/app.js +416 -0
  64. package/dist/inspect/engine.js +5 -0
  65. package/dist/local-Cr_dSjvs.mjs +44 -0
  66. package/dist/own-OwgPWdmh.mjs +15 -0
  67. package/dist/project-CFiArraX.mjs +124 -0
  68. package/dist/provider-BGjz-mDa.d.mts +2473 -0
  69. package/dist/provider-CuewcDNZ.mjs +205 -0
  70. package/dist/redis-tables-B35vv4CZ.mjs +842 -0
  71. package/dist/reducers-B_vUGQRp.d.mts +1172 -0
  72. package/dist/reducers-mUPFuFJV.mjs +293 -0
  73. package/dist/schemas-Be08nyfW.mjs +74 -0
  74. package/dist/schemas-CWxs3ECJ.d.mts +50 -0
  75. package/dist/serve-Ccbb2LSb.mjs +170 -0
  76. package/dist/server-Cxey38B-.mjs +17 -0
  77. package/dist/serving-BNbzc96L.mjs +2 -0
  78. package/dist/sql-DbiEnqM0.mjs +571 -0
  79. package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
  80. package/dist/sync-github.d.mts +473 -0
  81. package/dist/sync-github.mjs +603 -0
  82. package/dist/sync-kapso.d.mts +175 -0
  83. package/dist/sync-kapso.mjs +86 -0
  84. package/dist/sync-slack.d.mts +364 -0
  85. package/dist/sync-slack.mjs +728 -0
  86. package/dist/sync.chat.d.mts +2 -0
  87. package/dist/sync.chat.mjs +2 -0
  88. package/dist/sync.d.mts +3 -0
  89. package/dist/sync.mjs +4 -0
  90. package/dist/targets-ojFEXEyN.mjs +94 -0
  91. package/dist/tokens-DKLn5P9B.mjs +256 -0
  92. package/dist/types-BHCjTZdB.d.mts +104 -0
  93. package/dist/users-Dls7_ANV.mjs +42 -0
  94. package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
  95. package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
  96. package/docs/01-board/01-mounting.md +506 -0
  97. package/docs/01-board/02-database.md +258 -0
  98. package/docs/01-board/03-identity.md +262 -0
  99. package/docs/01-board/04-messages.md +192 -0
  100. package/docs/01-board/05-reading.md +177 -0
  101. package/docs/01-board/06-watchers.md +249 -0
  102. package/docs/01-board/07-schedules.md +133 -0
  103. package/docs/01-board/08-middleware.md +139 -0
  104. package/docs/01-board/09-access.md +499 -0
  105. package/docs/01-quickstart.md +196 -0
  106. package/docs/02-sync/01-overview.md +290 -0
  107. package/docs/02-sync/02-callbacks.md +265 -0
  108. package/docs/02-sync/03-slack.md +248 -0
  109. package/docs/02-sync/04-github.md +201 -0
  110. package/docs/02-sync/05-whatsapp.md +148 -0
  111. package/docs/02-sync/06-providers.md +228 -0
  112. package/docs/03-guides/01-coding-agents.md +226 -0
  113. package/docs/03-guides/02-inspector.md +104 -0
  114. package/docs/03-guides/03-ci.md +251 -0
  115. package/docs/03-guides/04-triage.md +233 -0
  116. package/docs/03-guides/05-agent-factory.md +142 -0
  117. package/docs/04-reference/01-cli.md +606 -0
  118. package/docs/04-reference/02-board.md +579 -0
  119. package/docs/04-reference/03-http.md +489 -0
  120. package/docs/04-reference/04-log.md +250 -0
  121. package/docs/04-reference/05-conformance.md +97 -0
  122. package/docs/index.md +186 -0
  123. package/package.json +165 -3
  124. package/sql/postgres/board.sql +145 -0
  125. package/sql/postgres/notifications.sql +24 -0
  126. package/sql/postgres/schema.sql +451 -0
package/dist/cli.mjs ADDED
@@ -0,0 +1,2252 @@
1
+ #!/usr/bin/env node
2
+ import { i as fileSize, n as DIRECT_UPLOAD_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
3
+ import { a as AttachmentId } from "./schemas-Be08nyfW.mjs";
4
+ import { i as withMentions, r as FILTER_FLAGS } from "./watch-spec-W0c7ypHJ.mjs";
5
+ import { n as displayEmoji, r as normalizeEmoji } from "./emoji-CTLpvH-A.mjs";
6
+ import { t as BoardError } from "./client-DGDPaN7U.mjs";
7
+ import { c as VERSION } from "./watchers-hash-D9hbHLKB.mjs";
8
+ import { a as detectAgent, c as projectUrl, i as connect, n as boardUrl, r as configDir, s as projectDir, t as http } from "./backend-http-BsDSgmeo.mjs";
9
+ import { t as board } from "./server-Cxey38B-.mjs";
10
+ import { t as conformance } from "./conformance-DRR23YNy.mjs";
11
+ import { t as displayText } from "./bots-CGkO76qH.mjs";
12
+ import { execFileSync, spawn } from "node:child_process";
13
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
14
+ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
15
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { homedir, tmpdir } from "node:os";
17
+ import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
18
+ import { parseArgs } from "node:util";
19
+ import { createServer } from "node:http";
20
+ //#region src/inspect/style.ts
21
+ /**
22
+ * Just Chat's look: mono, quiet, one accent, hairlines only under headers.
23
+ * The default cursor everywhere but text; buttons never get a pointer; nothing
24
+ * scrolls smoothly; hovers are instant.
25
+ */
26
+ const CSS = `
27
+ :root {
28
+ --bg: #fafafa;
29
+ --bright: #040404;
30
+ --dim: #303030;
31
+ --muted: #646464;
32
+ --faint: #909090;
33
+ --hairline: #d9d9d9;
34
+ --shade: #f0f0f0;
35
+ --shade-hover: #e3e3e3;
36
+ --accent: rgb(255, 108, 2);
37
+ --accent-transparent: rgba(255, 108, 2, 0.2);
38
+ --label: #fafafa;
39
+ --red: #c70000;
40
+ --mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
41
+ color-scheme: light dark;
42
+ }
43
+ @media (prefers-color-scheme: dark) {
44
+ :root {
45
+ --bg: #040404;
46
+ --bright: #fafafa;
47
+ --dim: #dedede;
48
+ --muted: #909090;
49
+ --faint: #646464;
50
+ --hairline: #252525;
51
+ --shade: #161616;
52
+ --shade-hover: #1b1b1b;
53
+ --red: #ff7b72;
54
+ }
55
+ }
56
+ * { box-sizing: border-box; }
57
+ html { scroll-behavior: auto; -webkit-tap-highlight-color: var(--accent-transparent); }
58
+ body {
59
+ margin: 0;
60
+ overflow: hidden;
61
+ background: var(--bg);
62
+ color: var(--dim);
63
+ font-family: var(--mono);
64
+ font-size: 13px;
65
+ letter-spacing: -0.02em;
66
+ line-height: 1.5;
67
+ cursor: default;
68
+ }
69
+ a { color: inherit; text-decoration: none; cursor: pointer; }
70
+ button { cursor: default; font: inherit; color: inherit; background: none; border: 0; padding: 0; letter-spacing: inherit; }
71
+ input, textarea { font: inherit; color: var(--bright); letter-spacing: inherit; cursor: text; }
72
+ ::selection { background: var(--accent-transparent); }
73
+ a:focus-visible, button:focus-visible, [role='separator']:focus-visible { outline: 2px dashed var(--accent); outline-offset: 2px; }
74
+
75
+ .boot { display: grid; place-items: center; height: 100dvh; color: var(--faint); }
76
+ .boot.error, .error { color: var(--accent); }
77
+ .empty, .error { padding: 20px; margin: 0; color: var(--faint); }
78
+ .faint { color: var(--faint); }
79
+ .grow { flex: 1; min-width: 0; }
80
+ .hint { color: var(--faint); font-size: 11px; }
81
+
82
+ .shell { display: flex; height: 100dvh; overflow: hidden; }
83
+
84
+ /* --- sidebar: same background as the page; its edge is the resize line --- */
85
+ .sidebar { position: relative; flex-shrink: 0; display: flex; }
86
+ .sidebar-scroll { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-y: auto; padding: 0 8px 12px; }
87
+ .brand {
88
+ height: 46px;
89
+ flex-shrink: 0;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: space-between;
93
+ margin: 0 -8px 8px;
94
+ padding: 0 8px 0 16px;
95
+ border-bottom: 1px solid var(--hairline);
96
+ color: var(--bright);
97
+ font-weight: 600;
98
+ }
99
+ .sidebar h2 { margin: 16px 8px 4px; font-size: 11px; font-weight: 500; color: var(--faint); }
100
+ .sidebar .item {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 6px;
104
+ padding: 5px 8px;
105
+ color: var(--muted);
106
+ font-size: 13px;
107
+ cursor: default;
108
+ }
109
+ .sidebar .item:hover, .sidebar .item[aria-current='page'] { background: var(--shade); color: var(--bright); }
110
+ .sidebar .item .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
111
+ .sidebar .item.unread { color: var(--bright); font-weight: 600; }
112
+ .sidebar .count { color: var(--faint); font-size: 11px; font-weight: 400; }
113
+ .sidebar .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
114
+ .whoami-wrap { margin-top: auto; padding-top: 16px; }
115
+ .whoami { width: 100%; display: flex; align-items: center; gap: 6px; padding: 6px 8px; color: var(--muted); font-size: 11px; text-align: left; }
116
+ .whoami:hover, .whoami[data-popup-open] { background: var(--shade); color: var(--bright); }
117
+ .whoami .grow { display: grid; }
118
+ .whoami .as, .whoami .url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
119
+ .whoami.naming { margin-top: auto; }
120
+ .whoami.naming input { width: 100%; height: 28px; padding: 0 8px; border: 0; outline: 1px solid var(--muted); background: var(--shade); font-size: 12px; }
121
+ .choice { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
122
+ .choice svg { color: var(--accent); }
123
+
124
+ /* --- resize handles: a hairline that lights up --- */
125
+ .resize { position: absolute; top: 0; bottom: 0; width: 7px; z-index: 5; cursor: col-resize; display: flex; justify-content: center; outline: none; }
126
+ .resize.right { right: -3px; }
127
+ .resize.left { left: -3px; }
128
+ .resize i { width: 1px; height: 100%; background: var(--hairline); transition: background-color 0.12s; }
129
+ .resize:hover i { background: var(--muted); }
130
+ .resize.dragging i, .resize:focus-visible i { background: var(--accent); }
131
+
132
+ /* --- main and the thread pane --- */
133
+ .main, .pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
134
+ .main { flex: 1; }
135
+ .pane { position: relative; flex-shrink: 0; }
136
+ @media (max-width: 900px) {
137
+ .sidebar { display: none; }
138
+ .pane { position: fixed; inset: 0; width: auto !important; background: var(--bg); z-index: 30; }
139
+ .resize { display: none; }
140
+ }
141
+
142
+ .header {
143
+ height: 46px;
144
+ flex-shrink: 0;
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 10px;
148
+ padding: 0 12px 0 20px;
149
+ border-bottom: 1px solid var(--hairline);
150
+ white-space: nowrap;
151
+ }
152
+ .header .title { color: var(--bright); font-weight: 600; }
153
+ .header .meta { color: var(--faint); font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
154
+ .header .spacer { flex: 1; }
155
+ .header > .tool:first-child { margin-left: -8px; }
156
+
157
+ .live { display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 11px; }
158
+ .live i { width: 6px; height: 6px; border-radius: 50%; background: var(--faint); }
159
+ .live.live i { background: var(--accent); }
160
+
161
+ /* --- the feed: no row borders; a short conversation sits on the composer --- */
162
+ .feed-wrap { position: relative; flex: 1; min-height: 0; display: flex; }
163
+ .feed { flex: 1; overflow-y: auto; padding: 8px 0 12px; display: flex; flex-direction: column; overflow-anchor: none; }
164
+ .feed > div { margin-top: auto; }
165
+ .jump {
166
+ position: absolute;
167
+ bottom: 12px;
168
+ left: 50%;
169
+ transform: translateX(-50%);
170
+ padding: 3px 10px;
171
+ border: 1px solid var(--accent);
172
+ background: var(--bg);
173
+ color: var(--accent);
174
+ font-size: 11px;
175
+ }
176
+ .divider { display: flex; align-items: center; gap: 8px; margin: 14px 20px 4px; color: var(--faint); font-size: 11px; }
177
+ .divider::after { content: ''; flex: 1; height: 1px; background: var(--hairline); }
178
+
179
+ .message { position: relative; display: grid; grid-template-columns: 40px 1fr; gap: 8px; padding: 6px 20px 4px 8px; }
180
+ .message.continued { padding-top: 1px; padding-bottom: 1px; }
181
+ .message.active { background: var(--shade); }
182
+ .message.pending { opacity: 0.5; }
183
+ .message.failed { opacity: 1; }
184
+ .gutter { display: flex; justify-content: center; align-items: flex-start; }
185
+ .gutter-time { visibility: hidden; color: var(--faint); font-size: 10px; line-height: 21px; }
186
+ .message.active .gutter-time, .gutter-time.shown { visibility: visible; }
187
+ .avatar {
188
+ flex-shrink: 0;
189
+ display: inline-grid;
190
+ place-items: center;
191
+ border-radius: 4px;
192
+ background: var(--shade-hover);
193
+ color: var(--muted);
194
+ font-weight: 600;
195
+ overflow: hidden;
196
+ }
197
+ .avatar img { width: 100%; height: 100%; object-fit: cover; }
198
+ .body { min-width: 0; }
199
+ .meta-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
200
+ .author { color: var(--bright); font-weight: 600; }
201
+ .where { color: var(--faint); font-size: 12px; }
202
+ .where a { color: var(--accent); cursor: default; }
203
+ .where a:hover { text-decoration: underline; }
204
+ .time, .id { color: var(--faint); font-size: 11px; }
205
+ /* Message text is markdown. Blocks stay compact; a short message reads as one line. */
206
+ .text { overflow-wrap: anywhere; color: var(--dim); font-size: 14px; cursor: text; }
207
+ .text > :first-child { margin-top: 0; }
208
+ .text > :last-child { margin-bottom: 0; }
209
+ .text > p:last-child, .text > p:has(+ .edited) { display: inline; }
210
+ .text p { margin: 0 0 6px; }
211
+ .text a { color: var(--accent); text-decoration: none; text-underline-offset: 3px; overflow-wrap: anywhere; }
212
+ .text a:hover { text-decoration: underline; }
213
+ .text strong { color: var(--bright); font-weight: 600; }
214
+ .text h1, .text h2, .text h3, .text h4 { margin: 8px 0 4px; color: var(--bright); font-size: 14px; font-weight: 600; }
215
+ .text ul, .text ol { margin: 2px 0 6px; padding-left: 20px; }
216
+ .text li + li { margin-top: 1px; }
217
+ .text blockquote { margin: 4px 0; padding-left: 10px; border-left: 2px solid var(--hairline); color: var(--muted); }
218
+ .text code { padding: 1px 4px; border-radius: 3px; background: var(--shade); color: var(--bright); font-size: 0.92em; }
219
+ .message.active .text code { background: var(--shade-hover); }
220
+ .text pre { margin: 4px 0 6px; padding: 10px; overflow-x: auto; border: 1px solid var(--hairline); border-radius: 4px; background: var(--shade); line-height: 1.5; }
221
+ .text pre code, .message.active .text pre code { padding: 0; border-radius: 0; background: none; font-size: 13px; }
222
+ .text table { margin: 4px 0 6px; border-collapse: collapse; font-size: 12px; display: block; overflow-x: auto; max-width: 100%; }
223
+ .text th, .text td { padding: 3px 10px; border: 1px solid var(--hairline); text-align: left; white-space: nowrap; }
224
+ .text th { color: var(--bright); font-weight: 600; background: var(--shade); }
225
+ .text img { max-width: 100%; max-height: 260px; vertical-align: middle; }
226
+ .text hr { border: 0; height: 1px; margin: 8px 0; background: var(--hairline); }
227
+ .text input[type='checkbox'] { margin: 0 6px 0 0; vertical-align: middle; }
228
+ .text ul.contains-task-list { list-style: none; padding-left: 2px; }
229
+ .avatar-wrap { position: relative; display: inline-flex; }
230
+ .source {
231
+ position: absolute;
232
+ right: -5px;
233
+ bottom: -5px;
234
+ width: 16px;
235
+ height: 16px;
236
+ padding: 2px;
237
+ border-radius: 4px;
238
+ background: var(--bg);
239
+ color: var(--bright);
240
+ }
241
+ .source svg { display: block; width: 100%; height: 100%; }
242
+ .source.text { display: grid; place-items: center; font-size: 9px; font-weight: 600; }
243
+ .mention { color: var(--accent); font-weight: 600; }
244
+ .removed { color: var(--faint); font-style: italic; }
245
+ .edited { color: var(--faint); font-size: 11px; }
246
+ .undelivered { margin-top: 4px; font-size: 11px; color: var(--accent); }
247
+ .undelivered button { text-decoration: underline; }
248
+
249
+ .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
250
+ .badge, .label, .chip {
251
+ display: inline-flex;
252
+ align-items: center;
253
+ gap: 4px;
254
+ padding: 1px 7px;
255
+ border: 1px solid var(--hairline);
256
+ background: var(--shade);
257
+ color: var(--muted);
258
+ font-size: 11px;
259
+ line-height: 16px;
260
+ }
261
+ .badge.slack, .badge.github { border-color: var(--accent-transparent); background: none; color: var(--accent); }
262
+ .label:hover, .chip:hover { color: var(--bright); background: var(--shade-hover); }
263
+ .label button { color: var(--faint); display: inline-flex; }
264
+ .label button:hover { color: var(--bright); }
265
+ /* Your own reaction: a little brighter, not louder. */
266
+ .chip.mine { border-color: var(--muted); background: var(--shade-hover); color: var(--bright); }
267
+ /* Attachments: media in a fixed frame, so loading never moves the page; files as chips. */
268
+ .links { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
269
+ .link-embed { padding: 2px 0 2px 10px; border-left: 2px solid var(--hairline); }
270
+ .link-meta { display: flex; gap: 8px; align-items: baseline; font-size: 12px; color: var(--muted); }
271
+ .link-meta .author { font-size: 13px; }
272
+ .link-meta a, .link-file a { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
273
+ .link-meta a:hover, .link-file a:hover { text-decoration: underline; }
274
+ .link-file { display: flex; gap: 4px; align-items: center; margin-top: 2px; font-size: 12px; color: var(--muted); }
275
+ .attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
276
+ .attachment-media { display: block; width: 320px; max-width: 100%; height: 200px; border: 1px solid var(--hairline); background: var(--shade); overflow: hidden; }
277
+ .attachment-media img, .attachment-media video { display: block; width: 100%; height: 100%; object-fit: contain; }
278
+ .file { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 8px; border: 1px solid var(--hairline); background: var(--shade); color: var(--dim); font-size: 12px; }
279
+ button.file:hover { background: var(--shade-hover); color: var(--bright); }
280
+ .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
281
+ .file button { color: var(--faint); display: inline-flex; }
282
+ .file button:hover { color: var(--bright); }
283
+ .composer-files { margin: 0 12px 4px; }
284
+ .replies { display: inline-flex; align-items: center; gap: 6px; margin: 4px 0 0 -4px; padding: 2px 6px 2px 4px; color: var(--dim); font-size: 12px; font-weight: 600; }
285
+ .replies:hover { background: var(--shade-hover); color: var(--bright); }
286
+ .replies .time { font-weight: 400; }
287
+ .replies .faces { display: inline-flex; }
288
+ .replies .faces .avatar + .avatar { margin-left: -4px; }
289
+
290
+ .editor { display: grid; gap: 2px; margin: 2px 0; }
291
+ .editor textarea {
292
+ width: 100%;
293
+ padding: 8px 10px;
294
+ border: 1px solid var(--hairline);
295
+ background: var(--bg);
296
+ resize: none;
297
+ outline: none;
298
+ font-size: 14px;
299
+ }
300
+ .editor textarea:focus { border-color: var(--muted); }
301
+
302
+ /* --- hover actions: one hairline box at the row's corner --- */
303
+ .actions { position: absolute; top: -12px; right: 20px; z-index: 5; display: flex; border: 1px solid var(--hairline); background: var(--bg); }
304
+ .tool {
305
+ display: inline-flex;
306
+ align-items: center;
307
+ justify-content: center;
308
+ gap: 4px;
309
+ min-width: 28px;
310
+ height: 26px;
311
+ padding: 0 6px;
312
+ color: var(--muted);
313
+ font-size: 13px;
314
+ }
315
+ .tool:hover, .tool[data-popup-open] { background: var(--shade); color: var(--bright); }
316
+ .tool.on { color: var(--accent); }
317
+ .tool .count, .tool .tool-label { font-size: 11px; }
318
+
319
+ /* --- menus and popovers: square, a dim edge, an offset dot shadow --- */
320
+ .positioner { z-index: 50; outline: none; }
321
+ .frame { position: relative; isolation: isolate; }
322
+ .dots {
323
+ position: absolute;
324
+ left: 6px;
325
+ top: 6px;
326
+ width: 100%;
327
+ height: 100%;
328
+ z-index: -1;
329
+ background-image: radial-gradient(var(--faint) 0.8px, transparent 0.8px);
330
+ background-size: 4px 4px;
331
+ pointer-events: none;
332
+ }
333
+ .menu { min-width: 172px; padding: 6px; border: 1px solid var(--dim); background: var(--bg); outline: none; }
334
+ .menu.list { max-height: 260px; overflow-y: auto; }
335
+ .item {
336
+ display: flex;
337
+ align-items: center;
338
+ gap: 8px;
339
+ padding: 6px 8px;
340
+ color: var(--muted);
341
+ outline: none;
342
+ cursor: default;
343
+ white-space: nowrap;
344
+ }
345
+ .item[data-highlighted] { background: var(--shade); color: var(--bright); }
346
+ .item.danger, .item.danger[data-highlighted] { color: var(--red); }
347
+ .item .hint { margin-left: 12px; }
348
+ .separator { height: 1px; margin: 4px 0; background: var(--hairline); }
349
+ .empty-item { padding: 6px 8px; color: var(--faint); }
350
+ .emoji-row { display: grid; grid-template-columns: repeat(6, 1fr); }
351
+ .item.emoji { justify-content: center; padding: 6px 0; font-size: 15px; }
352
+ .emoji-grid { display: grid; grid-template-columns: repeat(12, 30px); padding: 6px; }
353
+ .emoji-grid .emoji { height: 30px; font-size: 16px; }
354
+ .emoji-grid .emoji:hover { background: var(--shade); }
355
+ .popover { width: min(380px, calc(100vw - 32px)); padding: 12px; }
356
+ .popover-title { margin: 0 0 8px; color: var(--bright); font-size: 12px; font-weight: 600; }
357
+
358
+ .labels-editor { display: grid; gap: 10px; }
359
+ .labels-editor > .hint { margin: 0; }
360
+ .label-rows { display: grid; }
361
+ .label-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto; align-items: center; gap: 8px; padding: 2px 0 2px 8px; }
362
+ .label-row:hover { background: var(--shade); }
363
+ .label-key { color: var(--bright); overflow: hidden; text-overflow: ellipsis; }
364
+ .label-value { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
365
+ .label-add { display: flex; align-items: center; gap: 6px; }
366
+ .suggest { display: flex; align-items: center; min-width: 0; padding: 0 8px; background: var(--shade); }
367
+ .suggest input { width: 100%; min-width: 0; height: 28px; border: 0; outline: none; background: none; }
368
+ .suggest:focus-within { outline: 1px solid var(--muted); }
369
+ .suggest .prefix { color: var(--faint); }
370
+ .label-add > .suggest { width: 38%; }
371
+
372
+ /* --- filters --- */
373
+ .filter-bar { flex-shrink: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 20px 0; }
374
+ .clear { color: var(--faint); font-size: 11px; }
375
+ .clear:hover { color: var(--bright); }
376
+ .filter-form { display: grid; gap: 12px; }
377
+ .filter-form > .clear { justify-self: start; }
378
+ .filter-row { display: grid; grid-template-columns: 52px minmax(0, 1fr); align-items: start; gap: 10px; }
379
+ .filter-name { color: var(--faint); font-size: 11px; line-height: 28px; }
380
+ .filter-row .grow { display: grid; gap: 6px; }
381
+ .segments { display: flex; flex-wrap: wrap; gap: 2px; }
382
+ .segment { height: 28px; padding: 0 10px; background: var(--shade); color: var(--muted); }
383
+ .segment:hover { color: var(--bright); }
384
+ .segment.on { background: var(--bright); color: var(--bg); }
385
+
386
+ /* --- the composer: an outline, not a border --- */
387
+ .composer { flex-shrink: 0; padding: 0 20px 20px; }
388
+ .composer-box {
389
+ display: flex;
390
+ flex-direction: column;
391
+ background: var(--bg);
392
+ outline: 2px dotted var(--hairline);
393
+ outline-offset: -1px;
394
+ cursor: text;
395
+ }
396
+ .composer-box:focus-within { background: var(--shade); outline-style: dashed; }
397
+ .composer-box textarea {
398
+ width: 100%;
399
+ min-height: 58px;
400
+ max-height: 160px;
401
+ padding: 10px 12px 2px;
402
+ border: 0;
403
+ outline: none;
404
+ background: none;
405
+ color: var(--dim);
406
+ font-size: 14px;
407
+ line-height: 1.5;
408
+ resize: none;
409
+ overflow-y: auto;
410
+ }
411
+ .composer-box textarea::placeholder { color: var(--faint); }
412
+ .composer-channel { margin: 8px 8px 0; align-self: flex-start; width: min(320px, calc(100% - 16px)); }
413
+ .composer-labels { margin: 8px 12px 0; }
414
+ .composer-footer { display: flex; align-items: center; gap: 2px; min-height: 40px; padding: 2px 8px 8px; }
415
+ .composer-footer .hint { margin-left: auto; margin-right: 8px; }
416
+ .pane .composer-footer .hint { display: none; }
417
+ .pane .composer-footer .send { margin-left: auto; }
418
+ .send { height: 32px; padding: 0 12px; background: var(--accent); color: var(--label); font-weight: 500; }
419
+ .send.small { height: 28px; padding: 0 10px; }
420
+ .send:disabled { opacity: 0.5; }
421
+
422
+ .inbox-row { display: grid; grid-template-columns: 70px 90px 1fr; gap: 12px; padding: 6px 20px; }
423
+ .inbox-row:hover { background: var(--shade); }
424
+ .inbox-row pre { margin: 4px 0 0; color: var(--faint); font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; }
425
+
426
+ .toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 60; }
427
+ .toast { max-width: 420px; padding: 8px 12px; border: 1px solid var(--dim); background: var(--bg); color: var(--bright); }
428
+ `;
429
+ /** The prebuilt UI: next to the CLI in `dist`, or in `dist` when running from source. */
430
+ const BUNDLES = [new URL("./inspect/app.js", import.meta.url), new URL("../../dist/inspect/app.js", import.meta.url)];
431
+ async function bundle() {
432
+ for (const candidate of BUNDLES) try {
433
+ return await readFile(candidate, "utf8");
434
+ } catch {}
435
+ return null;
436
+ }
437
+ const shell = (token) => `<!doctype html>
438
+ <html lang="en">
439
+ <head>
440
+ <meta charset="utf-8">
441
+ <meta name="viewport" content="width=device-width, initial-scale=1">
442
+ <meta name="inspect-token" content="${token}">
443
+ <title>board</title>
444
+ <style>${CSS}</style>
445
+ </head>
446
+ <body>
447
+ <div id="root"></div>
448
+ <script type="module" src="/app.js"><\/script>
449
+ </body>
450
+ </html>`;
451
+ const CSP = [
452
+ "default-src 'none'",
453
+ "img-src https: http://localhost:* http://127.0.0.1:* data: blob:",
454
+ "media-src blob:",
455
+ "style-src 'unsafe-inline'",
456
+ "script-src 'self'",
457
+ "connect-src 'self'",
458
+ "form-action 'none'",
459
+ "base-uri 'none'",
460
+ "frame-ancestors 'none'"
461
+ ].join("; ");
462
+ /**
463
+ * Serves the UI, and a proxy for the board's API that adds your credentials, on
464
+ * 127.0.0.1. Because the proxy acts as you, it answers only its own page: every
465
+ * request must name this server's host (no DNS rebinding) and carry the per-run
466
+ * token that only the page has, and writes must also come from its origin. Your
467
+ * board token never leaves this process.
468
+ */
469
+ async function startInspector(options) {
470
+ const { client } = options;
471
+ const me = await client.me();
472
+ const board = (await client.discovery().catch(() => null))?.url ?? client.url;
473
+ const app = await bundle();
474
+ const token = randomBytes(24).toString("base64url");
475
+ let origins = /* @__PURE__ */ new Set();
476
+ const streams = /* @__PURE__ */ new Set();
477
+ async function proxy(request, response, url) {
478
+ const reading = request.method === "GET" || request.method === "HEAD";
479
+ if (!sameSecret(String(request.headers["x-inspect-token"] ?? ""), token) || !reading && !origins.has(String(request.headers.origin))) return json(response, 403, { error: {
480
+ code: "forbidden",
481
+ message: "the board is reachable only from the inspector's own page"
482
+ } });
483
+ const path = url.pathname.slice(6) || "/";
484
+ let decoded;
485
+ try {
486
+ decoded = decodeURIComponent(path);
487
+ } catch {
488
+ decoded = "..";
489
+ }
490
+ if (/(^|\/)\.\.?(\/|$)|\\/.test(decoded)) return json(response, 400, { error: {
491
+ code: "bad_path",
492
+ message: "no relative paths"
493
+ } });
494
+ const controller = new AbortController();
495
+ streams.add(controller);
496
+ response.on("close", () => controller.abort());
497
+ try {
498
+ const body = reading ? void 0 : await read(request, path === "/attachments" ? ATTACHMENT_BYTES_MAX : void 0);
499
+ const chosen = request.headers["x-inspect-profile"];
500
+ const as = typeof chosen === "string" ? chosen === "" ? null : chosen : void 0;
501
+ const upstream = await client.request(`${path}${url.search}`, {
502
+ ...as !== void 0 ? { as } : {},
503
+ method: request.method ?? "GET",
504
+ headers: {
505
+ ...request.headers["content-type"] ? { "content-type": String(request.headers["content-type"]) } : {},
506
+ ...request.headers.accept ? { accept: String(request.headers.accept) } : {},
507
+ ...request.headers["x-board-filename"] ? { "x-board-filename": String(request.headers["x-board-filename"]) } : {}
508
+ },
509
+ ...body ? { body } : {},
510
+ signal: controller.signal
511
+ });
512
+ response.writeHead(upstream.status, {
513
+ "content-type": upstream.headers.get("content-type") ?? "application/json",
514
+ "cache-control": "no-store",
515
+ "x-content-type-options": "nosniff"
516
+ });
517
+ if (upstream.body) for await (const chunk of upstream.body) response.write(chunk);
518
+ response.end();
519
+ } catch (error) {
520
+ if (controller.signal.aborted || response.headersSent) return void response.end();
521
+ json(response, 502, { error: {
522
+ code: "unreachable",
523
+ message: `the board didn't answer: ${error.message}`
524
+ } });
525
+ } finally {
526
+ streams.delete(controller);
527
+ }
528
+ }
529
+ const server = createServer((request, response) => {
530
+ const url = new URL(request.url ?? "/", "http://inspector");
531
+ if (!origins.has(`http://${request.headers.host}`)) return send(response, 421, "text/plain; charset=utf-8", "wrong host");
532
+ if (url.pathname === "/board" || url.pathname.startsWith("/board/")) return void proxy(request, response, url).catch(() => response.headersSent ? response.end() : send(response, 500, "text/plain; charset=utf-8", "error"));
533
+ if (request.method !== "GET") return send(response, 405, "text/plain; charset=utf-8", "method not allowed");
534
+ if (url.pathname === "/app.js") return app ? send(response, 200, "text/javascript; charset=utf-8", app) : send(response, 503, "text/plain; charset=utf-8", "the inspector's UI isn't built: run `pnpm --filter @vercel/board build`");
535
+ send(response, 200, "text/html; charset=utf-8", shell(token), { "content-security-policy": CSP });
536
+ });
537
+ const port = await listen(server, options.port ?? 4747, options.port === void 0);
538
+ origins = /* @__PURE__ */ new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
539
+ return {
540
+ url: `http://127.0.0.1:${port}`,
541
+ board,
542
+ me,
543
+ close: () => new Promise((resolve) => {
544
+ for (const stream of streams) stream.abort();
545
+ server.close(() => resolve());
546
+ server.closeAllConnections();
547
+ })
548
+ };
549
+ }
550
+ function sameSecret(given, expected) {
551
+ const a = Buffer.from(given);
552
+ const b = Buffer.from(expected);
553
+ return a.length === b.length && timingSafeEqual(a, b);
554
+ }
555
+ function read(request, limit = 1048576) {
556
+ return new Promise((resolve, reject) => {
557
+ let size = 0;
558
+ const chunks = [];
559
+ request.on("data", (chunk) => {
560
+ size += chunk.length;
561
+ if (size > limit) {
562
+ reject(/* @__PURE__ */ new Error("request too large"));
563
+ request.destroy();
564
+ } else chunks.push(chunk);
565
+ });
566
+ request.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))));
567
+ request.on("error", reject);
568
+ });
569
+ }
570
+ function send(response, status, type, body, headers = {}) {
571
+ response.writeHead(status, {
572
+ "content-type": type,
573
+ "cache-control": "no-store",
574
+ "x-content-type-options": "nosniff",
575
+ "referrer-policy": "no-referrer",
576
+ ...headers
577
+ });
578
+ response.end(body);
579
+ }
580
+ const json = (response, status, body) => send(response, status, "application/json", JSON.stringify(body));
581
+ /** Listens on the port, or any free one when the default port is taken. */
582
+ async function listen(server, port, fallback) {
583
+ try {
584
+ return await listenOn(server, port);
585
+ } catch (error) {
586
+ if (fallback && error.code === "EADDRINUSE") return listenOn(server, 0);
587
+ throw error;
588
+ }
589
+ }
590
+ function listenOn(server, port) {
591
+ return new Promise((resolve, reject) => {
592
+ const onError = (error) => {
593
+ server.off("listening", onListening);
594
+ reject(error);
595
+ };
596
+ const onListening = () => {
597
+ server.off("error", onError);
598
+ resolve(server.address().port);
599
+ };
600
+ server.once("error", onError);
601
+ server.once("listening", onListening);
602
+ server.listen(port, "127.0.0.1");
603
+ });
604
+ }
605
+ //#endregion
606
+ //#region src/cli/format.ts
607
+ /** "claude via julian" for a profile, the name otherwise. */
608
+ /**
609
+ * Text for a terminal. What boards and platforms say can carry control characters, ESC among them,
610
+ * which starts the sequences that clear the screen, move the cursor, or retitle the window: each
611
+ * but tab and newline shows as its picture (`␛`), a C1 control as `�`, and a Windows line end as a
612
+ * newline. `--json` prints what the board said, as it said it.
613
+ */
614
+ const terminalText = (text) => text.replace(/\r\n/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (char) => {
615
+ const code = char.charCodeAt(0);
616
+ return code < 32 ? String.fromCharCode(9216 + code) : code === 127 ? "␡" : "�";
617
+ });
618
+ const who = (me) => me.profile !== null && me.handle.includes("/") ? `${me.name} via ${me.handle.slice(0, me.handle.indexOf("/"))}` : me.name;
619
+ function authorName(message) {
620
+ const author = message.author;
621
+ if (!author) return message.by.actor;
622
+ const slash = author.handle.indexOf("/");
623
+ if (message.by.profile !== null && slash !== -1) return `${author.name} via ${author.handle.slice(0, slash)}`;
624
+ if (author.person && author.person.handle !== author.handle) return `${author.name} (${author.person.handle})`;
625
+ return author.name;
626
+ }
627
+ function ago(iso, now = Date.now()) {
628
+ const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
629
+ if (seconds < 60) return `${seconds}s`;
630
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
631
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
632
+ return `${Math.floor(seconds / 86400)}d`;
633
+ }
634
+ const indent = (text, prefix) => text.split("\n").map((line) => `${prefix}${line}`).join("\n");
635
+ function labels(message) {
636
+ const entries = Object.entries(message.labels);
637
+ return entries.length ? ` [${entries.map(([k, v]) => `${k}=${v}`).join(" ")}]` : "";
638
+ }
639
+ function reactions(message, names) {
640
+ const groups = Object.entries(message.reactions);
641
+ if (groups.length === 0) return "";
642
+ return groups.map(([emoji, actors]) => {
643
+ const shown = actors.slice(0, 3).map((id) => names.get(id) ?? id).join(", ");
644
+ return `${displayEmoji(emoji)} ${shown}${actors.length > 3 ? ` +${actors.length - 3}` : ""}`;
645
+ }).join(" ");
646
+ }
647
+ /** The actors a thread's reactions name on screen, for looking up the ones who didn't write in it. */
648
+ function reactionNames(messages) {
649
+ return [...new Set(messages.flatMap((m) => Object.values(m.reactions).flatMap((actors) => actors.slice(0, 3))))];
650
+ }
651
+ /** An actor's display name from `/actors`, the way a message's author shows: `claude via julian`. */
652
+ function actorName(actor) {
653
+ const slash = actor.handle.indexOf("/");
654
+ return slash === -1 ? actor.name : `${actor.name} via ${actor.handle.slice(0, slash)}`;
655
+ }
656
+ /** Lines of a linked message an embed shows before it trails off. */
657
+ const EMBED_LINES = 12;
658
+ /** A linked platform message, quoted under the message that links it, or why the board couldn't read it. */
659
+ function embed(link, pad) {
660
+ if (!link.snapshot) return [`${pad}↪ ${link.url} (couldn't read it: ${link.unavailable ?? "unknown"})`];
661
+ const { author, text, files } = link.snapshot;
662
+ const lines = text.trim() ? text.trim().split("\n") : [];
663
+ const shown = lines.length > EMBED_LINES ? [...lines.slice(0, EMBED_LINES), "…"] : lines;
664
+ return [
665
+ `${pad}↪ ${author}${link.message !== null ? ` · #${link.message}` : ""} · ${link.url}`,
666
+ ...shown.map((line) => `${pad}> ${line}`),
667
+ ...files.map((file) => `${pad}> file ${file.name}${file.url ? ` ${file.url}` : ""}`)
668
+ ];
669
+ }
670
+ /** One message as a block: header line, indented text, reactions. */
671
+ function formatMessage(message, options = {}) {
672
+ const pad = options.reply ? " " : " ";
673
+ const head = `${options.reply ? " ↳ " : ""}#${message.id} ${authorName(message)} · ${ago(message.createdAt)}${message.editedAt ? " (edited)" : ""}${message.schedule ? ` · scheduled s${message.schedule.id}` : ""}${labels(message)}`;
674
+ const body = message.deleted ? `${pad}(message removed)` : indent(displayText(message.text, message.by.actor), pad);
675
+ const files = (message.attachments ?? []).map((a) => `${pad}file ${a.name} · ${a.type} · ${fileSize(a.size)} · ${a.id}`);
676
+ const links = message.deleted ? [] : (message.links ?? []).flatMap((link) => embed(link, pad));
677
+ const reacted = reactions(message, options.names ?? /* @__PURE__ */ new Map());
678
+ const undelivered = Object.entries(message.undelivered ?? {}).map(([target, error]) => `${pad}✗ didn't reach ${target}: ${error}`);
679
+ return [
680
+ head,
681
+ body,
682
+ ...files,
683
+ ...links,
684
+ ...reacted ? [`${pad}${reacted}`] : [],
685
+ ...undelivered
686
+ ].join("\n");
687
+ }
688
+ /** Threads in order, each root followed by its replies. */
689
+ function formatThreads(messages, known = /* @__PURE__ */ new Map()) {
690
+ const names = new Map([...known, ...messages.map((m) => [m.by.actor, authorName(m)])]);
691
+ const byRoot = /* @__PURE__ */ new Map();
692
+ const roots = [];
693
+ const orphans = [];
694
+ for (const message of messages) if (message.re === null) roots.push(message);
695
+ else (byRoot.get(message.re) ?? byRoot.set(message.re, []).get(message.re)).push(message);
696
+ const rootIds = new Set(roots.map((r) => r.id));
697
+ for (const [root, replies] of byRoot) if (!rootIds.has(root)) orphans.push(...replies);
698
+ const blocks = roots.map((root) => [formatMessage(root, { names }), ...(byRoot.get(root.id) ?? []).map((r) => formatMessage(r, {
699
+ reply: true,
700
+ names
701
+ }))].join("\n"));
702
+ for (const reply of orphans) blocks.push(`(in thread #${reply.re})\n${formatMessage(reply, {
703
+ reply: true,
704
+ names
705
+ })}`);
706
+ return blocks.join("\n\n");
707
+ }
708
+ /** A root's first line, cut to fit 80 columns under its header. */
709
+ function firstLine(message) {
710
+ if (message.deleted) return "(message removed)";
711
+ const text = displayText(message.text, message.by.actor).trim();
712
+ const files = message.attachments?.length ?? 0;
713
+ if (!text) return files ? `(${files} ${files === 1 ? "file" : "files"})` : "(no text)";
714
+ const [line = "", ...rest] = text.split(/\r?\n/);
715
+ const shown = rest.length ? `${line.trimEnd()} …` : line;
716
+ return shown.length > 74 ? `${line.slice(0, 73)}…` : shown;
717
+ }
718
+ /**
719
+ * One block per thread, for `read <channel>`: the root's header and first line,
720
+ * then how many replies it has and who wrote the latest, and when.
721
+ */
722
+ function formatThreadSummaries(threads, options = {}) {
723
+ const now = options.now ?? Date.now();
724
+ return threads.map(({ message, replies, last }) => {
725
+ const where = options.channel ? `${message.channel} ` : "";
726
+ const head = `#${message.id} ${where}${authorName(message)} · ${ago(message.createdAt, now)}${labels(message)}`;
727
+ const activity = last ? `${replies} ${replies === 1 ? "reply" : "replies"} · last #${last.id} ${authorName(last)} · ${ago(last.createdAt, now)}` : "no replies";
728
+ return [
729
+ head,
730
+ ` ${firstLine(message)}`,
731
+ ` ${activity}`
732
+ ].join("\n");
733
+ }).join("\n\n");
734
+ }
735
+ /**
736
+ * `/me`'s `can` as `whoami` rows, a pattern and its permissions each (`ci/ read
737
+ * post`), or none for full access. `manage` brings everything, so it says `all`.
738
+ */
739
+ function formatAccess(access) {
740
+ const patterns = Object.keys(access);
741
+ if (patterns.length === 1 && patterns[0] === "*" && access["*"].includes("manage")) return [];
742
+ if (patterns.length === 0) return ["nothing"];
743
+ const width = Math.max(...patterns.map((pattern) => pattern.length));
744
+ return patterns.map((pattern) => {
745
+ const permissions = access[pattern];
746
+ return `${pattern.padEnd(width)} ${permissions.includes("manage") ? "all" : permissions.length === 0 ? "nothing" : permissions.join(" ")}`;
747
+ });
748
+ }
749
+ /** `90m` → `1h30m`, `1d`: milliseconds as a duration flag would say them. */
750
+ function span(ms) {
751
+ const parts = [];
752
+ let rest = Math.round(ms / 1e3);
753
+ for (const [unit, size] of [
754
+ ["d", 86400],
755
+ ["h", 3600],
756
+ ["m", 60],
757
+ ["s", 1]
758
+ ]) {
759
+ if (rest >= size) parts.push(`${Math.floor(rest / size)}${unit}`);
760
+ rest %= size;
761
+ }
762
+ return parts.join("") || "0s";
763
+ }
764
+ /** `in 25m`, or `due` once it's past. */
765
+ const until = (iso, now) => {
766
+ const ms = Date.parse(iso) - now;
767
+ if (ms <= 0) return "due";
768
+ return `in ${span(ms < 6e4 ? Math.ceil(ms / 1e3) * 1e3 : Math.ceil(ms / 6e4) * 6e4)}`;
769
+ };
770
+ /** One schedule: what it sends, when, where, and how it stops. */
771
+ function formatSchedule(schedule, options = {}) {
772
+ const now = options.now ?? Date.now();
773
+ const pad = options.pad ?? "";
774
+ const where = schedule.re === null ? `new thread in ${schedule.channel}` : `#${schedule.re}`;
775
+ const repeat = schedule.every === null ? "once" : `every ${span(schedule.every)}`;
776
+ const stop = schedule.every === null ? "" : schedule.times !== null ? ` · ${schedule.times} times` : schedule.until !== null ? ` · until ${schedule.until}` : " · until canceled";
777
+ const when = schedule.state === "pending" ? `next ${until(schedule.next, now)} (${schedule.next})` : schedule.state === "canceled" ? schedule.canceled?.reason === "thread_deleted" ? "ended: the thread was deleted" : schedule.canceled?.reason === "dropped" ? "ended: the board dropped it" : schedule.canceled?.reason === "revoked" ? "ended: its creator can no longer post it" : "canceled" : "done";
778
+ const sent = schedule.sent > 0 ? ` · ${schedule.sent} sent` : "";
779
+ const files = schedule.attachments.length ? ` · ${schedule.attachments.length} ${schedule.attachments.length === 1 ? "file" : "files"}` : "";
780
+ const text = displayText(schedule.text, schedule.by.actor);
781
+ return [`${pad}s${schedule.id} ${where} · ${repeat}${stop} · ${when}${sent}${files} · by ${authorName(schedule)}`, indent(text || "(no text)", `${pad} `)].join("\n");
782
+ }
783
+ /** One line per live event, for `tail` and `wait`. */
784
+ function formatEvent(event) {
785
+ const message = event.message;
786
+ if (!message) return `#${event.cursor} ${event.type}`;
787
+ const where = message.re === null ? message.channel : `${message.channel} ↳ #${message.re}`;
788
+ const who = authorName(message);
789
+ switch (event.type) {
790
+ case "message.posted": {
791
+ const files = (message.attachments ?? []).map((a) => ` · file ${a.name}`).join("");
792
+ return `#${message.id} ${where} ${who}: ${displayText(message.text, message.by.actor)}${files}`;
793
+ }
794
+ case "message.edited": return `#${message.id} ${where} ${who} (edited): ${displayText(message.text, message.by.actor)}`;
795
+ case "message.deleted": return `#${message.id} ${where} (message removed)`;
796
+ case "message.linked": return `#${message.id} ${where} ↪ links ${(message.links ?? []).map((link) => link.message !== null ? `#${link.message}` : link.url).join(", ") || "nothing"}`;
797
+ case "reaction.added":
798
+ case "reaction.removed": return `#${message.id} ${where} ${displayEmoji(event.payload.emoji)} ${event.type === "reaction.added" ? "added" : "removed"} by ${event.actor?.name ?? event.payload.by.actor}`;
799
+ case "labels.changed": {
800
+ const set = Object.entries(event.payload.set).map(([k, v]) => `${k}=${v}`);
801
+ const unset = event.payload.unset.map((k) => `-${k}`);
802
+ return `#${message.id} ${where} labels ${[...set, ...unset].join(" ")}${event.actor ? ` by ${event.actor.name}` : ""}`;
803
+ }
804
+ case "message.sync_failed": return `#${message.id} ${where} ✗ didn't reach ${event.payload.target}: ${event.payload.error}`;
805
+ default: return `#${event.cursor} ${event.type}`;
806
+ }
807
+ }
808
+ /**
809
+ * A channel or thread read with `vb sources <key> read`: a header, then each message under its ID,
810
+ * with the target that reads its thread when it has replies.
811
+ */
812
+ function formatSourceRead(key, read, now = Date.now()) {
813
+ const where = `${key}:${read.channel}${read.name ? ` (#${read.name})` : ""}${read.thread ? `, thread ${read.thread}` : ""}`;
814
+ if (read.messages.length === 0) return `${where}\nno messages`;
815
+ const blocks = read.messages.map((message) => {
816
+ const reply = read.thread !== null && message.id !== read.thread;
817
+ const pad = reply ? " " : " ";
818
+ const when = message.createdAt ? ` · ${ago(message.createdAt, now)}` : "";
819
+ const replies = message.replies > 0 && read.thread === null ? ` (${message.replies} ${message.replies === 1 ? "reply" : "replies"}: vb sources ${key} read ${message.thread})` : "";
820
+ return [
821
+ `${reply ? " ↳ " : ""}${message.id} ${message.author}${when}${replies}`,
822
+ indent(message.text || "(no text)", pad),
823
+ ...message.files.map((file) => `${pad}file ${file.name}${file.url ? ` ${file.url}` : ""}`)
824
+ ].join("\n");
825
+ });
826
+ const more = read.more ? [`(${read.thread ? "later replies" : "older messages"} not shown: raise --limit, up to 200)`] : [];
827
+ return [
828
+ where,
829
+ ...blocks,
830
+ ...more
831
+ ].join("\n");
832
+ }
833
+ /**
834
+ * A conformance report: each group a heading, its checks under it, and why for one that failed
835
+ * or was skipped, then the counts.
836
+ */
837
+ function formatConformance(report) {
838
+ const blocks = [];
839
+ for (const group of [...new Set(report.checks.map((check) => check.group))]) {
840
+ const lines = [group];
841
+ for (const check of report.checks.filter((c) => c.group === group)) {
842
+ lines.push(` ${check.status.padEnd(7)} ${check.name}`);
843
+ if (check.detail) lines.push(` ${check.detail}`);
844
+ }
845
+ blocks.push(lines.join("\n"));
846
+ }
847
+ const count = (status) => report.checks.filter((check) => check.status === status).length;
848
+ const counts = [
849
+ `${count("passed")} passed`,
850
+ ...count("skipped") ? [`${count("skipped")} skipped`] : [],
851
+ ...count("failed") ? [`${count("failed")} failed`] : []
852
+ ];
853
+ return `${blocks.join("\n\n")}\n\n${counts.join(", ")}`;
854
+ }
855
+ //#endregion
856
+ //#region src/cli/hooks.ts
857
+ /**
858
+ * Where each agent keeps hooks, and which of its events run ours. Claude Code
859
+ * and Codex read `{ hooks: { Stop: [{ hooks: [entry] }] } }`; Cursor reads
860
+ * `{ version: 1, hooks: { stop: [entry] } }`, and its stop can't block, so it's
861
+ * reminded with a follow-up message instead. Pi has no hook files: its hook is
862
+ * an extension we write whole (`piExtension`), in `$PI_CODING_AGENT_DIR/extensions`.
863
+ */
864
+ const AGENTS = {
865
+ claude: {
866
+ name: "Claude Code",
867
+ shape: "nested",
868
+ event: "Stop",
869
+ file: () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json")
870
+ },
871
+ codex: {
872
+ name: "Codex",
873
+ shape: "nested",
874
+ event: "Stop",
875
+ file: () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "hooks.json")
876
+ },
877
+ cursor: {
878
+ name: "Cursor",
879
+ shape: "flat",
880
+ event: "stop",
881
+ file: () => join(homedir(), ".cursor", "hooks.json")
882
+ },
883
+ pi: {
884
+ name: "Pi",
885
+ shape: "extension",
886
+ event: "agent_before_settle",
887
+ file: () => join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "extensions", "board.ts")
888
+ }
889
+ };
890
+ const ours = (entry) => / hook stop --agent /.test(entry.command ?? "");
891
+ function readConfig(path) {
892
+ try {
893
+ return JSON.parse(readFileSync(path, "utf8"));
894
+ } catch (error) {
895
+ if (error.code === "ENOENT") return {};
896
+ throw new Error(`can't read ${path}: ${error.message}`);
897
+ }
898
+ }
899
+ /** Written to a sibling, then renamed: a crash never leaves an agent's settings half-written. */
900
+ function writeConfig(path, config) {
901
+ mkdirSync(dirname(path), { recursive: true });
902
+ writeFileSync(`${path}.board-tmp`, `${JSON.stringify(config, null, 2)}\n`);
903
+ renameSync(`${path}.board-tmp`, path);
904
+ }
905
+ /** An event's entries without ours; a group left with no hooks goes too. */
906
+ function without(entries, shape) {
907
+ if (shape === "flat") return entries.filter((entry) => !ours(entry));
908
+ return entries.map((group) => ({
909
+ ...group,
910
+ hooks: (group.hooks ?? []).filter((h) => !ours(h))
911
+ })).filter((group) => group.hooks.length > 0);
912
+ }
913
+ /** The line of our Pi extension that holds the command it runs: how status finds it. */
914
+ const PI_COMMAND = /^const COMMAND = (".*")$/m;
915
+ /**
916
+ * Pi's turn-end hook, as an extension. Before Pi settles a completed run, it
917
+ * runs our command with what Claude Code's hook gets (the session, the
918
+ * directory, and whether this hook made it continue); a reminder comes back
919
+ * as a message, and Pi continues once to act on it. Pi's bash tool exports the
920
+ * same session as `PI_SESSION_ID`, so the agent's `vb reply` or `vb post` counts for it.
921
+ */
922
+ function piExtension(command) {
923
+ return `// Added by \`vb hooks install pi\`: reminds Pi, once per turn, to update the team's board.
924
+ // \`vb hooks uninstall pi\` removes it; reinstalling replaces this file.
925
+ import { spawn } from 'node:child_process'
926
+
927
+ const COMMAND = ${JSON.stringify(command)}
928
+
929
+ function run(cwd: string, input: string): Promise<string> {
930
+ return new Promise((resolve) => {
931
+ let out = ''
932
+ const child = spawn('/bin/sh', ['-c', COMMAND], { cwd, stdio: ['pipe', 'pipe', 'ignore'] })
933
+ const timer = setTimeout(() => child.kill(), 10_000)
934
+ child.stdout.on('data', (chunk) => (out += chunk))
935
+ child.on('error', () => resolve(''))
936
+ child.on('close', () => (clearTimeout(timer), resolve(out)))
937
+ child.stdin.on('error', () => {})
938
+ child.stdin.end(input)
939
+ })
940
+ }
941
+
942
+ export default function board(pi: any) {
943
+ let reminded = false
944
+ pi.on('agent_before_settle', async (event: any, ctx: any) => {
945
+ const continuing = reminded
946
+ reminded = false
947
+ if (event.outcome !== 'completed') return
948
+ try {
949
+ const input = JSON.stringify({ session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, stop_hook_active: continuing })
950
+ const reason = JSON.parse((await run(ctx.cwd, input)) || '{}').reason
951
+ if (typeof reason !== 'string' || !reason) return
952
+ reminded = true
953
+ return { entries: [...event.entries, { type: 'custom_message', customType: 'board', content: reason, display: true }], continue: true }
954
+ } catch {}
955
+ })
956
+ }
957
+ `;
958
+ }
959
+ /**
960
+ * The command our hook runs. `|| true`: whatever `cli` turns out to be (an older board without
961
+ * `hook` exits 2, which Claude Code and Codex read as "keep going"), the hook can't fail a turn.
962
+ */
963
+ const hookCommand = (agent, cli, flags = "") => `${cli} hook stop --agent ${agent}${flags} 2>/dev/null || true`;
964
+ /** Adds our hook, replacing an earlier one of ours; everything else in the file stays. Answers whether it changed. */
965
+ function installHook(agent, cli, flags = "") {
966
+ const spec = AGENTS[agent];
967
+ const command = hookCommand(agent, cli, flags);
968
+ if (spec.shape === "extension") {
969
+ const source = piExtension(command);
970
+ let current = null;
971
+ try {
972
+ current = readFileSync(spec.file(), "utf8");
973
+ } catch {}
974
+ if (current === source) return false;
975
+ if (current !== null && !current.startsWith("// Added by `vb hooks install pi`")) throw new Error(`${spec.file()} isn't board's: move it, then install again`);
976
+ mkdirSync(dirname(spec.file()), { recursive: true });
977
+ writeFileSync(`${spec.file()}.board-tmp`, source);
978
+ renameSync(`${spec.file()}.board-tmp`, spec.file());
979
+ return true;
980
+ }
981
+ const config = readConfig(spec.file());
982
+ const entry = spec.shape === "flat" ? {
983
+ command,
984
+ timeout: 10
985
+ } : { hooks: [{
986
+ type: "command",
987
+ command,
988
+ timeout: 10
989
+ }] };
990
+ const hooks = {
991
+ ...config.hooks,
992
+ [spec.event]: [...without(config.hooks?.[spec.event] ?? [], spec.shape), entry]
993
+ };
994
+ const next = {
995
+ ...spec.shape === "flat" ? { version: 1 } : {},
996
+ ...config,
997
+ hooks
998
+ };
999
+ if (JSON.stringify(next) === JSON.stringify(config)) return false;
1000
+ writeConfig(spec.file(), next);
1001
+ return true;
1002
+ }
1003
+ /** Removes our hook. A file that held only it (Cursor's, which we created) goes away with it. */
1004
+ function uninstallHook(agent) {
1005
+ const spec = AGENTS[agent];
1006
+ if (installedHook(agent) === null) return false;
1007
+ if (spec.shape === "extension") return unlinkSync(spec.file()), true;
1008
+ const config = readConfig(spec.file());
1009
+ const hooks = { ...config.hooks };
1010
+ const kept = without(hooks[spec.event] ?? [], spec.shape);
1011
+ if (kept.length > 0) hooks[spec.event] = kept;
1012
+ else delete hooks[spec.event];
1013
+ if (Object.keys(config).filter((key) => key !== "hooks" && key !== "version").length === 0 && Object.keys(hooks).length === 0) unlinkSync(spec.file());
1014
+ else writeConfig(spec.file(), {
1015
+ ...config,
1016
+ hooks
1017
+ });
1018
+ return true;
1019
+ }
1020
+ /** The command our hook runs for `agent`, if it's installed. */
1021
+ function installedHook(agent) {
1022
+ const spec = AGENTS[agent];
1023
+ if (spec.shape === "extension") try {
1024
+ const line = PI_COMMAND.exec(readFileSync(spec.file(), "utf8"));
1025
+ return line ? JSON.parse(line[1]) : null;
1026
+ } catch {
1027
+ return null;
1028
+ }
1029
+ const entries = readConfig(spec.file()).hooks?.[spec.event] ?? [];
1030
+ return (spec.shape === "flat" ? entries : entries.flatMap((group) => group.hooks ?? [])).find(ours)?.command ?? null;
1031
+ }
1032
+ /** A path under the home directory as `~/…`, the way people write it. */
1033
+ function tilde(path) {
1034
+ const home = homedir();
1035
+ return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
1036
+ }
1037
+ /** The CLI an installed hook runs: everything before ` hook stop`. */
1038
+ const cliOf = (command) => command.slice(0, command.indexOf(" hook stop "));
1039
+ /**
1040
+ * The reminder asks the agent to run `vb reply`, `vb post`, `vb read`, and `vb channels`;
1041
+ * where the agent keeps an allowlist, those run without a permission prompt.
1042
+ * Claude Code matches command prefixes; Cursor matches a command's name, so it
1043
+ * can allow only a `vb` on PATH. Codex has no command allowlist: its sandbox
1044
+ * decides (see `codexNetwork`).
1045
+ */
1046
+ const REMINDED = [
1047
+ "reply",
1048
+ "post",
1049
+ "read",
1050
+ "channels"
1051
+ ];
1052
+ /** Rules an earlier install added that this one doesn't: `vb update`, since removed. Uninstall drops them too. */
1053
+ function legacyRules(agent, cli) {
1054
+ return agent === "claude" ? [`Bash(${cli} update:*)`] : [];
1055
+ }
1056
+ function allowRules(agent, cli) {
1057
+ if (agent === "claude") return {
1058
+ file: AGENTS.claude.file(),
1059
+ rules: REMINDED.map((sub) => `Bash(${cli} ${sub}:*)`)
1060
+ };
1061
+ if (agent === "cursor" && cli === "vb") return {
1062
+ file: join(homedir(), ".cursor", "cli-config.json"),
1063
+ rules: ["Shell(vb)"]
1064
+ };
1065
+ return null;
1066
+ }
1067
+ /** Adds the rules a file doesn't have yet. Answers the ones it added. */
1068
+ function allow(file, rules) {
1069
+ const config = readConfig(file);
1070
+ const allowed = config.permissions?.allow ?? [];
1071
+ const added = rules.filter((rule) => !allowed.includes(rule));
1072
+ if (added.length > 0) writeConfig(file, {
1073
+ ...config,
1074
+ permissions: {
1075
+ ...config.permissions,
1076
+ allow: [...allowed, ...added]
1077
+ }
1078
+ });
1079
+ return added;
1080
+ }
1081
+ /** Removes the rules a file has. Answers whether it removed any. */
1082
+ function disallow(file, rules) {
1083
+ const config = readConfig(file);
1084
+ const allowed = config.permissions?.allow;
1085
+ if (!allowed?.some((rule) => rules.includes(rule))) return false;
1086
+ writeConfig(file, {
1087
+ ...config,
1088
+ permissions: {
1089
+ ...config.permissions,
1090
+ allow: allowed.filter((rule) => !rules.includes(rule))
1091
+ }
1092
+ });
1093
+ return true;
1094
+ }
1095
+ /**
1096
+ * Codex's default sandbox has no network, so `vb` can't reach the board. A
1097
+ * permission profile can allow just the board, and Vercel's API for its token.
1098
+ * In TOML a key after a `[table]` belongs to it, so `default_permissions` goes
1099
+ * at the top; the tables go anywhere (`[features]` merged into one that's there).
1100
+ */
1101
+ function codexNetwork(boardHost) {
1102
+ return {
1103
+ top: "default_permissions = \"board\"",
1104
+ tables: [
1105
+ "[features]",
1106
+ "network_proxy = true",
1107
+ "",
1108
+ "[permissions.board]",
1109
+ "extends = \":workspace\"",
1110
+ "",
1111
+ "[permissions.board.network]",
1112
+ "enabled = true",
1113
+ "",
1114
+ "[permissions.board.network.domains]",
1115
+ ...[boardHost ?? "<your board host>", "api.vercel.com"].map((host) => `"${host}" = "allow"`)
1116
+ ].join("\n")
1117
+ };
1118
+ }
1119
+ /** Names in words: `a`, `a or b`, `a, b, or c`. */
1120
+ const either = (names) => names.length < 3 ? names.join(" or ") : `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}`;
1121
+ /**
1122
+ * How an installed hook differs from what installing it again would write, as
1123
+ * short phrases (none when it's current): an older hook command or Pi
1124
+ * extension, rules for commands since removed, rules it doesn't have yet.
1125
+ * It keeps the CLI the hook runs, which `install` may choose differently.
1126
+ */
1127
+ function outdated(agent, installed) {
1128
+ const spec = AGENTS[agent];
1129
+ const cli = cliOf(installed);
1130
+ const problems = [];
1131
+ if (installed !== hookCommand(agent, cli)) problems.push("runs an older hook command");
1132
+ if (spec.shape === "extension") {
1133
+ let source = "";
1134
+ try {
1135
+ source = readFileSync(spec.file(), "utf8");
1136
+ } catch {}
1137
+ if (source !== piExtension(installed)) problems.push("the extension is from another version");
1138
+ }
1139
+ const rules = allowRules(agent, cli);
1140
+ if (rules) {
1141
+ const allowed = readConfig(rules.file).permissions?.allow ?? [];
1142
+ const sub = (rule) => {
1143
+ const inner = /^\w+\((.*?)(?::\*)?\)$/.exec(rule)?.[1] ?? rule;
1144
+ return inner.startsWith(`${cli} `) ? inner.slice(cli.length + 1) : inner;
1145
+ };
1146
+ const gone = legacyRules(agent, cli).filter((rule) => allowed.includes(rule));
1147
+ const missing = rules.rules.filter((rule) => !allowed.includes(rule));
1148
+ if (gone.length > 0) problems.push(`allows ${either(gone.map(sub))}, which ${gone.length === 1 ? "is" : "are"} gone`);
1149
+ if (missing.length > 0) problems.push(`doesn't allow ${either(missing.map(sub))}`);
1150
+ }
1151
+ return problems;
1152
+ }
1153
+ /**
1154
+ * In the temp directory, which agents' sandboxes let commands write (Codex's
1155
+ * doesn't allow ~/.config): a marker only has to last a few turns, and the
1156
+ * system clears it with the rest of its temp files. `XDG_RUNTIME_DIR`, where
1157
+ * there is one, is the user's own.
1158
+ */
1159
+ const turnsDir = () => join(process.env.XDG_RUNTIME_DIR || tmpdir(), `board-turns-${process.getuid?.() ?? "user"}`);
1160
+ const turnsFile = (session) => join(turnsDir(), `${session.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
1161
+ function readTurns(session) {
1162
+ try {
1163
+ return JSON.parse(readFileSync(turnsFile(session), "utf8"));
1164
+ } catch {
1165
+ return {};
1166
+ }
1167
+ }
1168
+ /** The marker directory, only if it's ours: in a shared /tmp, someone else could have made it first. */
1169
+ function ownTurnsDir() {
1170
+ const dir = turnsDir();
1171
+ mkdirSync(dir, {
1172
+ recursive: true,
1173
+ mode: 448
1174
+ });
1175
+ const { uid, mode } = statSync(dir);
1176
+ if (process.getuid && uid !== process.getuid()) throw new Error(`${dir} belongs to someone else`);
1177
+ if ((mode & 63) !== 0) chmodSync(dir, 448);
1178
+ return dir;
1179
+ }
1180
+ function writeTurns(session, turns) {
1181
+ ownTurnsDir();
1182
+ writeFileSync(turnsFile(session), JSON.stringify(turns));
1183
+ }
1184
+ /** A write to the board from an agent session: this turn needs no reminder. Best effort: the write already happened. */
1185
+ function markPosted(session, now = Date.now()) {
1186
+ try {
1187
+ writeTurns(session, {
1188
+ ...readTurns(session),
1189
+ posted: now
1190
+ });
1191
+ } catch {}
1192
+ }
1193
+ /**
1194
+ * The turn-end decision: let the agent stop, or remind it once to update the
1195
+ * board. It stops freely when it wrote to the board since its last turn
1196
+ * ended, when it was already reminded this turn, or when the agent says a
1197
+ * hook made it continue (`continuing`), so it can never loop.
1198
+ */
1199
+ function decideStop(session, continuing, now = Date.now()) {
1200
+ const turns = readTurns(session);
1201
+ const since = turns.settled ?? 0;
1202
+ if (continuing || (turns.posted ?? 0) > since || (turns.asked ?? 0) > since) {
1203
+ writeTurns(session, {
1204
+ ...turns,
1205
+ settled: now
1206
+ });
1207
+ return "stop";
1208
+ }
1209
+ writeTurns(session, {
1210
+ ...turns,
1211
+ asked: now
1212
+ });
1213
+ return "ask";
1214
+ }
1215
+ /**
1216
+ * The reminder, with the exact commands to run. It doesn't pick a channel: the
1217
+ * agent replies in a thread it's working in, or finds the channel that fits.
1218
+ * Finishing without a write is fine, since the hook asks only once a turn.
1219
+ */
1220
+ function reminder(cli) {
1221
+ return [
1222
+ "You didn't update the team's board this turn.",
1223
+ `If you changed anything or made progress, share a 1–3 sentence summary of what you did and what's next where the team will see it: ${cli} reply <id> "<text>" in a board thread you're working in, or ${cli} post <channel> "<text>" in the channel that fits (${cli} channels lists them, ${cli} read <channel> shows what's there). Add --attach <file> for proof, like a test log.`,
1224
+ `If you want to store memory, you can post to brain-<username>. Channels are public, so never store sensitive information.`,
1225
+ "If this turn changed nothing worth sharing (say, you just answered a question), just finish. Don't include the prompt, long output, or secrets."
1226
+ ].join(" ");
1227
+ }
1228
+ /** Marks the offer made for `board`. True only for the call that made it, and never when it can't be saved. */
1229
+ function claimLinkOffer(board) {
1230
+ const file = join(configDir(), "link-offered", createHash("sha256").update(board).digest("hex").slice(0, 32));
1231
+ try {
1232
+ mkdirSync(dirname(file), {
1233
+ recursive: true,
1234
+ mode: 448
1235
+ });
1236
+ writeFileSync(file, "", { flag: "wx" });
1237
+ return true;
1238
+ } catch {
1239
+ return false;
1240
+ }
1241
+ }
1242
+ function linkOffer(cli) {
1243
+ return [
1244
+ `One more thing, this time only: run ${cli} identity.`,
1245
+ "If it says some are not linked (say, slack or github), ask the person in one short question whether they'd like to link them, so the board knows their messages there are theirs and mentions reach them.",
1246
+ `If they say yes, run ${cli} identity link <key> --no-wait for each and give them the sign-in URL or code it prints: only they should open or post it.`,
1247
+ "If they say no, or there's nothing to link, drop it. This won't come up again."
1248
+ ].join(" ");
1249
+ }
1250
+ /**
1251
+ * The reminder as each agent reads it from the hook's stdout. Claude Code
1252
+ * shows a stop's `additionalContext` once, as hook feedback, and continues the
1253
+ * turn; a blocked stop would also show it as a hook error. Codex and Pi's
1254
+ * extension read a blocked stop's `reason`; Cursor, whose stop can't block,
1255
+ * a `followup_message`.
1256
+ */
1257
+ function stopOutput(agent, text) {
1258
+ if (agent === "claude") return { hookSpecificOutput: {
1259
+ hookEventName: "Stop",
1260
+ additionalContext: text
1261
+ } };
1262
+ if (agent === "cursor") return { followup_message: text };
1263
+ return {
1264
+ decision: "block",
1265
+ reason: text
1266
+ };
1267
+ }
1268
+ //#endregion
1269
+ //#region src/cli/main.ts
1270
+ const HELP = `board ${VERSION}: a message board you mount in one route
1271
+
1272
+ usage: board <command> [options] (also installed as \`vb\`)
1273
+
1274
+ whoami who am I, on which board, with which token, and what I can do
1275
+ link <url> link this project to a board (.board/config.json)
1276
+ use <url> save the default board
1277
+
1278
+ channels channels, grouped by path prefix
1279
+ actors actors and their handles
1280
+ identity your linked Slack and GitHub accounts
1281
+ identity link <key> [--code] [--no-wait] link your account on sync entry <key> (e.g. slack): sign in, or post a code from it
1282
+ identity unlink <account> unlink one, by handle or actor ID
1283
+
1284
+ read [channel] [filters] [--limit N] the latest threads, one each: root, replies, latest reply
1285
+ (no channel: every channel)
1286
+ read <id> one thread, every message (filters: the root and the replies that match)
1287
+
1288
+ post <channel|id> ["<text>"] [--label k=v]... [--attach <file>]... [--sync <target>]... [--no-sync] [--id <key>]
1289
+ a new thread in a channel, or with an id, a reply in its thread
1290
+ reply <id> ["<text>"] [--attach <file>]... [--id <key>] [--no-sync]
1291
+ schedule <channel|id> ["<text>"] (--in 1d | --at <time> | --every 30m) [--until <time> | --for 24h | --times N]
1292
+ [--label k=v]... [--attach <file>]... [--no-sync]
1293
+ post later, as you: once, or repeating until a stop or until canceled
1294
+ schedules [id] [--by <handle>] [--channel <c>] [--all]
1295
+ pending schedules (a thread's, with an id); --all adds done and canceled
1296
+ schedule cancel <schedule> stop one, e.g. schedule cancel s12 (yours, or with manage)
1297
+ edit <id> "<text>"
1298
+ delete <id>
1299
+ react <id> <emoji> [--remove]
1300
+ label <id> k=v... [--unset k]...
1301
+ typing <id> [status] show typing in the thread's synced platform threads (marks WhatsApp read);
1302
+ Slack shows the status, if given, instead of "Typing…"
1303
+ sync <id> <target> attach the thread to e.g. slack:#incidents, backfilling it
1304
+ inbox <key> [channel] [--limit N] raw platform events, for debugging sync
1305
+ sources the board's sources (its sync entries: slack, github, …)
1306
+ sources <key> read <channel|thread|link> [--limit N]
1307
+ read a source's channel, thread, or message link without syncing it,
1308
+ e.g. sources slack read '#general', or a Slack permalink (full access only)
1309
+ download <attachment> [path] save an attachment (its name, here, by default; - for stdout)
1310
+ conformance [--channel <c>] check that the board speaks the protocol: posts to conformance,
1311
+ kept on the board, and deletes what it posted (1 if a check fails)
1312
+
1313
+ hooks [install|uninstall|status] [claude|codex|cursor|pi]...
1314
+ after a turn that didn't update the board, remind the agent to (linked projects only)
1315
+
1316
+ tail [channel] [filters] [--all] live, until Ctrl-C
1317
+ wait [channel] [filters] [--timeout 30m] block for the next match, print it, then exit (124 on timeout)
1318
+
1319
+ inspect [--port N] [--no-open] the board in your browser, as you: read, post, react (local only)
1320
+ serve [--port N] [--dir <dir>] [--no-inspect] [--no-open]
1321
+ run a board on this machine (127.0.0.1:4100, no sync), link this
1322
+ project to it, and open the inspector; its log lives in .board/,
1323
+ and you're local:user:<your OS user> (or --handle)
1324
+
1325
+ filters: --re <id> --by <handle> --from <source> --label k[=v|w] --text <words> --mentions [@handle] --after <id>
1326
+ labels match a message or its thread's root; repeated flags all must match
1327
+ global: --url <url> --as <profile> --avatar <url> --human --handle <h> --json
1328
+ json: one object per line (JSONL): { message, replies, last } for read <channel>, a message for read <id>
1329
+ text: pass "-" to read it from stdin; it may be left out when you --attach
1330
+ env: BOARD_URL BOARD_TOKEN BOARD_AS BOARD_AVATAR BOARD_HANDLE`;
1331
+ var UsageError = class extends Error {};
1332
+ const OPTIONS = {
1333
+ ...FILTER_FLAGS,
1334
+ url: { type: "string" },
1335
+ as: { type: "string" },
1336
+ avatar: { type: "string" },
1337
+ human: { type: "boolean" },
1338
+ handle: { type: "string" },
1339
+ json: { type: "boolean" },
1340
+ help: {
1341
+ type: "boolean",
1342
+ short: "h"
1343
+ },
1344
+ version: {
1345
+ type: "boolean",
1346
+ short: "v"
1347
+ },
1348
+ unset: {
1349
+ type: "string",
1350
+ multiple: true
1351
+ },
1352
+ id: { type: "string" },
1353
+ "no-sync": { type: "boolean" },
1354
+ sync: {
1355
+ type: "string",
1356
+ multiple: true
1357
+ },
1358
+ attach: {
1359
+ type: "string",
1360
+ multiple: true
1361
+ },
1362
+ channel: { type: "string" },
1363
+ agent: { type: "string" },
1364
+ remove: { type: "boolean" },
1365
+ after: { type: "string" },
1366
+ limit: { type: "string" },
1367
+ all: { type: "boolean" },
1368
+ timeout: { type: "string" },
1369
+ port: { type: "string" },
1370
+ "no-open": { type: "boolean" },
1371
+ "no-inspect": { type: "boolean" },
1372
+ dir: { type: "string" },
1373
+ code: { type: "boolean" },
1374
+ "no-wait": { type: "boolean" },
1375
+ in: { type: "string" },
1376
+ at: { type: "string" },
1377
+ every: { type: "string" },
1378
+ until: { type: "string" },
1379
+ for: { type: "string" },
1380
+ times: { type: "string" }
1381
+ };
1382
+ const MARK = "\0";
1383
+ /**
1384
+ * Attachment IDs are base64url, so boards minted some that start with `-`,
1385
+ * which `parseArgs` reads as a flag. Where `download` takes its ID, a token
1386
+ * shaped like one is the ID: this marks it to parse as a positional.
1387
+ */
1388
+ function markAttachmentId(args) {
1389
+ const positionals = [];
1390
+ for (let i = 0; i < args.length; i++) {
1391
+ const arg = args[i];
1392
+ if (arg === "--") break;
1393
+ if (!arg.startsWith("-") || arg === "-") positionals.push(arg);
1394
+ else if (positionals.length === 1 && positionals[0] === "download" && AttachmentId.safeParse(arg).success) return [
1395
+ ...args.slice(0, i),
1396
+ `${MARK}${arg}`,
1397
+ ...args.slice(i + 1)
1398
+ ];
1399
+ else if (!arg.includes("=") && OPTIONS[arg.slice(2)]?.type === "string") i++;
1400
+ }
1401
+ return args;
1402
+ }
1403
+ /** Commands that are gone, and what to run instead. Their flags don't matter: an old hook's `vb update --skip` gets this too. */
1404
+ const REMOVED = {
1405
+ update: "`vb update` is gone: reply in the thread you're working in (vb reply <id>), or post where it fits (vb post <channel>)",
1406
+ stuck: "`vb stuck` is gone: a board on a2() keeps its dead letters in A2, on the backend's `servers`"
1407
+ };
1408
+ /** The command word: the first argument that isn't a flag or a string flag's value. */
1409
+ function commandWord(args) {
1410
+ for (let i = 0; i < args.length; i++) {
1411
+ const arg = args[i];
1412
+ if (arg === "--") return args[i + 1];
1413
+ if (!arg.startsWith("-") || arg === "-") return arg;
1414
+ if (!arg.includes("=") && OPTIONS[arg.slice(2)]?.type === "string") i++;
1415
+ }
1416
+ }
1417
+ const parsed = (() => {
1418
+ const args = withMentions(process.argv.slice(2));
1419
+ const removed = REMOVED[commandWord(args) ?? ""];
1420
+ try {
1421
+ if (removed) throw new UsageError(removed);
1422
+ return parseArgs({
1423
+ args: markAttachmentId(args),
1424
+ allowPositionals: true,
1425
+ options: OPTIONS
1426
+ });
1427
+ } catch (error) {
1428
+ const unknown = error.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" && /'([^']+)'/.exec(error.message)?.[1];
1429
+ process.stderr.write(terminalText(`error: ${unknown ? `unknown option: ${unknown} (run \`board help\`; text starting with - goes after --, as in -- "${unknown}")` : error.message}`) + "\n");
1430
+ return null;
1431
+ }
1432
+ })();
1433
+ const { values: flags, positionals: marked } = parsed ?? {
1434
+ values: {},
1435
+ positionals: []
1436
+ };
1437
+ const positionals = marked.map((arg) => arg.startsWith(MARK) ? arg.slice(1) : arg);
1438
+ const out = (text) => process.stdout.write(`${terminalText(text)}\n`);
1439
+ const printJson = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
1440
+ /** A message's ID as you'd write it: `#812` or `812`. It's passed to the board as it is. */
1441
+ function messageId(value, what = "message id") {
1442
+ const id = value?.replace(/^#/, "") ?? "";
1443
+ if (!/^[^\s/]+$/.test(id)) throw new UsageError(`expected a ${what}, got: ${value ?? "(nothing)"}`);
1444
+ return id;
1445
+ }
1446
+ /** Whether an argument names a message rather than a channel: `#<id>`, or a bare number. */
1447
+ const isMessageRef = (value) => /^#[^\s/]+$/.test(value) || /^\d+$/.test(value);
1448
+ function labelPairs(values) {
1449
+ const pairs = {};
1450
+ for (const value of values ?? []) {
1451
+ const at = value.indexOf("=");
1452
+ if (at <= 0) throw new UsageError(`labels look like key=value, got: ${value}`);
1453
+ pairs[value.slice(0, at)] = value.slice(at + 1);
1454
+ }
1455
+ return pairs;
1456
+ }
1457
+ async function text(value) {
1458
+ if (value === void 0) throw new UsageError("missing message text");
1459
+ if (value !== "-") return value;
1460
+ const chunks = [];
1461
+ for await (const chunk of process.stdin) chunks.push(chunk);
1462
+ return Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
1463
+ }
1464
+ /** Uploads each `--attach` file, checking them all before sending any. */
1465
+ async function attachments(board) {
1466
+ const paths = flags.attach ?? [];
1467
+ for (const path of paths) {
1468
+ const info = await stat(path).catch(() => null);
1469
+ if (!info?.isFile()) throw new UsageError(`no such file: ${path}`);
1470
+ if (info.size > 104857600) throw new UsageError(`${path} is over ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB, the most an attachment can be`);
1471
+ }
1472
+ const uploaded = [];
1473
+ for (const path of paths) uploaded.push(await board.upload({
1474
+ data: new Uint8Array(await readFile(path)),
1475
+ name: basename(path)
1476
+ }));
1477
+ return uploaded;
1478
+ }
1479
+ /** `s12` or `12`. */
1480
+ function scheduleId(value) {
1481
+ const id = value?.replace(/^s(?=\d)/i, "") ?? "";
1482
+ if (!/^[^\s/]+$/.test(id)) throw new UsageError(`expected a schedule id like s12, got: ${value ?? "(nothing)"}`);
1483
+ return id;
1484
+ }
1485
+ /** A time flag: anything `Date` reads, as ISO. */
1486
+ function instant(flag, value) {
1487
+ const time = new Date(value);
1488
+ if (Number.isNaN(time.getTime())) throw new UsageError(`--${flag} takes a time like 2026-10-08T09:00:00Z, got: ${value}`);
1489
+ return time.toISOString();
1490
+ }
1491
+ function filters(channel) {
1492
+ return {
1493
+ ...channel ? { channel } : {},
1494
+ ...flags.re ? { re: messageId(flags.re) } : {},
1495
+ ...flags.by ? { by: flags.by.replace(/^@/, "") } : {},
1496
+ ...flags.mentions ? { mentions: flags.mentions.replace(/^@/, "") } : {},
1497
+ ...flags.label?.length ? { label: flags.label } : {},
1498
+ ...flags.from ? { from: flags.from } : {},
1499
+ ...flags.text ? { text: flags.text } : {},
1500
+ ...flags.after !== void 0 ? { after: flags.after.replace(/^#/, "") } : {}
1501
+ };
1502
+ }
1503
+ /**
1504
+ * The board this command talks to, `--url`'s or the one the environment points at, as `--as`
1505
+ * says: a handle on it, and how it reaches it (the credentials `vb` finds, `--handle`'s handle).
1506
+ */
1507
+ function connection(url = flags.url, options = {}) {
1508
+ const at = (url ?? boardUrl()).replace(/\/+$/, "");
1509
+ const handle = options.handle ?? flags.handle;
1510
+ const transport = connect(at, {
1511
+ ...options.token !== void 0 ? { token: options.token } : {},
1512
+ ...handle ? { handle } : {}
1513
+ });
1514
+ const board$1 = board({ backend: http(at, { fetch: transport.fetch }) });
1515
+ const profile = flags.human ? null : flags.as ? flags.avatar ? {
1516
+ name: flags.as,
1517
+ avatar: flags.avatar
1518
+ } : flags.as : void 0;
1519
+ return {
1520
+ url: at,
1521
+ transport,
1522
+ board: profile !== void 0 ? board$1.as(profile) : board$1
1523
+ };
1524
+ }
1525
+ /** A handle on the board this command talks to. */
1526
+ const client = () => connection().board;
1527
+ /**
1528
+ * What names an agent's turns, for the turn-end hook: its session, or for
1529
+ * Cursor, whose shell has no session ID, the project it works in.
1530
+ */
1531
+ const turnKey = (agent, session) => session ?? (agent === "cursor" ? `cursor@${projectDir()}` : null);
1532
+ /** A write from an agent session counts as this turn's board update. */
1533
+ function wrote() {
1534
+ const detected = flags.human ? null : detectAgent();
1535
+ const key = detected ? turnKey(detected.profile, detected.session) : null;
1536
+ if (key) markPosted(key);
1537
+ }
1538
+ const fromNpx = (path) => /[\\/]_npx[\\/]/.test(path);
1539
+ /** The first `vb` on PATH outside npx's cache, which is gone once npx exits. */
1540
+ function globalVb() {
1541
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
1542
+ const candidate = join(dir, "vb");
1543
+ if (existsSync(candidate) && !fromNpx(realpathSync(candidate))) return candidate;
1544
+ }
1545
+ return null;
1546
+ }
1547
+ /**
1548
+ * Whether `bin` runs the CLI at `self`: a symlink to it (npm), or a shell
1549
+ * shim that execs node on it (pnpm, through `$basedir`- or `$basedir_abs`-relative paths).
1550
+ */
1551
+ function runsCli(bin, self) {
1552
+ try {
1553
+ const real = realpathSync(bin);
1554
+ if (real === self) return true;
1555
+ if (statSync(real).size > 65536) return false;
1556
+ const script = readFileSync(real, "utf8");
1557
+ if (!script.startsWith("#!")) return false;
1558
+ const basedir = dirname(bin);
1559
+ const paths = [...[...script.matchAll(/"([^"\n]+)"/g)].map(([, quoted]) => quoted.replace(/^\$(?:\{basedir(?:_abs)?\}|basedir(?:_abs)?)(?=\/)/, basedir)), ...[...script.matchAll(/^# cmd-shim-target=(.+)$/gm)].map(([, target]) => target.trim())];
1560
+ for (const path of paths) if (isAbsolute(path) && existsSync(path) && realpathSync(path) === self) return true;
1561
+ } catch {}
1562
+ return false;
1563
+ }
1564
+ /**
1565
+ * How an agent's hook should run this CLI, long after this command exits:
1566
+ * `vb` when that's this CLI; under npx, a global `vb` of this version, else
1567
+ * this version through npx; otherwise this CLI's full path.
1568
+ */
1569
+ function cliCommand() {
1570
+ const self = realpathSync(process.argv[1]);
1571
+ const installed = globalVb();
1572
+ if (!fromNpx(self)) {
1573
+ if (installed && runsCli(installed, self)) return { command: "vb" };
1574
+ const quote = (part) => /^[\w./-]+$/.test(part) ? part : `'${part.replace(/'/g, `'\\''`)}'`;
1575
+ return { command: [process.execPath, self].map(quote).join(" ") };
1576
+ }
1577
+ let version = "";
1578
+ try {
1579
+ version = installed ? execFileSync(installed, ["--version"], {
1580
+ encoding: "utf8",
1581
+ timeout: 5e3,
1582
+ stdio: [
1583
+ "ignore",
1584
+ "pipe",
1585
+ "ignore"
1586
+ ]
1587
+ }).trim() : "";
1588
+ } catch {}
1589
+ if (version === VERSION) return { command: "vb" };
1590
+ return {
1591
+ command: `npx -y @vercel/board@${VERSION}`,
1592
+ note: `the hook runs board ${VERSION} through npx${installed ? ` (your vb is ${version || "older"})` : ""}; \`npm i -g @vercel/board\` makes it start faster`
1593
+ };
1594
+ }
1595
+ /**
1596
+ * The board version a hook's CLI runs: pinned in an npx command, else what it
1597
+ * answers to `--version` (the first `vb` on PATH, for plain `vb`). Null when
1598
+ * it doesn't answer with one.
1599
+ */
1600
+ function hookVersion(cli) {
1601
+ const pinned = /^npx -y @vercel\/board@(\S+)$/.exec(cli);
1602
+ if (pinned) return pinned[1];
1603
+ try {
1604
+ const answer = execFileSync("/bin/sh", ["-c", `${cli} --version`], {
1605
+ encoding: "utf8",
1606
+ timeout: 5e3,
1607
+ stdio: [
1608
+ "ignore",
1609
+ "pipe",
1610
+ "ignore"
1611
+ ]
1612
+ }).trim();
1613
+ return /^\d+\.\d+\.\d+\S*$/.test(answer) ? answer : null;
1614
+ } catch {
1615
+ return null;
1616
+ }
1617
+ }
1618
+ /** Whether version `a` comes before `b`, by major, minor, and patch. */
1619
+ function olderVersion(a, b) {
1620
+ const parts = (version) => version.split(/[.-]/).slice(0, 3).map(Number);
1621
+ const [x, y] = [parts(a), parts(b)];
1622
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return (x[i] ?? 0) < (y[i] ?? 0);
1623
+ return false;
1624
+ }
1625
+ async function readStdin() {
1626
+ const chunks = [];
1627
+ for await (const chunk of process.stdin) chunks.push(chunk);
1628
+ return Buffer.concat(chunks).toString("utf8");
1629
+ }
1630
+ function hookAgents(names) {
1631
+ const known = Object.keys(AGENTS);
1632
+ for (const name of names) if (!known.includes(name)) throw new UsageError(`unknown agent: ${name} (known: ${known.join(", ")})`);
1633
+ return names.length ? names : known;
1634
+ }
1635
+ /** A write went through, but some handles in it matched no one: they stayed plain text. */
1636
+ function warnUnknownMentions(handles) {
1637
+ if (!handles?.length) return;
1638
+ const names = handles.map((handle) => `@${handle}`);
1639
+ const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
1640
+ const one = names.length === 1;
1641
+ process.stderr.write(terminalText(`warning: ${list} ${one ? "matches" : "match"} no one on the board, so ${one ? "it stays" : "they stay"} plain text and ${one ? "notifies" : "notify"} no one`) + "\n");
1642
+ }
1643
+ function printMessage(message) {
1644
+ if (flags.json) printJson(message);
1645
+ else out(String(message.id));
1646
+ }
1647
+ function printEvent(event) {
1648
+ if (flags.json) printJson(event);
1649
+ else out(formatEvent(event));
1650
+ }
1651
+ /** Best-effort: the URL is printed either way. */
1652
+ /** `url`, when it's an http or https URL as `new URL` reads it: the only kind the CLI opens, so a board can't hand the opener a flag or another scheme. */
1653
+ function browsable(url) {
1654
+ try {
1655
+ const parsed = new URL(url);
1656
+ return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.href : null;
1657
+ } catch {
1658
+ return null;
1659
+ }
1660
+ }
1661
+ /** Opens an http(s) URL in the browser, as one argument to the opener: no shell reads it, Windows' included. */
1662
+ function openBrowser(url) {
1663
+ const at = browsable(url);
1664
+ if (at === null) return;
1665
+ const [command, ...args] = process.platform === "darwin" ? ["open", at] : process.platform === "win32" ? [
1666
+ "rundll32",
1667
+ "url.dll,FileProtocolHandler",
1668
+ at
1669
+ ] : ["xdg-open", at];
1670
+ const child = spawn(command, args, {
1671
+ detached: true,
1672
+ stdio: "ignore"
1673
+ });
1674
+ child.on("error", () => {});
1675
+ child.unref();
1676
+ }
1677
+ /** Writes the board link: the project's `.board/` (kept out of Git), or the saved default when `root` is null. */
1678
+ async function saveLink(root, url, extra = {}) {
1679
+ const directory = root === null ? configDir() : join(root, ".board");
1680
+ if (root !== null) {
1681
+ const path = join(root, ".gitignore");
1682
+ const ignore = await readFile(path, "utf8").catch((error) => {
1683
+ if (error.code === "ENOENT") return "";
1684
+ throw error;
1685
+ });
1686
+ if (!ignore.split(/\r?\n/).some((line) => /^\/?\.board\/?$/.test(line))) await appendFile(path, `${ignore && !ignore.endsWith("\n") ? "\n" : ""}.board/\n`);
1687
+ }
1688
+ await mkdir(directory, {
1689
+ recursive: true,
1690
+ mode: 448
1691
+ });
1692
+ await writeFile(join(directory, "config.json"), `${JSON.stringify({
1693
+ url,
1694
+ ...extra
1695
+ }, null, 2)}\n`, { mode: 384 });
1696
+ }
1697
+ /** Whether the project's link was written by `vb serve` (`"serve": true`), so a new run may move it. */
1698
+ function linkedByServe(root) {
1699
+ try {
1700
+ return JSON.parse(readFileSync(join(root, ".board", "config.json"), "utf8")).serve === true;
1701
+ } catch {
1702
+ return false;
1703
+ }
1704
+ }
1705
+ function portFlag() {
1706
+ const port = flags.port === void 0 ? void 0 : Number(flags.port);
1707
+ if (port !== void 0 && !(Number.isInteger(port) && port >= 0 && port <= 65535)) throw new UsageError(`expected a port, got: ${flags.port}`);
1708
+ return port;
1709
+ }
1710
+ /** How long `identity link` waits for the sign-in or the code. */
1711
+ const LINK_WAIT_MS = 6e5;
1712
+ async function identity(args) {
1713
+ const board = client();
1714
+ const [sub, target] = args;
1715
+ if (sub === void 0) {
1716
+ const view = await board.identity();
1717
+ if (flags.json) return printJson(view), 0;
1718
+ if (view.links.length === 0) out("no linked accounts");
1719
+ for (const link of view.links) out(`${link.handle ? `@${link.handle} ` : ""}${link.actor} by ${link.method === "code" ? "code" : "sign-in"} · ${link.at}`);
1720
+ if (view.unlinked?.length) out(`not linked ${view.unlinked.join(", ")} (vb identity link <key>)`);
1721
+ return 0;
1722
+ }
1723
+ if (sub === "unlink") {
1724
+ if (!target || args.length !== 2) throw new UsageError("usage: board identity unlink <account> (a handle or actor ID from `vb identity`)");
1725
+ const result = await board.unlinkAccount(target);
1726
+ if (flags.json) printJson(result);
1727
+ else out(`unlinked ${result.actor}`);
1728
+ return 0;
1729
+ }
1730
+ if (sub !== "link" || !target || args.length !== 2) throw new UsageError("usage: board identity [link <key> [--code] [--no-wait] | unlink <account>]");
1731
+ const before = new Set((await board.identity()).links.map((link) => `${link.actor}|${link.at}`));
1732
+ const platform = (await board.discovery().catch(() => null))?.sync.find((entry) => entry.key === target)?.platform ?? target;
1733
+ const { code, expiresAt, signIn } = await board.syncCode(target);
1734
+ const consent = signIn !== void 0 && !flags.code;
1735
+ if (consent && browsable(signIn) === null) throw new Error(`the board's sign-in isn't an http or https URL: ${JSON.stringify(signIn)}`);
1736
+ if (flags.json) printJson({
1737
+ sync: target,
1738
+ platform,
1739
+ code,
1740
+ expiresAt,
1741
+ ...signIn !== void 0 ? { signIn } : {}
1742
+ });
1743
+ const until = Date.now() + LINK_WAIT_MS;
1744
+ const sleep = () => new Promise((resolve) => setTimeout(resolve, 2e3));
1745
+ /** An account linked on that platform since this run began: where the code or the sign-in landed. */
1746
+ const linkedSince = async () => (await board.identity()).links.find((link) => link.actor.startsWith(`${platform}:user:`) && !before.has(`${link.actor}|${link.at}`));
1747
+ if (consent) {
1748
+ if (!flags.json) {
1749
+ out(`Sign in to ${platform} as yourself to link it (and to post as you there):\n\n ${signIn}\n`);
1750
+ out(`Only open it yourself: whoever completes it links their account to you.`);
1751
+ }
1752
+ if (!flags["no-open"] && !flags.json) openBrowser(signIn);
1753
+ } else if (!flags.json) {
1754
+ const minutes = Math.round((Date.parse(expiresAt) - Date.now()) / 6e4);
1755
+ out(`From your ${platform} account, post this as a message anywhere the board syncs, within ${minutes} minutes:\n\n ${code}\n`);
1756
+ }
1757
+ if (flags["no-wait"]) return 0;
1758
+ while (Date.now() < Math.min(until, consent ? Infinity : Date.parse(expiresAt))) {
1759
+ await sleep();
1760
+ const linked = await linkedSince();
1761
+ if (linked) {
1762
+ if (flags.json) printJson(linked);
1763
+ else out(`linked ${linked.handle ? `@${linked.handle} ` : ""}(${linked.actor})`);
1764
+ return 0;
1765
+ }
1766
+ }
1767
+ process.stderr.write("timed out\n");
1768
+ return 124;
1769
+ }
1770
+ async function run(command, args) {
1771
+ switch (command) {
1772
+ case void 0:
1773
+ case "help":
1774
+ out(HELP);
1775
+ return 0;
1776
+ case "link":
1777
+ case "use": {
1778
+ const url = args[0];
1779
+ if (!url || args.length !== 1) throw new UsageError(`usage: board ${command} <url>`);
1780
+ const discovery = await connection(url).board.discovery();
1781
+ const root = command === "link" ? projectDir() : null;
1782
+ await saveLink(root, discovery.url);
1783
+ out(root === null ? `using ${discovery.url}` : `linked ${root} to ${discovery.url}`);
1784
+ return 0;
1785
+ }
1786
+ case "whoami": {
1787
+ const { board, url, transport } = connection();
1788
+ const me = await board.me();
1789
+ const source = transport.source();
1790
+ if (flags.json) {
1791
+ printJson({
1792
+ ...me,
1793
+ board: url,
1794
+ source
1795
+ });
1796
+ return 0;
1797
+ }
1798
+ const name = me.profile !== null ? `${me.name} via ${me.handle.split("/")[0]}` : me.name;
1799
+ out(`${name} (${me.actor})`);
1800
+ const rows = [["board", url]];
1801
+ if (source) rows.push(["source", source]);
1802
+ if (me.expiresAt) {
1803
+ const minutes = Math.max(0, Math.round((Date.parse(me.expiresAt) - Date.now()) / 6e4));
1804
+ rows.push(["token", `expires in ${Math.floor(minutes / 60)}h ${minutes % 60}m`]);
1805
+ }
1806
+ if (me.session) rows.push(["session", me.session]);
1807
+ (me.can === void 0 ? [] : formatAccess(me.can)).forEach((line, index) => rows.push([index === 0 ? "can" : "", line]));
1808
+ const width = Math.max(...rows.map(([subject]) => subject.length));
1809
+ for (const [subject, value] of rows) out(`${subject.padEnd(width)} ${value}`);
1810
+ return 0;
1811
+ }
1812
+ case "conformance": {
1813
+ if (args.length > 0) throw new UsageError("usage: board conformance [--channel <c>]");
1814
+ const report = await conformance(client(), flags.channel ? { channel: flags.channel } : {});
1815
+ if (flags.json) printJson(report);
1816
+ else out(formatConformance(report));
1817
+ return report.ok ? 0 : 1;
1818
+ }
1819
+ case "channels": {
1820
+ const channels = await client().channels();
1821
+ if (flags.json) return channels.forEach(printJson), 0;
1822
+ if (channels.length === 0) return out("no channels yet"), 0;
1823
+ let group = "";
1824
+ for (const channel of channels) {
1825
+ const prefix = channel.path.includes("/") ? channel.path.slice(0, channel.path.lastIndexOf("/") + 1) : "";
1826
+ if (prefix !== group) {
1827
+ if (prefix) out(prefix);
1828
+ group = prefix;
1829
+ }
1830
+ const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
1831
+ out(`${prefix ? " " : ""}${channel.path} ${count(channel.threads, "thread", "threads")} · ${count(channel.messages, "message", "messages")} · last #${channel.lastIndex}`);
1832
+ }
1833
+ return 0;
1834
+ }
1835
+ case "actors": {
1836
+ const actors = await client().actors();
1837
+ if (flags.json) return actors.forEach(printJson), 0;
1838
+ for (const actor of actors) out(`@${actor.handle} ${actor.name} ${actor.id}${actor.person ? ` → @${actor.person.handle}` : ""}`);
1839
+ return 0;
1840
+ }
1841
+ case "identity": return identity(args);
1842
+ case "read": {
1843
+ const target = args[0];
1844
+ const board = client();
1845
+ if (target && isMessageRef(target)) {
1846
+ const thread = await board.thread(messageId(target));
1847
+ const narrowed = Object.keys(filters()).length > 0;
1848
+ const replies = narrowed ? (await board.messages({
1849
+ ...filters(),
1850
+ re: thread.message.id,
1851
+ limit: 500
1852
+ })).messages.filter((m) => m.re !== null) : thread.replies;
1853
+ if (flags.json) return [thread.message, ...replies].forEach(printJson), 0;
1854
+ const authors = new Set([thread.message, ...replies].map((m) => m.by.actor));
1855
+ const unnamed = reactionNames([thread.message, ...replies]).filter((id) => !authors.has(id));
1856
+ const known = unnamed.length ? new Map((await board.actors().catch(() => [])).filter((a) => unnamed.includes(a.id)).map((a) => [a.id, actorName(a)])) : /* @__PURE__ */ new Map();
1857
+ out(formatThreads([thread.message, ...replies], known));
1858
+ if (!narrowed) for (const schedule of thread.schedules ?? []) out(formatSchedule(schedule, { pad: " " }));
1859
+ return 0;
1860
+ }
1861
+ const query = {
1862
+ ...filters(target),
1863
+ ...flags.limit ? { limit: Number(flags.limit) } : {}
1864
+ };
1865
+ const old = /* @__PURE__ */ new Error("this board can't list threads: update board where the board is deployed");
1866
+ const page = await board.threads(query).catch((error) => {
1867
+ throw error instanceof BoardError && error.status === 404 ? old : error;
1868
+ });
1869
+ if (!Array.isArray(page.threads)) throw old;
1870
+ if (!page.threads.length && target && !(await board.channels()).some((c) => c.path === target || c.path.startsWith(target.replace(/\/?$/, "/")))) throw new Error(`no channel ${target}: board channels lists them`);
1871
+ if (flags.json) return page.threads.forEach(printJson), 0;
1872
+ const count = `${page.threads.length} ${page.threads.length === 1 ? "thread" : "threads"}`;
1873
+ out(`${target ?? "all channels"} · ${count} · cursor ${page.cursor}${page.threads.length ? "\n" : ""}`);
1874
+ if (page.threads.length) out(formatThreadSummaries(page.threads, { channel: !target || target.endsWith("/") }));
1875
+ return 0;
1876
+ }
1877
+ case "post": {
1878
+ const [target, body] = args;
1879
+ if (!target) throw new UsageError("usage: board post <channel|id> \"<text>\"");
1880
+ const re = isMessageRef(target) ? messageId(target) : void 0;
1881
+ if (re !== void 0 && flags.sync?.length) throw new UsageError("--sync attaches whole threads: post to a channel, or use vb sync <id> <target>");
1882
+ if (flags.sync?.length && flags["no-sync"]) throw new UsageError("--sync and --no-sync contradict each other");
1883
+ const board = client();
1884
+ const content = body === void 0 && flags.attach?.length ? "" : await text(body);
1885
+ const files = await attachments(board);
1886
+ const options = {
1887
+ text: content,
1888
+ ...files.length ? { attachments: files } : {},
1889
+ ...flags.label?.length ? { labels: labelPairs(flags.label) } : {},
1890
+ ...flags.id ? { id: flags.id } : {},
1891
+ ...flags["no-sync"] ? { noSync: true } : {}
1892
+ };
1893
+ const message = re === void 0 ? await board.post(target, {
1894
+ ...options,
1895
+ ...flags.sync?.length ? { sync: flags.sync } : {}
1896
+ }) : await board.reply(re, options);
1897
+ wrote();
1898
+ warnUnknownMentions(message.unknownMentions);
1899
+ printMessage(message);
1900
+ return 0;
1901
+ }
1902
+ case "reply": {
1903
+ const id = messageId(args[0]);
1904
+ const board = client();
1905
+ const content = args[1] === void 0 && flags.attach?.length ? "" : await text(args[1]);
1906
+ const files = await attachments(board);
1907
+ const message = await board.reply(id, {
1908
+ text: content,
1909
+ ...files.length ? { attachments: files } : {},
1910
+ ...flags.id ? { id: flags.id } : {},
1911
+ ...flags["no-sync"] ? { noSync: true } : {}
1912
+ });
1913
+ wrote();
1914
+ warnUnknownMentions(message.unknownMentions);
1915
+ printMessage(message);
1916
+ return 0;
1917
+ }
1918
+ case "schedule": {
1919
+ if (args[0] === "cancel" && args.length === 2) {
1920
+ const { schedule, canceled } = await client().cancelSchedule(scheduleId(args[1]));
1921
+ if (flags.json) printJson(schedule);
1922
+ else out(canceled ? `canceled s${schedule.id}` : `s${schedule.id} is already ${schedule.state}`);
1923
+ return 0;
1924
+ }
1925
+ const [target, body] = args;
1926
+ if (!target) throw new UsageError("usage: board schedule <channel|id> \"<text>\" (--in 1d | --at <time> | --every 30m)");
1927
+ if (!flags.in && !flags.at && !flags.every) throw new UsageError("say when: --in 1d, --at <time>, or --every 30m");
1928
+ if (flags.sync?.length) throw new UsageError("a schedule posts where you point it: --sync is for post");
1929
+ const times = flags.times === void 0 ? void 0 : Number(flags.times);
1930
+ if (times !== void 0 && !(Number.isInteger(times) && times > 0)) throw new UsageError(`--times takes a count, got: ${flags.times}`);
1931
+ const board = client();
1932
+ const content = body === void 0 && flags.attach?.length ? "" : await text(body);
1933
+ const files = await attachments(board);
1934
+ const where = isMessageRef(target) ? { re: messageId(target) } : target;
1935
+ const schedule = await board.schedule(where, {
1936
+ text: content,
1937
+ ...files.length ? { attachments: files } : {},
1938
+ ...flags.label?.length ? { labels: labelPairs(flags.label) } : {},
1939
+ ...flags["no-sync"] ? { noSync: true } : {},
1940
+ ...flags.in ? { in: flags.in } : {},
1941
+ ...flags.at ? { at: instant("at", flags.at) } : {},
1942
+ ...flags.every ? { every: flags.every } : {},
1943
+ ...flags.until ? { until: instant("until", flags.until) } : {},
1944
+ ...flags.for ? { for: flags.for } : {},
1945
+ ...times !== void 0 ? { times } : {}
1946
+ });
1947
+ wrote();
1948
+ warnUnknownMentions(schedule.unknownMentions);
1949
+ if (flags.json) printJson(schedule);
1950
+ else out(formatSchedule(schedule));
1951
+ return 0;
1952
+ }
1953
+ case "schedules": {
1954
+ const list = await client().schedules({
1955
+ ...args[0] ? { re: messageId(args[0]) } : {},
1956
+ ...flags.by ? { by: flags.by.replace(/^@/, "") } : {},
1957
+ ...flags.channel ? { channel: flags.channel } : {},
1958
+ ...flags.all ? { all: true } : {}
1959
+ });
1960
+ if (flags.json) return list.forEach(printJson), 0;
1961
+ if (list.length === 0) return out(flags.all ? "no schedules" : "no pending schedules"), 0;
1962
+ out(list.map((schedule) => formatSchedule(schedule)).join("\n"));
1963
+ return 0;
1964
+ }
1965
+ case "hooks": {
1966
+ const [action = "status", ...names] = args;
1967
+ const agents = hookAgents(names);
1968
+ const width = Math.max(...agents.map((agent) => AGENTS[agent].name.length));
1969
+ const row = (agent, ...cells) => out([AGENTS[agent].name.padEnd(width), ...cells].join(" ").trimEnd());
1970
+ if (action === "status") {
1971
+ const versions = /* @__PURE__ */ new Map();
1972
+ for (const agent of agents) {
1973
+ const installed = installedHook(agent);
1974
+ if (installed === null) {
1975
+ row(agent, "not installed");
1976
+ continue;
1977
+ }
1978
+ const cli = cliOf(installed);
1979
+ const problems = outdated(agent, installed);
1980
+ if (!versions.has(cli)) versions.set(cli, hookVersion(cli));
1981
+ const version = versions.get(cli);
1982
+ const behind = version != null && olderVersion(version, VERSION);
1983
+ if (behind) problems.push(`runs board ${version}, not ${VERSION}`);
1984
+ row(agent, (problems.length ? "outdated" : "installed").padEnd(9), tilde(AGENTS[agent].file()));
1985
+ if (problems.length === 0) continue;
1986
+ for (const problem of problems) out(` ${problem}`);
1987
+ const global = behind && cli === "vb";
1988
+ const fixes = [...global ? ["npm i -g @vercel/board"] : [], ...problems.length > (global ? 1 : 0) ? [`vb hooks install ${agent}`] : []];
1989
+ out(` fix with ${fixes.join(", then ")}`);
1990
+ }
1991
+ return 0;
1992
+ }
1993
+ if (action === "uninstall") {
1994
+ for (const agent of agents) {
1995
+ const installed = installedHook(agent);
1996
+ const cli = installed ? cliOf(installed) : null;
1997
+ const rules = cli ? allowRules(agent, cli) : null;
1998
+ if (rules) disallow(rules.file, [...rules.rules, ...legacyRules(agent, cli)]);
1999
+ row(agent, uninstallHook(agent) ? "removed" : "not installed", installed ? tilde(AGENTS[agent].file()) : "");
2000
+ }
2001
+ return 0;
2002
+ }
2003
+ if (action !== "install") throw new UsageError("usage: board hooks [install|uninstall|status] [claude|codex|cursor|pi]...");
2004
+ const { command: cli, note } = cliCommand();
2005
+ for (const agent of agents) {
2006
+ const had = installedHook(agent) !== null;
2007
+ const changed = installHook(agent, cli);
2008
+ const rules = allowRules(agent, cli);
2009
+ const added = rules ? allow(rules.file, rules.rules) : [];
2010
+ const dropped = rules ? disallow(rules.file, legacyRules(agent, cli)) : false;
2011
+ out(`${AGENTS[agent].name} ${tilde(AGENTS[agent].file())}`);
2012
+ out(` ${!had ? "added the turn-end hook" : changed || added.length > 0 || dropped ? "updated" : "already installed"}`);
2013
+ if (added.length > 0) out(` ${agent === "claude" ? "reply, post, read, and channels run without asking" : "vb runs without asking"}`);
2014
+ if (agent === "cursor" && !rules) out(" npm i -g @vercel/board lets Cursor run vb without asking");
2015
+ if (agent === "codex") {
2016
+ if (changed) out(" approve it once in Codex with /hooks");
2017
+ let host = null;
2018
+ try {
2019
+ const url = process.env.BOARD_URL || projectUrl();
2020
+ host = url ? new URL(url).hostname : null;
2021
+ } catch {}
2022
+ const config = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
2023
+ const profile = codexNetwork(host);
2024
+ out(` to let vb reach the board from Codex's sandbox, add to ${tilde(config)}:`);
2025
+ out("");
2026
+ out([
2027
+ "# a permission profile (beta) that opens the network to just the board",
2028
+ "",
2029
+ "# at the top, before any [table]",
2030
+ profile.top,
2031
+ "",
2032
+ "# anywhere (merge [features] into yours)",
2033
+ profile.tables
2034
+ ].join("\n").replace(/^(?=.)/gm, " "));
2035
+ }
2036
+ out("");
2037
+ }
2038
+ if (note) out(`${note[0].toUpperCase()}${note.slice(1)}.`);
2039
+ out("The hooks remind once per turn without a board update.");
2040
+ out("They stay quiet outside projects linked with `vb link <url>`.");
2041
+ return 0;
2042
+ }
2043
+ case "hook": {
2044
+ if (args[0] !== "stop") throw new UsageError("usage: board hook stop --agent <agent> (run by an agent's turn-end hook)");
2045
+ const agent = flags.agent ?? "agent";
2046
+ try {
2047
+ const payload = JSON.parse(await readStdin());
2048
+ const cwd = payload.cwd ?? payload.workspace_roots?.[0];
2049
+ if (cwd && existsSync(cwd)) process.chdir(cwd);
2050
+ const session = turnKey(agent, agent === "cursor" ? null : payload.session_id ?? null);
2051
+ const boardUrl = process.env.BOARD_URL || projectUrl();
2052
+ if (!session || !boardUrl) return 0;
2053
+ if (payload.status && payload.status !== "completed") return 0;
2054
+ if (decideStop(session, payload.stop_hook_active === true || (payload.loop_count ?? 0) > 0) === "ask") {
2055
+ const cli = cliCommand().command;
2056
+ const text = claimLinkOffer(boardUrl) ? `${reminder(cli)}\n\n${linkOffer(cli)}` : reminder(cli);
2057
+ printJson(stopOutput(agent, text));
2058
+ }
2059
+ } catch {}
2060
+ return 0;
2061
+ }
2062
+ case "edit": {
2063
+ const { unknownMentions } = await client().edit(messageId(args[0]), await text(args[1]));
2064
+ warnUnknownMentions(unknownMentions);
2065
+ return 0;
2066
+ }
2067
+ case "delete":
2068
+ await client().delete(messageId(args[0]));
2069
+ return 0;
2070
+ case "react": {
2071
+ const id = messageId(args[0]);
2072
+ if (!args[1]) throw new UsageError("usage: board react <id> <emoji> [--remove]");
2073
+ let emoji;
2074
+ try {
2075
+ emoji = normalizeEmoji(args[1]);
2076
+ } catch (error) {
2077
+ throw new UsageError(error.message);
2078
+ }
2079
+ await client().react(id, emoji, flags.remove ? { remove: true } : {});
2080
+ return 0;
2081
+ }
2082
+ case "label": {
2083
+ const id = messageId(args[0]);
2084
+ const set = labelPairs(args.slice(1));
2085
+ const unset = flags.unset ?? [];
2086
+ if (Object.keys(set).length === 0 && unset.length === 0) throw new UsageError("usage: board label <id> k=v... [--unset k]...");
2087
+ await client().label(id, {
2088
+ set,
2089
+ unset
2090
+ });
2091
+ return 0;
2092
+ }
2093
+ case "typing": {
2094
+ const id = messageId(args[0]);
2095
+ const status = args.slice(1).join(" ").trim();
2096
+ const targets = await client().typing(id, status || void 0);
2097
+ if (flags.json) printJson({ targets });
2098
+ return 0;
2099
+ }
2100
+ case "sync": {
2101
+ const id = messageId(args[0]);
2102
+ if (!args[1]) throw new UsageError("usage: board sync <id> <target> (e.g. slack:#incidents)");
2103
+ const result = await client().sync(id, args[1]);
2104
+ if (flags.json) printJson(result);
2105
+ else out(result.attached ? `synced with ${result.target}` : `already synced with ${result.target}`);
2106
+ return 0;
2107
+ }
2108
+ case "download": {
2109
+ const id = args[0];
2110
+ if (!id) throw new UsageError("usage: board download <attachment> [path] (the id `vb read` shows on a file line)");
2111
+ const response = await client().download(id);
2112
+ const data = new Uint8Array(await response.arrayBuffer());
2113
+ if (args[1] === "-") return process.stdout.write(data), 0;
2114
+ const disposition = response.headers.get("content-disposition") ?? "";
2115
+ const named = /filename\*=UTF-8''([^;]+)/.exec(disposition)?.[1];
2116
+ const path = args[1] ?? basename(named ? decodeURIComponent(named) : id);
2117
+ await writeFile(path, data);
2118
+ out(path);
2119
+ return 0;
2120
+ }
2121
+ case "inbox": {
2122
+ const key = args[0];
2123
+ if (!key) throw new UsageError("usage: board inbox <key> [channel] [--limit N]");
2124
+ const events = await client().inbox(key, {
2125
+ ...args[1] ? { channel: args[1] } : {},
2126
+ ...flags.limit ? { limit: Number(flags.limit) } : {}
2127
+ });
2128
+ if (flags.json) return events.forEach(printJson), 0;
2129
+ if (events.length === 0) return out("no events"), 0;
2130
+ for (const entry of events) {
2131
+ const event = entry.event;
2132
+ const detail = event.text ?? event.type ?? event.emoji ?? "";
2133
+ out(`${entry.createdAt} ${entry.channel} ${event.kind} ${event.author?.handle ?? ""} ${detail.split("\n")[0]}`);
2134
+ }
2135
+ return 0;
2136
+ }
2137
+ case "sources": {
2138
+ const [key, sub, target] = args;
2139
+ if (!key) {
2140
+ const { sync } = await client().discovery();
2141
+ if (flags.json) return sync.forEach(printJson), 0;
2142
+ if (sync.length === 0) return out("no sources: this board has no sync configured"), 0;
2143
+ for (const entry of sync) out(`${entry.key} ${entry.platform}`);
2144
+ return 0;
2145
+ }
2146
+ if (sub !== "read" || !target) throw new UsageError("usage: board sources <key> read <channel|thread|link> [--limit N]");
2147
+ const read = await client().readSource(key, target, flags.limit ? { limit: Number(flags.limit) } : {});
2148
+ if (flags.json) return printJson(read), 0;
2149
+ return out(formatSourceRead(key, read)), 0;
2150
+ }
2151
+ case "tail": {
2152
+ const controller = new AbortController();
2153
+ process.once("SIGINT", () => controller.abort());
2154
+ for await (const event of client().events({
2155
+ ...filters(args[0]),
2156
+ ...flags.all ? { all: true } : {}
2157
+ }, { signal: controller.signal })) printEvent(event);
2158
+ return 0;
2159
+ }
2160
+ case "wait": {
2161
+ const controller = new AbortController();
2162
+ process.once("SIGINT", () => controller.abort());
2163
+ const event = await client().wait(filters(args[0]), {
2164
+ timeout: flags.timeout ?? "30m",
2165
+ signal: controller.signal
2166
+ });
2167
+ if (!event) {
2168
+ if (!controller.signal.aborted) process.stderr.write("timed out\n");
2169
+ return 124;
2170
+ }
2171
+ printEvent(event);
2172
+ return 0;
2173
+ }
2174
+ case "serve": {
2175
+ if (args.length > 0) throw new UsageError("usage: board serve [--port N] [--dir <dir>] [--no-inspect] [--no-open]");
2176
+ const port = portFlag();
2177
+ const root = projectDir();
2178
+ const { localHandle, serveLocal } = await import("./serve-Ccbb2LSb.mjs");
2179
+ const handle = flags.handle ?? process.env.BOARD_HANDLE;
2180
+ const local = await serveLocal({
2181
+ dir: flags.dir ?? join(root, ".board"),
2182
+ ...port !== void 0 ? { port } : {},
2183
+ ...handle ? { handle: localHandle(handle) } : {}
2184
+ });
2185
+ let inspector = null;
2186
+ try {
2187
+ const linked = projectUrl();
2188
+ if (linked === void 0 || linked === local.url || linkedByServe(root)) {
2189
+ await saveLink(root, local.url, { serve: true });
2190
+ if (linked !== local.url) out(`linked ${root} to ${local.url}`);
2191
+ } else out(`${root} stays linked to ${linked}; reach this board with BOARD_URL=${local.url}`);
2192
+ out(`serving a local board at ${local.url} as ${local.principal}, its log in ${local.dir} (Ctrl-C to stop)`);
2193
+ if (!flags["no-inspect"]) {
2194
+ const { board } = connection(local.url, {
2195
+ token: local.token,
2196
+ handle: local.handle
2197
+ });
2198
+ inspector = await startInspector({ client: board.as(null) });
2199
+ out(`inspector at ${inspector.url}`);
2200
+ if (!flags["no-open"]) openBrowser(inspector.url);
2201
+ }
2202
+ await new Promise((resolve) => {
2203
+ process.once("SIGINT", resolve);
2204
+ process.once("SIGTERM", resolve);
2205
+ });
2206
+ } finally {
2207
+ await inspector?.close();
2208
+ await local.close();
2209
+ }
2210
+ return 0;
2211
+ }
2212
+ case "inspect": {
2213
+ const port = portFlag();
2214
+ const inspector = await startInspector({
2215
+ client: client(),
2216
+ ...port !== void 0 ? { port } : {}
2217
+ });
2218
+ out(`inspecting ${inspector.board} as ${who(inspector.me)} at ${inspector.url} (Ctrl-C to stop)`);
2219
+ if (!flags["no-open"]) openBrowser(inspector.url);
2220
+ await new Promise((resolve) => {
2221
+ process.once("SIGINT", resolve);
2222
+ process.once("SIGTERM", resolve);
2223
+ });
2224
+ await inspector.close();
2225
+ return 0;
2226
+ }
2227
+ default: throw new UsageError(`unknown command: ${command} (run \`board help\`)`);
2228
+ }
2229
+ }
2230
+ if (parsed === null) exit(2);
2231
+ else if (flags.version) out(VERSION);
2232
+ else if (flags.help) out(HELP);
2233
+ else {
2234
+ const [command, ...args] = positionals;
2235
+ run(command, args).then((code) => exit(code), (error) => {
2236
+ process.stderr.write(`${terminalText(`error: ${error.message}`)}\n`);
2237
+ exit(error instanceof UsageError ? 2 : 1);
2238
+ });
2239
+ }
2240
+ /**
2241
+ * Exits once stdout and stderr have flushed. On a pipe, writes can still be
2242
+ * queued when a command returns, and `process.exit` would drop them: a long
2243
+ * `read <id> --json` would reach a slow reader cut off.
2244
+ */
2245
+ function exit(code) {
2246
+ let pending = 2;
2247
+ const done = () => --pending === 0 && process.exit(code);
2248
+ process.stdout.write("", done);
2249
+ process.stderr.write("", done);
2250
+ }
2251
+ //#endregion
2252
+ export {};