@cfbender/cesium 0.3.5

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 (44) hide show
  1. package/ARCHITECTURE.md +304 -0
  2. package/CHANGELOG.md +335 -0
  3. package/LICENSE +21 -0
  4. package/README.md +479 -0
  5. package/agents/cesium.md +39 -0
  6. package/assets/styleguide.html +857 -0
  7. package/package.json +61 -0
  8. package/src/cli/commands/ls.ts +186 -0
  9. package/src/cli/commands/open.ts +208 -0
  10. package/src/cli/commands/prune.ts +348 -0
  11. package/src/cli/commands/restart.ts +38 -0
  12. package/src/cli/commands/serve.ts +214 -0
  13. package/src/cli/commands/stop.ts +130 -0
  14. package/src/cli/commands/theme.ts +333 -0
  15. package/src/cli/index.ts +78 -0
  16. package/src/config.ts +94 -0
  17. package/src/index.ts +35 -0
  18. package/src/prompt/system-fragment.md +97 -0
  19. package/src/render/client-js.ts +316 -0
  20. package/src/render/controls.ts +302 -0
  21. package/src/render/critique.ts +360 -0
  22. package/src/render/extract.ts +83 -0
  23. package/src/render/scrub.ts +141 -0
  24. package/src/render/theme.ts +712 -0
  25. package/src/render/validate.ts +524 -0
  26. package/src/render/wrap.ts +165 -0
  27. package/src/server/api.ts +166 -0
  28. package/src/server/http.ts +195 -0
  29. package/src/server/lifecycle.ts +331 -0
  30. package/src/server/stop.ts +124 -0
  31. package/src/storage/index-cache.ts +71 -0
  32. package/src/storage/index-gen.ts +447 -0
  33. package/src/storage/lock.ts +108 -0
  34. package/src/storage/mutate.ts +396 -0
  35. package/src/storage/paths.ts +159 -0
  36. package/src/storage/project-summaries.ts +19 -0
  37. package/src/storage/theme-write.ts +19 -0
  38. package/src/storage/write.ts +75 -0
  39. package/src/tools/ask.ts +353 -0
  40. package/src/tools/critique.ts +66 -0
  41. package/src/tools/publish.ts +404 -0
  42. package/src/tools/stop.ts +53 -0
  43. package/src/tools/styleguide.ts +23 -0
  44. package/src/tools/wait.ts +192 -0
@@ -0,0 +1,404 @@
1
+ // Tool handler for cesium_publish — validates input, delegates to render + storage.
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { networkInterfaces } from "node:os";
5
+ import { join } from "node:path";
6
+ import { tool } from "@opencode-ai/plugin";
7
+ import type { PluginInput } from "@opencode-ai/plugin";
8
+ import { loadConfig, type CesiumConfig } from "../config.ts";
9
+ import { scrub } from "../render/scrub.ts";
10
+ import { extractTextContent } from "../render/extract.ts";
11
+ import { themeFromPreset, mergeTheme } from "../render/theme.ts";
12
+ import { validatePublishInput, htmlBodyWarnings, PUBLISH_KINDS } from "../render/validate.ts";
13
+ import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
14
+ import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
15
+ import { atomicWrite, patchEmbeddedMetadata } from "../storage/write.ts";
16
+ import { writeThemeCss } from "../storage/theme-write.ts";
17
+ import {
18
+ loadIndex,
19
+ writeIndex,
20
+ appendEntry,
21
+ patchEntry,
22
+ type IndexEntry,
23
+ } from "../storage/index-cache.ts";
24
+ import { withLock } from "../storage/lock.ts";
25
+ import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
26
+ import { buildProjectSummaries } from "../storage/project-summaries.ts";
27
+ import {
28
+ ensureRunning as defaultEnsureRunning,
29
+ type RunningInfo,
30
+ type LifecycleConfig,
31
+ } from "../server/lifecycle.ts";
32
+
33
+ export interface PublishToolOverrides {
34
+ loadConfig?: () => CesiumConfig;
35
+ now?: () => Date;
36
+ nanoid?: () => string;
37
+ ensureRunning?: (cfg: LifecycleConfig) => Promise<RunningInfo | null>;
38
+ }
39
+
40
+ export interface TerminalSummaryArgs {
41
+ title: string;
42
+ kind: string;
43
+ httpUrl: string;
44
+ fileUrl: string;
45
+ isSsh: boolean;
46
+ port: number;
47
+ }
48
+
49
+ export function buildTerminalSummary(args: TerminalSummaryArgs): string {
50
+ const { title, kind, httpUrl, fileUrl, isSsh, port } = args;
51
+ let summary = `Cesium · ${title} (${kind})\n ${httpUrl}\n ${fileUrl}`;
52
+ if (isSsh) {
53
+ summary += `\n\n (remote: forward port → ssh -L ${port}:localhost:${port} <host>)`;
54
+ }
55
+ return summary;
56
+ }
57
+
58
+ const TOOL_DESCRIPTION = `Publish a beautiful self-contained HTML document to the cesium artifacts directory.
59
+ Use this when responding with substantive content — plans, code reviews, comparisons,
60
+ reports, explainers, audits, design proposals — that the user is likely to re-read or share.
61
+
62
+ When to use cesium_publish (vs replying in terminal):
63
+ - Response would be ≥ ~400 words, OR
64
+ - Contains a comparison/decision matrix, OR
65
+ - Multi-section plan/PRD/RFC/design doc, OR
66
+ - Code review with > 3 findings, OR
67
+ - Anything the user might revisit later.
68
+
69
+ When to stay in the terminal: short factual answers, status updates ("done", "fixed",
70
+ "running tests"), mid-tool-call chatter, single-paragraph replies.
71
+
72
+ User overrides win: "/cesium" or "publish this" → publish; "in terminal" → don't.
73
+
74
+ The \`html\` argument is the BODY ONLY — do NOT include <!doctype>, <html>, <head>, <body>.
75
+ The plugin wraps your body with the design system.
76
+
77
+ Available CSS classes (call cesium_styleguide for full reference + examples):
78
+ - .eyebrow uppercase mono micro-label above headings
79
+ - .h-display page-title heading
80
+ - .h-section section heading paired with .section-num
81
+ - .section-num numbered chip ("01", "02") next to .h-section
82
+ - .card bordered surface block (1.5px border, 12px radius)
83
+ - .tldr clay-bordered summary box (use ONE per doc, near top)
84
+ - .callout info box; modifiers: .callout.note .callout.warn .callout.risk
85
+ - .code block-level code panel; inline highlights via .kw .str .cm .fn
86
+ - .timeline milestone list with dots and connectors
87
+ - .diagram wraps inline SVG with a caption below
88
+ - .compare-table bordered comparison grid
89
+ - .risk-table bordered risk-grid (likelihood/impact/mitigation columns)
90
+ - .kbd .pill .tag inline chips
91
+ - .byline rendered automatically as the footer
92
+
93
+ Inline \`style="..."\` and inline \`<svg>\` are encouraged for bespoke diagrams. NEVER reference
94
+ external resources: no <script src=>, no <link rel=stylesheet href=http>, no remote fonts,
95
+ no remote images. The plugin will silently strip external resources but the artifact will
96
+ look broken in the resulting render.
97
+
98
+ Title aim: 3-8 words, descriptive. Kind: pick the closest match.`;
99
+
100
+ export function createPublishTool(
101
+ ctx: PluginInput,
102
+ overrides?: PublishToolOverrides,
103
+ ): ReturnType<typeof tool> {
104
+ const resolveConfig = overrides?.loadConfig ?? loadConfig;
105
+ const now = overrides?.now ?? (() => new Date());
106
+ const genId = overrides?.nanoid ?? defaultNanoid;
107
+ const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureRunning;
108
+
109
+ return tool({
110
+ description: TOOL_DESCRIPTION,
111
+ args: {
112
+ title: tool.schema.string(),
113
+ kind: tool.schema.enum([...PUBLISH_KINDS] as [string, ...string[]]),
114
+ html: tool.schema.string(),
115
+ summary: tool.schema.string().optional(),
116
+ tags: tool.schema.array(tool.schema.string()).optional(),
117
+ supersedes: tool.schema.string().optional(),
118
+ },
119
+ async execute(args, _context) {
120
+ // 1. Validate input
121
+ const validation = validatePublishInput(args);
122
+ if (!validation.ok) {
123
+ return `Error: ${validation.error}`;
124
+ }
125
+ const input = validation.value;
126
+
127
+ // 2. Load config
128
+ const config = resolveConfig();
129
+
130
+ // 3. Probe git state
131
+ const shell = ctx.$.cwd(ctx.directory).nothrow();
132
+ let gitRemote: string | null = null;
133
+ let gitBranch: string | null = null;
134
+ let gitCommit: string | null = null;
135
+
136
+ try {
137
+ const remoteResult = await shell`git config --get remote.origin.url`.quiet();
138
+ if (remoteResult.exitCode === 0) {
139
+ gitRemote = remoteResult.text().trim() || null;
140
+ }
141
+ } catch {
142
+ // not a git repo or no remote
143
+ }
144
+
145
+ try {
146
+ const branchResult = await shell`git rev-parse --abbrev-ref HEAD`.quiet();
147
+ if (branchResult.exitCode === 0) {
148
+ gitBranch = branchResult.text().trim() || null;
149
+ }
150
+ } catch {
151
+ // not a git repo
152
+ }
153
+
154
+ try {
155
+ const commitResult = await shell`git rev-parse HEAD`.quiet();
156
+ if (commitResult.exitCode === 0) {
157
+ gitCommit = commitResult.text().trim() || null;
158
+ }
159
+ } catch {
160
+ // not a git repo
161
+ }
162
+
163
+ // 4. Derive project identity
164
+ const identity = deriveProjectIdentity({
165
+ cwd: ctx.directory,
166
+ gitRemote,
167
+ worktree: ctx.worktree ?? null,
168
+ });
169
+
170
+ // 5. Generate id
171
+ const id = genId();
172
+
173
+ // 6. Timestamps
174
+ const createdAt = now();
175
+
176
+ // 7. Scrub
177
+ const scrubbed = scrub(input.html);
178
+
179
+ // 7a. Extract body text for full-text search
180
+ const bodyText = extractTextContent(scrubbed.html);
181
+
182
+ // 8. Compute filename + paths
183
+ const filename = artifactFilename({ title: input.title, id, createdAt });
184
+ const paths = pathsFor({
185
+ stateDir: config.stateDir,
186
+ projectSlug: identity.slug,
187
+ filename,
188
+ });
189
+
190
+ // 9. Content SHA-256
191
+ const contentSha256 = createHash("sha256").update(scrubbed.html).digest("hex");
192
+
193
+ // 10. Build ArtifactMeta
194
+ const meta: ArtifactMeta = {
195
+ id,
196
+ title: input.title,
197
+ kind: input.kind,
198
+ summary: input.summary ?? null,
199
+ tags: input.tags ?? [],
200
+ createdAt: createdAt.toISOString(),
201
+ model: null,
202
+ sessionId: null,
203
+ projectSlug: identity.slug,
204
+ projectName: identity.name,
205
+ cwd: ctx.directory,
206
+ worktree: identity.worktree,
207
+ gitBranch,
208
+ gitCommit,
209
+ supersedes: input.supersedes ?? null,
210
+ supersededBy: null,
211
+ contentSha256,
212
+ };
213
+
214
+ // 11. Build warnings
215
+ const warnings: string[] = [];
216
+ if (scrubbed.removed.length > 0) {
217
+ warnings.push(`Removed ${scrubbed.removed.length} external resource(s) during scrub.`);
218
+ }
219
+ const bodyWarnings = htmlBodyWarnings(scrubbed.html);
220
+ for (const w of bodyWarnings) {
221
+ warnings.push(w);
222
+ }
223
+
224
+ // 12. Build theme + wrap document
225
+ const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
226
+
227
+ // 12a. Write theme.css (idempotent, outside index lock — separate file)
228
+ await writeThemeCss(config.stateDir, theme);
229
+
230
+ const fullHtml = wrapDocument({
231
+ body: scrubbed.html,
232
+ meta,
233
+ theme,
234
+ warnings,
235
+ themeCssHref: "../../../theme.css",
236
+ });
237
+
238
+ // 13. Atomic write
239
+ await atomicWrite(paths.artifactPath, fullHtml);
240
+
241
+ // 14. Build IndexEntry
242
+ const entry: IndexEntry = {
243
+ id: meta.id,
244
+ title: meta.title,
245
+ kind: meta.kind,
246
+ summary: meta.summary,
247
+ tags: meta.tags,
248
+ createdAt: meta.createdAt,
249
+ filename,
250
+ supersedes: meta.supersedes,
251
+ supersededBy: null,
252
+ gitBranch: meta.gitBranch,
253
+ gitCommit: meta.gitCommit,
254
+ contentSha256: meta.contentSha256,
255
+ projectSlug: identity.slug,
256
+ projectName: identity.name,
257
+ bodyText,
258
+ };
259
+
260
+ const lockPath = join(config.stateDir, ".index.lock");
261
+
262
+ await withLock({ lockPath }, async () => {
263
+ // 15. Update per-project index
264
+ const projectEntries = await loadIndex(paths.projectIndexJsonPath);
265
+ const updatedProjectEntries = appendEntry(projectEntries, entry);
266
+ await writeIndex(paths.projectIndexJsonPath, updatedProjectEntries);
267
+
268
+ // 16. Update global index
269
+ const globalEntries = await loadIndex(paths.globalIndexJsonPath);
270
+ const updatedGlobalEntries = appendEntry(globalEntries, entry);
271
+ await writeIndex(paths.globalIndexJsonPath, updatedGlobalEntries);
272
+
273
+ // 17. Handle supersedes chain
274
+ if (input.supersedes) {
275
+ const prevId = input.supersedes;
276
+ const prevEntryIdx = updatedProjectEntries.findIndex((e) => e.id === prevId);
277
+ if (prevEntryIdx !== -1) {
278
+ const prevEntry = updatedProjectEntries[prevEntryIdx];
279
+ if (prevEntry !== undefined) {
280
+ const prevFilename = prevEntry.filename;
281
+ const prevPaths = pathsFor({
282
+ stateDir: config.stateDir,
283
+ projectSlug: identity.slug,
284
+ filename: prevFilename,
285
+ });
286
+ // Patch embedded metadata in the previous artifact file
287
+ try {
288
+ await patchEmbeddedMetadata(prevPaths.artifactPath, { supersededBy: id });
289
+ } catch {
290
+ // File may not exist on disk (e.g. cleaned up); log but don't fail
291
+ }
292
+ // Patch the index entry
293
+ const patchedEntries = patchEntry(updatedProjectEntries, prevId, {
294
+ supersededBy: id,
295
+ });
296
+ await writeIndex(paths.projectIndexJsonPath, patchedEntries);
297
+ }
298
+ }
299
+ // If not found — publish still succeeds, warn is logged implicitly
300
+ }
301
+
302
+ // 18. Render and write project index.html
303
+ const latestProjectEntries = await loadIndex(paths.projectIndexJsonPath);
304
+ const projectIndexHtml = renderProjectIndex({
305
+ projectSlug: identity.slug,
306
+ projectName: identity.name,
307
+ entries: latestProjectEntries,
308
+ theme,
309
+ themeCssHref: "../../theme.css",
310
+ });
311
+ await atomicWrite(paths.projectIndexPath, projectIndexHtml);
312
+
313
+ // 19. Render and write global index.html
314
+ const latestGlobalEntries = await loadIndex(paths.globalIndexJsonPath);
315
+ const projectSummaries = buildProjectSummaries(latestGlobalEntries);
316
+ const globalIndexHtml = renderGlobalIndex({
317
+ projects: projectSummaries,
318
+ theme,
319
+ themeCssHref: "theme.css",
320
+ });
321
+ await atomicWrite(paths.globalIndexPath, globalIndexHtml);
322
+ });
323
+
324
+ // 20. Start server (best-effort) and build URLs
325
+ const displayHost = resolveDisplayHost(config.hostname);
326
+ let httpUrl = `http://${displayHost}:${config.port}${paths.serverPath}`;
327
+ let indexUrl = `http://${displayHost}:${config.port}/projects/${identity.slug}/index.html`;
328
+ let serverInfo: RunningInfo | null = null;
329
+
330
+ try {
331
+ const maybeInfo = await runEnsureRunning({
332
+ stateDir: config.stateDir,
333
+ port: config.port,
334
+ portMax: config.portMax,
335
+ idleTimeoutMs: config.idleTimeoutMs,
336
+ hostname: config.hostname,
337
+ });
338
+ if (maybeInfo !== null) {
339
+ serverInfo = maybeInfo;
340
+ const liveDisplay = resolveDisplayHost(config.hostname);
341
+ httpUrl = `http://${liveDisplay}:${serverInfo.port}${paths.serverPath}`;
342
+ indexUrl = `http://${liveDisplay}:${serverInfo.port}/projects/${identity.slug}/index.html`;
343
+ }
344
+ } catch {
345
+ // Server failed to start — proceed without; user can still use file:// URL.
346
+ }
347
+
348
+ const terminalSummary = buildTerminalSummary({
349
+ title: input.title,
350
+ kind: input.kind,
351
+ httpUrl,
352
+ fileUrl: paths.fileUrl,
353
+ isSsh: Boolean(process.env["SSH_CONNECTION"]),
354
+ port: serverInfo?.port ?? config.port,
355
+ });
356
+
357
+ // 21. Return result
358
+ const result = {
359
+ id,
360
+ filePath: paths.artifactPath,
361
+ fileUrl: paths.fileUrl,
362
+ httpUrl,
363
+ indexUrl,
364
+ terminalSummary,
365
+ };
366
+
367
+ return JSON.stringify(result, null, 2);
368
+ },
369
+ });
370
+ }
371
+
372
+ // Default nanoid implementation using built-in crypto for alphanumeric IDs
373
+ function defaultNanoid(): string {
374
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
375
+ const bytes = new Uint8Array(6);
376
+ crypto.getRandomValues(bytes);
377
+ let result = "";
378
+ for (const byte of bytes) {
379
+ result += alphabet[byte % alphabet.length];
380
+ }
381
+ return result;
382
+ }
383
+
384
+ // Pick a host for display URLs. When binding 0.0.0.0 (any-interface), substitute
385
+ // the first non-loopback IPv4 we can find so the URL is actually reachable from
386
+ // other machines on the LAN. For loopback/named bindings, just translate
387
+ // 127.0.0.1 to "localhost" for friendliness; everything else is used verbatim.
388
+ export function resolveDisplayHost(bindHost: string): string {
389
+ if (bindHost === "0.0.0.0" || bindHost === "::") {
390
+ const ifaces = networkInterfaces();
391
+ for (const list of Object.values(ifaces)) {
392
+ for (const iface of list ?? []) {
393
+ if (iface.family === "IPv4" && !iface.internal) {
394
+ return iface.address;
395
+ }
396
+ }
397
+ }
398
+ return "localhost";
399
+ }
400
+ if (bindHost === "127.0.0.1" || bindHost === "::1") {
401
+ return "localhost";
402
+ }
403
+ return bindHost;
404
+ }
@@ -0,0 +1,53 @@
1
+ // Tool handler for cesium_stop — stops the running cesium HTTP server cross-process.
2
+
3
+ import { tool } from "@opencode-ai/plugin";
4
+ import type { PluginInput } from "@opencode-ai/plugin";
5
+ import { loadConfig } from "../config.ts";
6
+ import { stopServer } from "../server/stop.ts";
7
+ import type { StopOutcome } from "../server/stop.ts";
8
+
9
+ const TOOL_DESCRIPTION = `Stop the running cesium HTTP server.
10
+
11
+ Idempotent: returns a clear message when no server is running. Sends SIGTERM
12
+ with a 3s grace period, then SIGKILL if the process hasn't exited. The agent
13
+ can call this when:
14
+ - The user explicitly asks to stop or restart the cesium server.
15
+ - The user is changing config (port, hostname, theme) and wants the new server
16
+ to start fresh on the next publish.
17
+ - Cleanup at session end is desired.
18
+
19
+ Note: the next call to cesium_publish will lazy-start a new server, so calling
20
+ this between publishes effectively cycles the server with the latest config.`;
21
+
22
+ export function formatStopOutcome(outcome: StopOutcome): string {
23
+ switch (outcome.kind) {
24
+ case "not-running":
25
+ return "no cesium server running";
26
+ case "stale":
27
+ return "server not running (stale PID file removed)";
28
+ case "stopped":
29
+ return `stopped cesium server (pid ${outcome.pid}, port ${outcome.port}, ${outcome.signal})`;
30
+ case "permission-denied":
31
+ return `could not stop server: permission denied (pid ${outcome.pid} owned by another user)`;
32
+ }
33
+ }
34
+
35
+ export function createStopTool(_ctx: PluginInput): ReturnType<typeof tool> {
36
+ return tool({
37
+ description: TOOL_DESCRIPTION,
38
+ args: {
39
+ force: tool.schema.boolean().optional(),
40
+ timeoutMs: tool.schema.number().optional(),
41
+ },
42
+ async execute(args) {
43
+ const config = loadConfig();
44
+ const stopArgs: Parameters<typeof stopServer>[0] = {
45
+ stateDir: config.stateDir,
46
+ ...(args.force !== undefined ? { force: args.force } : {}),
47
+ ...(args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
48
+ };
49
+ const outcome = await stopServer(stopArgs);
50
+ return formatStopOutcome(outcome);
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,23 @@
1
+ // Tool handler for cesium_styleguide — returns the full CSS reference page as a string.
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { tool } from "@opencode-ai/plugin";
7
+ import type { PluginInput } from "@opencode-ai/plugin";
8
+
9
+ const STYLEGUIDE_PATH = join(
10
+ dirname(fileURLToPath(import.meta.url)),
11
+ "../../assets/styleguide.html",
12
+ );
13
+
14
+ export function createStyleguideTool(_ctx: PluginInput): ReturnType<typeof tool> {
15
+ return tool({
16
+ description:
17
+ "Returns the cesium HTML design system reference page (CSS classes with example usage). Call this once at the start of writing a complex artifact to internalize the available components.",
18
+ args: {},
19
+ async execute(_args, _context) {
20
+ return await readFile(STYLEGUIDE_PATH, "utf8");
21
+ },
22
+ });
23
+ }
@@ -0,0 +1,192 @@
1
+ // Tool handler for cesium_wait — polls an interactive artifact until complete or timeout.
2
+
3
+ import { readdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { tool } from "@opencode-ai/plugin";
6
+ import type { PluginInput } from "@opencode-ai/plugin";
7
+ import { loadConfig, type CesiumConfig } from "../config.ts";
8
+ import { loadIndex } from "../storage/index-cache.ts";
9
+ import type { AnswerValue, InteractiveData } from "../render/validate.ts";
10
+ import { readEmbeddedMetadata } from "../storage/write.ts";
11
+ import { readFile } from "node:fs/promises";
12
+
13
+ export interface WaitToolOverrides {
14
+ loadConfig?: () => CesiumConfig;
15
+ }
16
+
17
+ export type WaitStatus = "complete" | "incomplete" | "expired" | "cancelled" | "not-found";
18
+
19
+ const TOOL_DESCRIPTION = `cesium_wait — Block until the user completes a cesium_ask interactive artifact, or
20
+ until timeout. Returns the user's answers as a map keyed by question id.
21
+
22
+ Pass the id returned from cesium_ask. Default timeout is 10 minutes. Polls the
23
+ artifact file every 500ms (no server-side coordination needed — disk is the source
24
+ of truth).
25
+
26
+ Use this immediately after cesium_ask when you need the user's input to continue.
27
+ If the user doesn't finish within the timeout, you'll get status: "incomplete"
28
+ with whatever they answered so far — handle that case (re-prompt, give up, or
29
+ publish a partial artifact).`;
30
+
31
+ export function createWaitTool(
32
+ _ctx: PluginInput,
33
+ overrides?: WaitToolOverrides,
34
+ ): ReturnType<typeof tool> {
35
+ const resolveConfig = overrides?.loadConfig ?? loadConfig;
36
+
37
+ return tool({
38
+ description: TOOL_DESCRIPTION,
39
+ args: {
40
+ id: tool.schema.string(),
41
+ timeoutMs: tool.schema.number().optional(),
42
+ pollIntervalMs: tool.schema.number().optional(),
43
+ },
44
+ async execute(args, _context) {
45
+ const config = resolveConfig();
46
+ const { id } = args;
47
+ const timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : 600_000;
48
+ const pollIntervalMs = Math.max(
49
+ 100,
50
+ typeof args.pollIntervalMs === "number" ? args.pollIntervalMs : 500,
51
+ );
52
+
53
+ // Resolve id → artifactPath by scanning project index.json files
54
+ const artifactPath = await resolveArtifactPath(config.stateDir, id);
55
+
56
+ if (artifactPath === null) {
57
+ const result = {
58
+ status: "not-found" as WaitStatus,
59
+ answers: {},
60
+ remaining: [] as string[],
61
+ };
62
+ return JSON.stringify(result, null, 2);
63
+ }
64
+
65
+ // Poll until complete, expired, cancelled, or timeout
66
+ const startTime = Date.now();
67
+
68
+ const pollResult = await pollLoop(artifactPath, timeoutMs, pollIntervalMs, startTime);
69
+ return JSON.stringify(pollResult, null, 2);
70
+ },
71
+ });
72
+ }
73
+
74
+ // ─── Artifact path resolution ──────────────────────────────────────────────────
75
+
76
+ async function resolveArtifactPath(stateDir: string, id: string): Promise<string | null> {
77
+ const projectsDir = join(stateDir, "projects");
78
+
79
+ let projectDirs: string[];
80
+ try {
81
+ projectDirs = await readdir(projectsDir);
82
+ } catch {
83
+ return null;
84
+ }
85
+
86
+ for (const projectSlug of projectDirs) {
87
+ const indexPath = join(projectsDir, projectSlug, "index.json");
88
+ let entries;
89
+ try {
90
+ entries = await loadIndex(indexPath);
91
+ } catch {
92
+ continue;
93
+ }
94
+ const entry = entries.find((e) => e.id === id);
95
+ if (entry !== undefined) {
96
+ return join(projectsDir, projectSlug, "artifacts", entry.filename);
97
+ }
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ // ─── Poll loop (recursive to satisfy no-await-in-loop) ────────────────────────
104
+
105
+ interface PollResult {
106
+ status: WaitStatus;
107
+ answers: Record<string, AnswerValue>;
108
+ remaining: string[];
109
+ }
110
+
111
+ async function pollLoop(
112
+ artifactPath: string,
113
+ timeoutMs: number,
114
+ pollIntervalMs: number,
115
+ startTime: number,
116
+ ): Promise<PollResult> {
117
+ // Read the artifact HTML
118
+ let html: string;
119
+ try {
120
+ html = await readFile(artifactPath, "utf8");
121
+ } catch (err) {
122
+ const e = err as NodeJS.ErrnoException;
123
+ if (e.code === "ENOENT") {
124
+ return { status: "not-found", answers: {}, remaining: [] };
125
+ }
126
+ throw err;
127
+ }
128
+
129
+ const meta = readEmbeddedMetadata(html);
130
+ if (meta === null || !isInteractiveData(meta["interactive"])) {
131
+ return { status: "not-found", answers: {}, remaining: [] };
132
+ }
133
+
134
+ const interactive = meta["interactive"] as InteractiveData;
135
+
136
+ const answers = extractAnswers(interactive);
137
+ const remaining = interactive.questions
138
+ .map((q) => q.id)
139
+ .filter((qid) => interactive.answers[qid] === undefined);
140
+
141
+ switch (interactive.status) {
142
+ case "complete":
143
+ return { status: "complete", answers, remaining };
144
+ case "expired":
145
+ return { status: "expired", answers, remaining };
146
+ case "cancelled":
147
+ return { status: "cancelled", answers, remaining };
148
+ case "open":
149
+ break;
150
+ }
151
+
152
+ // Check timeout
153
+ if (Date.now() - startTime >= timeoutMs) {
154
+ return { status: "incomplete", answers, remaining };
155
+ }
156
+
157
+ // Sleep then recurse
158
+ await sleep(pollIntervalMs);
159
+
160
+ // Check timeout again after sleep (in case sleep took longer)
161
+ if (Date.now() - startTime >= timeoutMs) {
162
+ return { status: "incomplete", answers, remaining };
163
+ }
164
+
165
+ return pollLoop(artifactPath, timeoutMs, pollIntervalMs, startTime);
166
+ }
167
+
168
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
169
+
170
+ function isInteractiveData(v: unknown): v is InteractiveData {
171
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
172
+ const raw = v as Record<string, unknown>;
173
+ return (
174
+ (raw["status"] === "open" ||
175
+ raw["status"] === "complete" ||
176
+ raw["status"] === "expired" ||
177
+ raw["status"] === "cancelled") &&
178
+ Array.isArray(raw["questions"])
179
+ );
180
+ }
181
+
182
+ function extractAnswers(interactive: InteractiveData): Record<string, AnswerValue> {
183
+ const answers: Record<string, AnswerValue> = {};
184
+ for (const [id, entry] of Object.entries(interactive.answers)) {
185
+ answers[id] = entry.value;
186
+ }
187
+ return answers;
188
+ }
189
+
190
+ async function sleep(ms: number): Promise<void> {
191
+ return new Promise((resolve) => setTimeout(resolve, ms));
192
+ }