@pinagent/react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,4681 @@
1
+ import { Buffer as Buffer$1 } from "node:buffer";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { mkdir, open, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
6
+ import { and, and as and$1, asc, asc as asc$1, count, eq, eq as eq$1, gt, gte, lt, notInArray, sql } from "drizzle-orm";
7
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
8
+ import { z } from "zod";
9
+ import { nanoid } from "nanoid";
10
+ import { createHash } from "node:crypto";
11
+ import { DatabaseSync } from "node:sqlite";
12
+ import { fileURLToPath } from "node:url";
13
+ import { drizzle } from "drizzle-orm/sqlite-proxy";
14
+ import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
15
+ import { createInterface } from "node:readline";
16
+ import { WebSocket, WebSocketServer } from "ws";
17
+ //#region \0rolldown/runtime.js
18
+ var __defProp = Object.defineProperty;
19
+ var __exportAll = (all, no_symbols) => {
20
+ let target = {};
21
+ for (var name in all) __defProp(target, name, {
22
+ get: all[name],
23
+ enumerable: true
24
+ });
25
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
26
+ return target;
27
+ };
28
+ //#endregion
29
+ //#region ../db/dist/schema.js
30
+ var schema_exports = /* @__PURE__ */ __exportAll({
31
+ activeRuns: () => activeRuns,
32
+ auditEvents: () => auditEvents,
33
+ conversations: () => conversations,
34
+ messages: () => messages,
35
+ pullRequests: () => pullRequests,
36
+ widgetAnchors: () => widgetAnchors
37
+ });
38
+ /**
39
+ * Pinagent persistent state. Shared between the dev-side server
40
+ * (Node's built-in `node:sqlite` via `@pinagent/agent-runner`'s
41
+ * `src/db/client.ts`) and the browser cache (`@sqlite.org/sqlite-wasm` via
42
+ * `@pinagent/widget/db/client`).
43
+ *
44
+ * Server-side is the source of truth: it owns the agent runs, log
45
+ * files, and worktrees. The browser store mirrors only the
46
+ * conversations the current page cares about and is rebuilt from
47
+ * server state if it ever diverges.
48
+ *
49
+ * Naming follows the v2 plan (`pinagent-v2-plan.md` §4.2). When you
50
+ * change a column here, run `pnpm --filter @pinagent/db drizzle:gen`
51
+ * to produce a new migration; the server applies migrations on
52
+ * connect.
53
+ */
54
+ /**
55
+ * One row per pinagent comment a developer submits. id is the nanoid
56
+ * (matches the existing flat-file feedback id) so we can migrate
57
+ * piecemeal without rewriting IDs.
58
+ *
59
+ * `status` mirrors what the MCP server writes today via `Storage.patch`
60
+ * (`pending` / `fixed` / `wontfix` / `deferred`). `agentSessionId` is
61
+ * the Claude Agent SDK session id we resume on follow-up turns.
62
+ */
63
+ const conversations = sqliteTable("conversations", {
64
+ id: text("id").primaryKey(),
65
+ /**
66
+ * Comment text the developer wrote when submitting. Kept here in
67
+ * addition to the original feedback JSON so SQLite-only readers
68
+ * (browser cache) have it without a round-trip.
69
+ */
70
+ comment: text("comment").notNull(),
71
+ /** SDK session id, set after the first agent run. */
72
+ agentSessionId: text("agent_session_id"),
73
+ status: text("status", { enum: [
74
+ "pending",
75
+ "fixed",
76
+ "wontfix",
77
+ "deferred"
78
+ ] }).notNull().default("pending"),
79
+ /** Optional note left by the agent on resolve. */
80
+ note: text("note"),
81
+ /** Optional commit sha if the agent committed its fix. */
82
+ commitSha: text("commit_sha"),
83
+ /** When spawn mode is `worktree`, the git branch the agent ran in. */
84
+ branch: text("branch"),
85
+ /** When spawn mode is `worktree`, the absolute worktree path. */
86
+ worktreePath: text("worktree_path"),
87
+ /**
88
+ * Lifecycle of the worktree itself, orthogonal to `status` (which
89
+ * tracks the developer's intent toward the feedback). `none` for
90
+ * inline-mode rows that never created a worktree; `active` while the
91
+ * worktree exists on disk; `landed` after a successful merge into
92
+ * the project's HEAD branch; `discarded` after the developer threw
93
+ * the work away. Once non-`active`, Land/Discard controls are hidden.
94
+ */
95
+ worktreeState: text("worktree_state", { enum: [
96
+ "none",
97
+ "active",
98
+ "landed",
99
+ "discarded"
100
+ ] }).notNull().default("none"),
101
+ /**
102
+ * User-supplied title override. NULL means the dock falls back to a
103
+ * title derived from `comment` (first non-empty line, ≤80 chars).
104
+ * Renaming clears to NULL on empty input so the user can revert to
105
+ * the agent-derived title without remembering what it was.
106
+ */
107
+ title: text("title"),
108
+ /**
109
+ * Soft-archive flag. Archived conversations are hidden from the
110
+ * default Conversations list and excluded from the FAB pending count.
111
+ * Orthogonal to status + worktreeState — you can archive an active
112
+ * worktree (the worktree stays on disk) or a landed one (just hides
113
+ * it from the active surface).
114
+ */
115
+ archived: integer("archived", { mode: "boolean" }).notNull().default(false),
116
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().default(sql`(unixepoch() * 1000)`),
117
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().default(sql`(unixepoch() * 1000)`),
118
+ resolvedAt: integer("resolved_at", { mode: "timestamp_ms" })
119
+ });
120
+ /**
121
+ * DOM anchor metadata captured at pick time so the widget can
122
+ * re-anchor itself across HMR / reloads (v2 plan Phase G). One row per
123
+ * conversation.
124
+ *
125
+ * `clickX` / `clickY` are the cursor position relative to the target
126
+ * element's top-left — preserved through scrolls and layout shifts the
127
+ * same way the live widget does it.
128
+ */
129
+ const widgetAnchors = sqliteTable("widget_anchors", {
130
+ conversationId: text("conversation_id").primaryKey().references(() => conversations.id, { onDelete: "cascade" }),
131
+ url: text("url").notNull(),
132
+ /** From the `data-pa-loc` build-time attribute. */
133
+ file: text("file"),
134
+ line: integer("line"),
135
+ col: integer("col"),
136
+ /** CSS-selector fallback for when `data-pa-loc` isn't available. */
137
+ selector: text("selector").notNull(),
138
+ clickX: integer("click_x"),
139
+ clickY: integer("click_y"),
140
+ viewportW: integer("viewport_w"),
141
+ viewportH: integer("viewport_h"),
142
+ /** From `navigator.userAgent` at pick time. Mostly for debugging. */
143
+ userAgent: text("user_agent"),
144
+ /**
145
+ * Enclosing component name, from the `data-pa-comp` attribute the
146
+ * Babel plugin stamps next to `data-pa-loc`. Lets the agent prompt say
147
+ * "you clicked inside `<PriceCard>`" rather than a bare file:line. Null
148
+ * in uninstrumented apps or when the element sits outside any
149
+ * PascalCase component.
150
+ */
151
+ component: text("component"),
152
+ /**
153
+ * Outer→inner chain of distinct enclosing component names, e.g.
154
+ * `["App", "PriceList", "PriceCard"]`. Gives the agent structural
155
+ * context about where in the component tree the target lives. Null
156
+ * when nothing is instrumented.
157
+ */
158
+ componentPath: text("component_path", { mode: "json" }).$type(),
159
+ /**
160
+ * Loop-instance disambiguation. When the same JSX literal is rendered
161
+ * more than once (a `.map()`), `data-pa-loc` is ambiguous: many DOM
162
+ * nodes share one file:line. These capture *which* instance the user
163
+ * clicked — `instanceIndex` is the 0-based position among siblings
164
+ * sharing the loc and `instanceTotal` the count. Both null for the
165
+ * common unique-loc case so single-pick rows are unchanged.
166
+ */
167
+ instanceIndex: integer("instance_index"),
168
+ instanceTotal: integer("instance_total"),
169
+ /**
170
+ * A short content fingerprint (text snippet + identity-ish attributes)
171
+ * of the clicked instance, so the agent can locate the right `.map()`
172
+ * item from the screenshot + source even though the file:line points
173
+ * at the shared JSX literal. Null unless the loc was ambiguous.
174
+ */
175
+ instanceFingerprint: text("instance_fingerprint"),
176
+ /**
177
+ * Secondary elements picked via Cmd/Ctrl-click before the committing
178
+ * click. Same shape as the primary anchor's location columns, minus
179
+ * the viewport/userAgent context (those are shared at the conversation
180
+ * level). Null when the user picked exactly one element — the common
181
+ * case — so the column adds no cost to single-pick conversations.
182
+ */
183
+ additionalAnchors: text("additional_anchors", { mode: "json" }).$type()
184
+ });
185
+ /**
186
+ * Append-only transcript of agent events. One row per AgentEvent
187
+ * (init / text / tool_use / tool_result / ask_user / error / result)
188
+ * plus user messages typed in the widget (`role: 'user'`).
189
+ *
190
+ * `turn` increments per agent turn so we can group events for replay
191
+ * and for the "Turn N" sections in the markdown log file.
192
+ *
193
+ * This table IS the source of truth for the event stream. The bus
194
+ * (`packages/agent-runner/src/bus.ts`) writes every publish straight
195
+ * to this table and subscribers poll it — so cross-process delivery
196
+ * works even when Vite-style dual-context module loading would
197
+ * otherwise split an in-memory bus into separate instances. A `role`
198
+ * of `__finished` is the bus's end-of-run sentinel (excluded from
199
+ * external reads).
200
+ */
201
+ const messages = sqliteTable("messages", {
202
+ id: integer("id").primaryKey({ autoIncrement: true }),
203
+ conversationId: text("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
204
+ turn: integer("turn").notNull(),
205
+ /**
206
+ * Discriminator. Matches AgentEvent.type plus `'user'` for typed
207
+ * follow-ups. Keep as text (not an enum) so adding new event types
208
+ * doesn't require a migration.
209
+ */
210
+ role: text("role").notNull(),
211
+ /** Full event payload as JSON — kept opaque so schema evolves freely. */
212
+ content: text("content", { mode: "json" }).notNull(),
213
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().default(sql`(unixepoch() * 1000)`)
214
+ });
215
+ /**
216
+ * One row per in-flight SDK run. Server-only — the browser doesn't
217
+ * need to know which runs are active, only their event stream.
218
+ *
219
+ * `awaitingAskId` is set whenever the `ask_user` tool is blocking on a
220
+ * human response; lets the widget render the pending question
221
+ * authoritatively even on a fresh page load.
222
+ */
223
+ const activeRuns = sqliteTable("active_runs", {
224
+ conversationId: text("conversation_id").primaryKey().references(() => conversations.id, { onDelete: "cascade" }),
225
+ startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(),
226
+ currentTurn: integer("current_turn").notNull(),
227
+ awaitingAskId: text("awaiting_ask_id"),
228
+ lastError: text("last_error")
229
+ });
230
+ /**
231
+ * One row per GitHub PR the dock's compose flow has opened. Populated
232
+ * by `composePullRequest` on the success path and read by the dock's
233
+ * PRs route. The `state` column starts as `open` and is intended to be
234
+ * reconciled against the GitHub API by a future refresh job — the
235
+ * write path here only knows about creation.
236
+ *
237
+ * `conversationIds` is stored as a JSON array of the feedback ids the
238
+ * compose flow bundled into the PR, mirroring what
239
+ * `ComposeOpts.feedbackIds` carried in.
240
+ */
241
+ const pullRequests = sqliteTable("pull_requests", {
242
+ id: integer("id").primaryKey({ autoIncrement: true }),
243
+ /** GitHub PR number (unique within the repo). */
244
+ number: integer("number").notNull(),
245
+ /** Octokit's `html_url` — what the dock links out to. */
246
+ url: text("url").notNull(),
247
+ /** Compose branch name pushed for this PR. */
248
+ branch: text("branch").notNull(),
249
+ /** Target branch the PR merges into. */
250
+ baseBranch: text("base_branch").notNull(),
251
+ title: text("title").notNull(),
252
+ /**
253
+ * PR body (markdown). Kept so the dock can show a preview without
254
+ * round-tripping GitHub.
255
+ */
256
+ body: text("body").notNull().default(""),
257
+ state: text("state", { enum: [
258
+ "open",
259
+ "merged",
260
+ "closed",
261
+ "draft"
262
+ ] }).notNull().default("open"),
263
+ /** Feedback/conversation ids bundled into this PR. */
264
+ conversationIds: text("conversation_ids", { mode: "json" }).$type().notNull().default(sql`('[]')`),
265
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().default(sql`(unixepoch() * 1000)`),
266
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().default(sql`(unixepoch() * 1000)`)
267
+ });
268
+ /**
269
+ * Append-only audit trail of meaningful project actions. Backs the
270
+ * History route's Activity tab. Rows are derived from explicit emit
271
+ * sites (Storage.create, mergeWorktree, discardWorktree,
272
+ * composePullRequest); the table isn't a generic event sink and isn't
273
+ * trying to mirror every WS event.
274
+ *
275
+ * `conversationId` is nullable so project-wide events (e.g. future
276
+ * settings changes) can live here too, but every action emitted today
277
+ * has one. `action` is kept as text — not an enum — so new actions can
278
+ * land without a migration. `payload` is opaque JSON, shaped per-action
279
+ * (e.g. `{ branch, commitSha }` for `conversation_landed`).
280
+ */
281
+ const auditEvents = sqliteTable("audit_events", {
282
+ id: integer("id").primaryKey({ autoIncrement: true }),
283
+ conversationId: text("conversation_id"),
284
+ actor: text("actor", { enum: [
285
+ "agent",
286
+ "user",
287
+ "system"
288
+ ] }).notNull(),
289
+ action: text("action").notNull(),
290
+ payload: text("payload", { mode: "json" }).$type().notNull().default(sql`('{}')`),
291
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().default(sql`(unixepoch() * 1000)`)
292
+ });
293
+ //#endregion
294
+ //#region ../shared/dist/index.js
295
+ /**
296
+ * Zod schemas for the dock's HTTP boundary. The widget-dock package's
297
+ * LocalTransport uses these to parse responses before handing them to
298
+ * React; the server side (agent-runner via vite-plugin / next-plugin)
299
+ * is free to use the same schemas to typecheck its return values.
300
+ *
301
+ * Phase 7b expanded the coverage to every read endpoint the dock hits.
302
+ * Write endpoints (mutations) reuse the same response schemas.
303
+ *
304
+ * `.loose()` everywhere — unknown fields survive the parse so
305
+ * additions to a payload don't break old dock builds.
306
+ */
307
+ /** Dock-rendered status. Mirrors @pinagent/ui's StatusKey. */
308
+ const StatusKeySchema = z.enum([
309
+ "pending",
310
+ "working",
311
+ "awaitingClarification",
312
+ "readyToLand",
313
+ "landed",
314
+ "discarded",
315
+ "error",
316
+ "anchorLost"
317
+ ]);
318
+ const AnchorSchema = z.object({
319
+ loc: z.string(),
320
+ selector: z.string(),
321
+ snippet: z.string()
322
+ }).loose();
323
+ z.object({
324
+ id: z.string(),
325
+ shortId: z.string(),
326
+ title: z.string(),
327
+ status: StatusKeySchema,
328
+ page: z.string(),
329
+ anchor: AnchorSchema,
330
+ branch: z.string(),
331
+ updatedAt: z.string(),
332
+ lastMessage: z.string(),
333
+ messageCount: z.number().int().nonnegative()
334
+ }).loose().extend({
335
+ comment: z.string(),
336
+ screenshot: z.string().nullable()
337
+ }).loose();
338
+ z.object({
339
+ id: z.string(),
340
+ conversationId: z.string(),
341
+ conversationTitle: z.string(),
342
+ status: z.enum([
343
+ "readyToLand",
344
+ "pending",
345
+ "landed",
346
+ "error"
347
+ ]),
348
+ branch: z.string(),
349
+ filesChanged: z.number().int().nonnegative(),
350
+ additions: z.number().int().nonnegative(),
351
+ deletions: z.number().int().nonnegative(),
352
+ preview: z.string(),
353
+ updatedAt: z.string()
354
+ }).loose();
355
+ z.object({
356
+ diff: z.string(),
357
+ truncated: z.boolean(),
358
+ /**
359
+ * Absolute path of the conversation's worktree, so the dock can open a
360
+ * changed file at the agent's edited version. Optional for older servers.
361
+ */
362
+ worktreePath: z.string().optional()
363
+ }).loose();
364
+ z.object({
365
+ id: z.string(),
366
+ name: z.string(),
367
+ conversationId: z.string().nullable(),
368
+ conversationTitle: z.string().nullable(),
369
+ createdAt: z.string(),
370
+ lastActivity: z.string(),
371
+ state: z.enum([
372
+ "clean",
373
+ "uncommitted",
374
+ "behind-base"
375
+ ]),
376
+ diskMb: z.number().nullable()
377
+ }).loose();
378
+ z.object({
379
+ id: z.string(),
380
+ number: z.number().int(),
381
+ title: z.string(),
382
+ state: z.enum([
383
+ "open",
384
+ "merged",
385
+ "closed",
386
+ "draft"
387
+ ]),
388
+ branch: z.string(),
389
+ baseBranch: z.string(),
390
+ url: z.string(),
391
+ updatedAt: z.string(),
392
+ conversationIds: z.array(z.string())
393
+ }).loose();
394
+ const WorkingCopyFileSchema = z.object({
395
+ path: z.string(),
396
+ added: z.number().int().nonnegative(),
397
+ deleted: z.number().int().nonnegative(),
398
+ status: z.enum([
399
+ "modified",
400
+ "added",
401
+ "deleted",
402
+ "renamed"
403
+ ])
404
+ }).loose();
405
+ z.object({
406
+ branch: z.string(),
407
+ baseBranch: z.string(),
408
+ isDefaultBranch: z.boolean(),
409
+ filesChanged: z.number().int().nonnegative(),
410
+ additions: z.number().int().nonnegative(),
411
+ deletions: z.number().int().nonnegative(),
412
+ files: z.array(WorkingCopyFileSchema),
413
+ ahead: z.number().int().nonnegative(),
414
+ behind: z.number().int().nonnegative(),
415
+ hasUpstream: z.boolean(),
416
+ dirty: z.boolean(),
417
+ pr: z.object({
418
+ number: z.number().int(),
419
+ url: z.string(),
420
+ state: z.enum([
421
+ "open",
422
+ "merged",
423
+ "closed",
424
+ "draft"
425
+ ])
426
+ }).nullable()
427
+ }).loose();
428
+ z.object({
429
+ github: z.object({
430
+ connected: z.boolean(),
431
+ login: z.string().nullable()
432
+ }).loose(),
433
+ anthropic: z.object({ keySet: z.boolean() }).loose()
434
+ }).loose();
435
+ /**
436
+ * The three permission-mode options the dock's Settings picker exposes,
437
+ * pinned alongside their SDK-mode equivalents and all human-facing
438
+ * label text. Single source of truth — adding a fourth mode = one
439
+ * append to this list, no scattered edits across the dock UI, the
440
+ * detail-header chip, and the server-side translator.
441
+ *
442
+ * Consumers:
443
+ * - `PermissionModeSchema` below (zod enum derived from `projectMode`)
444
+ * - `agent-runner/settings-store` re-exports the schema/type
445
+ * - `agent-runner/agent.toSdkPermissionMode` looks up `sdkMode`
446
+ * - `widget-dock/routes/Settings.tsx` iterates this list for the
447
+ * picker (uses `label` + `description`)
448
+ * - `widget-dock/lib/permissionMode.ts` looks up the SDK→display
449
+ * mapping for the detail-header chip (uses `shortLabel` + `tooltip`)
450
+ *
451
+ * `as const` so consumers can derive literal-string types.
452
+ */
453
+ const PROJECT_PERMISSION_MODES = [
454
+ {
455
+ projectMode: "auto",
456
+ sdkMode: "acceptEdits",
457
+ label: "Auto-accept edits",
458
+ shortLabel: "Auto-accept",
459
+ description: "Agent edits land in the worktree without confirmation.",
460
+ tooltip: "Auto-accept edits — tool calls run without prompting."
461
+ },
462
+ {
463
+ projectMode: "approve",
464
+ sdkMode: "default",
465
+ label: "Require approval",
466
+ shortLabel: "Approval required",
467
+ description: "Each edit pauses for your approval before applying.",
468
+ tooltip: "Approval required — the agent prompts before each tool call."
469
+ },
470
+ {
471
+ projectMode: "dry-run",
472
+ sdkMode: "plan",
473
+ label: "Dry-run only",
474
+ shortLabel: "Dry-run",
475
+ description: "Agents propose but never write. Useful for review-only setups.",
476
+ tooltip: "Dry-run — plan mode: the agent reasons without running tools."
477
+ }
478
+ ];
479
+ const PermissionModeSchema = z.enum(PROJECT_PERMISSION_MODES.map((m) => m.projectMode));
480
+ z.object({
481
+ baseBranch: z.string(),
482
+ worktreeRetentionDays: z.number().int().nonnegative(),
483
+ perConversationCapUsd: z.number(),
484
+ monthlyBudgetUsd: z.number().nullable(),
485
+ permissionMode: PermissionModeSchema,
486
+ /**
487
+ * If `PINAGENT_AGENT_PERMISSION_MODE` is set on the dev server, this
488
+ * carries the resolved SDK mode that will *actually* be used at
489
+ * spawn time — overriding the user's saved `permissionMode` above.
490
+ * `null` when no env override is active. Read-only on the wire:
491
+ * `ProjectSettingsPatchSchema.partial()` silently drops it on PATCH.
492
+ *
493
+ * Default is `null` so older servers without this field still parse
494
+ * cleanly into the dock.
495
+ */
496
+ permissionModeOverride: z.string().nullable().default(null)
497
+ }).loose();
498
+ z.object({
499
+ pruned: z.array(z.string()),
500
+ failed: z.array(z.object({
501
+ feedbackId: z.string(),
502
+ error: z.string()
503
+ }).loose()),
504
+ retentionDays: z.number().int().nonnegative()
505
+ }).loose();
506
+ const AuditActorSchema = z.enum([
507
+ "agent",
508
+ "user",
509
+ "system"
510
+ ]);
511
+ z.object({
512
+ id: z.string(),
513
+ conversationId: z.string().nullable(),
514
+ actor: AuditActorSchema,
515
+ action: z.string(),
516
+ payload: z.record(z.string(), z.unknown()),
517
+ createdAt: z.string()
518
+ }).loose();
519
+ const HistoryMatchedFieldSchema = z.enum([
520
+ "comment",
521
+ "note",
522
+ "branch",
523
+ "anchor",
524
+ "selector"
525
+ ]);
526
+ z.object({
527
+ id: z.string(),
528
+ comment: z.string(),
529
+ status: z.enum([
530
+ "fixed",
531
+ "wontfix",
532
+ "pending",
533
+ "deferred"
534
+ ]),
535
+ worktreeState: z.enum([
536
+ "none",
537
+ "active",
538
+ "landed",
539
+ "discarded"
540
+ ]),
541
+ branch: z.string().nullable(),
542
+ file: z.string().nullable(),
543
+ line: z.number().nullable(),
544
+ col: z.number().nullable(),
545
+ selector: z.string(),
546
+ url: z.string(),
547
+ createdAt: z.string(),
548
+ updatedAt: z.string(),
549
+ resolvedAt: z.string().nullable(),
550
+ matchedFields: z.array(HistoryMatchedFieldSchema),
551
+ snippet: z.string()
552
+ }).loose();
553
+ /**
554
+ * postMessage protocol between the embedded dock iframe and its host
555
+ * page. Defines the wire contract; no runtime yet — the EmbeddedTransport
556
+ * class that implements this protocol lands in a follow-up phase once
557
+ * the dock has a real cross-origin context to talk to (the hosted
558
+ * dashboard relay).
559
+ *
560
+ * Why the contract lives in @pinagent/shared today rather than in the
561
+ * dock or host-script package:
562
+ *
563
+ * - Both sides of the boundary (the dock and the host's relay) need
564
+ * the same schemas to validate inbound frames; pulling them from
565
+ * a third package keeps either side independently swappable.
566
+ *
567
+ * - The hosted relay (apps/cloud) and the local dev relay
568
+ * (packages/vite-plugin / packages/next-plugin) will both
569
+ * implement the same host-side surface; one source of truth for
570
+ * their input grammar makes the parity story explicit.
571
+ *
572
+ * Origin checking is strict in both directions and lives in the future
573
+ * EmbeddedTransport class; the schemas here only validate shapes, not
574
+ * provenance.
575
+ *
576
+ * Spec reference: pinpoint-dock-surface.md §5 (postMessage protocol).
577
+ */
578
+ /**
579
+ * RPC-style request. `id` is a UUID-ish correlation token the host
580
+ * echoes back on the response so the dock can resolve the right
581
+ * Promise. `path` mirrors the same-origin URL the LocalTransport hits
582
+ * today (`/__pinagent/...`) so the host relay can forward without
583
+ * its own routing table.
584
+ */
585
+ const DockToHostQuerySchema = z.object({
586
+ type: z.literal("query"),
587
+ id: z.string().min(1),
588
+ path: z.string().min(1),
589
+ params: z.record(z.string(), z.unknown()).optional()
590
+ }).strict();
591
+ const DockToHostMutateSchema = z.object({
592
+ type: z.literal("mutate"),
593
+ id: z.string().min(1),
594
+ path: z.string().min(1),
595
+ body: z.unknown()
596
+ }).strict();
597
+ /**
598
+ * Open a long-lived subscription to a server channel (project events,
599
+ * per-conversation event stream, etc). The host relay keeps a map of
600
+ * `subscriptionId → backend listener` and pushes back `event` frames.
601
+ */
602
+ const DockToHostSubscribeSchema = z.object({
603
+ type: z.literal("subscribe"),
604
+ channel: z.string().min(1),
605
+ subscriptionId: z.string().min(1),
606
+ params: z.record(z.string(), z.unknown()).optional()
607
+ }).strict();
608
+ const DockToHostUnsubscribeSchema = z.object({
609
+ type: z.literal("unsubscribe"),
610
+ subscriptionId: z.string().min(1)
611
+ }).strict();
612
+ /**
613
+ * Ask the host to open a popup window (OAuth flow, external link). The
614
+ * host owns the window because the iframe sandbox bars
615
+ * `allow-top-navigation`. Response comes back as `popup-closed` with
616
+ * an optional result the popup posted to its opener pre-close.
617
+ */
618
+ const DockToHostOpenPopupSchema = z.object({
619
+ type: z.literal("open-popup"),
620
+ url: z.string().url(),
621
+ subscriptionId: z.string().min(1)
622
+ }).strict();
623
+ /**
624
+ * Pure UI signals — open/close/resize — that the host might react to
625
+ * (e.g. shifting the underlying page when the dock opens in panel
626
+ * mode). The host doesn't have to handle these; the dock fires them
627
+ * for observability.
628
+ */
629
+ const DockToHostUiEventSchema = z.object({
630
+ type: z.literal("ui-event"),
631
+ event: z.enum([
632
+ "open",
633
+ "close",
634
+ "resize"
635
+ ]),
636
+ payload: z.record(z.string(), z.unknown()).optional()
637
+ }).strict();
638
+ z.discriminatedUnion("type", [
639
+ DockToHostQuerySchema,
640
+ DockToHostMutateSchema,
641
+ DockToHostSubscribeSchema,
642
+ DockToHostUnsubscribeSchema,
643
+ DockToHostOpenPopupSchema,
644
+ DockToHostUiEventSchema
645
+ ]);
646
+ /**
647
+ * RPC response — `ok: true` carries the data, `ok: false` carries a
648
+ * code + human-readable message so the dock can route into its
649
+ * ErrorState components without re-deriving from string contents.
650
+ */
651
+ const HostToDockResponseSchema = z.discriminatedUnion("ok", [z.object({
652
+ type: z.literal("response"),
653
+ id: z.string().min(1),
654
+ ok: z.literal(true),
655
+ data: z.unknown()
656
+ }).strict(), z.object({
657
+ type: z.literal("response"),
658
+ id: z.string().min(1),
659
+ ok: z.literal(false),
660
+ error: z.object({
661
+ code: z.string().min(1),
662
+ message: z.string().min(1)
663
+ }).strict()
664
+ }).strict()]);
665
+ const HostToDockEventSchema = z.object({
666
+ type: z.literal("event"),
667
+ subscriptionId: z.string().min(1),
668
+ payload: z.unknown()
669
+ }).strict();
670
+ const HostToDockPopupClosedSchema = z.object({
671
+ type: z.literal("popup-closed"),
672
+ subscriptionId: z.string().min(1),
673
+ result: z.record(z.string(), z.unknown()).optional()
674
+ }).strict();
675
+ /**
676
+ * Once at iframe load (and on any host-side change worth re-broadcasting),
677
+ * the host pushes its environment: which URL it's on, current viewport
678
+ * dimensions, current theme. Lets the dock skip its own re-render
679
+ * dance for context the host already knows.
680
+ */
681
+ const HostToDockContextSchema = z.object({
682
+ type: z.literal("host-context"),
683
+ payload: z.object({
684
+ url: z.string(),
685
+ viewport: z.object({
686
+ w: z.number().int().nonnegative(),
687
+ h: z.number().int().nonnegative()
688
+ }).strict(),
689
+ theme: z.enum(["light", "dark"])
690
+ }).strict()
691
+ }).strict();
692
+ z.union([
693
+ HostToDockResponseSchema,
694
+ HostToDockEventSchema,
695
+ HostToDockPopupClosedSchema,
696
+ HostToDockContextSchema
697
+ ]);
698
+ /**
699
+ * Zod mirror of the AgentEvent union. Kept alongside the TS type so
700
+ * the wire-boundary parse (ws-client on the dock) catches shape drift
701
+ * the moment the server adds or renames a field — better than React
702
+ * rendering `undefined` at runtime. Use `.loose()` on each
703
+ * object so unknown fields survive the parse instead of being
704
+ * stripped; future event-payload additions stay backwards compatible
705
+ * for old clients.
706
+ */
707
+ const AgentEventSchema = z.discriminatedUnion("type", [
708
+ z.object({
709
+ type: z.literal("init"),
710
+ sessionId: z.string(),
711
+ model: z.string(),
712
+ permissionMode: z.string(),
713
+ apiKeySource: z.string()
714
+ }).loose(),
715
+ z.object({
716
+ type: z.literal("text"),
717
+ text: z.string()
718
+ }).loose(),
719
+ z.object({
720
+ type: z.literal("tool_use"),
721
+ name: z.string(),
722
+ summary: z.string()
723
+ }).loose(),
724
+ z.object({
725
+ type: z.literal("tool_result"),
726
+ ok: z.boolean()
727
+ }).loose(),
728
+ z.object({
729
+ type: z.literal("progress"),
730
+ turn: z.number()
731
+ }).loose(),
732
+ z.object({
733
+ type: z.literal("ask_user"),
734
+ askId: z.string(),
735
+ question: z.string(),
736
+ context: z.string().optional(),
737
+ options: z.array(z.string()).optional()
738
+ }).loose(),
739
+ z.object({
740
+ type: z.literal("error"),
741
+ message: z.string()
742
+ }).loose(),
743
+ z.object({
744
+ type: z.literal("result"),
745
+ subtype: z.string(),
746
+ numTurns: z.number(),
747
+ totalCostUsd: z.number(),
748
+ durationMs: z.number(),
749
+ errors: z.array(z.string()).optional()
750
+ }).loose(),
751
+ z.object({
752
+ type: z.literal("status_changed"),
753
+ status: z.enum([
754
+ "pending",
755
+ "fixed",
756
+ "wontfix",
757
+ "deferred"
758
+ ]),
759
+ note: z.string().nullable(),
760
+ commitSha: z.string().nullable(),
761
+ resolvedAt: z.string().nullable()
762
+ }).loose()
763
+ ]);
764
+ /**
765
+ * True when a run's reported `total_cost_usd` is notional rather than a
766
+ * real charge — i.e. the SDK authenticated via a `claude login` session
767
+ * (`apiKeySource === 'oauth'`) and the cost is billed against the Claude
768
+ * subscription quota, not the developer's card. Both the in-page widget
769
+ * footer and the dock's cost badge gate on this so the two surfaces can
770
+ * never disagree on what counts as a billed run. If the SDK ever reports
771
+ * a different string for subscription auth, change it here only.
772
+ */
773
+ function isNotionalCost(apiKeySource) {
774
+ return apiKeySource === "oauth";
775
+ }
776
+ /**
777
+ * Wire-format messages between the browser widget and the dev-side
778
+ * WebSocket server.
779
+ *
780
+ * Validated on the server with the Zod schemas below. Client-side is
781
+ * untyped at the wire boundary — the widget renders defensively.
782
+ *
783
+ * Reserved for the connection lifecycle:
784
+ * - `ping` / `pong` — explicit liveness check (the `ws` library also
785
+ * runs lower-level WS ping frames; this is
786
+ * application-level and visible in protocol logs).
787
+ *
788
+ * Per-feedback subscribe/unsubscribe so one socket can multiplex
789
+ * multiple in-flight agents — sets us up for the v2 "multiple widgets
790
+ * per page" goal without changing the wire format.
791
+ */
792
+ const FeedbackId = z.string().min(8).max(16).regex(/^[A-Za-z0-9_-]+$/);
793
+ const ClientMessageSchema = z.discriminatedUnion("type", [
794
+ z.object({
795
+ type: z.literal("subscribe"),
796
+ feedbackId: FeedbackId
797
+ }),
798
+ z.object({
799
+ type: z.literal("unsubscribe"),
800
+ feedbackId: FeedbackId
801
+ }),
802
+ z.object({
803
+ type: z.literal("user_message"),
804
+ feedbackId: FeedbackId,
805
+ content: z.string().min(1).max(8e3)
806
+ }),
807
+ z.object({
808
+ type: z.literal("ask_response"),
809
+ askId: z.string().min(1).max(64),
810
+ answer: z.string().max(8e3)
811
+ }),
812
+ z.object({
813
+ type: z.literal("interrupt"),
814
+ feedbackId: FeedbackId
815
+ }),
816
+ z.object({
817
+ type: z.literal("land_request"),
818
+ feedbackId: FeedbackId
819
+ }),
820
+ z.object({
821
+ type: z.literal("discard_request"),
822
+ feedbackId: FeedbackId
823
+ }),
824
+ z.object({
825
+ type: z.literal("reopen_request"),
826
+ feedbackId: FeedbackId
827
+ }),
828
+ z.object({ type: z.literal("subscribe_project") }),
829
+ z.object({ type: z.literal("unsubscribe_project") }),
830
+ z.object({
831
+ type: z.literal("extension_hello"),
832
+ version: z.string().max(32).optional()
833
+ }),
834
+ z.object({ type: z.literal("query_extension") }),
835
+ z.object({
836
+ type: z.literal("set_branch_routing"),
837
+ defaultBaseBranch: z.string().min(1).max(128).nullable(),
838
+ allowedBranchPatterns: z.array(z.string().min(1).max(128)).max(50)
839
+ }),
840
+ z.object({ type: z.literal("ping") })
841
+ ]);
842
+ const ProjectEventSchema = z.discriminatedUnion("type", [z.object({ type: z.literal("conversations_changed") }).loose(), z.object({ type: z.literal("worktree_servers_changed") }).loose()]);
843
+ /**
844
+ * Phase H lifecycle states the server can broadcast for a worktree.
845
+ * Mirrors `Conversation.worktreeState` plus two transient states:
846
+ * - `landing` / `discarding` — operation is in flight (optimistic UI hint)
847
+ * - `conflict` — merge aborted because of conflicts; `conflicts` lists files
848
+ * - `ttl_warning` — orphan-sweeper found this worktree past TTL
849
+ */
850
+ const WorktreeWireStateSchema = z.enum([
851
+ "none",
852
+ "active",
853
+ "landing",
854
+ "landed",
855
+ "discarding",
856
+ "discarded",
857
+ "conflict",
858
+ "ttl_warning"
859
+ ]);
860
+ z.discriminatedUnion("type", [
861
+ z.object({
862
+ type: z.literal("event"),
863
+ feedbackId: z.string(),
864
+ event: AgentEventSchema
865
+ }).loose(),
866
+ z.object({
867
+ type: z.literal("done"),
868
+ feedbackId: z.string()
869
+ }).loose(),
870
+ z.object({
871
+ type: z.literal("error"),
872
+ feedbackId: z.string().optional(),
873
+ message: z.string()
874
+ }).loose(),
875
+ z.object({
876
+ type: z.literal("worktree_state"),
877
+ feedbackId: z.string(),
878
+ state: WorktreeWireStateSchema,
879
+ commitSha: z.string().optional(),
880
+ conflicts: z.array(z.string()).optional(),
881
+ message: z.string().optional(),
882
+ changesCount: z.number().optional()
883
+ }).loose(),
884
+ z.object({
885
+ type: z.literal("project_event"),
886
+ event: ProjectEventSchema
887
+ }).loose(),
888
+ z.object({
889
+ type: z.literal("extension_status"),
890
+ present: z.boolean(),
891
+ version: z.string().optional()
892
+ }).loose(),
893
+ z.object({ type: z.literal("pong") }).loose()
894
+ ]);
895
+ //#endregion
896
+ //#region ../agent-runner/dist/host-branch-pr.js
897
+ /**
898
+ * Small helpers for safe on-disk JSON stores (secrets, settings):
899
+ *
900
+ * - `withFileLock` serializes a read-modify-write critical section against a
901
+ * path across all callers in this process, so two concurrent `patch()` calls
902
+ * can't both read the same base and clobber each other's update.
903
+ * - `atomicWriteFile` writes a sibling temp file (at the requested mode) then
904
+ * renames it over the target, so a reader never sees a half-written file and
905
+ * a sensitive file is never momentarily world-readable. A crash mid-write
906
+ * leaves the previous file intact instead of truncating it.
907
+ */
908
+ const locks = /* @__PURE__ */ new Map();
909
+ let tmpCounter = 0;
910
+ /** Run `fn` holding an exclusive in-process lock keyed by `key` (a file path). */
911
+ function withFileLock(key, fn) {
912
+ const run = (locks.get(key) ?? Promise.resolve()).then(fn, fn);
913
+ const tail = run.then(() => {}, () => {});
914
+ locks.set(key, tail);
915
+ tail.then(() => {
916
+ if (locks.get(key) === tail) locks.delete(key);
917
+ });
918
+ return run;
919
+ }
920
+ /**
921
+ * Atomically write `data` to `path`: write a temp sibling (created with `mode`
922
+ * when given, so it's never momentarily more permissive than intended) and
923
+ * rename it over the target. Cleans up the temp on failure.
924
+ */
925
+ async function atomicWriteFile(path, data, mode) {
926
+ await mkdir(dirname(path), { recursive: true });
927
+ const tmp = `${path}.tmp-${process.pid}-${tmpCounter++}`;
928
+ try {
929
+ await writeFile(tmp, data, mode !== void 0 ? { mode } : void 0);
930
+ await rename(tmp, path);
931
+ } catch (err) {
932
+ await rm(tmp, { force: true }).catch(() => {});
933
+ throw err;
934
+ }
935
+ }
936
+ const ProjectSettingsSchema = z.object({
937
+ baseBranch: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9/_.-]*$/, "invalid branch name"),
938
+ worktreeRetentionDays: z.number().int().min(1).max(60),
939
+ perConversationCapUsd: z.number().min(.1).max(1e3),
940
+ monthlyBudgetUsd: z.number().min(0).max(1e5).nullable(),
941
+ permissionMode: PermissionModeSchema,
942
+ allowedBranchPatterns: z.array(z.string().min(1).max(128)).max(50)
943
+ });
944
+ ProjectSettingsSchema.partial();
945
+ const DEFAULT_SETTINGS = {
946
+ baseBranch: "main",
947
+ worktreeRetentionDays: 7,
948
+ perConversationCapUsd: 5,
949
+ monthlyBudgetUsd: null,
950
+ permissionMode: "auto",
951
+ allowedBranchPatterns: []
952
+ };
953
+ var SettingsStore = class {
954
+ projectRoot;
955
+ constructor(projectRoot) {
956
+ this.projectRoot = projectRoot;
957
+ }
958
+ path() {
959
+ return join(this.projectRoot, ".pinagent", "config.json");
960
+ }
961
+ async read() {
962
+ const path = this.path();
963
+ if (!existsSync(path)) return DEFAULT_SETTINGS;
964
+ try {
965
+ const raw = await readFile(path, "utf8");
966
+ const merged = {
967
+ ...DEFAULT_SETTINGS,
968
+ ...JSON.parse(raw)
969
+ };
970
+ return ProjectSettingsSchema.parse(merged);
971
+ } catch {
972
+ return DEFAULT_SETTINGS;
973
+ }
974
+ }
975
+ async patch(patch) {
976
+ const path = this.path();
977
+ return withFileLock(path, async () => {
978
+ const current = await this.read();
979
+ const next = ProjectSettingsSchema.parse({
980
+ ...current,
981
+ ...patch
982
+ });
983
+ await atomicWriteFile(path, JSON.stringify(next, null, 2));
984
+ return next;
985
+ });
986
+ }
987
+ };
988
+ {
989
+ const proc = process;
990
+ const originalEmit = proc.emit.bind(proc);
991
+ proc.emit = (event, ...args) => {
992
+ if (event === "warning") {
993
+ const warning = args[0];
994
+ if (warning instanceof Error && warning.name === "ExperimentalWarning" && warning.message.startsWith("SQLite is an experimental feature")) return false;
995
+ }
996
+ return originalEmit(event, ...args);
997
+ };
998
+ }
999
+ /**
1000
+ * One Drizzle handle per dev process, keyed by project root so a
1001
+ * monorepo with multiple Pinagent-enabled apps gets distinct DBs.
1002
+ *
1003
+ * Storage is per-process and held on a globalThis Symbol so Next 16's
1004
+ * route module re-evaluations don't open the same DB file twice. The
1005
+ * underlying node:sqlite handle is cheap to keep open for the lifetime
1006
+ * of `next dev`.
1007
+ *
1008
+ * We use Node's built-in `node:sqlite` (stable since Node 22.13) rather
1009
+ * than `better-sqlite3` to avoid the native-build install step (pnpm
1010
+ * 10+ blocks postinstall by default; users were hitting
1011
+ * `Could not locate the bindings file` after fresh installs). Same
1012
+ * underlying SQLite engine, same on-disk format — no data migration.
1013
+ *
1014
+ * `drizzle-orm/sqlite-proxy` is the right adapter shape: it takes a
1015
+ * callback that executes SQL against any backend. We thread node:sqlite
1016
+ * synchronous calls through it. Drizzle exposes an async API to
1017
+ * consumers but our storage layer already `await`s everything, so call
1018
+ * sites are unchanged.
1019
+ */
1020
+ const SINGLETON_KEY = Symbol.for("pinagent.db");
1021
+ const moduleUrl = import.meta.url;
1022
+ const MIGRATIONS_DIR = (() => {
1023
+ const base = moduleUrl ? dirname(fileURLToPath(moduleUrl)) : __dirname;
1024
+ const candidates = [
1025
+ resolve(base, "..", "drizzle"),
1026
+ resolve(base, "..", "..", "db", "drizzle"),
1027
+ resolve(base, "..", "..", "..", "db", "drizzle")
1028
+ ];
1029
+ return candidates.find((p) => existsSync(resolve(p, "meta", "_journal.json"))) ?? candidates[0];
1030
+ })();
1031
+ /**
1032
+ * Wrap a `node:sqlite` DatabaseSync in the drizzle-orm/sqlite-proxy
1033
+ * callback shape. `method` distinguishes 'run' (no rows expected,
1034
+ * returns changes / lastInsertRowid) from 'all' / 'get' / 'values'
1035
+ * (rows). The proxy callback is declared async; we just call the sync
1036
+ * primitives and return synchronously — Drizzle awaits the result.
1037
+ */
1038
+ function makeDrizzle(raw) {
1039
+ return drizzle(async (sql, params, method) => {
1040
+ const stmt = raw.prepare(sql);
1041
+ if (method === "run") {
1042
+ const info = stmt.run(...params);
1043
+ return { rows: [{
1044
+ changes: Number(info.changes),
1045
+ lastInsertRowid: info.lastInsertRowid
1046
+ }] };
1047
+ }
1048
+ const rows = stmt.all(...params);
1049
+ const columns = stmt.columns().map((c) => c.column ?? c.name);
1050
+ const projected = rows.map((r) => columns.map((c) => r[c] ?? null));
1051
+ if (method === "get") return { rows: projected[0] ?? [] };
1052
+ return { rows: projected };
1053
+ }, { schema: schema_exports });
1054
+ }
1055
+ /**
1056
+ * Apply every `.sql` migration in journal order. Replaces
1057
+ * `drizzle-orm/better-sqlite3/migrator` (which is bound to the
1058
+ * better-sqlite3 driver) with a tiny equivalent that drives node:sqlite
1059
+ * directly. Mirrors the browser-side migrator pattern in
1060
+ * `packages/widget/src/db/migrations.ts`.
1061
+ */
1062
+ function runMigrations(raw, migrationsDir) {
1063
+ const journalPath = join(migrationsDir, "meta", "_journal.json");
1064
+ if (!existsSync(journalPath)) {
1065
+ console.warn(`[pinagent:db] no migrations dir at ${migrationsDir}; skipping migrate(). Run \`pnpm --filter @pinagent/db drizzle:gen\` to generate.`);
1066
+ return;
1067
+ }
1068
+ raw.exec(`CREATE TABLE IF NOT EXISTS __drizzle_migrations (
1069
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1070
+ hash TEXT NOT NULL,
1071
+ created_at NUMERIC
1072
+ )`);
1073
+ const sortedEntries = JSON.parse(readFileSync(journalPath, "utf8")).entries.slice().sort((a, b) => a.idx - b.idx);
1074
+ const hashOf = (entry) => {
1075
+ const sql = readFileSync(join(migrationsDir, `${entry.tag}.sql`), "utf8");
1076
+ return createHash("sha256").update(sql).digest("hex");
1077
+ };
1078
+ const insert = raw.prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)");
1079
+ const lastRow = raw.prepare("SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1").get();
1080
+ let lastWhen = lastRow?.created_at != null ? Number(lastRow.created_at) : null;
1081
+ if (lastWhen == null && sortedEntries.length > 0) {
1082
+ if (raw.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='conversations'").get()) {
1083
+ for (const entry of sortedEntries) insert.run(hashOf(entry), entry.when);
1084
+ return;
1085
+ }
1086
+ }
1087
+ for (const entry of sortedEntries) {
1088
+ if (lastWhen != null && lastWhen >= entry.when) continue;
1089
+ const sql = readFileSync(join(migrationsDir, `${entry.tag}.sql`), "utf8");
1090
+ const statements = sql.split("--> statement-breakpoint").map((s) => s.trim()).filter(Boolean);
1091
+ for (const stmt of statements) raw.exec(stmt);
1092
+ insert.run(createHash("sha256").update(sql).digest("hex"), entry.when);
1093
+ lastWhen = entry.when;
1094
+ }
1095
+ }
1096
+ function getDb(projectRoot) {
1097
+ const g = globalThis;
1098
+ if (!g[SINGLETON_KEY]) g[SINGLETON_KEY] = /* @__PURE__ */ new Map();
1099
+ const cache = g[SINGLETON_KEY];
1100
+ const root = resolve(projectRoot);
1101
+ const existing = cache.get(root);
1102
+ if (existing) return existing.db;
1103
+ const dbPath = join(root, ".pinagent", "db.sqlite");
1104
+ const pinagentDir = dirname(dbPath);
1105
+ mkdirSync(pinagentDir, { recursive: true });
1106
+ const giPath = join(pinagentDir, ".gitignore");
1107
+ if (!existsSync(giPath)) try {
1108
+ writeFileSync(giPath, "*\n");
1109
+ } catch {}
1110
+ const raw = new DatabaseSync(dbPath);
1111
+ raw.exec("PRAGMA journal_mode = WAL");
1112
+ raw.exec("PRAGMA busy_timeout = 5000");
1113
+ raw.exec("PRAGMA foreign_keys = ON");
1114
+ runMigrations(raw, MIGRATIONS_DIR);
1115
+ raw.exec("DELETE FROM active_runs");
1116
+ const db = makeDrizzle(raw);
1117
+ cache.set(root, {
1118
+ db,
1119
+ raw
1120
+ });
1121
+ return db;
1122
+ }
1123
+ /**
1124
+ * Shared `git` runner. Lifted out of agent.ts so other modules
1125
+ * (changes.ts, future PR-composer code) can use the same spawn shape
1126
+ * without importing the whole agent module + its SDK pulls.
1127
+ */
1128
+ /**
1129
+ * Spawn `git` with the given args in `cwd`, capturing stdout and stderr.
1130
+ * Never rejects on non-zero exit — callers inspect `code` themselves
1131
+ * because non-zero exits are meaningful for several git commands
1132
+ * (`git merge` returns 1 on conflict, `git rev-parse` returns non-zero
1133
+ * for missing refs, etc).
1134
+ */
1135
+ function runGitCapture(cwd, args, opts = {}) {
1136
+ return runCapture("git", args, cwd, opts);
1137
+ }
1138
+ /**
1139
+ * Generic command-capture, same drain-safe shape as {@link runGitCapture}
1140
+ * (resolves on 'close', never rejects on non-zero exit). Used for `gh` (PR
1141
+ * creation fallback) as well as `git`. Rejects only if the binary can't be
1142
+ * spawned (e.g. `gh` not installed → ENOENT) — callers catch that to treat
1143
+ * the tool as unavailable.
1144
+ */
1145
+ function runCapture(file, args, cwd, opts = {}) {
1146
+ return new Promise((resolve, reject) => {
1147
+ const child = spawn(file, args, {
1148
+ cwd,
1149
+ stdio: "pipe"
1150
+ });
1151
+ let stdout = "";
1152
+ let stderr = "";
1153
+ let capped = false;
1154
+ const max = opts.maxBytes;
1155
+ child.stdout.on("data", (d) => {
1156
+ if (capped) return;
1157
+ stdout += d.toString("utf8");
1158
+ if (max !== void 0 && stdout.length >= max) {
1159
+ stdout = stdout.slice(0, max);
1160
+ capped = true;
1161
+ child.kill();
1162
+ }
1163
+ });
1164
+ child.stderr.on("data", (d) => {
1165
+ stderr += d.toString("utf8");
1166
+ });
1167
+ child.on("error", reject);
1168
+ child.on("close", (code) => resolve({
1169
+ code: code ?? -1,
1170
+ stdout,
1171
+ stderr,
1172
+ capped
1173
+ }));
1174
+ });
1175
+ }
1176
+ /**
1177
+ * Spawn `git` and reject on non-zero exit, appending a diagnostic line to
1178
+ * the conversation log. Used for fire-and-forget mutations (`worktree add`)
1179
+ * where the caller wants an exception on failure rather than inspecting a
1180
+ * code — contrast with `runGitCapture`, which never rejects.
1181
+ */
1182
+ function runGit(cwd, args, logPath) {
1183
+ return new Promise((res, rej) => {
1184
+ const child = spawn("git", args, {
1185
+ cwd,
1186
+ stdio: "pipe"
1187
+ });
1188
+ let stderr = "";
1189
+ child.stderr.on("data", (d) => {
1190
+ stderr += d.toString("utf8");
1191
+ });
1192
+ child.on("error", rej);
1193
+ child.on("close", (code) => {
1194
+ if (code === 0) res();
1195
+ else {
1196
+ appendLog(logPath, `[pinagent:git] git ${args.join(" ")} → exit ${code}\n${stderr}\n`).catch(() => {});
1197
+ rej(/* @__PURE__ */ new Error(`git ${args.join(" ")} exited ${code}: ${stderr.trim()}`));
1198
+ }
1199
+ });
1200
+ });
1201
+ }
1202
+ /** Append `text` to the file at `path`, creating it if needed. No-op on empty text. */
1203
+ async function appendLog(path, text) {
1204
+ if (!text) return;
1205
+ const h = await open(path, "a");
1206
+ try {
1207
+ await h.write(text);
1208
+ } finally {
1209
+ await h.close();
1210
+ }
1211
+ }
1212
+ /**
1213
+ * Plaintext secrets at rest under `.pinagent/secrets.json`. The
1214
+ * dock's Connections route reads / writes via the HTTP endpoints
1215
+ * in `vite-plugin` / `next-plugin`, which call into this.
1216
+ *
1217
+ * For local dev: the secrets file lives on the same filesystem as
1218
+ * the user's git credentials, `.env` files, and shell history —
1219
+ * there's no real threat model improvement from encrypting at
1220
+ * rest. The hosted dashboard tier (spec §13 onwards) will need a
1221
+ * different storage backend; the API surface here is shaped so
1222
+ * that swap is local.
1223
+ *
1224
+ * Tokens never round-trip back out — readers either consume the
1225
+ * raw token inside the agent process (composer, SDK), or get the
1226
+ * presentable shape via `presentable()`.
1227
+ */
1228
+ const SecretsFileSchema = z.object({
1229
+ github: z.object({
1230
+ token: z.string().min(1),
1231
+ /** Cached from the validate-on-set GitHub `/user` call. */
1232
+ login: z.string().min(1)
1233
+ }).nullable().optional(),
1234
+ anthropic: z.object({ key: z.string().min(1) }).nullable().optional()
1235
+ }).default({});
1236
+ var SecretsStore = class {
1237
+ projectRoot;
1238
+ constructor(projectRoot) {
1239
+ this.projectRoot = projectRoot;
1240
+ }
1241
+ path() {
1242
+ return join(this.projectRoot, ".pinagent", "secrets.json");
1243
+ }
1244
+ /** Read + parse. Returns `{}` for "no file yet" — that's expected on first run. */
1245
+ async read() {
1246
+ const path = this.path();
1247
+ if (!existsSync(path)) return SecretsFileSchema.parse({});
1248
+ try {
1249
+ const raw = await readFile(path, "utf8");
1250
+ return SecretsFileSchema.parse(JSON.parse(raw));
1251
+ } catch {
1252
+ return SecretsFileSchema.parse({});
1253
+ }
1254
+ }
1255
+ async patch(patch) {
1256
+ const path = this.path();
1257
+ return withFileLock(path, async () => {
1258
+ const next = {
1259
+ ...await this.read(),
1260
+ ...patch
1261
+ };
1262
+ await atomicWriteFile(path, JSON.stringify(next, null, 2), 384);
1263
+ return next;
1264
+ });
1265
+ }
1266
+ async setGithub(token, login) {
1267
+ await this.patch({ github: {
1268
+ token,
1269
+ login
1270
+ } });
1271
+ }
1272
+ async clearGithub() {
1273
+ await this.patch({ github: null });
1274
+ }
1275
+ async setAnthropic(key) {
1276
+ await this.patch({ anthropic: { key } });
1277
+ }
1278
+ async clearAnthropic() {
1279
+ await this.patch({ anthropic: null });
1280
+ }
1281
+ async getGithubToken() {
1282
+ return (await this.read()).github?.token ?? null;
1283
+ }
1284
+ async getAnthropicKey() {
1285
+ return (await this.read()).anthropic?.key ?? null;
1286
+ }
1287
+ /** Token-free view safe to ship over the wire. */
1288
+ async presentable() {
1289
+ const f = await this.read();
1290
+ return {
1291
+ github: {
1292
+ connected: Boolean(f.github),
1293
+ login: f.github?.login ?? null
1294
+ },
1295
+ anthropic: { keySet: Boolean(f.anthropic) }
1296
+ };
1297
+ }
1298
+ };
1299
+ /**
1300
+ * Audit log — append-only record of meaningful project actions. Backs
1301
+ * the dock's History → Activity tab.
1302
+ *
1303
+ * Emitted at the action sites (Storage.create, mergeWorktree,
1304
+ * discardWorktree, composePullRequest) rather than tailing every event
1305
+ * the WS bus carries: the goal is a human-readable trail of what
1306
+ * happened to each conversation, not a transaction log.
1307
+ *
1308
+ * Writes are best-effort — `recordAuditEvent` swallows DB errors so a
1309
+ * failed audit insert can never mask a successful land/discard/PR. The
1310
+ * read side is exposed via `listAuditEvents` and surfaces a single
1311
+ * GET /__pinagent/audit-log endpoint to the dock.
1312
+ */
1313
+ async function recordAuditEvent(projectRoot, input) {
1314
+ try {
1315
+ await getDb(projectRoot).insert(auditEvents).values({
1316
+ conversationId: input.conversationId ?? null,
1317
+ actor: input.actor,
1318
+ action: input.action,
1319
+ payload: input.payload ?? {}
1320
+ });
1321
+ } catch {}
1322
+ }
1323
+ //#endregion
1324
+ //#region ../agent-runner/dist/index.js
1325
+ /**
1326
+ * Resolve the SDK permission mode for a run. Precedence:
1327
+ * `PINAGENT_AGENT_PERMISSION_MODE` env override > project settings
1328
+ * (`.pinagent/config.json` permissionMode) > default.
1329
+ * The env override is kept so CI / power users can bypass the dock UI
1330
+ * without editing the settings file.
1331
+ */
1332
+ async function resolveRunPermissionMode(projectRoot) {
1333
+ const override = resolvePermissionModeOverride(process.env);
1334
+ if (override) return override;
1335
+ return toSdkPermissionMode((await new SettingsStore(projectRoot).read()).permissionMode);
1336
+ }
1337
+ function resolvePermissionMode(env) {
1338
+ const v = env.PINAGENT_AGENT_PERMISSION_MODE;
1339
+ if (v === "default" || v === "acceptEdits" || v === "bypassPermissions" || v === "plan" || v === "dontAsk" || v === "auto") return v;
1340
+ return "acceptEdits";
1341
+ }
1342
+ /**
1343
+ * The active env override for permission mode, or `null` when no
1344
+ * override is set. Different shape from `resolvePermissionMode`, which
1345
+ * falls back to `'acceptEdits'` whether the env was unset or invalid —
1346
+ * callers that need to distinguish "no override" from "override → some
1347
+ * mode" (e.g. the dock's Settings UI banner) want this signal.
1348
+ */
1349
+ function resolvePermissionModeOverride(env) {
1350
+ if (!env.PINAGENT_AGENT_PERMISSION_MODE) return null;
1351
+ return resolvePermissionMode(env);
1352
+ }
1353
+ /**
1354
+ * Map the user-facing project setting to the SDK's permission-mode
1355
+ * value-space. Looks up the shared `PROJECT_PERMISSION_MODES` table so
1356
+ * the mapping stays in sync with the dock's Settings labels and the
1357
+ * detail-header chip.
1358
+ */
1359
+ function toSdkPermissionMode(mode) {
1360
+ return PROJECT_PERMISSION_MODES.find((m) => m.projectMode === mode)?.sdkMode ?? "acceptEdits";
1361
+ }
1362
+ const POLL_INTERVAL_MS = 100;
1363
+ const FINISHED_ROLE = "__finished";
1364
+ var SqliteEventBus = class {
1365
+ feedbackId;
1366
+ projectRoot;
1367
+ constructor(feedbackId, projectRoot) {
1368
+ this.feedbackId = feedbackId;
1369
+ this.projectRoot = projectRoot;
1370
+ }
1371
+ /**
1372
+ * Append an event to the bus. INSERTs one row into `messages` keyed
1373
+ * by the conversation id. Idempotent failures (e.g. the conversation
1374
+ * row doesn't exist yet because POST handler hasn't finished writing
1375
+ * it) are silently swallowed — the caller's event ordering is
1376
+ * preserved by the autoincrement id, and a missing row would only
1377
+ * happen during a narrow startup race we already tolerate.
1378
+ */
1379
+ async publish(event) {
1380
+ try {
1381
+ await getDb(this.projectRoot).insert(messages).values({
1382
+ conversationId: this.feedbackId,
1383
+ turn: 1,
1384
+ role: event.type,
1385
+ content: event
1386
+ });
1387
+ } catch {}
1388
+ }
1389
+ /**
1390
+ * Start delivering events to `sub`. Replays everything written so
1391
+ * far for this feedback id (via the first poll), then delivers new
1392
+ * events as they arrive. Calling the returned function stops
1393
+ * polling.
1394
+ *
1395
+ * Polling is per-subscriber. For our actual subscriber load (1–3
1396
+ * widget connections per feedback) this is cheaper than maintaining
1397
+ * a shared poll loop with a fan-out Set.
1398
+ */
1399
+ subscribe(sub) {
1400
+ let lastSeenId = 0;
1401
+ let stopped = false;
1402
+ let polling = false;
1403
+ const poll = async () => {
1404
+ if (stopped || polling) return;
1405
+ polling = true;
1406
+ try {
1407
+ const rows = await getDb(this.projectRoot).select().from(messages).where(and(eq(messages.conversationId, this.feedbackId), gt(messages.id, lastSeenId))).orderBy(asc(messages.id));
1408
+ for (const row of rows) {
1409
+ if (stopped) return;
1410
+ lastSeenId = row.id;
1411
+ if (row.role === FINISHED_ROLE) {
1412
+ stopped = true;
1413
+ clearInterval(interval);
1414
+ try {
1415
+ sub.onClose();
1416
+ } catch {}
1417
+ return;
1418
+ }
1419
+ try {
1420
+ sub.onEvent(row.content);
1421
+ } catch {}
1422
+ }
1423
+ } catch {} finally {
1424
+ polling = false;
1425
+ }
1426
+ };
1427
+ poll();
1428
+ const interval = setInterval(() => {
1429
+ poll();
1430
+ }, POLL_INTERVAL_MS);
1431
+ return () => {
1432
+ stopped = true;
1433
+ clearInterval(interval);
1434
+ };
1435
+ }
1436
+ /**
1437
+ * Signal that no more events will be published for this feedback.
1438
+ * Writes a sentinel row that subscribers pick up on their next poll
1439
+ * and translate into `onClose`. The DB rows themselves stay until
1440
+ * the parent `conversations` row is deleted (FK cascade).
1441
+ */
1442
+ async markFinished() {
1443
+ try {
1444
+ await getDb(this.projectRoot).insert(messages).values({
1445
+ conversationId: this.feedbackId,
1446
+ turn: 1,
1447
+ role: FINISHED_ROLE,
1448
+ content: {}
1449
+ });
1450
+ } catch {}
1451
+ }
1452
+ };
1453
+ /**
1454
+ * Per-context cache of bus instances. Cross-context publish/subscribe
1455
+ * still works because the instances all hit the same SQLite file —
1456
+ * the cache here just avoids re-allocating the bus object on every
1457
+ * lookup within one context.
1458
+ */
1459
+ const BUSES_SYMBOL = Symbol.for("pinagent.agent-runner.bus");
1460
+ const buses = globalThis[BUSES_SYMBOL] ?? /* @__PURE__ */ new Map();
1461
+ globalThis[BUSES_SYMBOL] = buses;
1462
+ function getOrCreateBus(feedbackId, projectRoot) {
1463
+ const root = projectRoot ?? process.env.PINAGENT_PROJECT_ROOT ?? process.cwd();
1464
+ let bus = buses.get(feedbackId);
1465
+ if (!bus) {
1466
+ bus = new SqliteEventBus(feedbackId, root);
1467
+ buses.set(feedbackId, bus);
1468
+ }
1469
+ return bus;
1470
+ }
1471
+ /**
1472
+ * `ask_user` custom SDK tool — the agent's only blessed way to pause and
1473
+ * wait for a typed human answer mid-run.
1474
+ *
1475
+ * Flow:
1476
+ * 1. Model calls `ask_user({ question, ... })`.
1477
+ * 2. Handler generates an askId, publishes an `ask_user` AgentEvent to
1478
+ * the feedback's bus (so subscribed WS clients render a form), and
1479
+ * returns a Promise.
1480
+ * 3. User types an answer in the widget; the widget sends
1481
+ * `ask_response { askId, answer }` over WS.
1482
+ * 4. The WS server calls `resolveAsk(askId, answer)`; the Promise
1483
+ * resolves with a `CallToolResult` carrying the answer text; the
1484
+ * agent receives it as the tool result and continues.
1485
+ *
1486
+ * If the run ends, the dev server restarts, or a TTL elapses with no
1487
+ * answer, the Promise rejects so the agent gets a clear failure rather
1488
+ * than hanging on a dead UI.
1489
+ */
1490
+ const ASK_TTL_MS = 600 * 1e3;
1491
+ /**
1492
+ * Pending asks LOCAL TO THIS CONTEXT. The resolve/reject closures are
1493
+ * tied to the agent's Promise — process-bound, not serialisable. The
1494
+ * WS server can land in a different context than the one running the
1495
+ * agent (Next 16 Turbopack, Vite 8), so we route cross-context responses
1496
+ * via `process.emit(ASK_RESPONSE_EVENT, ...)` — see `resolveAsk`.
1497
+ */
1498
+ const pending = /* @__PURE__ */ new Map();
1499
+ const ASK_RESPONSE_EVENT = "pinagent:ask-response";
1500
+ const inputSchema = {
1501
+ question: z.string().min(1).max(2e3).describe("The question to ask the user. Be specific and concise."),
1502
+ context: z.string().max(2e3).optional().describe("Optional: what you are trying to do and why you need this clarification. Helps the user answer with the right context."),
1503
+ options: z.array(z.string().min(1).max(200)).max(6).optional().describe("Optional: suggested answers. Rendered as one-click buttons. Use sparingly — only when the answer is genuinely closed-ended.")
1504
+ };
1505
+ /**
1506
+ * Build an SDK MCP server that exposes a single `ask_user` tool scoped to
1507
+ * one feedback id. The handler closes over `feedbackId` so the published
1508
+ * event lands on the correct bus.
1509
+ */
1510
+ function createAskUserMcpServer(feedbackId) {
1511
+ return createSdkMcpServer({
1512
+ name: "pinagent-ask-user",
1513
+ version: "0.1.0",
1514
+ tools: [tool("ask_user", [
1515
+ "Ask the human developer a question and wait for their typed answer.",
1516
+ "Use this when you cannot proceed without clarification — preferred over",
1517
+ "guessing or making an assumption. The user sees the question in their",
1518
+ "browser widget and types a response."
1519
+ ].join(" "), inputSchema, async (args) => {
1520
+ const askId = nanoid(10);
1521
+ const bus = getOrCreateBus(feedbackId);
1522
+ return { content: [{
1523
+ type: "text",
1524
+ text: await new Promise((resolve, reject) => {
1525
+ const timeout = setTimeout(() => {
1526
+ pending.delete(askId);
1527
+ process.off(ASK_RESPONSE_EVENT, onResponse);
1528
+ reject(/* @__PURE__ */ new Error(`ask_user timed out after ${ASK_TTL_MS / 1e3}s with no response`));
1529
+ }, ASK_TTL_MS);
1530
+ const onResponse = (payload) => {
1531
+ if (payload.askId !== askId) return;
1532
+ const entry = pending.get(askId);
1533
+ if (entry) entry.resolve(payload.answer);
1534
+ };
1535
+ process.on(ASK_RESPONSE_EVENT, onResponse);
1536
+ pending.set(askId, {
1537
+ feedbackId,
1538
+ resolve: (a) => {
1539
+ clearTimeout(timeout);
1540
+ pending.delete(askId);
1541
+ process.off(ASK_RESPONSE_EVENT, onResponse);
1542
+ resolve(a);
1543
+ },
1544
+ reject: (reason) => {
1545
+ clearTimeout(timeout);
1546
+ pending.delete(askId);
1547
+ process.off(ASK_RESPONSE_EVENT, onResponse);
1548
+ reject(new Error(reason));
1549
+ },
1550
+ timeout
1551
+ });
1552
+ bus.publish({
1553
+ type: "ask_user",
1554
+ askId,
1555
+ question: args.question,
1556
+ context: args.context,
1557
+ options: args.options
1558
+ });
1559
+ })
1560
+ }] };
1561
+ })]
1562
+ });
1563
+ }
1564
+ /**
1565
+ * Resolve the matching pending ask. Tries the local-context Map first
1566
+ * for the same-context case; otherwise broadcasts via `process.emit`
1567
+ * so the context running the agent (and holding the resolve closure)
1568
+ * can settle the Promise. Returns true optimistically when emitting
1569
+ * cross-context — we can't know synchronously whether another context
1570
+ * had a matching pending entry, but stale UI / double-submits are rare
1571
+ * enough that swallowing the "no pending ask" error is acceptable.
1572
+ */
1573
+ function resolveAsk(askId, answer) {
1574
+ const entry = pending.get(askId);
1575
+ if (entry) {
1576
+ entry.resolve(answer);
1577
+ return true;
1578
+ }
1579
+ const payload = {
1580
+ askId,
1581
+ answer
1582
+ };
1583
+ process.emit(ASK_RESPONSE_EVENT, payload);
1584
+ return true;
1585
+ }
1586
+ /**
1587
+ * Reject every pending ask tied to this feedback id. Called when the
1588
+ * agent stream ends so the SDK Promise unblocks rather than hanging
1589
+ * until TTL.
1590
+ */
1591
+ function rejectAsk(feedbackId, reason) {
1592
+ for (const [askId, entry] of pending.entries()) if (entry.feedbackId === feedbackId) {
1593
+ entry.reject(reason);
1594
+ pending.delete(askId);
1595
+ }
1596
+ }
1597
+ /**
1598
+ * MCP namespaces tools as `mcp__<server-name>__<tool-name>`. Pass this in
1599
+ * the SDK's `allowedTools` so the model can actually call it without a
1600
+ * permission prompt — otherwise `acceptEdits` mode wouldn't auto-allow a
1601
+ * non-Edit tool call.
1602
+ */
1603
+ const ASK_USER_TOOL_NAME = "mcp__pinagent-ask-user__ask_user";
1604
+ const LISTENERS_SYMBOL = Symbol.for("pinagent.project-event.listeners");
1605
+ const listeners = globalThis[LISTENERS_SYMBOL] ?? /* @__PURE__ */ new Set();
1606
+ globalThis[LISTENERS_SYMBOL] = listeners;
1607
+ function emitProjectChange(event) {
1608
+ for (const listener of listeners) try {
1609
+ listener(event);
1610
+ } catch {}
1611
+ }
1612
+ function onProjectChange(listener) {
1613
+ listeners.add(listener);
1614
+ return () => {
1615
+ listeners.delete(listener);
1616
+ };
1617
+ }
1618
+ /**
1619
+ * Auth for agent runs — the explicit-key contract.
1620
+ *
1621
+ * Pinagent must never authenticate a run with an API key it merely *found* in
1622
+ * the environment. The Claude Agent SDK (and agentic CLIs like Codex) read a
1623
+ * raw `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` straight from `process.env`, so a
1624
+ * stale, scoped, or third-party key a developer exported for some unrelated
1625
+ * tool would otherwise be picked up silently — billing their key and, worse,
1626
+ * shadowing the Claude Code / Codex subscription they actually meant to use, so
1627
+ * the run dies with `authentication_failed` ("Invalid API key").
1628
+ *
1629
+ * A key is therefore used ONLY when the developer hands one to pinagent
1630
+ * explicitly, through one of two channels:
1631
+ *
1632
+ * 1. the `apiKey` option in the consuming app's plugin config
1633
+ * (`pinagent({ apiKey })` in vite.config / next.config), bridged to the
1634
+ * runner as `PINAGENT_AGENT_API_KEY`; or
1635
+ * 2. a key saved at runtime via the dock's Connections route (`SecretsStore`).
1636
+ *
1637
+ * With neither set, the implicit key is stripped from the run's environment and
1638
+ * the provider falls back to the agentic subscription — the behaviour a
1639
+ * developer running Pinagent locally expects.
1640
+ */
1641
+ /**
1642
+ * pinagent-namespaced bridge var the plugins set from the explicit `apiKey`
1643
+ * option. This is the ONLY env channel pinagent treats as an opt-in to use a
1644
+ * raw key — never the ambient `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` a
1645
+ * developer may have exported for other tools.
1646
+ */
1647
+ const EXPLICIT_API_KEY_ENV = "PINAGENT_AGENT_API_KEY";
1648
+ /**
1649
+ * Credentials provider SDKs / agentic CLIs read straight from the environment.
1650
+ * Stripped from any env pinagent hands to a run so a stray shell key can't
1651
+ * authenticate by accident; an explicitly-configured key is re-supplied on top.
1652
+ */
1653
+ const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
1654
+ const PROVIDER_KEY_VARS = [ANTHROPIC_KEY_VAR, "OPENAI_API_KEY"];
1655
+ /** The key the developer configured for `pinagent({ apiKey })`, if any. */
1656
+ function configuredKey() {
1657
+ return process.env["PINAGENT_AGENT_API_KEY"]?.trim() || null;
1658
+ }
1659
+ /**
1660
+ * Resolve the explicitly-configured Anthropic key for a Claude Agent SDK run,
1661
+ * or null when the developer configured neither channel (→ subscription).
1662
+ * A dock-saved key is the runtime override and wins, so a user can swap auth
1663
+ * without editing config or restarting the dev server; otherwise the
1664
+ * plugin-config key applies.
1665
+ */
1666
+ async function resolveAnthropicKey(projectRoot) {
1667
+ return await new SecretsStore(projectRoot).getAnthropicKey() ?? configuredKey();
1668
+ }
1669
+ /**
1670
+ * Build the environment handed to a Claude Agent SDK `query()`: the inherited
1671
+ * environment with the implicit `ANTHROPIC_API_KEY` (and the internal bridge
1672
+ * var) removed, plus the explicitly-configured key re-added when one exists.
1673
+ * Entries in `extra` are applied last so callers can still pin run-scoped vars
1674
+ * (e.g. `PINAGENT_PROJECT_ROOT`).
1675
+ */
1676
+ async function buildSdkAuthEnv(projectRoot, extra = {}) {
1677
+ const env = { ...process.env };
1678
+ delete env[ANTHROPIC_KEY_VAR];
1679
+ delete env[EXPLICIT_API_KEY_ENV];
1680
+ const key = await resolveAnthropicKey(projectRoot);
1681
+ if (key) env[ANTHROPIC_KEY_VAR] = key;
1682
+ return {
1683
+ ...env,
1684
+ ...extra
1685
+ };
1686
+ }
1687
+ /**
1688
+ * Build the environment for a wrapped agent CLI (Codex, aider, …): the
1689
+ * inherited environment with the implicit provider keys (and the internal
1690
+ * bridge var) stripped, plus the explicitly-configured key re-supplied under
1691
+ * both provider names so whichever the CLI reads picks it up. Absent an
1692
+ * explicit key, the CLI sees none and falls back to its own login (e.g. Codex →
1693
+ * the ChatGPT subscription). `extra` overrides win last.
1694
+ *
1695
+ * Only the plugin-config key feeds the CLI: the dock's saved key is
1696
+ * Claude-provider-specific, so it isn't reinterpreted as an arbitrary CLI's
1697
+ * credential.
1698
+ */
1699
+ function buildCliAuthEnv(extra = {}) {
1700
+ const env = { ...process.env };
1701
+ const key = configuredKey();
1702
+ for (const v of PROVIDER_KEY_VARS) delete env[v];
1703
+ delete env[EXPLICIT_API_KEY_ENV];
1704
+ if (key) for (const v of PROVIDER_KEY_VARS) env[v] = key;
1705
+ return {
1706
+ ...env,
1707
+ ...extra
1708
+ };
1709
+ }
1710
+ /**
1711
+ * Surfacing the project guidance nearest to the element the developer
1712
+ * clicked. When feedback lands with a `file:line`, walk up the directory
1713
+ * tree from that file toward the project root and pull in the closest
1714
+ * `CLAUDE.md` / `AGENTS.md` so the agent starts with the conventions that
1715
+ * actually govern the code it's about to touch.
1716
+ *
1717
+ * The Claude Agent SDK (and Codex) already discover guide files by walking
1718
+ * UP from the agent's working directory — but that misses a nested guide
1719
+ * sitting *below* the worktree/project root, right next to the clicked
1720
+ * file. That nested guide is exactly the one most relevant to the edit, so
1721
+ * we resolve it explicitly here and inject it into the system prompt.
1722
+ */
1723
+ /**
1724
+ * Guide filenames we recognise. `CLAUDE.md` is Claude's convention and
1725
+ * `AGENTS.md` is the cross-agent (Codex, etc.) one; both are checked at
1726
+ * each directory so whichever a project uses is found.
1727
+ */
1728
+ const GUIDE_FILENAMES = ["CLAUDE.md", "AGENTS.md"];
1729
+ /**
1730
+ * Hard cap on injected guide bytes. A sprawling guide shouldn't crowd out
1731
+ * the actual task in the prompt; past this we truncate with a marker.
1732
+ */
1733
+ const MAX_GUIDE_BYTES = 16e3;
1734
+ /**
1735
+ * Find the guide file nearest to `file`, searching its own directory first
1736
+ * and walking up to (and including) `projectRoot`. Returns `null` when the
1737
+ * feedback has no file, the path escapes the project, or no guide exists
1738
+ * anywhere on the path.
1739
+ */
1740
+ function findNearestAgentGuide(file, projectRoot, opts = {}) {
1741
+ if (!file) return null;
1742
+ const root = resolve(projectRoot);
1743
+ const absFile = isAbsolute(file) ? resolve(file) : resolve(root, file);
1744
+ if (!isWithin(root, absFile)) return null;
1745
+ const order = guideOrder(opts.prefer);
1746
+ let dir = dirname(absFile);
1747
+ while (true) {
1748
+ for (const filename of order) {
1749
+ const guidePath = resolve(dir, filename);
1750
+ const content = tryRead(guidePath);
1751
+ if (content !== null) {
1752
+ const clipped = clip(content);
1753
+ return {
1754
+ filename,
1755
+ relativePath: toPosix(relative(root, guidePath)),
1756
+ content: clipped.content,
1757
+ truncated: clipped.truncated
1758
+ };
1759
+ }
1760
+ }
1761
+ if (dir === root) break;
1762
+ const parent = dirname(dir);
1763
+ if (parent === dir) break;
1764
+ dir = parent;
1765
+ }
1766
+ return null;
1767
+ }
1768
+ /**
1769
+ * Render a found guide as a block to append to the agent's system prompt
1770
+ * (or, for a wrapped CLI with no system prompt, its task prompt). Framed so
1771
+ * the agent knows the guidance is scoped to the file it's editing.
1772
+ */
1773
+ function renderAgentGuide(guide) {
1774
+ return [
1775
+ "",
1776
+ `Project guidance applies to the code you're editing. The nearest guide to`,
1777
+ `the clicked element is \`${guide.relativePath}\` — follow it${guide.truncated ? " (truncated below; read the full file if you need more)" : ""}:`,
1778
+ "",
1779
+ `<project-guidance path="${guide.relativePath}">`,
1780
+ guide.content,
1781
+ "</project-guidance>"
1782
+ ].join("\n");
1783
+ }
1784
+ /** Order the filenames so the preferred one is checked first at each dir. */
1785
+ function guideOrder(prefer) {
1786
+ if (!prefer || prefer === GUIDE_FILENAMES[0]) return GUIDE_FILENAMES;
1787
+ return [prefer, ...GUIDE_FILENAMES.filter((f) => f !== prefer)];
1788
+ }
1789
+ /** Read a file as UTF-8, returning null for any read error (missing, dir, …). */
1790
+ function tryRead(path) {
1791
+ try {
1792
+ return readFileSync(path, "utf8");
1793
+ } catch {
1794
+ return null;
1795
+ }
1796
+ }
1797
+ /** True when `child` is `parent` or sits inside it. */
1798
+ function isWithin(parent, child) {
1799
+ if (child === parent) return true;
1800
+ const rel = relative(parent, child);
1801
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1802
+ }
1803
+ /** Clip content to the byte cap on a line boundary, with a marker. */
1804
+ function clip(content) {
1805
+ if (Buffer.byteLength(content, "utf8") <= MAX_GUIDE_BYTES) return {
1806
+ content,
1807
+ truncated: false
1808
+ };
1809
+ let sliced = content.slice(0, MAX_GUIDE_BYTES);
1810
+ while (Buffer.byteLength(sliced, "utf8") > MAX_GUIDE_BYTES - 32) sliced = sliced.slice(0, -32);
1811
+ const lastNl = sliced.lastIndexOf("\n");
1812
+ if (lastNl > 0) sliced = sliced.slice(0, lastNl);
1813
+ return {
1814
+ content: `${sliced}\n\n… [truncated]`,
1815
+ truncated: true
1816
+ };
1817
+ }
1818
+ /** Normalise path separators to POSIX for stable prompt/display output. */
1819
+ function toPosix(path) {
1820
+ return path.split(/[\\/]/).join("/");
1821
+ }
1822
+ /**
1823
+ * Render a single SDK message as a markdown fragment to append to the log.
1824
+ *
1825
+ * Returns '' for messages we don't surface (status pings, partial deltas,
1826
+ * etc.) so the caller can no-op cheaply.
1827
+ *
1828
+ * The aim is a readable transcript, not a raw event dump — text comes
1829
+ * through as plain markdown, tool calls collapse to a single-line chip,
1830
+ * errors stand out. If a new SDK message type arrives we don't recognise,
1831
+ * we drop it silently rather than serialising a JSON blob into the log.
1832
+ */
1833
+ function renderMessage(message) {
1834
+ switch (message.type) {
1835
+ case "assistant": return renderAssistant(message);
1836
+ case "result": return "\n---\n";
1837
+ case "user": return renderUser(message);
1838
+ default: return "";
1839
+ }
1840
+ }
1841
+ function renderInitFooter(message) {
1842
+ const mcp = message.mcp_servers.map((s) => `${s.name}=${s.status}`).join(", ");
1843
+ const lines = [`> _session_ \`${message.session_id}\` · model \`${message.model}\` · ${message.permissionMode}`];
1844
+ if (mcp) lines.push(`> _mcp_ ${mcp}`);
1845
+ lines.push("");
1846
+ return `${lines.join("\n")}\n`;
1847
+ }
1848
+ function renderResultFooter(result, apiKeySource) {
1849
+ const lines = [];
1850
+ if (result.subtype === "success") lines.push(`**Outcome:** success (${result.num_turns} turn${result.num_turns === 1 ? "" : "s"})`);
1851
+ else {
1852
+ lines.push(`**Outcome:** \`${result.subtype}\``);
1853
+ if (result.errors?.length) {
1854
+ lines.push("");
1855
+ for (const e of result.errors) lines.push(`> ${e}`);
1856
+ }
1857
+ }
1858
+ lines.push(`**Tokens:** in=${result.usage.input_tokens} · out=${result.usage.output_tokens}${result.usage.cache_read_input_tokens ? ` · cache_read=${result.usage.cache_read_input_tokens}` : ""}${result.usage.cache_creation_input_tokens ? ` · cache_write=${result.usage.cache_creation_input_tokens}` : ""}`);
1859
+ lines.push(isNotionalCost(apiKeySource) ? `**Cost:** ≈$${result.total_cost_usd.toFixed(4)} API-equivalent (subscription — not billed)` : `**Cost:** $${result.total_cost_usd.toFixed(4)}`);
1860
+ lines.push(`**Duration:** ${(result.duration_ms / 1e3).toFixed(1)}s`);
1861
+ return lines.join(" \n");
1862
+ }
1863
+ function renderAssistant(message) {
1864
+ const blocks = message.message.content;
1865
+ if (!Array.isArray(blocks)) return "";
1866
+ const out = [];
1867
+ for (const block of blocks) if (block.type === "text" && block.text.trim()) out.push(`${block.text}\n`);
1868
+ else if (block.type === "tool_use") out.push(renderToolUse(block.name, block.input));
1869
+ else if (block.type === "thinking") out.push("<!-- thinking -->\n");
1870
+ if (message.error) out.push(`\n> ⚠️ assistant error: \`${message.error}\`\n`);
1871
+ if (out.length === 0) return "";
1872
+ return `${out.join("\n")}\n`;
1873
+ }
1874
+ function renderUser(message) {
1875
+ const content = message.message?.content;
1876
+ if (!Array.isArray(content)) return "";
1877
+ const chips = [];
1878
+ for (const block of content) if (block.type === "tool_result") chips.push(renderToolResult(block));
1879
+ if (chips.length === 0) return "";
1880
+ return `${chips.join("\n")}\n`;
1881
+ }
1882
+ function renderToolUse(name, input) {
1883
+ const summary = summariseToolInput(name, input);
1884
+ return `\`[${name}]\`${summary ? ` ${summary}` : ""}\n`;
1885
+ }
1886
+ function renderToolResult(block) {
1887
+ return `${block.is_error ? "✗" : "✓"} _tool result_`;
1888
+ }
1889
+ function summariseToolInput(name, input) {
1890
+ if (input == null || typeof input !== "object") return "";
1891
+ const obj = input;
1892
+ for (const f of [
1893
+ "file_path",
1894
+ "path",
1895
+ "filePath",
1896
+ "notebook_path"
1897
+ ]) if (typeof obj[f] === "string") return `\`${obj[f]}\``;
1898
+ if (typeof obj.command === "string") return `\`${truncate(obj.command, 80)}\``;
1899
+ if (typeof obj.pattern === "string") return `pattern=\`${truncate(obj.pattern, 60)}\``;
1900
+ if (typeof obj.url === "string") return obj.url;
1901
+ if (typeof obj.prompt === "string") return `\`${truncate(obj.prompt, 60)}\``;
1902
+ if (name.startsWith("mcp__")) {
1903
+ const keys = Object.keys(obj);
1904
+ const first = keys[0];
1905
+ if (keys.length === 1 && first != null && typeof obj[first] !== "object") return `${first}=\`${String(obj[first])}\``;
1906
+ }
1907
+ return "";
1908
+ }
1909
+ function truncate(s, n) {
1910
+ if (s.length <= n) return s;
1911
+ return `${s.slice(0, n - 1)}…`;
1912
+ }
1913
+ /**
1914
+ * @pinagent/mcp tool names the spawned agent needs to do its job:
1915
+ *
1916
+ * - `get_feedback` — fetch the full feedback record incl. screenshot
1917
+ * - `resolve_feedback` — mark fixed/wontfix/deferred when done
1918
+ * - `get_source_context` — read a window of source around file:line
1919
+ * - `list_pending_feedback`— rarely needed by a spawned agent (it knows its
1920
+ * own id), included for parity with pull mode
1921
+ *
1922
+ * They are surfaced to the SDK via the user's `.mcp.json` (loaded by
1923
+ * `settingSources: ['user', 'project', 'local']`). Allowlisting them
1924
+ * makes the spawned agent auto-accept the calls instead of timing out
1925
+ * waiting for a non-existent permission prompt.
1926
+ */
1927
+ const PINAGENT_MCP_TOOLS = [
1928
+ "mcp__pinagent__get_feedback",
1929
+ "mcp__pinagent__resolve_feedback",
1930
+ "mcp__pinagent__get_source_context",
1931
+ "mcp__pinagent__list_pending_feedback"
1932
+ ];
1933
+ /**
1934
+ * Tools a dry-run must never be allowed to call: anything that writes to
1935
+ * the workspace, runs a command, or transitions the agent out of plan
1936
+ * mode. See `buildSdkOptions` for why denying `ExitPlanMode` is the load-
1937
+ * bearing entry — without it a headless `plan`-mode run silently writes.
1938
+ */
1939
+ const DRY_RUN_DENIED_TOOLS = new Set([
1940
+ "ExitPlanMode",
1941
+ "Edit",
1942
+ "MultiEdit",
1943
+ "Write",
1944
+ "NotebookEdit",
1945
+ "Bash"
1946
+ ]);
1947
+ /**
1948
+ * The default, most capable provider: the Claude Agent SDK. Runs the full
1949
+ * agentic loop (tool calls, edits, permission gating, session resume) and
1950
+ * streams its `SDKMessage`s, which we normalize into Pinagent's
1951
+ * `AgentEvent` union here so nothing downstream has to know it was Claude.
1952
+ */
1953
+ var ClaudeCodeProvider = class {
1954
+ id = "claude-code";
1955
+ async *run(req) {
1956
+ const sdkOptions = await buildSdkOptions(req);
1957
+ const startedAt = Date.now();
1958
+ let apiKeySource = null;
1959
+ let turn = 0;
1960
+ let sawResult = false;
1961
+ try {
1962
+ for await (const message of query({
1963
+ prompt: req.prompt,
1964
+ options: sdkOptions
1965
+ })) {
1966
+ const sessionId = "session_id" in message && typeof message.session_id === "string" ? message.session_id : void 0;
1967
+ if (message.type === "system" && message.subtype === "init") {
1968
+ apiKeySource = message.apiKeySource ?? null;
1969
+ yield {
1970
+ events: toAgentEvents(message),
1971
+ log: renderInitFooter(message),
1972
+ sessionId
1973
+ };
1974
+ continue;
1975
+ }
1976
+ if (message.type === "result") {
1977
+ sawResult = true;
1978
+ yield {
1979
+ events: toAgentEvents(message),
1980
+ log: renderMessage(message),
1981
+ sessionId,
1982
+ isResult: true,
1983
+ resultFooter: renderResultFooter(message, apiKeySource)
1984
+ };
1985
+ continue;
1986
+ }
1987
+ const events = toAgentEvents(message);
1988
+ if (message.type === "assistant") {
1989
+ turn += 1;
1990
+ events.push({
1991
+ type: "progress",
1992
+ turn
1993
+ });
1994
+ }
1995
+ yield {
1996
+ events,
1997
+ log: renderMessage(message),
1998
+ sessionId
1999
+ };
2000
+ }
2001
+ } catch (err) {
2002
+ if (sawResult) return;
2003
+ const aborted = req.abortSignal.aborted;
2004
+ const detail = err instanceof Error ? err.message : String(err);
2005
+ const durationMs = Date.now() - startedAt;
2006
+ const resultEvent = {
2007
+ type: "result",
2008
+ subtype: aborted ? "aborted" : "error",
2009
+ numTurns: turn,
2010
+ totalCostUsd: 0,
2011
+ durationMs
2012
+ };
2013
+ const events = [];
2014
+ const seconds = `${(durationMs / 1e3).toFixed(1)}s`;
2015
+ let footer;
2016
+ if (aborted) footer = `**Outcome:** \`aborted\` \n**Duration:** ${seconds}`;
2017
+ else {
2018
+ resultEvent.errors = [detail];
2019
+ events.push({
2020
+ type: "error",
2021
+ message: detail
2022
+ });
2023
+ footer = `**Outcome:** \`error\` \n> ${detail} \n**Duration:** ${seconds}`;
2024
+ }
2025
+ events.push(resultEvent);
2026
+ yield {
2027
+ events,
2028
+ log: `\n> [pinagent] ${aborted ? "run aborted by user" : `agent stream errored: ${detail}`}\n`,
2029
+ isResult: true,
2030
+ resultFooter: footer
2031
+ };
2032
+ }
2033
+ }
2034
+ };
2035
+ /**
2036
+ * Build the Claude Agent SDK options for a run. Kept byte-for-byte
2037
+ * equivalent to the original inline construction in `agent.ts` so the
2038
+ * SDK-mocking tests (which assert on the params handed to `query()`)
2039
+ * continue to pass unchanged.
2040
+ */
2041
+ async function buildSdkOptions(req) {
2042
+ const env = await buildSdkAuthEnv(req.projectRoot, {
2043
+ PINAGENT_PROJECT_ROOT: req.projectRoot,
2044
+ CLAUDE_CODE_STREAM_CLOSE_TIMEOUT: "720000"
2045
+ });
2046
+ const guide = findNearestAgentGuide(req.targetFile, req.projectRoot, { prefer: "CLAUDE.md" });
2047
+ const options = {
2048
+ cwd: req.cwd,
2049
+ permissionMode: req.permissionMode,
2050
+ env,
2051
+ settingSources: [
2052
+ "user",
2053
+ "project",
2054
+ "local"
2055
+ ],
2056
+ abortController: toAbortController(req.abortSignal),
2057
+ mcpServers: { "pinagent-ask-user": createAskUserMcpServer(req.feedbackId) },
2058
+ allowedTools: [ASK_USER_TOOL_NAME, ...PINAGENT_MCP_TOOLS],
2059
+ systemPrompt: {
2060
+ type: "preset",
2061
+ preset: "claude_code",
2062
+ append: [
2063
+ "",
2064
+ "You are running inside Pinagent, a tool that lets developers click a UI",
2065
+ "element in the browser and leave a comment for you to act on. The user",
2066
+ "is watching your output stream into a small widget pane next to the",
2067
+ "element they clicked.",
2068
+ "",
2069
+ `If you need clarification mid-task, call the \`${ASK_USER_TOOL_NAME}\``,
2070
+ "tool with a clear question (and optional `options` for closed-ended",
2071
+ "answers). Prefer asking over guessing on ambiguous requirements.",
2072
+ ...guide ? [renderAgentGuide(guide)] : []
2073
+ ].join("\n")
2074
+ }
2075
+ };
2076
+ if (req.permissionMode === "plan") options.canUseTool = async (toolName) => DRY_RUN_DENIED_TOOLS.has(toolName) ? {
2077
+ behavior: "deny",
2078
+ message: `Dry-run mode: \`${toolName}\` is blocked. Pinagent is in dry-run (plan) mode — describe the change you would make, but do not edit files, run commands, or exit plan mode.`
2079
+ } : { behavior: "allow" };
2080
+ if (req.resume) options.resume = req.resume;
2081
+ return options;
2082
+ }
2083
+ /**
2084
+ * The SDK wants an `AbortController`, but the provider contract hands us a
2085
+ * bare `AbortSignal` (so non-SDK providers aren't forced to fabricate a
2086
+ * controller). Bridge the two by forwarding the signal's abort to a fresh
2087
+ * controller the SDK can own.
2088
+ */
2089
+ function toAbortController(signal) {
2090
+ const controller = new AbortController();
2091
+ if (signal.aborted) controller.abort();
2092
+ else signal.addEventListener("abort", () => controller.abort(), { once: true });
2093
+ return controller;
2094
+ }
2095
+ /** Translate one SDK message into zero or more Pinagent bus events. */
2096
+ function toAgentEvents(message) {
2097
+ switch (message.type) {
2098
+ case "system":
2099
+ if (message.subtype === "init") return [{
2100
+ type: "init",
2101
+ sessionId: message.session_id,
2102
+ model: message.model,
2103
+ permissionMode: message.permissionMode,
2104
+ apiKeySource: message.apiKeySource
2105
+ }];
2106
+ return [];
2107
+ case "assistant": {
2108
+ const out = [];
2109
+ const blocks = message.message?.content;
2110
+ if (!Array.isArray(blocks)) return out;
2111
+ for (const block of blocks) if (block.type === "text" && block.text.trim()) out.push({
2112
+ type: "text",
2113
+ text: block.text
2114
+ });
2115
+ else if (block.type === "tool_use") {
2116
+ if (block.name === "mcp__pinagent-ask-user__ask_user") continue;
2117
+ out.push({
2118
+ type: "tool_use",
2119
+ name: block.name,
2120
+ summary: summariseToolInput(block.name, block.input)
2121
+ });
2122
+ }
2123
+ if (message.error) out.push({
2124
+ type: "error",
2125
+ message: `assistant error: ${message.error}`
2126
+ });
2127
+ return out;
2128
+ }
2129
+ case "user": {
2130
+ const out = [];
2131
+ const blocks = message.message?.content;
2132
+ if (!Array.isArray(blocks)) return out;
2133
+ for (const block of blocks) if (typeof block === "object" && block !== null && block.type === "tool_result") out.push({
2134
+ type: "tool_result",
2135
+ ok: !block.is_error
2136
+ });
2137
+ return out;
2138
+ }
2139
+ case "result": {
2140
+ const event = {
2141
+ type: "result",
2142
+ subtype: message.subtype,
2143
+ numTurns: message.num_turns,
2144
+ totalCostUsd: message.total_cost_usd,
2145
+ durationMs: message.duration_ms
2146
+ };
2147
+ if (message.subtype !== "success" && Array.isArray(message.errors)) event.errors = message.errors;
2148
+ return [event];
2149
+ }
2150
+ default: return [];
2151
+ }
2152
+ }
2153
+ /**
2154
+ * Bring-your-own-model provider: wrap an arbitrary agentic CLI (Codex,
2155
+ * aider, opencode, Cline headless, a shell script, …) and translate its
2156
+ * stdout into Pinagent's `AgentEvent` stream.
2157
+ *
2158
+ * The wrapped CLI owns its own agentic loop and edits files directly in
2159
+ * `req.cwd`. We don't intercept its tool calls — we surface its narration
2160
+ * to the widget and let its on-disk edits land in the project (or
2161
+ * worktree) exactly as the Claude provider's edits do.
2162
+ *
2163
+ * Configuration (env, read per-run so a dev-server restart isn't needed):
2164
+ *
2165
+ * - `PINAGENT_AGENT_CLI_COMMAND` (required) the command to run. Either a
2166
+ * JSON array (`["aider","--yes-always"]`) or a space-separated string
2167
+ * (`aider --yes-always`). The first element is the executable.
2168
+ * - `PINAGENT_AGENT_CLI_PROMPT` `arg` (default) appends the prompt as the
2169
+ * final argv; `stdin` writes it to the child's stdin and closes it.
2170
+ * - `PINAGENT_AGENT_CLI_FORMAT` `text` (default) treats each stdout line
2171
+ * as assistant text; `stream-json` parses each line as JSON and maps a
2172
+ * pragmatic subset of common agent event shapes.
2173
+ * - `PINAGENT_AGENT_CLI_MODEL` label for the `init` event (defaults to
2174
+ * the executable name). Cosmetic — drives the widget's model chip.
2175
+ *
2176
+ * The child inherits the parent env plus `PINAGENT_PROJECT_ROOT`,
2177
+ * `PINAGENT_FEEDBACK_ID`, and `PINAGENT_RESUME_SESSION` so an MCP-aware
2178
+ * CLI (or a wrapper script) can connect to the pinagent MCP server, fetch
2179
+ * the feedback, and call `resolve_feedback` itself.
2180
+ *
2181
+ * Auth is explicit-only (see agent-auth.ts): `buildCliAuthEnv` strips any
2182
+ * ambient `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` from the child's env so a
2183
+ * stray shell key can't bill against a wrapped CLI by accident, and re-supplies
2184
+ * a key only when the consuming app passed one via the `apiKey` plugin option.
2185
+ * Absent that, the CLI falls back to its own login (e.g. Codex → the ChatGPT
2186
+ * subscription).
2187
+ */
2188
+ var CliAgentProvider = class {
2189
+ id = "cli";
2190
+ async *run(req) {
2191
+ const config = resolveCliConfig(process.env);
2192
+ const sessionId = req.resume ?? nanoid(12);
2193
+ const startedAt = Date.now();
2194
+ yield {
2195
+ events: [{
2196
+ type: "init",
2197
+ sessionId,
2198
+ model: config.model,
2199
+ permissionMode: req.permissionMode,
2200
+ apiKeySource: "cli"
2201
+ }],
2202
+ log: `> _cli_ \`${config.argv.join(" ")}\` · session \`${sessionId}\`\n\n`,
2203
+ sessionId
2204
+ };
2205
+ const guide = findNearestAgentGuide(req.targetFile, req.projectRoot, { prefer: "AGENTS.md" });
2206
+ const prompt = guide ? `${req.prompt}\n${renderAgentGuide(guide)}` : req.prompt;
2207
+ const args = [...config.argv.slice(1)];
2208
+ if (config.promptMode === "arg") args.push(prompt);
2209
+ const child = spawn(config.argv[0], args, {
2210
+ cwd: req.cwd,
2211
+ env: buildCliAuthEnv({
2212
+ PINAGENT_PROJECT_ROOT: req.projectRoot,
2213
+ PINAGENT_FEEDBACK_ID: req.feedbackId,
2214
+ PINAGENT_RESUME_SESSION: req.resume ?? ""
2215
+ }),
2216
+ stdio: [
2217
+ "pipe",
2218
+ "pipe",
2219
+ "pipe"
2220
+ ]
2221
+ });
2222
+ const onAbort = () => child.kill("SIGTERM");
2223
+ if (req.abortSignal.aborted) onAbort();
2224
+ else req.abortSignal.addEventListener("abort", onAbort, { once: true });
2225
+ child.stdin.on("error", () => {});
2226
+ try {
2227
+ if (config.promptMode === "stdin") child.stdin.write(prompt);
2228
+ child.stdin.end();
2229
+ } catch {}
2230
+ const queue = new AsyncLineQueue();
2231
+ const stdout = createInterface({ input: child.stdout });
2232
+ const stderr = createInterface({ input: child.stderr });
2233
+ stdout.on("line", (line) => queue.push({
2234
+ line,
2235
+ stream: "stdout"
2236
+ }));
2237
+ stderr.on("line", (line) => queue.push({
2238
+ line,
2239
+ stream: "stderr"
2240
+ }));
2241
+ let openStreams = 2;
2242
+ const onClose = () => {
2243
+ openStreams -= 1;
2244
+ if (openStreams === 0) queue.close();
2245
+ };
2246
+ stdout.on("close", onClose);
2247
+ stderr.on("close", onClose);
2248
+ const exit = new Promise((resolve) => {
2249
+ child.on("exit", (code, signal) => resolve({
2250
+ code,
2251
+ signal,
2252
+ spawnError: null
2253
+ }));
2254
+ child.on("error", (err) => resolve({
2255
+ code: null,
2256
+ signal: null,
2257
+ spawnError: err instanceof Error ? err : new Error(String(err))
2258
+ }));
2259
+ });
2260
+ let turn = 0;
2261
+ for await (const { line, stream } of queue) {
2262
+ const events = stream === "stderr" ? parseTextLine(line, stream) : config.format === "stream-json" ? parseStreamJsonLine(line) : parseTextLine(line, stream);
2263
+ if (events.length === 0) continue;
2264
+ if (stream === "stdout" && events.some((e) => e.type === "text")) {
2265
+ turn += 1;
2266
+ events.push({
2267
+ type: "progress",
2268
+ turn
2269
+ });
2270
+ }
2271
+ yield {
2272
+ events,
2273
+ log: renderCliLine(line, stream)
2274
+ };
2275
+ }
2276
+ req.abortSignal.removeEventListener("abort", onAbort);
2277
+ const { code, signal, spawnError } = await exit;
2278
+ const aborted = req.abortSignal.aborted;
2279
+ const subtype = aborted ? "aborted" : spawnError || signal || code !== 0 ? "error" : "success";
2280
+ const resultEvent = {
2281
+ type: "result",
2282
+ subtype,
2283
+ numTurns: turn,
2284
+ totalCostUsd: 0,
2285
+ durationMs: Date.now() - startedAt
2286
+ };
2287
+ if (subtype !== "success") {
2288
+ let reason;
2289
+ if (aborted) reason = "run aborted";
2290
+ else if (spawnError) reason = `failed to start ${config.argv[0]}: ${spawnError.message}`;
2291
+ else if (signal) reason = `${config.argv[0]} terminated by signal ${signal}`;
2292
+ else reason = `${config.argv[0]} exited with code ${code}`;
2293
+ resultEvent.errors = [reason];
2294
+ }
2295
+ yield {
2296
+ events: [resultEvent],
2297
+ log: "\n---\n",
2298
+ isResult: true,
2299
+ resultFooter: renderCliFooter(subtype, turn, Date.now() - startedAt)
2300
+ };
2301
+ }
2302
+ };
2303
+ /** Parse the CLI provider config out of the environment, validating it. */
2304
+ function resolveCliConfig(env) {
2305
+ const raw = env.PINAGENT_AGENT_CLI_COMMAND?.trim();
2306
+ if (!raw) throw new Error("PINAGENT_AGENT_CLI_COMMAND is required when PINAGENT_AGENT_PROVIDER=cli. Set it to the agent CLI to run, e.g. PINAGENT_AGENT_CLI_COMMAND='[\"aider\",\"--yes-always\"]'.");
2307
+ let argv;
2308
+ if (raw.startsWith("[")) try {
2309
+ const parsed = JSON.parse(raw);
2310
+ if (!Array.isArray(parsed) || parsed.some((a) => typeof a !== "string")) throw new Error("not a string array");
2311
+ argv = parsed;
2312
+ } catch (err) {
2313
+ throw new Error(`PINAGENT_AGENT_CLI_COMMAND looked like JSON but failed to parse as a string array: ${err instanceof Error ? err.message : String(err)}`);
2314
+ }
2315
+ else argv = raw.split(/\s+/).filter(Boolean);
2316
+ if (argv.length === 0) throw new Error("PINAGENT_AGENT_CLI_COMMAND resolved to an empty command");
2317
+ const promptMode = env.PINAGENT_AGENT_CLI_PROMPT === "stdin" ? "stdin" : "arg";
2318
+ const format = env.PINAGENT_AGENT_CLI_FORMAT === "stream-json" ? "stream-json" : "text";
2319
+ const model = env.PINAGENT_AGENT_CLI_MODEL?.trim() || argv[0];
2320
+ return {
2321
+ argv,
2322
+ promptMode,
2323
+ format,
2324
+ model
2325
+ };
2326
+ }
2327
+ /** Plain-text mode: every non-blank line is assistant narration. */
2328
+ function parseTextLine(line, stream) {
2329
+ if (!line.trim()) return [];
2330
+ return [{
2331
+ type: "text",
2332
+ text: stream === "stderr" ? `[stderr] ${line}` : line
2333
+ }];
2334
+ }
2335
+ /**
2336
+ * stream-json mode: best-effort mapping of the common shapes emitted by
2337
+ * agentic CLIs. Unknown/unparseable lines fall back to raw text so output
2338
+ * is never silently dropped.
2339
+ */
2340
+ function parseStreamJsonLine(line) {
2341
+ const trimmed = line.trim();
2342
+ if (!trimmed) return [];
2343
+ let obj;
2344
+ try {
2345
+ obj = JSON.parse(trimmed);
2346
+ } catch {
2347
+ return [{
2348
+ type: "text",
2349
+ text: line
2350
+ }];
2351
+ }
2352
+ if (obj == null || typeof obj !== "object") return [{
2353
+ type: "text",
2354
+ text: line
2355
+ }];
2356
+ const o = obj;
2357
+ const direct = pickString(o.text) ?? pickString(o.content) ?? pickString(o.delta) ?? deltaText(o.delta);
2358
+ if (direct) return [{
2359
+ type: "text",
2360
+ text: direct
2361
+ }];
2362
+ const message = o.message;
2363
+ if (message && typeof message === "object") {
2364
+ const blocks = message.content;
2365
+ if (Array.isArray(blocks)) return blocksToEvents(blocks);
2366
+ }
2367
+ if (Array.isArray(o.content)) return blocksToEvents(o.content);
2368
+ const type = pickString(o.type);
2369
+ if ((type === "tool_use" || type === "tool_call") && typeof o.name === "string") return [{
2370
+ type: "tool_use",
2371
+ name: o.name,
2372
+ summary: summariseToolInput(o.name, o.input)
2373
+ }];
2374
+ return [{
2375
+ type: "text",
2376
+ text: line
2377
+ }];
2378
+ }
2379
+ function blocksToEvents(blocks) {
2380
+ const out = [];
2381
+ for (const block of blocks) {
2382
+ if (block == null || typeof block !== "object") continue;
2383
+ const b = block;
2384
+ if (b.type === "text" && typeof b.text === "string" && b.text.trim()) out.push({
2385
+ type: "text",
2386
+ text: b.text
2387
+ });
2388
+ else if ((b.type === "tool_use" || b.type === "tool_call") && typeof b.name === "string") out.push({
2389
+ type: "tool_use",
2390
+ name: b.name,
2391
+ summary: summariseToolInput(b.name, b.input)
2392
+ });
2393
+ else if (b.type === "tool_result") out.push({
2394
+ type: "tool_result",
2395
+ ok: !b.is_error
2396
+ });
2397
+ }
2398
+ return out;
2399
+ }
2400
+ function pickString(v) {
2401
+ return typeof v === "string" && v.trim() ? v : null;
2402
+ }
2403
+ function deltaText(v) {
2404
+ if (v && typeof v === "object") return pickString(v.text);
2405
+ return null;
2406
+ }
2407
+ function renderCliLine(line, stream) {
2408
+ if (!line.trim()) return "";
2409
+ return stream === "stderr" ? `> \`${line}\`\n` : `${line}\n`;
2410
+ }
2411
+ function renderCliFooter(subtype, turns, durationMs) {
2412
+ return [
2413
+ subtype === "success" ? `**Outcome:** success (${turns} chunk${turns === 1 ? "" : "s"})` : `**Outcome:** \`${subtype}\``,
2414
+ "**Cost:** n/a (external CLI)",
2415
+ `**Duration:** ${(durationMs / 1e3).toFixed(1)}s`
2416
+ ].join(" \n");
2417
+ }
2418
+ /**
2419
+ * Minimal async iterable backed by a push queue. `readline` is push-based
2420
+ * (`'line'` events) but our provider is pull-based (`for await`), so this
2421
+ * buffers lines until the consumer asks for them and resolves cleanly when
2422
+ * the underlying streams close.
2423
+ */
2424
+ var AsyncLineQueue = class {
2425
+ buffer = [];
2426
+ resolvers = [];
2427
+ done = false;
2428
+ push(item) {
2429
+ if (this.done) return;
2430
+ const resolve = this.resolvers.shift();
2431
+ if (resolve) resolve({
2432
+ value: item,
2433
+ done: false
2434
+ });
2435
+ else this.buffer.push(item);
2436
+ }
2437
+ close() {
2438
+ this.done = true;
2439
+ for (const resolve of this.resolvers.splice(0)) resolve({
2440
+ value: void 0,
2441
+ done: true
2442
+ });
2443
+ }
2444
+ [Symbol.asyncIterator]() {
2445
+ return { next: () => {
2446
+ const item = this.buffer.shift();
2447
+ if (item) return Promise.resolve({
2448
+ value: item,
2449
+ done: false
2450
+ });
2451
+ if (this.done) return Promise.resolve({
2452
+ value: void 0,
2453
+ done: true
2454
+ });
2455
+ return new Promise((resolve) => this.resolvers.push(resolve));
2456
+ } };
2457
+ }
2458
+ };
2459
+ /**
2460
+ * Resolve which agent backend a run should use. Precedence:
2461
+ * `PINAGENT_AGENT_PROVIDER` env > default (`claude-code`).
2462
+ *
2463
+ * Defaulting to `claude-code` keeps every existing setup working with no
2464
+ * config; "bring your own model" is opt-in via the env var.
2465
+ */
2466
+ function resolveProviderId(env) {
2467
+ if (env.PINAGENT_AGENT_PROVIDER?.trim().toLowerCase() === "cli") return "cli";
2468
+ return "claude-code";
2469
+ }
2470
+ /** Instantiate the provider for an id. */
2471
+ function createProvider(id) {
2472
+ switch (id) {
2473
+ case "cli": return new CliAgentProvider();
2474
+ default: return new ClaudeCodeProvider();
2475
+ }
2476
+ }
2477
+ /** Convenience: resolve the provider straight from the environment. */
2478
+ function resolveProvider(env = process.env) {
2479
+ return createProvider(resolveProviderId(env));
2480
+ }
2481
+ /**
2482
+ * Bus event roles that shouldn't count toward "transcript depth". The
2483
+ * `__finished` sentinel comes from `bus.ts` and isn't a real event;
2484
+ * `init` and `result` are agent-run bookkeeping the user-facing badge
2485
+ * shouldn't include. Anything else (text / tool_use / tool_result /
2486
+ * ask_user / error / user / ask_response) counts.
2487
+ */
2488
+ const NON_MESSAGE_ROLES = [
2489
+ "__finished",
2490
+ "init",
2491
+ "result"
2492
+ ];
2493
+ const ID_RE = /^[A-Za-z0-9_-]{8,16}$/;
2494
+ /** Per-extra anchor in the multi-pick payload — flat fields mirroring
2495
+ * the primary widget_anchors columns, minus viewport/userAgent (those
2496
+ * are conversation-level). */
2497
+ const AdditionalAnchorSchema = z.object({
2498
+ file: z.string().max(512).nullable(),
2499
+ line: z.number().int().min(1).max(1e6).nullable(),
2500
+ col: z.number().int().min(0).max(1e6).nullable(),
2501
+ selector: z.string().max(2e3),
2502
+ clickX: z.number().int().min(-1e6).max(1e6),
2503
+ clickY: z.number().int().min(-1e6).max(1e6),
2504
+ /** Enclosing component name for this extra pick, when instrumented. */
2505
+ component: z.string().max(256).nullable().optional()
2506
+ });
2507
+ /**
2508
+ * Loop-instance disambiguation captured at pick time. Present only when
2509
+ * the target's `data-pa-loc` is shared by more than one live element (a
2510
+ * `.map()`), so the agent can tell which item was clicked.
2511
+ */
2512
+ const InstanceInfoSchema = z.object({
2513
+ index: z.number().int().min(0).max(1e6),
2514
+ total: z.number().int().min(1).max(1e6),
2515
+ fingerprint: z.string().max(512)
2516
+ });
2517
+ const FeedbackInputSchema = z.object({
2518
+ comment: z.string().min(1).max(8e3),
2519
+ loc: z.object({
2520
+ file: z.string().min(1).max(512),
2521
+ line: z.number().int().min(1).max(1e6),
2522
+ col: z.number().int().min(0).max(1e6)
2523
+ }).nullable(),
2524
+ selector: z.string().max(2e3),
2525
+ url: z.string().max(2048),
2526
+ viewport: z.object({
2527
+ w: z.number().int().min(1),
2528
+ h: z.number().int().min(1)
2529
+ }),
2530
+ userAgent: z.string().max(1024),
2531
+ screenshot: z.string().min(1),
2532
+ createdAt: z.string().min(1),
2533
+ /**
2534
+ * Cmd/Ctrl-click extras queued before the committing click. Optional
2535
+ * for backward compatibility with single-pick clients; capped at 32
2536
+ * to keep a runaway client from blowing up the JSON column.
2537
+ */
2538
+ additionalAnchors: z.array(AdditionalAnchorSchema).max(32).optional(),
2539
+ /**
2540
+ * Enclosing-component context from `data-pa-comp`. All optional for
2541
+ * backward compatibility with clients that predate the attribute.
2542
+ */
2543
+ component: z.string().max(256).nullable().optional(),
2544
+ componentPath: z.array(z.string().max(256)).max(16).optional(),
2545
+ instance: InstanceInfoSchema.nullable().optional()
2546
+ });
2547
+ const StatusSchema = z.enum([
2548
+ "pending",
2549
+ "fixed",
2550
+ "wontfix",
2551
+ "deferred"
2552
+ ]);
2553
+ const WorktreeStateSchema = z.enum([
2554
+ "none",
2555
+ "active",
2556
+ "landed",
2557
+ "discarded"
2558
+ ]);
2559
+ z.object({
2560
+ status: StatusSchema.optional(),
2561
+ note: z.string().max(8e3).nullable().optional(),
2562
+ commitSha: z.string().max(64).nullable().optional(),
2563
+ agentSessionId: z.string().max(128).nullable().optional(),
2564
+ branch: z.string().max(256).nullable().optional(),
2565
+ worktreePath: z.string().max(1024).nullable().optional(),
2566
+ worktreeState: WorktreeStateSchema.optional(),
2567
+ /**
2568
+ * Title override. Pass an empty string or `null` to clear back to the
2569
+ * comment-derived title. Capped at 200 chars so the list rows don't
2570
+ * blow up.
2571
+ */
2572
+ title: z.string().max(200).nullable().optional(),
2573
+ archived: z.boolean().optional()
2574
+ });
2575
+ /**
2576
+ * Server-side feedback storage. Backed by SQLite via Drizzle on the
2577
+ * shared `@pinagent/db` schema; screenshots stay on disk as PNG files
2578
+ * under `.pinagent/screenshots/` because they're large binary blobs.
2579
+ *
2580
+ * The class deliberately preserves the v1 `FeedbackRecord` shape and
2581
+ * method names so the MCP server, route handler, and agent code don't
2582
+ * need to change. Internally it joins `conversations` and
2583
+ * `widget_anchors` on every read.
2584
+ *
2585
+ * On first open, any legacy `.pinagent/feedback/<id>.json` files are
2586
+ * imported into SQLite (and left on disk as a backup). Once they're in
2587
+ * the DB, subsequent reads come from SQLite.
2588
+ */
2589
+ var Storage = class {
2590
+ root;
2591
+ feedbackDir;
2592
+ screenshotsDir;
2593
+ legacyImportDone = false;
2594
+ constructor(root) {
2595
+ this.root = root;
2596
+ this.feedbackDir = join(root, ".pinagent", "feedback");
2597
+ this.screenshotsDir = join(root, ".pinagent", "screenshots");
2598
+ }
2599
+ db() {
2600
+ return getDb(this.root);
2601
+ }
2602
+ async ensureDirs() {
2603
+ await mkdir(this.screenshotsDir, { recursive: true });
2604
+ }
2605
+ /**
2606
+ * One-shot import of legacy JSON feedback records into SQLite.
2607
+ * Safe to call multiple times — uses ON CONFLICT DO NOTHING so
2608
+ * already-imported rows aren't overwritten.
2609
+ */
2610
+ async maybeImportLegacy() {
2611
+ if (this.legacyImportDone) return;
2612
+ this.legacyImportDone = true;
2613
+ if (!existsSync(this.feedbackDir)) return;
2614
+ try {
2615
+ const names = await readdir(this.feedbackDir);
2616
+ for (const n of names) {
2617
+ if (!n.endsWith(".json") || n.endsWith(".tmp")) continue;
2618
+ const id = n.slice(0, -5);
2619
+ if (!ID_RE.test(id)) continue;
2620
+ try {
2621
+ const raw = await readFile(join(this.feedbackDir, n), "utf8");
2622
+ const rec = JSON.parse(raw);
2623
+ if (!rec.id || !rec.comment) continue;
2624
+ await this.insertRecord(rec);
2625
+ } catch {}
2626
+ }
2627
+ } catch {}
2628
+ }
2629
+ async insertRecord(rec) {
2630
+ const db = this.db();
2631
+ await db.insert(conversations).values({
2632
+ id: rec.id,
2633
+ comment: rec.comment,
2634
+ agentSessionId: rec.agentSessionId ?? null,
2635
+ status: rec.status,
2636
+ note: rec.note ?? null,
2637
+ commitSha: rec.commitSha ?? null,
2638
+ createdAt: parseDate(rec.createdAt),
2639
+ updatedAt: parseDate(rec.createdAt),
2640
+ resolvedAt: rec.resolvedAt ? parseDate(rec.resolvedAt) : null
2641
+ }).onConflictDoNothing();
2642
+ await db.insert(widgetAnchors).values({
2643
+ conversationId: rec.id,
2644
+ url: rec.url,
2645
+ file: rec.file,
2646
+ line: rec.line,
2647
+ col: rec.col,
2648
+ selector: rec.selector,
2649
+ viewportW: rec.viewport.w,
2650
+ viewportH: rec.viewport.h,
2651
+ userAgent: rec.userAgent,
2652
+ component: rec.component,
2653
+ componentPath: rec.componentPath,
2654
+ instanceIndex: rec.instanceIndex,
2655
+ instanceTotal: rec.instanceTotal,
2656
+ instanceFingerprint: rec.instanceFingerprint,
2657
+ additionalAnchors: rec.additionalAnchors
2658
+ }).onConflictDoNothing();
2659
+ }
2660
+ async create(id, input) {
2661
+ await this.ensureDirs();
2662
+ const pngBuf = Buffer$1.from(input.screenshot, "base64");
2663
+ const pngRel = join("screenshots", `${id}.png`);
2664
+ const pngAbs = join(this.root, ".pinagent", pngRel);
2665
+ await this.atomicWriteBytes(pngAbs, pngBuf);
2666
+ const db = this.db();
2667
+ const createdAt = parseDate(input.createdAt);
2668
+ await db.insert(conversations).values({
2669
+ id,
2670
+ comment: input.comment,
2671
+ status: "pending",
2672
+ createdAt,
2673
+ updatedAt: createdAt
2674
+ });
2675
+ const componentPath = input.componentPath && input.componentPath.length > 0 ? input.componentPath : null;
2676
+ await db.insert(widgetAnchors).values({
2677
+ conversationId: id,
2678
+ url: input.url,
2679
+ file: input.loc?.file ?? null,
2680
+ line: input.loc?.line ?? null,
2681
+ col: input.loc?.col ?? null,
2682
+ selector: input.selector,
2683
+ viewportW: input.viewport.w,
2684
+ viewportH: input.viewport.h,
2685
+ userAgent: input.userAgent,
2686
+ component: input.component ?? null,
2687
+ componentPath,
2688
+ instanceIndex: input.instance?.index ?? null,
2689
+ instanceTotal: input.instance?.total ?? null,
2690
+ instanceFingerprint: input.instance?.fingerprint ?? null,
2691
+ additionalAnchors: input.additionalAnchors && input.additionalAnchors.length > 0 ? input.additionalAnchors : null
2692
+ });
2693
+ const record = {
2694
+ id,
2695
+ comment: input.comment,
2696
+ file: input.loc?.file ?? null,
2697
+ line: input.loc?.line ?? null,
2698
+ col: input.loc?.col ?? null,
2699
+ selector: input.selector,
2700
+ url: input.url,
2701
+ viewport: input.viewport,
2702
+ userAgent: input.userAgent,
2703
+ additionalAnchors: input.additionalAnchors && input.additionalAnchors.length > 0 ? input.additionalAnchors : null,
2704
+ component: input.component ?? null,
2705
+ componentPath,
2706
+ instanceIndex: input.instance?.index ?? null,
2707
+ instanceTotal: input.instance?.total ?? null,
2708
+ instanceFingerprint: input.instance?.fingerprint ?? null,
2709
+ screenshot: pngRel,
2710
+ status: "pending",
2711
+ note: null,
2712
+ commitSha: null,
2713
+ agentSessionId: null,
2714
+ branch: null,
2715
+ worktreePath: null,
2716
+ worktreeState: "none",
2717
+ title: null,
2718
+ archived: false,
2719
+ createdAt: input.createdAt,
2720
+ updatedAt: input.createdAt,
2721
+ resolvedAt: null,
2722
+ messageCount: 0,
2723
+ totalCostUsd: 0,
2724
+ apiKeySource: null,
2725
+ isRunning: false
2726
+ };
2727
+ emitProjectChange({ type: "conversations_changed" });
2728
+ await recordAuditEvent(this.root, {
2729
+ conversationId: id,
2730
+ actor: "user",
2731
+ action: "conversation_created",
2732
+ payload: {
2733
+ page: input.url,
2734
+ ...input.loc?.file ? { file: input.loc.file } : {}
2735
+ }
2736
+ });
2737
+ return record;
2738
+ }
2739
+ async list() {
2740
+ await this.maybeImportLegacy();
2741
+ const db = this.db();
2742
+ const rows = await db.select().from(conversations).leftJoin(widgetAnchors, eq$1(conversations.id, widgetAnchors.conversationId)).orderBy(asc$1(conversations.createdAt));
2743
+ const counts = await db.select({
2744
+ id: messages.conversationId,
2745
+ n: count()
2746
+ }).from(messages).where(notInArray(messages.role, NON_MESSAGE_ROLES)).groupBy(messages.conversationId);
2747
+ const countByConvId = new Map(counts.map((c) => [c.id, c.n]));
2748
+ const costRows = await db.select({
2749
+ id: messages.conversationId,
2750
+ content: messages.content
2751
+ }).from(messages).where(eq$1(messages.role, "result"));
2752
+ const costByConvId = /* @__PURE__ */ new Map();
2753
+ for (const r of costRows) {
2754
+ const c = extractResultCost(r.content);
2755
+ if (c > 0) costByConvId.set(r.id, (costByConvId.get(r.id) ?? 0) + c);
2756
+ }
2757
+ const initRows = await db.select({
2758
+ id: messages.conversationId,
2759
+ content: messages.content
2760
+ }).from(messages).where(eq$1(messages.role, "init"));
2761
+ const apiKeySourceByConvId = /* @__PURE__ */ new Map();
2762
+ for (const r of initRows) {
2763
+ if (apiKeySourceByConvId.has(r.id)) continue;
2764
+ const src = extractApiKeySource(r.content);
2765
+ if (src) apiKeySourceByConvId.set(r.id, src);
2766
+ }
2767
+ const runningRows = await db.select({ id: activeRuns.conversationId }).from(activeRuns);
2768
+ const runningIds = new Set(runningRows.map((r) => r.id));
2769
+ return rows.map((r) => rowToRecord(r, countByConvId.get(r.conversations.id) ?? 0, costByConvId.get(r.conversations.id) ?? 0, apiKeySourceByConvId.get(r.conversations.id) ?? null, runningIds.has(r.conversations.id)));
2770
+ }
2771
+ async read(id) {
2772
+ if (!ID_RE.test(id)) return null;
2773
+ await this.maybeImportLegacy();
2774
+ const db = this.db();
2775
+ const row = (await db.select().from(conversations).leftJoin(widgetAnchors, eq$1(conversations.id, widgetAnchors.conversationId)).where(eq$1(conversations.id, id)).limit(1))[0];
2776
+ if (!row) return null;
2777
+ const countRows = await db.select({ n: count() }).from(messages).where(and$1(eq$1(messages.conversationId, id), notInArray(messages.role, NON_MESSAGE_ROLES)));
2778
+ const totalCostUsd = await this.computeConversationCost(id);
2779
+ const apiKeySource = await this.readApiKeySource(id);
2780
+ const runningRows = await db.select({ id: activeRuns.conversationId }).from(activeRuns).where(eq$1(activeRuns.conversationId, id)).limit(1);
2781
+ return rowToRecord(row, countRows[0]?.n ?? 0, totalCostUsd, apiKeySource, runningRows.length > 0);
2782
+ }
2783
+ /**
2784
+ * Read `apiKeySource` off the conversation's `init` event. Drives the
2785
+ * dock's notional-cost relabeling (see `isNotionalCost`). Null when no
2786
+ * `init` has been recorded yet (created but never run).
2787
+ */
2788
+ async readApiKeySource(id) {
2789
+ if (!ID_RE.test(id)) return null;
2790
+ const rows = await this.db().select({ content: messages.content }).from(messages).where(and$1(eq$1(messages.conversationId, id), eq$1(messages.role, "init"))).orderBy(asc$1(messages.id)).limit(1);
2791
+ return rows[0] ? extractApiKeySource(rows[0].content) : null;
2792
+ }
2793
+ /**
2794
+ * Sum the SDK-reported `totalCostUsd` across every `result` event
2795
+ * recorded for one conversation. Used both for the per-row display
2796
+ * value and for the per-conversation cap check at spawn time.
2797
+ */
2798
+ async computeConversationCost(id) {
2799
+ if (!ID_RE.test(id)) return 0;
2800
+ const rows = await this.db().select({ content: messages.content }).from(messages).where(and$1(eq$1(messages.conversationId, id), eq$1(messages.role, "result")));
2801
+ let total = 0;
2802
+ for (const r of rows) total += extractResultCost(r.content);
2803
+ return total;
2804
+ }
2805
+ /**
2806
+ * Sum the SDK-reported `totalCostUsd` across every `result` event
2807
+ * recorded in the given UTC calendar month, across all conversations.
2808
+ * Drives the `monthlyBudgetUsd` cap check. The `month` argument's
2809
+ * day-of-month is ignored — we always span the calendar month it
2810
+ * falls in. Callers pass `new Date()` for "current month".
2811
+ */
2812
+ async computeMonthlySpend(month) {
2813
+ const db = this.db();
2814
+ const start = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth(), 1));
2815
+ const end = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1));
2816
+ const rows = await db.select({ content: messages.content }).from(messages).where(and$1(eq$1(messages.role, "result"), gte(messages.createdAt, start), lt(messages.createdAt, end)));
2817
+ let total = 0;
2818
+ for (const r of rows) total += extractResultCost(r.content);
2819
+ return total;
2820
+ }
2821
+ /**
2822
+ * Full transcript for one conversation, in insertion order. Returns
2823
+ * the raw `AgentEvent` payloads as they were appended to the bus,
2824
+ * skipping the `__finished` sentinel (which is internal bookkeeping,
2825
+ * not an event subscribers should see). Backs the HTTP transcript
2826
+ * endpoint that external clients (CLI, hosted dock) read.
2827
+ *
2828
+ * Returns [] for unknown ids — matches `read()`'s "no error on
2829
+ * missing row" contract; the route handler distinguishes via `read`.
2830
+ */
2831
+ async listMessages(id) {
2832
+ if (!ID_RE.test(id)) return [];
2833
+ await this.maybeImportLegacy();
2834
+ return (await this.db().select().from(messages).where(and$1(eq$1(messages.conversationId, id), notInArray(messages.role, ["__finished"]))).orderBy(asc$1(messages.id))).map((r) => r.content);
2835
+ }
2836
+ async readScreenshotBase64(rec) {
2837
+ const abs = join(this.root, ".pinagent", rec.screenshot);
2838
+ if (!existsSync(abs)) return null;
2839
+ return (await readFile(abs)).toString("base64");
2840
+ }
2841
+ /**
2842
+ * Returns the patched record + the previous status (so callers can
2843
+ * tell whether a terminal-status transition just happened and fire a
2844
+ * `status_changed` event without re-reading).
2845
+ */
2846
+ async patchWithDiff(id, patch) {
2847
+ if (!ID_RE.test(id)) return null;
2848
+ const current = await this.read(id);
2849
+ if (!current) return null;
2850
+ const previousStatus = current.status;
2851
+ const update = { updatedAt: /* @__PURE__ */ new Date() };
2852
+ if (patch.status !== void 0) {
2853
+ update.status = patch.status;
2854
+ if (patch.status !== "pending") update.resolvedAt = /* @__PURE__ */ new Date();
2855
+ else update.resolvedAt = null;
2856
+ }
2857
+ if (patch.note !== void 0) update.note = patch.note;
2858
+ if (patch.commitSha !== void 0) update.commitSha = patch.commitSha;
2859
+ if (patch.agentSessionId !== void 0) update.agentSessionId = patch.agentSessionId;
2860
+ if (patch.branch !== void 0) update.branch = patch.branch;
2861
+ if (patch.worktreePath !== void 0) update.worktreePath = patch.worktreePath;
2862
+ if (patch.worktreeState !== void 0) update.worktreeState = patch.worktreeState;
2863
+ if (patch.title !== void 0) update.title = patch.title && patch.title.trim().length > 0 ? patch.title.trim() : null;
2864
+ if (patch.archived !== void 0) update.archived = patch.archived;
2865
+ await this.db().update(conversations).set(update).where(eq$1(conversations.id, id));
2866
+ const next = await this.read(id);
2867
+ if (!next) return null;
2868
+ emitProjectChange({ type: "conversations_changed" });
2869
+ return {
2870
+ record: next,
2871
+ previousStatus
2872
+ };
2873
+ }
2874
+ async patch(id, patch) {
2875
+ const result = await this.patchWithDiff(id, patch);
2876
+ return result ? result.record : null;
2877
+ }
2878
+ async atomicWriteBytes(p, data) {
2879
+ const tmp = `${p}.tmp`;
2880
+ await writeFile(tmp, data);
2881
+ await rename(tmp, p);
2882
+ }
2883
+ };
2884
+ function parseDate(iso) {
2885
+ const d = new Date(iso);
2886
+ if (Number.isNaN(d.getTime())) return /* @__PURE__ */ new Date();
2887
+ return d;
2888
+ }
2889
+ function rowToRecord(row, messageCount, totalCostUsd, apiKeySource, isRunning) {
2890
+ const c = row.conversations;
2891
+ const a = row.widget_anchors;
2892
+ return {
2893
+ id: c.id,
2894
+ comment: c.comment,
2895
+ file: a?.file ?? null,
2896
+ line: a?.line ?? null,
2897
+ col: a?.col ?? null,
2898
+ selector: a?.selector ?? "",
2899
+ url: a?.url ?? "",
2900
+ viewport: {
2901
+ w: a?.viewportW ?? 0,
2902
+ h: a?.viewportH ?? 0
2903
+ },
2904
+ userAgent: a?.userAgent ?? "",
2905
+ additionalAnchors: a?.additionalAnchors ?? null,
2906
+ component: a?.component ?? null,
2907
+ componentPath: a?.componentPath ?? null,
2908
+ instanceIndex: a?.instanceIndex ?? null,
2909
+ instanceTotal: a?.instanceTotal ?? null,
2910
+ instanceFingerprint: a?.instanceFingerprint ?? null,
2911
+ screenshot: join("screenshots", `${c.id}.png`),
2912
+ status: c.status,
2913
+ note: c.note,
2914
+ commitSha: c.commitSha,
2915
+ agentSessionId: c.agentSessionId,
2916
+ branch: c.branch,
2917
+ worktreePath: c.worktreePath,
2918
+ worktreeState: c.worktreeState,
2919
+ title: c.title,
2920
+ archived: c.archived,
2921
+ createdAt: c.createdAt.toISOString(),
2922
+ updatedAt: c.updatedAt.toISOString(),
2923
+ resolvedAt: c.resolvedAt ? c.resolvedAt.toISOString() : null,
2924
+ messageCount,
2925
+ totalCostUsd,
2926
+ apiKeySource,
2927
+ isRunning
2928
+ };
2929
+ }
2930
+ /**
2931
+ * Pull the `totalCostUsd` out of a stored `result` event's JSON content.
2932
+ * Tolerant of missing/non-numeric values so a malformed historical row
2933
+ * (or a future event shape) doesn't poison the aggregate — those just
2934
+ * count as 0.
2935
+ */
2936
+ function extractResultCost(content) {
2937
+ if (content && typeof content === "object" && "totalCostUsd" in content) {
2938
+ const v = content.totalCostUsd;
2939
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v;
2940
+ }
2941
+ return 0;
2942
+ }
2943
+ /**
2944
+ * Pull `apiKeySource` out of a stored `init` event's JSON content. Mirrors
2945
+ * `extractResultCost`'s tolerance: missing/non-string values yield null so
2946
+ * the dock simply falls back to showing the raw cost (the pre-existing
2947
+ * behavior) rather than mis-labeling it.
2948
+ */
2949
+ function extractApiKeySource(content) {
2950
+ if (content && typeof content === "object" && "apiKeySource" in content) {
2951
+ const v = content.apiKeySource;
2952
+ if (typeof v === "string" && v) return v;
2953
+ }
2954
+ return null;
2955
+ }
2956
+ /**
2957
+ * Branch-routing enforcement primitives (OSS / dev-side).
2958
+ *
2959
+ * The cloud control plane owns the *policy* (an org's default base branch +
2960
+ * allowed land-target patterns); this module is the on-machine enforcement of
2961
+ * it. The matching logic is re-implemented here rather than imported from the
2962
+ * Elastic `@pinagent/ee-team-features` package so the OSS agent-runner stays
2963
+ * free of any source-available dependency — the policy crosses the boundary
2964
+ * as plain data (today via local project settings; later pushed over the relay
2965
+ * channel) and is enforced with this code.
2966
+ *
2967
+ * Keep this behaviourally in sync with ee-team-features' `branch-routing.ts`:
2968
+ * patterns are anchored full-string globs where `*` matches any run of
2969
+ * characters, and an empty pattern list allows any branch.
2970
+ */
2971
+ function escapeRegExp(literal) {
2972
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2973
+ }
2974
+ /** Match a branch name against a single `*`-glob pattern (anchored). */
2975
+ function matchBranchPattern(pattern, branch) {
2976
+ return new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`).test(branch);
2977
+ }
2978
+ /**
2979
+ * Whether `branch` may be landed on under `allowedPatterns`. An empty list
2980
+ * (the default) allows any branch — branch routing is opt-in.
2981
+ */
2982
+ function isBranchAllowed(allowedPatterns, branch) {
2983
+ if (allowedPatterns.length === 0) return true;
2984
+ return allowedPatterns.some((pattern) => matchBranchPattern(pattern, branch));
2985
+ }
2986
+ async function createWorktree(projectRoot, feedbackId, logPath) {
2987
+ if (!existsSync(join(projectRoot, ".git"))) throw new Error("project root is not a git repository");
2988
+ const worktreeDir = join(projectRoot, ".pinagent", "worktrees");
2989
+ await mkdir(worktreeDir, { recursive: true });
2990
+ const worktreePath = join(worktreeDir, feedbackId);
2991
+ const branch = `pinagent/${feedbackId}`;
2992
+ const { baseBranch } = await new SettingsStore(projectRoot).read();
2993
+ const baseResolves = (await runGitCapture(projectRoot, [
2994
+ "rev-parse",
2995
+ "--verify",
2996
+ "--quiet",
2997
+ baseBranch
2998
+ ])).code === 0;
2999
+ const addArgs = baseResolves ? [
3000
+ "worktree",
3001
+ "add",
3002
+ "-b",
3003
+ branch,
3004
+ worktreePath,
3005
+ baseBranch
3006
+ ] : [
3007
+ "worktree",
3008
+ "add",
3009
+ "-b",
3010
+ branch,
3011
+ worktreePath
3012
+ ];
3013
+ if (!baseResolves) await appendLog(logPath, `> [pinagent] base branch \`${baseBranch}\` not found; forking worktree from HEAD\n`);
3014
+ await runGit(projectRoot, addArgs, logPath);
3015
+ try {
3016
+ await new Storage(projectRoot).patch(feedbackId, {
3017
+ branch,
3018
+ worktreePath,
3019
+ worktreeState: "active"
3020
+ });
3021
+ } catch {}
3022
+ return worktreePath;
3023
+ }
3024
+ /**
3025
+ * Land the agent's worktree onto the project's HEAD branch — but only when HEAD
3026
+ * still matches the base the worktree forked from (`settings.baseBranch`). If
3027
+ * the developer switched the main checkout to another branch, landing is
3028
+ * refused rather than silently merging the work into the wrong branch.
3029
+ *
3030
+ * The agent intentionally does not commit (see `buildInitialPrompt`) so the
3031
+ * developer can review the diff before landing; we stage and commit on its
3032
+ * behalf as a single squash here. On merge conflict the merge is aborted —
3033
+ * the worktree is left intact so the user can resolve manually and retry.
3034
+ *
3035
+ * Should be called via `merge-queue.ts` so concurrent landings on the same
3036
+ * project serialize cleanly.
3037
+ */
3038
+ async function mergeWorktree(projectRoot, feedbackId, logPath) {
3039
+ const storage = new Storage(projectRoot);
3040
+ const rec = await storage.read(feedbackId);
3041
+ if (!rec) return {
3042
+ ok: false,
3043
+ error: `feedback not found: ${feedbackId}`
3044
+ };
3045
+ if (!rec.worktreePath || !rec.branch) return {
3046
+ ok: false,
3047
+ error: "this conversation has no worktree (inline-mode submission)"
3048
+ };
3049
+ if (rec.worktreeState !== "active") return {
3050
+ ok: false,
3051
+ error: `cannot land: worktree state is ${rec.worktreeState}`
3052
+ };
3053
+ if (!existsSync(rec.worktreePath)) return {
3054
+ ok: false,
3055
+ error: `worktree no longer exists at ${rec.worktreePath}`
3056
+ };
3057
+ if (!existsSync(join(projectRoot, ".git"))) return {
3058
+ ok: false,
3059
+ error: "project root is not a git repository"
3060
+ };
3061
+ await appendLog(logPath, `\n## Land · ${(/* @__PURE__ */ new Date()).toISOString()}\n\n`);
3062
+ const head = await runGitCapture(projectRoot, [
3063
+ "symbolic-ref",
3064
+ "--short",
3065
+ "HEAD"
3066
+ ]);
3067
+ if (head.code !== 0) return {
3068
+ ok: false,
3069
+ error: `cannot resolve project HEAD branch (detached?): ${head.stderr.trim()}`
3070
+ };
3071
+ const targetBranch = head.stdout.trim();
3072
+ if (targetBranch === rec.branch) return {
3073
+ ok: false,
3074
+ error: `project HEAD is already on ${rec.branch}; nothing to land`
3075
+ };
3076
+ const settings = await new SettingsStore(projectRoot).read();
3077
+ if ((await runGitCapture(projectRoot, [
3078
+ "rev-parse",
3079
+ "--verify",
3080
+ "--quiet",
3081
+ settings.baseBranch
3082
+ ])).code === 0 && targetBranch !== settings.baseBranch) return {
3083
+ ok: false,
3084
+ error: `cannot land: the project is on "${targetBranch}" but this worktree was based on "${settings.baseBranch}". Check out "${settings.baseBranch}" to land, or discard.`
3085
+ };
3086
+ const { allowedBranchPatterns } = settings;
3087
+ if (!isBranchAllowed(allowedBranchPatterns, targetBranch)) {
3088
+ const allowed = allowedBranchPatterns.join(", ");
3089
+ await appendLog(logPath, `> [pinagent] branch routing blocked landing onto \`${targetBranch}\` (allowed: ${allowed})\n`);
3090
+ return {
3091
+ ok: false,
3092
+ error: `branch routing policy does not allow landing onto "${targetBranch}" (allowed: ${allowed})`
3093
+ };
3094
+ }
3095
+ const status = await runGitCapture(rec.worktreePath, ["status", "--porcelain"]);
3096
+ if (status.code !== 0) return {
3097
+ ok: false,
3098
+ error: `git status failed in worktree: ${status.stderr.trim()}`
3099
+ };
3100
+ if (status.stdout.trim()) {
3101
+ const add = await runGitCapture(rec.worktreePath, ["add", "-A"]);
3102
+ if (add.code !== 0) return {
3103
+ ok: false,
3104
+ error: `git add failed: ${add.stderr.trim()}`
3105
+ };
3106
+ const commit = await runGitCapture(rec.worktreePath, [
3107
+ "commit",
3108
+ "-m",
3109
+ formatLandCommitMessage(rec)
3110
+ ]);
3111
+ if (commit.code !== 0) {
3112
+ const combined = `${commit.stdout}\n${commit.stderr}`;
3113
+ if (!/nothing to commit/.test(combined)) return {
3114
+ ok: false,
3115
+ error: `git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}`
3116
+ };
3117
+ }
3118
+ }
3119
+ const ahead = await runGitCapture(projectRoot, [
3120
+ "rev-list",
3121
+ "--count",
3122
+ `${targetBranch}..${rec.branch}`
3123
+ ]);
3124
+ if (ahead.code !== 0) return {
3125
+ ok: false,
3126
+ error: `cannot compare branches: ${ahead.stderr.trim()}`
3127
+ };
3128
+ if (Number(ahead.stdout.trim()) === 0) {
3129
+ await appendLog(logPath, "> [pinagent] no changes to land\n");
3130
+ await cleanupWorktreeFiles(rec.worktreePath, rec.branch, projectRoot, logPath);
3131
+ await storage.patch(feedbackId, { worktreeState: "landed" });
3132
+ await recordAuditEvent(projectRoot, {
3133
+ conversationId: feedbackId,
3134
+ actor: "user",
3135
+ action: "conversation_landed",
3136
+ payload: {
3137
+ branch: rec.branch,
3138
+ target: targetBranch,
3139
+ noop: true
3140
+ }
3141
+ });
3142
+ return { ok: true };
3143
+ }
3144
+ if ((await runGitCapture(projectRoot, [
3145
+ "merge",
3146
+ "--no-ff",
3147
+ "--no-edit",
3148
+ rec.branch
3149
+ ])).code !== 0) {
3150
+ const conflicts = (await runGitCapture(projectRoot, [
3151
+ "diff",
3152
+ "--name-only",
3153
+ "--diff-filter=U"
3154
+ ])).stdout.split("\n").map((s) => s.trim()).filter(Boolean);
3155
+ const abort = await runGitCapture(projectRoot, ["merge", "--abort"]);
3156
+ await appendLog(logPath, `> [pinagent] merge into \`${targetBranch}\` failed: ${conflicts.length} conflicted file(s)\n${conflicts.map((c) => `> - \`${c}\`\n`).join("")}\n`);
3157
+ if (abort.code !== 0) {
3158
+ await appendLog(logPath, `> [pinagent] WARNING: \`git merge --abort\` failed; the project working tree may be left mid-merge: ${abort.stderr.trim()}\n`);
3159
+ return {
3160
+ ok: false,
3161
+ error: `merge conflicted and \`git merge --abort\` failed; the project working tree may be left mid-merge — resolve manually: ${abort.stderr.trim()}`,
3162
+ conflicts
3163
+ };
3164
+ }
3165
+ return {
3166
+ ok: false,
3167
+ conflicts
3168
+ };
3169
+ }
3170
+ const sha = await runGitCapture(projectRoot, ["rev-parse", "HEAD"]);
3171
+ const commitSha = sha.code === 0 ? sha.stdout.trim() : void 0;
3172
+ await cleanupWorktreeFiles(rec.worktreePath, rec.branch, projectRoot, logPath);
3173
+ await storage.patch(feedbackId, {
3174
+ worktreeState: "landed",
3175
+ ...commitSha ? { commitSha } : {}
3176
+ });
3177
+ await recordAuditEvent(projectRoot, {
3178
+ conversationId: feedbackId,
3179
+ actor: "user",
3180
+ action: "conversation_landed",
3181
+ payload: {
3182
+ branch: rec.branch,
3183
+ target: targetBranch,
3184
+ ...commitSha ? { commitSha } : {}
3185
+ }
3186
+ });
3187
+ await appendLog(logPath, `> [pinagent] landed onto \`${targetBranch}\`${commitSha ? ` as \`${commitSha.slice(0, 12)}\`` : ""}\n`);
3188
+ return {
3189
+ ok: true,
3190
+ ...commitSha ? { commitSha } : {}
3191
+ };
3192
+ }
3193
+ /**
3194
+ * Reverse a landed/discarded conversation: put it back in the active
3195
+ * list so the user can follow up with the agent. We reset
3196
+ * `worktreeState` to `'none'` and `status` to `'pending'`; we do NOT
3197
+ * recreate the worktree (it was cleaned up at land/discard time and
3198
+ * the developer's actual changes have either already merged or were
3199
+ * thrown away). For inline-mode runs that's all that's needed — the
3200
+ * user can immediately send a follow-up. For ex-worktree runs the
3201
+ * conversation is conceptually inline-mode from this point forward.
3202
+ *
3203
+ * Refuses on conversations that aren't already resolved so a stray
3204
+ * client click can't reset a still-active worktree.
3205
+ */
3206
+ async function reopenConversation(projectRoot, feedbackId, logPath) {
3207
+ const storage = new Storage(projectRoot);
3208
+ const rec = await storage.read(feedbackId);
3209
+ if (!rec) return {
3210
+ ok: false,
3211
+ error: `feedback not found: ${feedbackId}`
3212
+ };
3213
+ if (rec.worktreeState !== "landed" && rec.worktreeState !== "discarded") return {
3214
+ ok: false,
3215
+ error: `cannot reopen: worktree state is ${rec.worktreeState} (expected landed or discarded)`
3216
+ };
3217
+ await appendLog(logPath, `\n## Reopen · ${(/* @__PURE__ */ new Date()).toISOString()}\n\n`);
3218
+ await storage.patch(feedbackId, {
3219
+ worktreeState: "none",
3220
+ status: "pending"
3221
+ });
3222
+ await recordAuditEvent(projectRoot, {
3223
+ conversationId: feedbackId,
3224
+ actor: "user",
3225
+ action: "conversation_reopened",
3226
+ payload: {
3227
+ previousWorktreeState: rec.worktreeState,
3228
+ previousStatus: rec.status
3229
+ }
3230
+ });
3231
+ return { ok: true };
3232
+ }
3233
+ /**
3234
+ * Throw away the worktree and its branch without merging. Idempotent —
3235
+ * tolerates a missing worktree or branch (the user may have cleaned
3236
+ * them up manually).
3237
+ */
3238
+ async function discardWorktree(projectRoot, feedbackId, logPath) {
3239
+ const storage = new Storage(projectRoot);
3240
+ const rec = await storage.read(feedbackId);
3241
+ if (!rec) return { ok: true };
3242
+ await appendLog(logPath, `\n## Discard · ${(/* @__PURE__ */ new Date()).toISOString()}\n\n`);
3243
+ if (rec.worktreePath && rec.branch) await cleanupWorktreeFiles(rec.worktreePath, rec.branch, projectRoot, logPath);
3244
+ await storage.patch(feedbackId, { worktreeState: "discarded" });
3245
+ await recordAuditEvent(projectRoot, {
3246
+ conversationId: feedbackId,
3247
+ actor: "user",
3248
+ action: "conversation_discarded",
3249
+ payload: rec.branch ? { branch: rec.branch } : {}
3250
+ });
3251
+ return { ok: true };
3252
+ }
3253
+ async function cleanupWorktreeFiles(worktreePath, branch, projectRoot, logPath) {
3254
+ if (existsSync(worktreePath)) {
3255
+ const rm = await runGitCapture(projectRoot, [
3256
+ "worktree",
3257
+ "remove",
3258
+ "--force",
3259
+ worktreePath
3260
+ ]);
3261
+ if (rm.code !== 0) await appendLog(logPath, `> [pinagent:git] worktree remove → exit ${rm.code}\n${rm.stderr}\n`);
3262
+ }
3263
+ await runGitCapture(projectRoot, ["worktree", "prune"]);
3264
+ const br = await runGitCapture(projectRoot, [
3265
+ "branch",
3266
+ "-D",
3267
+ branch
3268
+ ]);
3269
+ if (br.code !== 0 && !/not found|did not match/i.test(br.stderr)) await appendLog(logPath, `> [pinagent:git] branch -D ${branch} → exit ${br.code}\n${br.stderr}\n`);
3270
+ }
3271
+ function formatLandCommitMessage(rec) {
3272
+ const firstLine = rec.comment.split(/\r?\n/)[0]?.trim() ?? "";
3273
+ const subject = firstLine.length > 70 ? `${firstLine.slice(0, 67)}…` : firstLine;
3274
+ const where = rec.file ? `${rec.file}:${rec.line ?? "?"}${rec.col != null ? `:${rec.col}` : ""}` : rec.selector;
3275
+ return [
3276
+ `pinagent: ${subject || "agent edit"}`,
3277
+ "",
3278
+ "Landed via pinagent.",
3279
+ "",
3280
+ `Feedback: ${rec.id}`,
3281
+ `Target: ${where}`,
3282
+ ""
3283
+ ].join("\n");
3284
+ }
3285
+ /**
3286
+ * Count files with uncommitted changes in a worktree (`git status --porcelain`
3287
+ * line count). Returns `null` if the worktree path doesn't exist or `git`
3288
+ * fails — the caller treats that as "unknown" rather than zero, so the widget
3289
+ * can omit the count from its label instead of showing a misleading "0 changes".
3290
+ */
3291
+ async function countWorktreeChanges(worktreePath) {
3292
+ if (!existsSync(worktreePath)) return null;
3293
+ const status = await runGitCapture(worktreePath, ["status", "--porcelain"]);
3294
+ if (status.code !== 0) return null;
3295
+ const trimmed = status.stdout.trim();
3296
+ if (!trimmed) return 0;
3297
+ return trimmed.split("\n").length;
3298
+ }
3299
+ /**
3300
+ * In-flight runs LOCAL TO THIS CONTEXT. The AbortController is a
3301
+ * process-bound object — there's no way to serialise it across
3302
+ * contexts/processes, so we keep the per-context Map. Cross-context
3303
+ * `interruptRun` calls reach the owning context via `process.emit`
3304
+ * (see INTERRUPT_EVENT below), which all contexts in the same Node
3305
+ * process share.
3306
+ *
3307
+ * Cross-context "is a run in flight?" visibility is handled separately
3308
+ * by the `active_runs` SQLite table — see `hasActiveRun` below.
3309
+ */
3310
+ const activeRuns$1 = /* @__PURE__ */ new Map();
3311
+ /**
3312
+ * Cross-context signalling channel for interrupts. The WS server can
3313
+ * land in a different context than the one running the SDK loop (Next 16
3314
+ * Turbopack, Vite 8 environments), so the in-memory `activeRuns` Map in
3315
+ * the WS server's context wouldn't see the entry. Node's `process`
3316
+ * EventEmitter is shared across all contexts in one process, so emit-ing
3317
+ * here reliably reaches the owning context's listener (registered in
3318
+ * `runQuery`). Same idea as the SQLite-backed bus, but for a transient
3319
+ * signal rather than a stream — no persistence needed.
3320
+ */
3321
+ const INTERRUPT_EVENT = "pinagent:interrupt";
3322
+ async function recordActiveRun(projectRoot, feedbackId) {
3323
+ try {
3324
+ await getDb(projectRoot).insert(activeRuns).values({
3325
+ conversationId: feedbackId,
3326
+ startedAt: /* @__PURE__ */ new Date(),
3327
+ currentTurn: 1
3328
+ }).onConflictDoUpdate({
3329
+ target: activeRuns.conversationId,
3330
+ set: { startedAt: /* @__PURE__ */ new Date() }
3331
+ });
3332
+ emitProjectChange({ type: "conversations_changed" });
3333
+ } catch {}
3334
+ }
3335
+ async function clearActiveRun(projectRoot, feedbackId) {
3336
+ try {
3337
+ await getDb(projectRoot).delete(activeRuns).where(eq(activeRuns.conversationId, feedbackId));
3338
+ emitProjectChange({ type: "conversations_changed" });
3339
+ } catch {}
3340
+ }
3341
+ /**
3342
+ * Run an isolated Claude Agent SDK query for a single freshly-submitted
3343
+ * feedback record. Kicks off in the background; the route handler resolves
3344
+ * its POST as soon as this returns "started", not when the agent finishes.
3345
+ *
3346
+ * Log file at `.pinagent/logs/<id>.md` accumulates the transcript across
3347
+ * the initial run plus any follow-up turns the user sends over WS.
3348
+ */
3349
+ async function spawnAgent(ctx) {
3350
+ if (ctx.mode === false) return;
3351
+ const logsDir = join(ctx.projectRoot, ".pinagent", "logs");
3352
+ await mkdir(logsDir, { recursive: true });
3353
+ const logPath = join(logsDir, `${ctx.feedback.id}.md`);
3354
+ const capCheck = await checkCostCaps(ctx.projectRoot, ctx.feedback.id);
3355
+ if (!capCheck.ok) {
3356
+ await getOrCreateBus(ctx.feedback.id, ctx.projectRoot).publish({
3357
+ type: "error",
3358
+ message: capCheck.reason
3359
+ });
3360
+ await appendLog(logPath, `\n> [pinagent] spawn refused: ${capCheck.reason}\n`);
3361
+ return;
3362
+ }
3363
+ let cwd = ctx.projectRoot;
3364
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3365
+ if (ctx.mode === "worktree") try {
3366
+ cwd = await createWorktree(ctx.projectRoot, ctx.feedback.id, logPath);
3367
+ } catch (err) {
3368
+ await appendLog(logPath, `${renderHeader(ctx, cwd, startedAt, false)}\n> [pinagent] worktree creation failed: ${stringifyErr(err)}\n`);
3369
+ return;
3370
+ }
3371
+ await appendLog(logPath, renderHeader(ctx, cwd, startedAt, true));
3372
+ const prompt = buildInitialPrompt(ctx.feedback, ctx.mode, cwd);
3373
+ const permissionMode = await resolveRunPermissionMode(ctx.projectRoot);
3374
+ runQuery({
3375
+ projectRoot: ctx.projectRoot,
3376
+ feedbackId: ctx.feedback.id,
3377
+ cwd,
3378
+ logPath,
3379
+ prompt,
3380
+ isInitial: true,
3381
+ permissionMode,
3382
+ targetFile: ctx.feedback.file
3383
+ });
3384
+ }
3385
+ /**
3386
+ * Send a follow-up message into the existing conversation for `feedbackId`.
3387
+ * Resumes the prior SDK session so the agent keeps full context. Resolves
3388
+ * once the new turn has been started, not when it finishes.
3389
+ *
3390
+ * Refuses if there's no prior session (the feedback was never spawn-mode).
3391
+ *
3392
+ * The widget queues follow-ups client-side and flushes the next one the
3393
+ * instant it sees the prior turn's `result` event. That flush can reach us
3394
+ * a few ms BEFORE the just-finished run has torn down its `active_runs`
3395
+ * row (the teardown's `clearActiveRun` runs in `runQuery`'s finally, after
3396
+ * the `result` was already published). The widget only ever has one turn
3397
+ * in flight, so a lingering active run here is that finishing turn — not a
3398
+ * parallel one — so we briefly wait for it to clear rather than bouncing
3399
+ * the follow-up back. A bounced follow-up is effectively dropped: the
3400
+ * widget re-queues it but has no further turn-end event to re-flush it on,
3401
+ * so the agent appears to "just end" without addressing the follow-up.
3402
+ */
3403
+ async function runFollowUpTurn(feedbackId, content) {
3404
+ const projectRoot = process.env.PINAGENT_PROJECT_ROOT ?? process.cwd();
3405
+ if (!await waitForRunToClear(feedbackId, projectRoot)) throw new Error("a turn is already in progress for this feedback");
3406
+ const rec = await new Storage(projectRoot).read(feedbackId);
3407
+ if (!rec) throw new Error(`feedback not found: ${feedbackId}`);
3408
+ if (!rec.agentSessionId) throw new Error("no prior agent session — only spawn-mode submissions support follow-ups");
3409
+ const capCheck = await checkCostCaps(projectRoot, feedbackId);
3410
+ if (!capCheck.ok) {
3411
+ await getOrCreateBus(feedbackId, projectRoot).publish({
3412
+ type: "error",
3413
+ message: capCheck.reason
3414
+ });
3415
+ throw new Error(capCheck.reason);
3416
+ }
3417
+ const worktreePath = join(projectRoot, ".pinagent", "worktrees", feedbackId);
3418
+ const cwd = existsSync(worktreePath) ? worktreePath : projectRoot;
3419
+ const logsDir = join(projectRoot, ".pinagent", "logs");
3420
+ await mkdir(logsDir, { recursive: true });
3421
+ const logPath = join(logsDir, `${feedbackId}.md`);
3422
+ await appendLog(logPath, `\n## Follow-up turn · ${(/* @__PURE__ */ new Date()).toISOString()}\n\n> **User**\n> \n> ${content.split("\n").join("\n> ")}\n\n`);
3423
+ runQuery({
3424
+ projectRoot,
3425
+ feedbackId,
3426
+ cwd,
3427
+ logPath,
3428
+ prompt: content,
3429
+ isInitial: false,
3430
+ permissionMode: await resolveRunPermissionMode(projectRoot),
3431
+ resume: rec.agentSessionId,
3432
+ targetFile: rec.file
3433
+ });
3434
+ }
3435
+ /**
3436
+ * True iff an SDK run is currently in flight for this feedback id, in
3437
+ * ANY context. Reads the `active_runs` SQLite row inserted by
3438
+ * `runQuery`. Local-Map check is cheap and short-circuits the common
3439
+ * case where the run was started in the same context.
3440
+ */
3441
+ async function hasActiveRun(feedbackId, projectRoot) {
3442
+ if (activeRuns$1.has(feedbackId)) return true;
3443
+ const root = projectRoot ?? process.env.PINAGENT_PROJECT_ROOT ?? process.cwd();
3444
+ try {
3445
+ return (await getDb(root).select().from(activeRuns).where(eq(activeRuns.conversationId, feedbackId)).limit(1)).length > 0;
3446
+ } catch {
3447
+ return false;
3448
+ }
3449
+ }
3450
+ /** Longest a follow-up waits for the prior turn's cleanup to land. */
3451
+ const FOLLOWUP_RUN_CLEAR_TIMEOUT_MS = 3e3;
3452
+ /** How often `waitForRunToClear` re-checks the `active_runs` row. */
3453
+ const FOLLOWUP_RUN_CLEAR_POLL_MS = 25;
3454
+ /**
3455
+ * Wait (bounded) for any in-flight run for `feedbackId` to finish, polling
3456
+ * `hasActiveRun`. Returns true once it's clear, or false if the timeout
3457
+ * elapses with a run still active. Returns true immediately when nothing is
3458
+ * in flight — the common case — so this is cheap. See `runFollowUpTurn` for
3459
+ * why a follow-up tolerates a briefly-lingering run rather than bouncing it.
3460
+ */
3461
+ async function waitForRunToClear(feedbackId, projectRoot) {
3462
+ const deadline = Date.now() + FOLLOWUP_RUN_CLEAR_TIMEOUT_MS;
3463
+ while (await hasActiveRun(feedbackId, projectRoot)) {
3464
+ if (Date.now() >= deadline) return false;
3465
+ await new Promise((resolve) => setTimeout(resolve, FOLLOWUP_RUN_CLEAR_POLL_MS));
3466
+ }
3467
+ return true;
3468
+ }
3469
+ /**
3470
+ * Abort an in-flight run for `feedbackId`. Returns true if the run
3471
+ * either lives in this context (local abort) OR lives in some other
3472
+ * context in this process (cross-context signal sent via `process.emit`
3473
+ * — the owning context's listener will call `abort` on its
3474
+ * AbortController). Returns false if no SQLite row exists for an
3475
+ * active run, i.e. nothing was actually in flight.
3476
+ *
3477
+ * The SDK propagates the abort through its tool loop and exits the
3478
+ * iterator; `consumeStream` catches the abort error and writes a
3479
+ * minimal footer.
3480
+ */
3481
+ async function interruptRun(feedbackId, projectRoot) {
3482
+ const local = activeRuns$1.get(feedbackId);
3483
+ if (local) {
3484
+ local.abort.abort();
3485
+ return true;
3486
+ }
3487
+ const root = projectRoot ?? process.env.PINAGENT_PROJECT_ROOT ?? process.cwd();
3488
+ let exists = false;
3489
+ try {
3490
+ exists = (await getDb(root).select().from(activeRuns).where(eq(activeRuns.conversationId, feedbackId)).limit(1)).length > 0;
3491
+ } catch {
3492
+ exists = false;
3493
+ }
3494
+ if (!exists) return false;
3495
+ process.emit(INTERRUPT_EVENT, feedbackId);
3496
+ return true;
3497
+ }
3498
+ /**
3499
+ * Gate every new turn (initial spawn + follow-ups) on the cost caps in
3500
+ * the project's settings. The cap is breached when the *running total*
3501
+ * is already at or above the cap — that lets the first-ever turn run
3502
+ * freely (totalCostUsd starts at 0) but blocks the next turn once
3503
+ * spending has caught up.
3504
+ *
3505
+ * Returns `{ ok: true }` when within both caps, or `{ ok: false, reason }`
3506
+ * with a user-facing message. Callers emit the message on the bus so
3507
+ * every subscriber sees it, and refuse to actually start the turn.
3508
+ */
3509
+ async function checkCostCaps(projectRoot, feedbackId) {
3510
+ const settings = await new SettingsStore(projectRoot).read();
3511
+ const storage = new Storage(projectRoot);
3512
+ const notional = isNotionalCost(await storage.readApiKeySource(feedbackId));
3513
+ const conversationCost = await storage.computeConversationCost(feedbackId);
3514
+ if (conversationCost >= settings.perConversationCapUsd) return {
3515
+ ok: false,
3516
+ reason: `per-conversation cost cap reached: ${formatCapSpend(conversationCost, settings.perConversationCapUsd, notional)}. Raise the cap in Settings or resolve this conversation.`
3517
+ };
3518
+ if (settings.monthlyBudgetUsd !== null) {
3519
+ const monthlySpend = await storage.computeMonthlySpend(/* @__PURE__ */ new Date());
3520
+ if (monthlySpend >= settings.monthlyBudgetUsd) return {
3521
+ ok: false,
3522
+ reason: `monthly budget reached: ${formatCapSpend(monthlySpend, settings.monthlyBudgetUsd, notional)} this month. Raise the budget in Settings.`
3523
+ };
3524
+ }
3525
+ return { ok: true };
3526
+ }
3527
+ /**
3528
+ * Format the `<used> of <cap>` fragment of a cap-breach message. For
3529
+ * notional (subscription) runs the amount is API-equivalent and was never
3530
+ * billed, so we say so rather than "spent".
3531
+ */
3532
+ function formatCapSpend(used, cap, notional) {
3533
+ const usedUsd = `$${used.toFixed(2)}`;
3534
+ const capUsd = `$${cap.toFixed(2)}`;
3535
+ return notional ? `≈${usedUsd} of ${capUsd} API-equivalent (subscription — not billed)` : `${usedUsd} of ${capUsd} spent`;
3536
+ }
3537
+ async function runQuery(opts) {
3538
+ const { permissionMode } = opts;
3539
+ const abort = new AbortController();
3540
+ activeRuns$1.set(opts.feedbackId, { abort });
3541
+ const onInterrupt = (id) => {
3542
+ if (id === opts.feedbackId) abort.abort();
3543
+ };
3544
+ process.on(INTERRUPT_EVENT, onInterrupt);
3545
+ recordActiveRun(opts.projectRoot, opts.feedbackId);
3546
+ const provider = resolveProvider(process.env);
3547
+ const request = {
3548
+ projectRoot: opts.projectRoot,
3549
+ feedbackId: opts.feedbackId,
3550
+ cwd: opts.cwd,
3551
+ targetFile: opts.targetFile,
3552
+ prompt: opts.prompt,
3553
+ isInitial: opts.isInitial,
3554
+ permissionMode,
3555
+ resume: opts.resume,
3556
+ abortSignal: abort.signal
3557
+ };
3558
+ try {
3559
+ await consumeStream(opts, provider.run(request));
3560
+ } finally {
3561
+ activeRuns$1.delete(opts.feedbackId);
3562
+ process.off(INTERRUPT_EVENT, onInterrupt);
3563
+ await clearActiveRun(opts.projectRoot, opts.feedbackId);
3564
+ rejectAsk(opts.feedbackId, "agent run ended");
3565
+ }
3566
+ }
3567
+ /**
3568
+ * Drive one provider run: publish its events to the bus, append its log
3569
+ * chunks to the transcript, persist the session id, and finalize the
3570
+ * resolution block. Provider-neutral — every backend funnels through the
3571
+ * same `ProviderRunItem` stream, so cost/session/status handling is shared.
3572
+ */
3573
+ async function consumeStream(opts, stream) {
3574
+ let sessionRecorded = false;
3575
+ let resultRendered = false;
3576
+ const bus = getOrCreateBus(opts.feedbackId);
3577
+ try {
3578
+ for await (const item of stream) {
3579
+ if (!sessionRecorded && item.sessionId) {
3580
+ sessionRecorded = true;
3581
+ await persistSessionId(opts.projectRoot, opts.feedbackId, item.sessionId);
3582
+ }
3583
+ for (const ev of item.events ?? []) await bus.publish(ev);
3584
+ if (item.isResult) {
3585
+ resultRendered = true;
3586
+ if (item.log) await appendLog(opts.logPath, item.log);
3587
+ if (opts.isInitial) await appendResolution(opts.projectRoot, opts.feedbackId, opts.logPath, item.resultFooter ?? null);
3588
+ else if (item.resultFooter) await appendLog(opts.logPath, `\n${item.resultFooter}\n`);
3589
+ try {
3590
+ const rec = await new Storage(opts.projectRoot).read(opts.feedbackId);
3591
+ if (rec && rec.status !== "pending") {
3592
+ await bus.publish({
3593
+ type: "status_changed",
3594
+ status: rec.status,
3595
+ note: rec.note,
3596
+ commitSha: rec.commitSha,
3597
+ resolvedAt: rec.resolvedAt
3598
+ });
3599
+ emitProjectChange({ type: "conversations_changed" });
3600
+ }
3601
+ } catch {}
3602
+ continue;
3603
+ }
3604
+ if (item.log) await appendLog(opts.logPath, item.log);
3605
+ }
3606
+ } catch (err) {
3607
+ const msg = stringifyErr(err);
3608
+ await bus.publish({
3609
+ type: "error",
3610
+ message: msg
3611
+ });
3612
+ await appendLog(opts.logPath, `\n> [pinagent] agent stream errored: ${msg}\n`);
3613
+ } finally {
3614
+ if (!resultRendered && opts.isInitial) await appendResolution(opts.projectRoot, opts.feedbackId, opts.logPath, null);
3615
+ }
3616
+ }
3617
+ async function persistSessionId(projectRoot, feedbackId, sessionId) {
3618
+ try {
3619
+ await new Storage(projectRoot).patch(feedbackId, { agentSessionId: sessionId });
3620
+ } catch {}
3621
+ }
3622
+ function stringifyErr(e) {
3623
+ return e instanceof Error ? e.message : String(e);
3624
+ }
3625
+ function renderHeader(ctx, cwd, startedAt, worktreeReady) {
3626
+ const rec = ctx.feedback;
3627
+ const where = rec.file ? `${rec.file}:${rec.line ?? "?"}${rec.col != null ? `:${rec.col}` : ""}` : rec.selector;
3628
+ const branchLine = ctx.mode === "worktree" && worktreeReady ? `branch: pinagent/${rec.id}\n` : "";
3629
+ return [
3630
+ "---",
3631
+ `id: ${rec.id}`,
3632
+ `mode: ${ctx.mode}`,
3633
+ `target: ${where}`,
3634
+ `url: ${rec.url}`,
3635
+ `started: ${startedAt}`,
3636
+ `cwd: ${cwd}`,
3637
+ branchLine.trimEnd(),
3638
+ "---",
3639
+ "",
3640
+ `# Pinagent feedback \`${rec.id}\``,
3641
+ "",
3642
+ `**Target:** \`${where}\` `,
3643
+ `**URL:** ${rec.url} `,
3644
+ `**Mode:** ${ctx.mode}${ctx.mode === "worktree" && worktreeReady ? ` · **Branch:** \`pinagent/${rec.id}\`` : ""}`,
3645
+ "",
3646
+ "> **Comment**",
3647
+ "> ",
3648
+ `> ${rec.comment.split("\n").join("\n> ")}`,
3649
+ "",
3650
+ "## Agent output",
3651
+ "",
3652
+ ""
3653
+ ].filter((l) => l !== null).join("\n");
3654
+ }
3655
+ async function appendResolution(projectRoot, feedbackId, logPath, footer) {
3656
+ const updated = await new Storage(projectRoot).read(feedbackId);
3657
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
3658
+ const lines = [];
3659
+ lines.push("");
3660
+ lines.push("## Resolution");
3661
+ lines.push("");
3662
+ lines.push(`**Finished:** ${finishedAt} `);
3663
+ if (footer) lines.push(footer);
3664
+ else lines.push("> Stream ended without a `result` message.");
3665
+ if (!updated) {
3666
+ lines.push("");
3667
+ lines.push("> Feedback record disappeared between spawn and exit.");
3668
+ } else {
3669
+ lines.push(`**Status:** \`${updated.status}\``);
3670
+ if (updated.resolvedAt) lines.push(`**Resolved at:** ${updated.resolvedAt}`);
3671
+ if (updated.commitSha) lines.push(`**Commit:** \`${updated.commitSha}\``);
3672
+ if (updated.agentSessionId) lines.push(`**Session:** \`${updated.agentSessionId}\``);
3673
+ if (updated.note) {
3674
+ lines.push("");
3675
+ lines.push("### Note from agent");
3676
+ lines.push("");
3677
+ lines.push(updated.note);
3678
+ }
3679
+ if (updated.status === "pending") {
3680
+ lines.push("");
3681
+ lines.push("> ⚠️ Agent exited without calling `resolve_feedback`. The record is still pending.");
3682
+ }
3683
+ }
3684
+ lines.push("");
3685
+ await appendLog(logPath, lines.join("\n"));
3686
+ }
3687
+ function buildInitialPrompt(rec, mode, cwd) {
3688
+ const where = rec.file ? `${rec.file}:${rec.line ?? "?"}${rec.col != null ? `:${rec.col}` : ""}` : rec.selector;
3689
+ const worktreeContext = mode === "worktree" ? [
3690
+ "",
3691
+ "You are working in a FRESH git worktree at:",
3692
+ ` ${cwd}`,
3693
+ `on branch pinagent/${rec.id} (forked from current HEAD).`,
3694
+ "",
3695
+ "Make edits freely. DO NOT commit — the developer will review your",
3696
+ "changes by diffing this branch against main."
3697
+ ].join("\n") : "";
3698
+ const componentLine = rec.component ? `Component: <${rec.component}>` : "";
3699
+ const componentPathLine = rec.componentPath && rec.componentPath.length > 1 ? `Component path: ${rec.componentPath.join(" › ")}` : "";
3700
+ const instanceNote = rec.instanceTotal && rec.instanceTotal > 1 ? [
3701
+ "",
3702
+ `Heads up: this target's source location is rendered ${rec.instanceTotal} times`,
3703
+ `(likely a list/.map()). The developer clicked instance #${(rec.instanceIndex ?? 0) + 1} of ${rec.instanceTotal}.`,
3704
+ rec.instanceFingerprint ? `That instance's content: ${rec.instanceFingerprint}` : "",
3705
+ `The file:line points at the *shared* JSX literal — edit there, but use the`,
3706
+ `screenshot and the content above to act on the correct item if the change is`,
3707
+ `instance-specific (e.g. its data source) rather than the markup itself.`
3708
+ ].filter((l) => l !== "").join("\n") : "";
3709
+ const additional = rec.additionalAnchors ?? [];
3710
+ const additionalTargets = additional.length > 0 ? [
3711
+ "",
3712
+ `The developer multi-selected ${additional.length + 1} elements and left a single`,
3713
+ `comment that applies to ALL of them. Besides the primary Target above, also`,
3714
+ `address these (apply the same change to each unless the comment says otherwise):`,
3715
+ ...additional.map((a, i) => {
3716
+ const aloc = a.file ? `${a.file}:${a.line ?? "?"}${a.col != null ? `:${a.col}` : ""}` : a.selector;
3717
+ const comp = a.component ? ` (<${a.component}>)` : "";
3718
+ return ` ${i + 2}. ${aloc}${comp}`;
3719
+ })
3720
+ ].join("\n") : "";
3721
+ return [
3722
+ "A developer submitted Pinagent feedback. Address it autonomously.",
3723
+ "",
3724
+ `Feedback id: ${rec.id}`,
3725
+ `Target: ${where}`,
3726
+ componentLine,
3727
+ componentPathLine,
3728
+ `Comment: "${rec.comment.replace(/\s+/g, " ").slice(0, 200)}"`,
3729
+ instanceNote,
3730
+ additionalTargets,
3731
+ worktreeContext,
3732
+ "",
3733
+ "Workflow:",
3734
+ " 1. Call the pinagent MCP tool `get_feedback` with the id above —",
3735
+ " it returns the full comment plus a screenshot of what the user",
3736
+ " selected.",
3737
+ " 2. Optionally call `get_source_context` to see code around the target.",
3738
+ " 3. Edit the file(s) to address the request. Be conservative: only",
3739
+ " change what the comment asks for.",
3740
+ " 4. Call `resolve_feedback` with status=\"fixed\" and a one-sentence",
3741
+ " note describing what you changed. Use status=\"wontfix\" with a",
3742
+ " reason if you cannot apply the change."
3743
+ ].filter((l) => l !== "").join("\n");
3744
+ }
3745
+ /**
3746
+ * Per-project FIFO queue for landing/discarding pinagent worktrees.
3747
+ *
3748
+ * v2 plan §6 calls for serialised merges so two widgets racing to land
3749
+ * onto the same target branch can't interleave. We do that by chaining
3750
+ * each enqueued job onto the project's current tail Promise. Failures
3751
+ * are isolated — a rejected job's error is returned to its caller, but
3752
+ * the queue continues from the same logical point so the next job
3753
+ * isn't poisoned.
3754
+ *
3755
+ * In-memory only. On an agent-runner restart, in-flight queue entries are
3756
+ * dropped along with the WS connections that initiated them; the DB
3757
+ * still shows `worktreeState='active'` for any conversation that
3758
+ * hadn't reached `landed`/`discarded`, so the user can re-click Land
3759
+ * from the widget after the server comes back.
3760
+ */
3761
+ const QUEUES_SYMBOL = Symbol.for("pinagent.merge-queue.tails");
3762
+ const tails = globalThis[QUEUES_SYMBOL] ?? /* @__PURE__ */ new Map();
3763
+ globalThis[QUEUES_SYMBOL] = tails;
3764
+ /**
3765
+ * Enqueue `fn` onto the FIFO for `projectRoot`. The returned Promise
3766
+ * resolves/rejects with whatever `fn` does once every previously
3767
+ * enqueued job for this project has settled. A throwing `fn` does not
3768
+ * poison the queue: subsequent enqueues still run.
3769
+ */
3770
+ function enqueue(projectRoot, fn) {
3771
+ const result = (tails.get(projectRoot) ?? Promise.resolve()).then(fn, fn);
3772
+ const next = result.then(() => void 0, () => void 0);
3773
+ tails.set(projectRoot, next);
3774
+ next.then(() => {
3775
+ if (tails.get(projectRoot) === next) tails.delete(projectRoot);
3776
+ });
3777
+ return result;
3778
+ }
3779
+ const REGISTRY_SYMBOL = Symbol.for("pinagent.worktreeServers");
3780
+ const registry = globalThis[REGISTRY_SYMBOL] ?? /* @__PURE__ */ new Map();
3781
+ globalThis[REGISTRY_SYMBOL] = registry;
3782
+ const CLEANUP_SYMBOL = Symbol.for("pinagent.worktreeServersCleanup");
3783
+ if (!globalThis[CLEANUP_SYMBOL]) {
3784
+ globalThis[CLEANUP_SYMBOL] = true;
3785
+ const killAll = () => {
3786
+ for (const id of [...registry.keys()]) killEntry(id);
3787
+ };
3788
+ process.once("exit", killAll);
3789
+ process.once("SIGINT", killAll);
3790
+ process.once("SIGTERM", killAll);
3791
+ }
3792
+ function killEntry(feedbackId) {
3793
+ const entry = registry.get(feedbackId);
3794
+ if (!entry) return;
3795
+ registry.delete(feedbackId);
3796
+ const pid = entry.child.pid;
3797
+ if (pid === void 0) return;
3798
+ try {
3799
+ process.kill(-pid, "SIGTERM");
3800
+ } catch {
3801
+ try {
3802
+ entry.child.kill("SIGTERM");
3803
+ } catch {}
3804
+ }
3805
+ }
3806
+ z.object({ feedbackIds: z.array(z.string().regex(ID_RE)).min(1).max(200) });
3807
+ z.object({
3808
+ ids: z.array(z.string().regex(ID_RE)).min(1).max(200),
3809
+ patch: z.object({ archived: z.boolean() })
3810
+ });
3811
+ z.object({ feedbackIds: z.array(z.string().regex(ID_RE)).min(1).max(200) });
3812
+ z.object({
3813
+ feedbackIds: z.array(z.string().min(1)).min(1).max(50),
3814
+ branchName: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9/_.-]*$/, "invalid branch name"),
3815
+ title: z.string().min(1).max(200),
3816
+ description: z.string().max(2e4),
3817
+ baseBranch: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9/_.-]*$/, "invalid base branch")
3818
+ });
3819
+ /**
3820
+ * Phase H — orphan-worktree TTL sweep.
3821
+ *
3822
+ * On agent-runner boot, scan SQLite for conversations whose worktree has been
3823
+ * sitting in `worktreeState='active'` past the configured TTL. Add each one
3824
+ * to an in-memory set; next time the widget subscribes to that feedback,
3825
+ * the WS server overrides the initial `worktree_state` emission from
3826
+ * `active` to `ttl_warning` so the UI nudges the user to land or discard.
3827
+ *
3828
+ * We do NOT auto-discard: the user might genuinely want a multi-week
3829
+ * worktree, and silent data loss is the worst failure mode for a tool
3830
+ * whose pitch is "trust what the agents did". TTL is advisory only.
3831
+ *
3832
+ * Env:
3833
+ * PINAGENT_WORKTREE_TTL_DAYS (default 7) — days since last update
3834
+ * before a worktree is flagged. Set to 0 to disable the sweep.
3835
+ */
3836
+ const DEFAULT_TTL_DAYS = 7;
3837
+ const TTL_SYMBOL = Symbol.for("pinagent.worktree-ttl.flagged");
3838
+ const flagged = globalThis[TTL_SYMBOL] ?? /* @__PURE__ */ new Set();
3839
+ globalThis[TTL_SYMBOL] = flagged;
3840
+ function ttlDays(env) {
3841
+ const raw = env.PINAGENT_WORKTREE_TTL_DAYS;
3842
+ if (raw === void 0 || raw === "") return DEFAULT_TTL_DAYS;
3843
+ const n = Number(raw);
3844
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_TTL_DAYS;
3845
+ return n;
3846
+ }
3847
+ /**
3848
+ * One-shot scan at startup. Idempotent — re-running just refreshes the
3849
+ * flag set. Best-effort; swallows DB errors so an agent-runner boot is
3850
+ * never blocked by a TTL sweep failure.
3851
+ */
3852
+ async function sweepStaleWorktrees(projectRoot) {
3853
+ const days = ttlDays(process.env);
3854
+ if (days === 0) {
3855
+ flagged.clear();
3856
+ return;
3857
+ }
3858
+ try {
3859
+ const cutoff = /* @__PURE__ */ new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
3860
+ const rows = await getDb(projectRoot).select({ id: conversations.id }).from(conversations).where(and$1(eq$1(conversations.worktreeState, "active"), lt(conversations.updatedAt, cutoff)));
3861
+ flagged.clear();
3862
+ for (const r of rows) flagged.add(r.id);
3863
+ } catch {}
3864
+ }
3865
+ /**
3866
+ * True iff the most recent sweep flagged this conversation. Returns false
3867
+ * after the user has taken an action (land/discard) and `clearWarning`
3868
+ * was called.
3869
+ */
3870
+ function isStale(feedbackId) {
3871
+ return flagged.has(feedbackId);
3872
+ }
3873
+ /**
3874
+ * Drop a feedback from the flagged set. Called from the WS server's
3875
+ * `land_request` / `discard_request` handlers so the warning doesn't
3876
+ * reappear for repeat subscribes after the user has acted.
3877
+ */
3878
+ function clearWarning(feedbackId) {
3879
+ flagged.delete(feedbackId);
3880
+ }
3881
+ /**
3882
+ * Build a `noServer` WebSocket endpoint to hand to a host that owns its own
3883
+ * HTTP server and routes the `upgrade` event by pathname — notably Metro's
3884
+ * `config.server.websocketEndpoints` (React Native / Expo).
3885
+ *
3886
+ * Unlike {@link startWsServer}, this binds no port: the caller's dev server
3887
+ * (Metro) already listens, performs the HTTP upgrade, and dispatches matching
3888
+ * requests here via `handleUpgrade`. That means RN streaming rides the *same*
3889
+ * host:port the widget already talks to for `POST /__pinagent/feedback`, so a
3890
+ * physical device needs no second port and no port discovery.
3891
+ *
3892
+ * Each accepted socket is wired to the same {@link attachConnection} the web
3893
+ * path uses — identical wire protocol, bus subscription, and worktree
3894
+ * controls. We also start the project-event fan-out and relay (idempotent,
3895
+ * singleton-guarded) so worktree Land/Discard and cloud mode behave the same.
3896
+ */
3897
+ function createPinagentWsEndpoint() {
3898
+ const wss = new WebSocketServer({ noServer: true });
3899
+ wss.on("connection", (socket) => {
3900
+ attachConnection(socket);
3901
+ });
3902
+ sweepStaleWorktrees(projectRoot());
3903
+ ensureProjectListener();
3904
+ ensureCrossProcessProjectPoller();
3905
+ maybeStartRelayClient();
3906
+ return wss;
3907
+ }
3908
+ /**
3909
+ * Worktree-state fan-out: keyed by feedbackId, value is the set of
3910
+ * sockets currently subscribed. Lives separately from the
3911
+ * `event-bus` because worktree state is a property of the conversation
3912
+ * row (durable in SQLite) rather than an agent-run event — and changes
3913
+ * to it can happen long after the agent run's bus has been finished
3914
+ * and evicted. Survives module re-eval via globalThis pinning.
3915
+ */
3916
+ const WT_SUBS_SYMBOL = Symbol.for("pinagent.ws.worktreeSubs");
3917
+ const worktreeSubs = globalThis[WT_SUBS_SYMBOL] ?? /* @__PURE__ */ new Map();
3918
+ globalThis[WT_SUBS_SYMBOL] = worktreeSubs;
3919
+ function addWorktreeSub(feedbackId, socket) {
3920
+ let set = worktreeSubs.get(feedbackId);
3921
+ if (!set) {
3922
+ set = /* @__PURE__ */ new Set();
3923
+ worktreeSubs.set(feedbackId, set);
3924
+ }
3925
+ set.add(socket);
3926
+ }
3927
+ function removeWorktreeSub(feedbackId, socket) {
3928
+ const set = worktreeSubs.get(feedbackId);
3929
+ if (!set) return;
3930
+ set.delete(socket);
3931
+ if (set.size === 0) worktreeSubs.delete(feedbackId);
3932
+ }
3933
+ function broadcastWorktreeState(feedbackId, payload) {
3934
+ const set = worktreeSubs.get(feedbackId);
3935
+ if (!set) return;
3936
+ const msg = {
3937
+ type: "worktree_state",
3938
+ feedbackId,
3939
+ ...payload
3940
+ };
3941
+ for (const sock of set) send(sock, msg);
3942
+ }
3943
+ /**
3944
+ * Project-wide subscribers. Sockets self-register via `subscribe_project`
3945
+ * and receive `project_event` messages whenever Storage emits a change
3946
+ * (see project-events.ts). Lives separately from `worktreeSubs` because
3947
+ * it's not keyed by feedbackId — every project subscriber gets every
3948
+ * project event.
3949
+ *
3950
+ * Pinned via globalThis so Next 16 / HMR re-evaluation doesn't lose
3951
+ * subscribers across module reloads — same approach as worktreeSubs.
3952
+ */
3953
+ const PROJECT_SUBS_SYMBOL = Symbol.for("pinagent.ws.projectSubs");
3954
+ const projectSubs = globalThis[PROJECT_SUBS_SYMBOL] ?? /* @__PURE__ */ new Set();
3955
+ globalThis[PROJECT_SUBS_SYMBOL] = projectSubs;
3956
+ /**
3957
+ * Singleton-guarded listener registration. `onProjectChange` returns an
3958
+ * unsubscribe handle; we keep the handle around the same global slot so
3959
+ * a duplicate `startWsServer` call doesn't stack listeners (which would
3960
+ * cause each event to fan out twice, then three times, etc).
3961
+ */
3962
+ const PROJECT_LISTENER_SYMBOL = Symbol.for("pinagent.ws.projectListener");
3963
+ function ensureProjectListener() {
3964
+ const slot = globalThis;
3965
+ if (slot[PROJECT_LISTENER_SYMBOL]) return;
3966
+ slot[PROJECT_LISTENER_SYMBOL] = onProjectChange((event) => fanoutProjectEvent(event));
3967
+ }
3968
+ /**
3969
+ * Watches `conversations.updatedAt` so writes from OTHER processes
3970
+ * (notably the MCP server's `resolve_feedback`, which runs as a child
3971
+ * Node process under the SDK and never touches our in-process
3972
+ * `emitProjectChange`) still trigger a dock refresh.
3973
+ *
3974
+ * Mirrors the polling pattern in `bus.ts` (per-conversation events).
3975
+ * Latency upper bound is one poll interval; the dock's TanStack Query
3976
+ * invalidation is idempotent, so the worst case if an in-process event
3977
+ * also fires is one extra refetch ~`POLL_MS` later — harmless.
3978
+ *
3979
+ * The initial watermark is seeded asynchronously; until the seed
3980
+ * resolves, the very first poll might fire a redundant
3981
+ * `conversations_changed`. That's acceptable: project subscribers
3982
+ * connect AFTER startWsServer, so they normally won't see it; if they
3983
+ * do, it's one wasted refetch on first connect.
3984
+ */
3985
+ const PROJECT_POLLER_SYMBOL = Symbol.for("pinagent.ws.projectPoller");
3986
+ const PROJECT_POLL_MS = 250;
3987
+ function ensureCrossProcessProjectPoller() {
3988
+ const slot = globalThis;
3989
+ if (slot[PROJECT_POLLER_SYMBOL]) return;
3990
+ let lastSeenMs = 0;
3991
+ let polling = false;
3992
+ (async () => {
3993
+ try {
3994
+ const ms = (await getDb(projectRoot()).select({ max: sql`MAX(${conversations.updatedAt})` }).from(conversations))[0]?.max ?? null;
3995
+ if (ms !== null) lastSeenMs = Number(ms);
3996
+ } catch {}
3997
+ })();
3998
+ const interval = setInterval(() => {
3999
+ if (polling) return;
4000
+ polling = true;
4001
+ (async () => {
4002
+ try {
4003
+ const ms = (await getDb(projectRoot()).select({ max: sql`MAX(${conversations.updatedAt})` }).from(conversations))[0]?.max ?? null;
4004
+ if (ms !== null && Number(ms) > lastSeenMs) {
4005
+ lastSeenMs = Number(ms);
4006
+ fanoutProjectEvent({ type: "conversations_changed" });
4007
+ }
4008
+ } catch {} finally {
4009
+ polling = false;
4010
+ }
4011
+ })();
4012
+ }, PROJECT_POLL_MS);
4013
+ slot[PROJECT_POLLER_SYMBOL] = () => clearInterval(interval);
4014
+ }
4015
+ function fanoutProjectEvent(event) {
4016
+ const msg = {
4017
+ type: "project_event",
4018
+ event
4019
+ };
4020
+ for (const sock of projectSubs) send(sock, msg);
4021
+ }
4022
+ /**
4023
+ * Connected VSCode-extension sockets, mapped to their reported version.
4024
+ * An entry exists for every socket that sent `extension_hello`. The dock
4025
+ * consults presence (size > 0) to decide whether to nudge the user to
4026
+ * install the editor bridge.
4027
+ *
4028
+ * Pinned to globalThis for the same Next-16 / HMR-survival reason as the
4029
+ * subscriber sets above — a module re-eval mustn't forget that an
4030
+ * extension is live and start telling docks it's missing.
4031
+ */
4032
+ const EXTENSION_SOCKETS_SYMBOL = Symbol.for("pinagent.ws.extensionSockets");
4033
+ const extensionSockets = globalThis[EXTENSION_SOCKETS_SYMBOL] ?? /* @__PURE__ */ new Map();
4034
+ globalThis[EXTENSION_SOCKETS_SYMBOL] = extensionSockets;
4035
+ /**
4036
+ * Snapshot the current presence state. `version` is the last-registered
4037
+ * extension's version (newest connection wins) — good enough for the
4038
+ * single-editor common case; multi-window users just see one of them.
4039
+ */
4040
+ function extensionStatusMessage() {
4041
+ let version;
4042
+ for (const v of extensionSockets.values()) if (v) version = v;
4043
+ const msg = {
4044
+ type: "extension_status",
4045
+ present: extensionSockets.size > 0
4046
+ };
4047
+ if (version) msg.version = version;
4048
+ return msg;
4049
+ }
4050
+ /** Push the current presence snapshot to every project subscriber. */
4051
+ function broadcastExtensionStatus() {
4052
+ const msg = extensionStatusMessage();
4053
+ for (const sock of projectSubs) send(sock, msg);
4054
+ }
4055
+ function projectRoot() {
4056
+ return process.env.PINAGENT_PROJECT_ROOT ?? process.cwd();
4057
+ }
4058
+ function logPathFor(root, feedbackId) {
4059
+ return join(root, ".pinagent", "logs", `${feedbackId}.md`);
4060
+ }
4061
+ const PING_INTERVAL_MS = 3e4;
4062
+ function attachConnection(socket) {
4063
+ const state = {
4064
+ subscriptions: /* @__PURE__ */ new Map(),
4065
+ projectSubscribed: false,
4066
+ alive: true
4067
+ };
4068
+ const ping = setInterval(() => {
4069
+ if (!state.alive) {
4070
+ socket.terminate();
4071
+ return;
4072
+ }
4073
+ state.alive = false;
4074
+ try {
4075
+ socket.ping();
4076
+ } catch {}
4077
+ }, PING_INTERVAL_MS);
4078
+ socket.on("pong", () => {
4079
+ state.alive = true;
4080
+ });
4081
+ socket.on("message", (raw) => {
4082
+ let parsed;
4083
+ try {
4084
+ parsed = JSON.parse(raw.toString("utf8"));
4085
+ } catch {
4086
+ sendError(socket, void 0, "invalid JSON");
4087
+ return;
4088
+ }
4089
+ const validated = ClientMessageSchema.safeParse(parsed);
4090
+ if (!validated.success) {
4091
+ sendError(socket, void 0, `invalid message: ${validated.error.message}`);
4092
+ return;
4093
+ }
4094
+ handleClientMessage(socket, state, validated.data);
4095
+ });
4096
+ socket.on("close", () => {
4097
+ clearInterval(ping);
4098
+ for (const [feedbackId, unsub] of state.subscriptions.entries()) {
4099
+ unsub();
4100
+ removeWorktreeSub(feedbackId, socket);
4101
+ }
4102
+ state.subscriptions.clear();
4103
+ if (state.projectSubscribed) {
4104
+ projectSubs.delete(socket);
4105
+ state.projectSubscribed = false;
4106
+ }
4107
+ if (extensionSockets.delete(socket)) broadcastExtensionStatus();
4108
+ });
4109
+ socket.on("error", () => {});
4110
+ }
4111
+ async function handleClientMessage(socket, state, msg) {
4112
+ switch (msg.type) {
4113
+ case "subscribe": {
4114
+ if (state.subscriptions.has(msg.feedbackId)) return;
4115
+ const unsub = getOrCreateBus(msg.feedbackId).subscribe({
4116
+ onEvent(event) {
4117
+ send(socket, {
4118
+ type: "event",
4119
+ feedbackId: msg.feedbackId,
4120
+ event
4121
+ });
4122
+ },
4123
+ onClose() {
4124
+ send(socket, {
4125
+ type: "done",
4126
+ feedbackId: msg.feedbackId
4127
+ });
4128
+ state.subscriptions.delete(msg.feedbackId);
4129
+ removeWorktreeSub(msg.feedbackId, socket);
4130
+ }
4131
+ });
4132
+ state.subscriptions.set(msg.feedbackId, unsub);
4133
+ addWorktreeSub(msg.feedbackId, socket);
4134
+ emitCurrentWorktreeState(socket, msg.feedbackId);
4135
+ return;
4136
+ }
4137
+ case "unsubscribe": {
4138
+ const unsub = state.subscriptions.get(msg.feedbackId);
4139
+ if (unsub) {
4140
+ unsub();
4141
+ state.subscriptions.delete(msg.feedbackId);
4142
+ }
4143
+ removeWorktreeSub(msg.feedbackId, socket);
4144
+ return;
4145
+ }
4146
+ case "ask_response":
4147
+ if (!resolveAsk(msg.askId, msg.answer)) sendError(socket, void 0, `no pending ask ${msg.askId}`);
4148
+ return;
4149
+ case "user_message":
4150
+ try {
4151
+ await runFollowUpTurn(msg.feedbackId, msg.content);
4152
+ } catch (err) {
4153
+ sendError(socket, msg.feedbackId, err instanceof Error ? err.message : String(err));
4154
+ }
4155
+ return;
4156
+ case "interrupt":
4157
+ if (!await interruptRun(msg.feedbackId)) sendError(socket, msg.feedbackId, "no in-flight run to interrupt");
4158
+ return;
4159
+ case "land_request": {
4160
+ const root = projectRoot();
4161
+ const logPath = logPathFor(root, msg.feedbackId);
4162
+ clearWarning(msg.feedbackId);
4163
+ broadcastWorktreeState(msg.feedbackId, { state: "landing" });
4164
+ const result = await enqueue(root, () => mergeWorktree(root, msg.feedbackId, logPath));
4165
+ if (result.ok) broadcastWorktreeState(msg.feedbackId, {
4166
+ state: "landed",
4167
+ ...result.commitSha ? { commitSha: result.commitSha } : {}
4168
+ });
4169
+ else if (result.conflicts && result.conflicts.length > 0) broadcastWorktreeState(msg.feedbackId, {
4170
+ state: "conflict",
4171
+ conflicts: result.conflicts
4172
+ });
4173
+ else broadcastWorktreeState(msg.feedbackId, {
4174
+ state: "active",
4175
+ ...result.error ? { message: result.error } : {}
4176
+ });
4177
+ return;
4178
+ }
4179
+ case "discard_request": {
4180
+ const root = projectRoot();
4181
+ const logPath = logPathFor(root, msg.feedbackId);
4182
+ clearWarning(msg.feedbackId);
4183
+ broadcastWorktreeState(msg.feedbackId, { state: "discarding" });
4184
+ try {
4185
+ await enqueue(root, () => discardWorktree(root, msg.feedbackId, logPath));
4186
+ broadcastWorktreeState(msg.feedbackId, { state: "discarded" });
4187
+ } catch (err) {
4188
+ broadcastWorktreeState(msg.feedbackId, {
4189
+ state: "active",
4190
+ message: err instanceof Error ? err.message : String(err)
4191
+ });
4192
+ }
4193
+ return;
4194
+ }
4195
+ case "reopen_request": {
4196
+ const root = projectRoot();
4197
+ const logPath = logPathFor(root, msg.feedbackId);
4198
+ const result = await enqueue(root, () => reopenConversation(root, msg.feedbackId, logPath));
4199
+ if (result.ok) broadcastWorktreeState(msg.feedbackId, { state: "none" });
4200
+ else sendError(socket, msg.feedbackId, result.error);
4201
+ return;
4202
+ }
4203
+ case "subscribe_project":
4204
+ if (state.projectSubscribed) return;
4205
+ projectSubs.add(socket);
4206
+ state.projectSubscribed = true;
4207
+ send(socket, extensionStatusMessage());
4208
+ return;
4209
+ case "unsubscribe_project":
4210
+ if (!state.projectSubscribed) return;
4211
+ projectSubs.delete(socket);
4212
+ state.projectSubscribed = false;
4213
+ return;
4214
+ case "extension_hello":
4215
+ extensionSockets.set(socket, msg.version);
4216
+ broadcastExtensionStatus();
4217
+ return;
4218
+ case "set_branch_routing": {
4219
+ const patch = { allowedBranchPatterns: msg.allowedBranchPatterns };
4220
+ if (msg.defaultBaseBranch !== null) patch.baseBranch = msg.defaultBaseBranch;
4221
+ try {
4222
+ await new SettingsStore(projectRoot()).patch(patch);
4223
+ } catch (err) {
4224
+ console.error("[pinagent] failed to apply pushed branch-routing policy:", err);
4225
+ }
4226
+ return;
4227
+ }
4228
+ case "query_extension":
4229
+ send(socket, extensionStatusMessage());
4230
+ return;
4231
+ case "ping":
4232
+ send(socket, { type: "pong" });
4233
+ return;
4234
+ }
4235
+ }
4236
+ async function emitCurrentWorktreeState(socket, feedbackId) {
4237
+ try {
4238
+ const rec = await new Storage(projectRoot()).read(feedbackId);
4239
+ if (!rec) return;
4240
+ const state = rec.worktreeState === "active" && isStale(feedbackId) ? "ttl_warning" : rec.worktreeState;
4241
+ const payload = {
4242
+ type: "worktree_state",
4243
+ feedbackId,
4244
+ state
4245
+ };
4246
+ if (rec.commitSha) payload.commitSha = rec.commitSha;
4247
+ if (rec.worktreePath && (state === "active" || state === "ttl_warning")) {
4248
+ const n = await countWorktreeChanges(rec.worktreePath);
4249
+ if (n !== null) payload.changesCount = n;
4250
+ }
4251
+ send(socket, payload);
4252
+ } catch {}
4253
+ }
4254
+ function send(socket, message) {
4255
+ if (socket.readyState !== socket.OPEN) return;
4256
+ try {
4257
+ socket.send(JSON.stringify(message));
4258
+ } catch {}
4259
+ }
4260
+ function sendError(socket, feedbackId, message) {
4261
+ send(socket, feedbackId ? {
4262
+ type: "error",
4263
+ feedbackId,
4264
+ message
4265
+ } : {
4266
+ type: "error",
4267
+ message
4268
+ });
4269
+ }
4270
+ const DEFAULT_MIN_BACKOFF_MS = 1e3;
4271
+ const DEFAULT_MAX_BACKOFF_MS = 3e4;
4272
+ const DEVICE_PATH = "/__pinagent/device";
4273
+ /** Build the relay's device-endpoint URL for a session. */
4274
+ function buildDeviceUrl(origin, sessionId) {
4275
+ return `${origin.replace(/\/+$/, "")}${DEVICE_PATH}?session=${encodeURIComponent(sessionId)}`;
4276
+ }
4277
+ /**
4278
+ * Full-jitter exponential backoff: a random delay in `[exp/2, exp]` where
4279
+ * `exp = min(max, min * 2^attempt)`. Jitter avoids a thundering herd of
4280
+ * dev machines all reconnecting in lockstep after a relay blip.
4281
+ */
4282
+ function nextBackoff(attempt, opts) {
4283
+ const exp = Math.min(opts.max, opts.min * 2 ** attempt);
4284
+ return Math.round(exp / 2 + Math.random() * (exp / 2));
4285
+ }
4286
+ function defaultConnect(url, token) {
4287
+ return new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
4288
+ }
4289
+ /**
4290
+ * Open and maintain the outbound device connection, reconnecting with
4291
+ * backoff on any drop. Returns a handle whose `close()` stops reconnecting
4292
+ * and tears down the current socket.
4293
+ */
4294
+ function startRelayClient(opts) {
4295
+ const min = opts.minBackoffMs ?? DEFAULT_MIN_BACKOFF_MS;
4296
+ const max = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
4297
+ const connect = opts.connect ?? defaultConnect;
4298
+ const attach = opts.attach ?? attachConnection;
4299
+ const schedule = opts.schedule ?? ((fn, ms) => void setTimeout(fn, ms));
4300
+ const log = opts.log ?? ((msg) => console.log(`[pinagent] ${msg}`));
4301
+ const deviceUrl = buildDeviceUrl(opts.url, opts.sessionId);
4302
+ let stopped = false;
4303
+ let attempt = 0;
4304
+ let socket = null;
4305
+ const open = () => {
4306
+ if (stopped) return;
4307
+ const ws = connect(deviceUrl, opts.token);
4308
+ socket = ws;
4309
+ ws.on("open", () => {
4310
+ attempt = 0;
4311
+ log(`relay connected (${deviceUrl})`);
4312
+ attach(ws);
4313
+ });
4314
+ ws.on("close", () => {
4315
+ if (stopped) return;
4316
+ const delay = nextBackoff(attempt++, {
4317
+ min,
4318
+ max
4319
+ });
4320
+ log(`relay disconnected; reconnecting in ${delay}ms`);
4321
+ schedule(open, delay);
4322
+ });
4323
+ ws.on("error", () => {});
4324
+ };
4325
+ open();
4326
+ return { close() {
4327
+ stopped = true;
4328
+ try {
4329
+ socket?.close();
4330
+ } catch {}
4331
+ } };
4332
+ }
4333
+ /**
4334
+ * Derive a stable session id from the project root when one isn't supplied
4335
+ * via env. Stable across restarts (so a machine reconnects to its own
4336
+ * Durable Object) and opaque (doesn't leak the filesystem path).
4337
+ */
4338
+ function defaultSessionId() {
4339
+ const root = process.env.PINAGENT_PROJECT_ROOT ?? process.cwd();
4340
+ return createHash("sha256").update(root).digest("hex").slice(0, 16);
4341
+ }
4342
+ const RELAY_CLIENT_SYMBOL = Symbol.for("pinagent.relayClient");
4343
+ /**
4344
+ * Start the dial-out client iff cloud mode is configured. Reads:
4345
+ * - `PINAGENT_RELAY_URL` (required to enable; e.g. wss://relay.pinagent.dev)
4346
+ * - `PINAGENT_RELAY_TOKEN` (required; bearer token for the relay — must be a
4347
+ * `device`-scoped session token; the relay rejects a `client` token on the
4348
+ * device endpoint)
4349
+ * - `PINAGENT_RELAY_SESSION` (optional; defaults to a hash of the project root)
4350
+ *
4351
+ * Singleton-guarded via globalThis for the same Next-16 / HMR re-eval
4352
+ * reason the ws-server subscriber sets are pinned — a module reload mustn't
4353
+ * open a second device connection.
4354
+ */
4355
+ function maybeStartRelayClient() {
4356
+ const url = process.env.PINAGENT_RELAY_URL;
4357
+ const token = process.env.PINAGENT_RELAY_TOKEN;
4358
+ if (!url || !token) return null;
4359
+ const g = globalThis;
4360
+ const existing = g[RELAY_CLIENT_SYMBOL];
4361
+ if (existing) return existing;
4362
+ const handle = startRelayClient({
4363
+ url,
4364
+ token,
4365
+ sessionId: process.env.PINAGENT_RELAY_SESSION ?? defaultSessionId()
4366
+ });
4367
+ g[RELAY_CLIENT_SYMBOL] = handle;
4368
+ return handle;
4369
+ }
4370
+ const CONVENTIONAL_SPEC = [
4371
+ "Format the subject as a Conventional Commit: `type(scope): summary`.",
4372
+ "- type is one of: feat, fix, chore, docs, refactor, test, perf, build, ci.",
4373
+ "- scope is the main area changed, inferred from the file paths in the diff",
4374
+ " (e.g. dock, widget, agent-runner, mcp, vite-plugin, next-plugin, ui, db).",
4375
+ " Omit the parentheses only if the change is genuinely repo-wide.",
4376
+ "- summary is concise, imperative, lowercase, no trailing period, <70 chars.",
4377
+ "Examples: \"fix(dock): commit working changes before opening the PR\",",
4378
+ "\"feat(widget): add multi-element selection\"."
4379
+ ].join("\n");
4380
+ [
4381
+ "You write pull-request descriptions for Pinagent, a click-to-fix dev tool.",
4382
+ "You are given a git diff and commit log for a feature branch.",
4383
+ "Respond with ONLY a single JSON object, no prose, no code fences:",
4384
+ "{ \"title\": \"<Conventional Commits PR title>\", \"body\": \"<markdown body>\" }",
4385
+ "",
4386
+ CONVENTIONAL_SPEC,
4387
+ "",
4388
+ "The body should open with a one-paragraph summary, then a \"## Changes\"",
4389
+ "section with bullet points of what changed and why. Keep it factual and",
4390
+ "grounded in the diff. End the body with this exact line (keep the link):",
4391
+ "🤖 Generated with [Pinagent](https://pinagent.dev)"
4392
+ ].join("\n");
4393
+ [
4394
+ "You write git commit messages. You are given a diff of uncommitted changes.",
4395
+ "Respond with ONLY the commit message: a subject line, then optionally a",
4396
+ "blank line and a short body. No prose, no code fences, no quotes.",
4397
+ "",
4398
+ CONVENTIONAL_SPEC
4399
+ ].join("\n");
4400
+ //#endregion
4401
+ //#region src/server/ws-endpoint.ts
4402
+ /**
4403
+ * Returns the `{ [path]: ws.Server }` map to spread into Metro's
4404
+ * `config.server.websocketEndpoints`. Mounts the Pinagent stream at
4405
+ * `/__pinagent/ws`.
4406
+ */
4407
+ function pinagentWebsocketEndpoints(opts) {
4408
+ if (!process.env.PINAGENT_PROJECT_ROOT) process.env.PINAGENT_PROJECT_ROOT = opts.projectRoot;
4409
+ return { "/__pinagent/ws": createPinagentWsEndpoint() };
4410
+ }
4411
+ /** Per-server guard so we only take over the `upgrade` event once. */
4412
+ const UPGRADE_HOOKED = Symbol.for("pinagent.rn.upgradeHooked");
4413
+ /**
4414
+ * Idempotently mount `/__pinagent/ws` on a live HTTP server, regardless of
4415
+ * whether the host honors `config.server.websocketEndpoints`.
4416
+ *
4417
+ * Expo's dev server registers a single `upgrade` listener that destroys any
4418
+ * path it doesn't know, so simply *adding* a second listener races with that
4419
+ * destroy. Instead we take the listener over: capture whatever `upgrade`
4420
+ * listeners are already attached (Metro's `/hot`, Expo's devtools/debugger
4421
+ * sockets, …), remove them, and install one router that handles
4422
+ * `/__pinagent/ws` itself and delegates every other path back to the captured
4423
+ * listeners untouched. HMR and the rest keep working; our path no longer gets
4424
+ * destroyed out from under us.
4425
+ *
4426
+ * Called lazily from `pinagentMiddleware` with `req.socket.server` — by the
4427
+ * time any HTTP request reaches the middleware the dev server is listening and
4428
+ * its own `upgrade` listener is already registered, so the capture is complete.
4429
+ */
4430
+ function ensurePinagentUpgrade(server) {
4431
+ const flagged = server;
4432
+ if (flagged[UPGRADE_HOOKED]) return;
4433
+ flagged[UPGRADE_HOOKED] = true;
4434
+ const wss = createPinagentWsEndpoint();
4435
+ const prior = server.listeners("upgrade");
4436
+ server.removeAllListeners("upgrade");
4437
+ server.on("upgrade", (req, socket, head) => {
4438
+ let pathname = "";
4439
+ try {
4440
+ pathname = new URL(req.url ?? "", "http://localhost").pathname;
4441
+ } catch {
4442
+ pathname = (req.url ?? "").split("?")[0] ?? "";
4443
+ }
4444
+ if (pathname === "/__pinagent/ws") {
4445
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
4446
+ return;
4447
+ }
4448
+ for (const fn of prior) fn.call(server, req, socket, head);
4449
+ });
4450
+ }
4451
+ //#endregion
4452
+ //#region src/server/metro-middleware.ts
4453
+ /**
4454
+ * Metro dev-server adapter for Pinagent.
4455
+ *
4456
+ * This is a near-copy of the `POST /__pinagent/feedback` arm of
4457
+ * `packages/vite-plugin/src/middleware.ts`. It reuses the existing
4458
+ * backend verbatim — `Storage` (writes to `.pinagent/db.sqlite` +
4459
+ * screenshots), `FeedbackInputSchema` (the wire contract), and
4460
+ * `spawnAgent` (inline / worktree agent runs). Nothing about agent
4461
+ * pickup is RN-specific; only the route mounting differs.
4462
+ *
4463
+ * Wire it in `metro.config.js`:
4464
+ *
4465
+ * const { pinagentMiddleware } = require('@pinagent/react-native/server');
4466
+ * module.exports = {
4467
+ * server: {
4468
+ * enhanceMiddleware: (metroMiddleware, server) =>
4469
+ * pinagentMiddleware({ projectRoot: __dirname }).chain(metroMiddleware),
4470
+ * },
4471
+ * };
4472
+ *
4473
+ * `.chain(next)` returns a single connect handler that runs our routes
4474
+ * first and defers everything else to Metro's own middleware.
4475
+ */
4476
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
4477
+ function pinagentMiddleware(opts) {
4478
+ const storage = new Storage(opts.projectRoot);
4479
+ const spawnMode = opts.spawnMode ?? "inline";
4480
+ if (!process.env.PINAGENT_PROJECT_ROOT) process.env.PINAGENT_PROJECT_ROOT = opts.projectRoot;
4481
+ if (opts.apiKey) process.env.PINAGENT_AGENT_API_KEY = opts.apiKey;
4482
+ const handler = (async (req, res, next) => {
4483
+ const server = req.socket?.server;
4484
+ if (server) ensurePinagentUpgrade(server);
4485
+ const url = req.url ?? "";
4486
+ if (!url.startsWith("/__pinagent")) return next();
4487
+ try {
4488
+ if (req.method === "POST" && url === "/__pinagent/feedback") {
4489
+ const raw = await readJsonBody(req);
4490
+ const parsed = FeedbackInputSchema.safeParse(raw);
4491
+ if (!parsed.success) return badRequest(res, parsed.error.message);
4492
+ if (Buffer$1.from(parsed.data.screenshot, "base64").length > 5 * 1024 * 1024) return badRequest(res, "screenshot exceeds 5MB");
4493
+ const id = nanoid(10);
4494
+ const rec = await storage.create(id, parsed.data);
4495
+ const agentSpawned = spawnMode !== false;
4496
+ if (agentSpawned) try {
4497
+ await spawnAgent({
4498
+ projectRoot: storage.root,
4499
+ feedback: rec,
4500
+ mode: spawnMode
4501
+ });
4502
+ } catch {}
4503
+ return json(res, 200, {
4504
+ id: rec.id,
4505
+ agentSpawned
4506
+ });
4507
+ }
4508
+ if (req.method === "GET" && url === "/__pinagent/feedback") return json(res, 200, await storage.list());
4509
+ if (req.method === "POST" && url === "/__pinagent/open") {
4510
+ const raw = await readJsonBody(req);
4511
+ const file = typeof raw?.file === "string" ? raw.file : "";
4512
+ if (!file) return badRequest(res, "missing file");
4513
+ const abs = resolve(opts.projectRoot, file);
4514
+ const rel = relative(opts.projectRoot, abs);
4515
+ if (rel.startsWith("..") || isAbsolute(rel)) return badRequest(res, "path outside project root");
4516
+ return json(res, 200, { ok: openInEditor(abs, Number.isFinite(Number(raw?.line)) ? Number(raw?.line) : 1, Number.isFinite(Number(raw?.col)) ? Number(raw?.col) : 1) });
4517
+ }
4518
+ return json(res, 404, { error: "not found" });
4519
+ } catch (err) {
4520
+ return json(res, 500, { error: err instanceof Error ? err.message : String(err) });
4521
+ }
4522
+ });
4523
+ handler.chain = (nextMw) => {
4524
+ return (req, res, next) => {
4525
+ if ((req.url ?? "").startsWith("/__pinagent")) handler(req, res, () => nextMw(req, res, next));
4526
+ else nextMw(req, res, next);
4527
+ };
4528
+ };
4529
+ return handler;
4530
+ }
4531
+ function readJsonBody(req) {
4532
+ return new Promise((resolve, reject) => {
4533
+ const chunks = [];
4534
+ let total = 0;
4535
+ req.on("data", (chunk) => {
4536
+ total += chunk.length;
4537
+ if (total > MAX_BODY_BYTES) {
4538
+ reject(/* @__PURE__ */ new Error("payload too large"));
4539
+ req.destroy();
4540
+ return;
4541
+ }
4542
+ chunks.push(chunk);
4543
+ });
4544
+ req.on("end", () => {
4545
+ if (chunks.length === 0) return resolve(null);
4546
+ try {
4547
+ resolve(JSON.parse(Buffer$1.concat(chunks).toString("utf8")));
4548
+ } catch (e) {
4549
+ reject(e);
4550
+ }
4551
+ });
4552
+ req.on("error", reject);
4553
+ });
4554
+ }
4555
+ function json(res, status, body) {
4556
+ res.statusCode = status;
4557
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
4558
+ res.setHeader("Cache-Control", "no-store");
4559
+ res.end(JSON.stringify(body));
4560
+ }
4561
+ function badRequest(res, msg) {
4562
+ json(res, 400, { error: msg });
4563
+ }
4564
+ /** Find an executable on `PATH`. Returns the full path, or null. */
4565
+ function findOnPath(bin) {
4566
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) if (dir && existsSync(join(dir, bin))) return join(dir, bin);
4567
+ return null;
4568
+ }
4569
+ const CLI_EDITORS = [
4570
+ {
4571
+ bin: "code",
4572
+ args: ["-g"]
4573
+ },
4574
+ {
4575
+ bin: "cursor",
4576
+ args: ["-g"]
4577
+ },
4578
+ {
4579
+ bin: "windsurf",
4580
+ args: ["-g"]
4581
+ },
4582
+ {
4583
+ bin: "code-insiders",
4584
+ args: ["-g"]
4585
+ },
4586
+ {
4587
+ bin: "codium",
4588
+ args: ["-g"]
4589
+ },
4590
+ {
4591
+ bin: "zed",
4592
+ args: []
4593
+ },
4594
+ {
4595
+ bin: "subl",
4596
+ args: []
4597
+ }
4598
+ ];
4599
+ const MAC_APPS = [
4600
+ {
4601
+ app: "Cursor",
4602
+ args: ["-g"]
4603
+ },
4604
+ {
4605
+ app: "Visual Studio Code",
4606
+ args: ["-g"]
4607
+ },
4608
+ {
4609
+ app: "Windsurf",
4610
+ args: ["-g"]
4611
+ },
4612
+ {
4613
+ app: "VSCodium",
4614
+ args: ["-g"]
4615
+ },
4616
+ {
4617
+ app: "Zed",
4618
+ args: []
4619
+ },
4620
+ {
4621
+ app: "Sublime Text",
4622
+ args: []
4623
+ }
4624
+ ];
4625
+ /**
4626
+ * Pick a command that can open a `file:line:col` target. Order:
4627
+ * `PINAGENT_EDITOR` (explicit) → a known editor CLI on `PATH` → a known macOS
4628
+ * editor app. Returns null if nothing suitable is found.
4629
+ */
4630
+ function resolveOpener() {
4631
+ const override = process.env.PINAGENT_EDITOR?.trim();
4632
+ if (override) {
4633
+ const [cmd, ...prefixArgs] = override.split(/\s+/);
4634
+ if (cmd) return {
4635
+ cmd,
4636
+ prefixArgs
4637
+ };
4638
+ }
4639
+ for (const e of CLI_EDITORS) if (findOnPath(e.bin)) return {
4640
+ cmd: e.bin,
4641
+ prefixArgs: e.args
4642
+ };
4643
+ if (process.platform === "darwin") {
4644
+ for (const a of MAC_APPS) if (existsSync(`/Applications/${a.app}.app`)) return {
4645
+ cmd: "open",
4646
+ prefixArgs: [
4647
+ "-a",
4648
+ a.app,
4649
+ "--args",
4650
+ ...a.args
4651
+ ]
4652
+ };
4653
+ }
4654
+ return null;
4655
+ }
4656
+ /**
4657
+ * Open `<file>:<line>:<col>` in the developer's editor (see
4658
+ * {@link resolveOpener} for selection). Best-effort and fully detached — a
4659
+ * missing editor must never crash Metro. Returns whether a launch was
4660
+ * attempted, so the device can tell the developer when no editor was found.
4661
+ */
4662
+ function openInEditor(abs, line, col) {
4663
+ const opener = resolveOpener();
4664
+ if (!opener) return false;
4665
+ const target = `${abs}:${line}:${col}`;
4666
+ try {
4667
+ const child = spawn(opener.cmd, [...opener.prefixArgs, target], {
4668
+ stdio: "ignore",
4669
+ detached: true
4670
+ });
4671
+ child.on("error", () => {});
4672
+ child.unref();
4673
+ return true;
4674
+ } catch {
4675
+ return false;
4676
+ }
4677
+ }
4678
+ //#endregion
4679
+ export { ensurePinagentUpgrade, pinagentMiddleware, pinagentWebsocketEndpoints };
4680
+
4681
+ //# sourceMappingURL=server.js.map