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