@pinagent/react-native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -0
- package/dist/babel.cjs +130 -0
- package/dist/babel.cjs.map +1 -0
- package/dist/babel.d.cts +16 -0
- package/dist/babel.d.cts.map +1 -0
- package/dist/babel.d.ts +16 -0
- package/dist/babel.d.ts.map +1 -0
- package/dist/babel.js +130 -0
- package/dist/babel.js.map +1 -0
- package/dist/server.cjs +4684 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +109 -0
- package/dist/server.d.cts.map +1 -0
- package/dist/server.d.ts +110 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +4681 -0
- package/dist/server.js.map +1 -0
- package/package.json +83 -0
- package/src/native/Pinagent.tsx +703 -0
- package/src/native/StreamSheet.tsx +426 -0
- package/src/native/index.ts +9 -0
- package/src/native/inspector.ts +407 -0
- package/src/native/multi-pick.ts +74 -0
- package/src/native/restore.ts +91 -0
- package/src/native/screenshot.ts +34 -0
- package/src/native/submit-outcome.ts +70 -0
- package/src/native/transcript.ts +143 -0
- package/src/native/transport.ts +162 -0
- package/src/native/types.ts +95 -0
- package/src/native/ws-client.ts +173 -0
package/dist/server.cjs
ADDED
|
@@ -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
|