@timqi/pier 0.1.14 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/listing.js +14 -10
  5. package/dist/agent/packages.js +6 -10
  6. package/dist/agent/pi.js +113 -70
  7. package/dist/agent/roles.js +104 -0
  8. package/dist/agent/types.js +78 -0
  9. package/dist/boards/boards.js +42 -32
  10. package/dist/channels/chains.js +0 -4
  11. package/dist/channels/commands.js +7 -12
  12. package/dist/channels/config.js +27 -1
  13. package/dist/channels/control.js +5 -16
  14. package/dist/channels/conversations.js +4 -9
  15. package/dist/channels/lark-api.js +20 -26
  16. package/dist/channels/lark-outbound.js +21 -29
  17. package/dist/channels/lark-panel.js +1 -1
  18. package/dist/channels/lark.js +81 -39
  19. package/dist/channels/lines.js +33 -1
  20. package/dist/channels/panel.js +17 -107
  21. package/dist/channels/routes.js +11 -21
  22. package/dist/channels/runtime.js +79 -25
  23. package/dist/channels/slack-outbound.js +12 -14
  24. package/dist/channels/slack-panel.js +1 -1
  25. package/dist/channels/slack-render.js +1 -1
  26. package/dist/channels/slack-thread.js +65 -0
  27. package/dist/channels/slack.js +85 -124
  28. package/dist/channels/types.js +1 -1
  29. package/dist/cli.js +7 -2
  30. package/dist/config-sync.js +42 -37
  31. package/dist/core/chain.js +221 -0
  32. package/dist/core/identity.js +36 -13
  33. package/dist/core/inbound-file.js +11 -4
  34. package/dist/core/reply.js +111 -81
  35. package/dist/core/router.js +111 -118
  36. package/dist/core/search.js +65 -0
  37. package/dist/core/types.js +38 -77
  38. package/dist/db.js +41 -2
  39. package/dist/drain.js +1 -1
  40. package/dist/main.js +65 -40
  41. package/dist/settings.js +26 -19
  42. package/dist/socket.js +1 -0
  43. package/dist/tasks/agent.js +11 -5
  44. package/dist/tasks/callbacks.js +55 -5
  45. package/dist/tasks/cli.js +22 -11
  46. package/dist/tasks/definitions.js +30 -17
  47. package/dist/tasks/execution.js +11 -5
  48. package/dist/tasks/groups.js +5 -2
  49. package/dist/tasks/messages.js +7 -7
  50. package/dist/tasks/open-items.js +88 -0
  51. package/dist/tasks/operations.js +76 -39
  52. package/dist/tasks/outbox.js +19 -6
  53. package/dist/tasks/routes.js +19 -249
  54. package/dist/tasks/service.js +166 -34
  55. package/dist/tasks/store.js +123 -57
  56. package/dist/tasks/types.js +3 -0
  57. package/dist/tools.js +12 -5
  58. package/dist/web/auth.js +2 -2
  59. package/dist/web/config-sync.js +4 -5
  60. package/dist/web/config.js +9 -2
  61. package/dist/web/explorer.js +2 -2
  62. package/dist/web/fs.js +16 -1
  63. package/dist/web/instance.js +8 -5
  64. package/dist/web/packages.js +1 -1
  65. package/dist/web/passkeys.js +3 -3
  66. package/dist/web/providers.js +3 -3
  67. package/dist/web/public/assets/code-CWT27KH1.js +2 -0
  68. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  69. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
  71. package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
  72. package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
  73. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  74. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  75. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  76. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  77. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  78. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  79. package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
  80. package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
  81. package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
  82. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  83. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  84. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  85. package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
  86. package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
  87. package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
  88. package/dist/web/public/index.html +39 -109
  89. package/dist/web/public/index.html.br +0 -0
  90. package/dist/web/public/index.html.gz +0 -0
  91. package/dist/web/public/manifest.webmanifest +0 -2
  92. package/dist/web/push.js +3 -3
  93. package/dist/web/server.js +89 -116
  94. package/dist/web/session-state.js +4 -40
  95. package/docs/deploy.md +5 -3
  96. package/package.json +2 -1
  97. package/skills/pier-boards/SKILL.md +14 -4
  98. package/skills/pier-help/SKILL.md +62 -23
  99. package/skills/pier-search/SKILL.md +27 -0
  100. package/skills/pier-tasks/SKILL.md +68 -18
  101. package/dist/channels/handoff.js +0 -94
  102. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  103. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  104. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  105. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  106. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  107. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  108. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  109. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  110. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  111. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  112. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  113. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  114. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  115. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  116. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  117. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  118. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  119. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  120. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  121. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  122. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  123. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  124. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  125. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  126. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  129. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  130. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -1,10 +1,13 @@
1
1
  // Every query this area makes against pier.db, and nothing else; the schema is
2
2
  // db.ts's migration list.
3
3
  import { pierDb, statements, transact } from "../db.js";
4
+ import { createdRole } from "./types.js";
4
5
  const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
5
6
  /** Kept unmatched probes per watch: one page of history, which is all any
6
7
  * surface lists of a probe that found nothing. */
7
8
  const KEPT_PROBES = 50;
9
+ /** The error of a run a restart or shutdown stopped: not a result, not a cancel. */
10
+ export const INTERRUPTED = "Pier restarted while the run was active";
8
11
  export class TaskStore {
9
12
  db;
10
13
  /** Every query below is a fixed string, so each is compiled once. */
@@ -42,11 +45,8 @@ export class TaskStore {
42
45
  listDueTasks(now) {
43
46
  return this.#many("SELECT json FROM tasks WHERE next_run_at IS NOT NULL AND next_run_at <= ?", now);
44
47
  }
45
- listRuns(taskId, limit = 50, offset = 0) {
46
- return this.#many(`
47
- SELECT json FROM task_runs WHERE task_id = ?
48
- ORDER BY queued_at DESC LIMIT ? OFFSET ?
49
- `, taskId, limit, offset);
48
+ listRuns(taskId, limit = 50) {
49
+ return this.#many("SELECT json FROM task_runs WHERE task_id = ? ORDER BY queued_at DESC LIMIT ?", taskId, limit);
50
50
  }
51
51
  getRun(id) {
52
52
  return this.#one("SELECT json FROM task_runs WHERE id = ?", id);
@@ -79,57 +79,15 @@ export class TaskStore {
79
79
  )
80
80
  `).run(taskId, taskId, KEPT_PROBES);
81
81
  }
82
- /** The id breaks timestamp ties so a page boundary never repeats or skips a
83
- * row. Built per call, not cached: the filter set makes it. */
84
- queryRuns(query = {}) {
85
- const where = [];
86
- const params = [];
87
- const add = (sql, value) => {
88
- if (value !== undefined) {
89
- where.push(sql);
90
- params.push(value);
91
- }
92
- };
93
- add("r.state = ?", query.state);
94
- add("r.task_id = ?", query.taskId);
95
- add("json_extract(r.json, '$.triggerSource') = ?", query.source);
96
- add("r.queued_at >= ?", query.since);
97
- add("r.queued_at <= ?", query.until);
98
- // Keep this predicate identical to migration 17's partial index.
99
- if (!query.showUnmatched)
100
- where.push("NOT (r.state = 'succeeded' AND json_extract(r.json, '$.matched') IS 0)");
101
- if (query.cursor) {
102
- where.push("(r.queued_at, r.id) < (?, ?)");
103
- params.push(query.cursor.queuedAt, query.cursor.id);
104
- }
105
- const limit = clamp(query.limit ?? 50, 200);
106
- const rows = this.db.prepare(`
107
- SELECT r.json, g.callback_state AS group_callback_state
108
- FROM task_runs r LEFT JOIN task_groups g ON g.id = json_extract(r.json, '$.groupId')
109
- ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
110
- ORDER BY r.queued_at DESC, r.id DESC LIMIT ?
111
- `).all(...params, limit + 1);
112
- const runs = rows.slice(0, limit).map((row) => ({
113
- ...JSON.parse(row.json), groupCallbackState: row.group_callback_state,
114
- }));
115
- const last = runs.at(-1);
116
- return {
117
- runs,
118
- nextCursor: rows.length > limit && last ? { queuedAt: last.queuedAt, id: last.id } : null,
119
- };
120
- }
121
- /** Activity never limits live work; only its optional history is bounded. */
122
- activityRuns(since) {
123
- const active = this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running') ORDER BY queued_at DESC, id DESC");
124
- if (since === undefined)
125
- return active;
126
- const recent = this.#many(`
82
+ /** Runs launched by any of `sessionIds`: in flight, or finished at or after `since`. */
83
+ ledgerRuns(sessionIds, since) {
84
+ return this.#many(`
127
85
  SELECT json FROM task_runs
128
- WHERE state NOT IN ('queued', 'running') AND queued_at >= ?
86
+ WHERE json_extract(json, '$.invokedBySessionId') IN (SELECT value FROM json_each(?))
87
+ AND (state IN ('queued', 'running') OR json_extract(json, '$.finishedAt') >= ?)
129
88
  AND NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0)
130
89
  ORDER BY queued_at DESC, id DESC LIMIT 200
131
- `, since);
132
- return [...active, ...recent].sort((a, b) => b.queuedAt - a.queuedAt || b.id.localeCompare(a.id));
90
+ `, JSON.stringify(sessionIds), since);
133
91
  }
134
92
  /** A `task` action's child runs; a cancel walks them. */
135
93
  listChildRuns(parentRunId) {
@@ -177,15 +135,97 @@ export class TaskStore {
177
135
  return new Map(rows.map((row) => [row.session_id, row.n]));
178
136
  }
179
137
  /** `fresh` is the one mode that makes a session rather than borrowing one;
180
- * these are the agent's conversations with itself, which the rail does not list. */
138
+ * these are the agent's conversations with itself, which the session list does not
139
+ * list. A feature lead's is the user's too, so it is not one of them. */
181
140
  taskOwnedSessionIds() {
182
141
  const rows = this.sql(`
183
142
  SELECT DISTINCT json_extract(json, '$.context.sessionId') AS id
184
143
  FROM task_runs
185
144
  WHERE json_extract(json, '$.sessionMode') = 'fresh' AND id IS NOT NULL
145
+ AND json_extract(json, '$.context.definition.action.launch.role') IS NOT 'lead'
186
146
  `).all();
187
147
  return new Set(rows.map((row) => row.id));
188
148
  }
149
+ /** The fresh run that made the session; a resume or a `--session`
150
+ * continuation never does, so the session's role is this run's for its life. */
151
+ creatorOf(sessionId) {
152
+ return this.#one(`
153
+ SELECT json FROM task_runs
154
+ WHERE json_extract(json, '$.targetSessionId') = ? AND json_extract(json, '$.sessionMode') = 'fresh'
155
+ ORDER BY queued_at LIMIT 1
156
+ `, sessionId);
157
+ }
158
+ roleOf(sessionId) {
159
+ const run = this.creatorOf(sessionId);
160
+ return run && createdRole(run);
161
+ }
162
+ leadPhaseOf(sessionId) {
163
+ const run = this.creatorOf(sessionId);
164
+ if (!run || createdRole(run) !== "lead" || run.context.definition.action.type !== "agent")
165
+ return undefined;
166
+ return run.context.definition.action.launch?.design ? "design" : "build";
167
+ }
168
+ /** Every lead session with its phase (as `leadPhaseOf`), its creating run,
169
+ * whether a run targeting it is queued or running, and — a design lead's —
170
+ * whether no run of it has reported `Design final:` yet, which leaves the
171
+ * design on the user; in one statement for the session list's listing. */
172
+ leads() {
173
+ const rows = this.sql(`
174
+ SELECT c.id, c.run_id, c.design, l.id IS NOT NULL AS live, f.id IS NOT NULL AS final FROM (
175
+ SELECT id AS run_id, json_extract(json, '$.targetSessionId') AS id,
176
+ json_extract(json, '$.context.definition.action.launch.role') AS role,
177
+ json_extract(json, '$.context.definition.action.launch.design') AS design,
178
+ ROW_NUMBER() OVER (PARTITION BY json_extract(json, '$.targetSessionId') ORDER BY queued_at) AS n
179
+ FROM task_runs
180
+ WHERE json_extract(json, '$.sessionMode') = 'fresh' AND json_extract(json, '$.targetSessionId') IS NOT NULL
181
+ ) c
182
+ LEFT JOIN (
183
+ SELECT DISTINCT json_extract(json, '$.targetSessionId') AS id FROM task_runs WHERE state IN ('queued', 'running')
184
+ ) l ON l.id = c.id
185
+ LEFT JOIN (
186
+ SELECT DISTINCT json_extract(json, '$.targetSessionId') AS id FROM task_runs
187
+ WHERE json_extract(json, '$.result.type') = 'agent'
188
+ AND instr(char(10) || json_extract(json, '$.result.text'), char(10) || 'Design final:') > 0
189
+ ) f ON f.id = c.id
190
+ WHERE c.n = 1 AND c.role = 'lead'
191
+ `).all();
192
+ return new Map(rows.map((r) => {
193
+ const phase = r.design === 1 ? "design" : "build";
194
+ return [r.id, { phase, runId: r.run_id, runLive: r.live === 1, designOpen: phase === "design" && r.final === 0 }];
195
+ }));
196
+ }
197
+ /** What a milestone resumes, and whose supervisor it reports to. */
198
+ latestRunForTarget(sessionId) {
199
+ // Two runs queued in one millisecond are common (a turn and the run it records); ids are random, rowid is insertion order.
200
+ return this.#one(`
201
+ SELECT json FROM task_runs WHERE json_extract(json, '$.targetSessionId') = ? ORDER BY queued_at DESC, rowid DESC LIMIT 1
202
+ `, sessionId);
203
+ }
204
+ /** Runs in flight whose result is owed to the session, their own or their
205
+ * unfinished group's; a finished group's losers still cancelling owe nothing. */
206
+ countOwedTo(sessionId) {
207
+ const row = this.sql(`
208
+ SELECT COUNT(*) AS n FROM task_runs r
209
+ WHERE r.state IN ('queued', 'running') AND (
210
+ json_extract(r.json, '$.callbackSessionId') = ?
211
+ OR EXISTS (SELECT 1 FROM task_groups g WHERE g.id = json_extract(r.json, '$.groupId')
212
+ AND g.finished_at IS NULL AND json_extract(g.json, '$.callbackSessionId') = ?))
213
+ `).get(sessionId, sessionId);
214
+ return row.n;
215
+ }
216
+ /** Whether a result is still coming to the session: a run in flight owed
217
+ * to it, or a finished run's or group's callback not yet delivered. */
218
+ awaitsResults(sessionId) {
219
+ if (this.countOwedTo(sessionId) > 0)
220
+ return true;
221
+ const row = this.sql(`
222
+ SELECT EXISTS (SELECT 1 FROM task_runs WHERE callback_state IN ('pending', 'failed')
223
+ AND json_extract(json, '$.callbackSessionId') = ?)
224
+ OR EXISTS (SELECT 1 FROM task_groups WHERE callback_state IN ('pending', 'failed')
225
+ AND json_extract(json, '$.callbackSessionId') = ?) AS owed
226
+ `).get(sessionId, sessionId);
227
+ return row.owed === 1;
228
+ }
189
229
  listRunsForSession(sessionId, limit = 50) {
190
230
  return this.#many(`
191
231
  SELECT json FROM task_runs
@@ -228,8 +268,21 @@ export class TaskStore {
228
268
  listMessages(runId) {
229
269
  return this.#many("SELECT json FROM task_messages WHERE run_id = ? ORDER BY created_at", runId);
230
270
  }
231
- listRecentMessages(since) {
232
- return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
271
+ /** Follow-ups parked for the session until it idles: its queue shows them. */
272
+ pendingFollowUpsTo(sessionId) {
273
+ return this.#many(`
274
+ SELECT json FROM task_messages
275
+ WHERE state = 'pending' AND json_extract(json, '$.kind') = 'follow_up'
276
+ AND json_extract(json, '$.toSessionId') = ?
277
+ ORDER BY created_at
278
+ `, sessionId);
279
+ }
280
+ countPendingFollowUps(runId) {
281
+ const row = this.sql(`
282
+ SELECT COUNT(*) AS n FROM task_messages
283
+ WHERE run_id = ? AND state = 'pending' AND json_extract(json, '$.kind') = 'follow_up'
284
+ `).get(runId);
285
+ return row.n;
233
286
  }
234
287
  /** Each beside its run, so the sweep costs one query. A message whose run is
235
288
  * gone comes back with `run: undefined`. */
@@ -266,7 +319,7 @@ export class TaskStore {
266
319
  interruptRunning(now = Date.now()) {
267
320
  return this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running')").map((run) => {
268
321
  run.state = "interrupted";
269
- run.error = "Pier restarted while the run was active";
322
+ run.error = INTERRUPTED;
270
323
  run.finishedAt = now;
271
324
  if (run.callbackSessionId)
272
325
  run.callbackState = "pending";
@@ -274,4 +327,17 @@ export class TaskStore {
274
327
  return run;
275
328
  });
276
329
  }
330
+ /** The continuous conversation's open items, oldest first; `runIds` as main wrote them. */
331
+ openItems() {
332
+ return this.sql("SELECT problem, stage, run_ids FROM open_items ORDER BY updated_at, rowid").all()
333
+ .map((r) => ({ problem: r.problem, stage: r.stage, runIds: JSON.parse(r.run_ids) }));
334
+ }
335
+ /** Main's markers, in reply order, as one write; answers how many rows changed. */
336
+ markOpenItems(markers, now) {
337
+ return this.transact(() => markers.reduce((n, m) => n + Number(m.op === "open"
338
+ ? this.sql(`INSERT INTO open_items(problem, stage, run_ids, updated_at) VALUES (?, ?, ?, ?)
339
+ ON CONFLICT(problem) DO UPDATE SET stage = excluded.stage, run_ids = excluded.run_ids, updated_at = excluded.updated_at`)
340
+ .run(m.problem, m.stage, JSON.stringify(m.runIds), now).changes
341
+ : this.sql("DELETE FROM open_items WHERE problem = ?").run(m.problem).changes), 0));
342
+ }
277
343
  }
@@ -14,3 +14,6 @@ export const isTerminal = (state) => state === "succeeded" ||
14
14
  state === "cancelled" ||
15
15
  state === "interrupted" ||
16
16
  state === "skipped";
17
+ /** The role a fresh run's session is created with and keeps: a lead's, or a
18
+ * worker's when a session launched it; a cron or watch run's has none. */
19
+ export const createdRole = ({ context: { definition: { action } }, invokedBySessionId }) => action.type === "agent" && action.launch?.role === "lead" ? "lead" : invokedBySessionId !== null ? "worker" : undefined;
package/dist/tools.js CHANGED
@@ -22,8 +22,8 @@ export const MANAGED = [
22
22
  `post_install = ["rtk", "init", "-g", "--agent", "pi", "--auto-patch"]\n` +
23
23
  `pre_remove = ["rtk", "init", "--uninstall", "--agent", "pi", "--global", "--auto-patch"]`,
24
24
  summary: "Compresses long bash output before it reaches the model. Its install hook " +
25
- "writes its own Pi extension (extensions/rtk.ts, listed under the local " +
26
- "package) — refreshed on every update.",
25
+ "writes its own Pi extension (extensions/rtk.ts), and this switch is that " +
26
+ "extension's too — refreshed on every update.",
27
27
  },
28
28
  {
29
29
  name: "rg",
@@ -392,6 +392,8 @@ export class ManagedTools {
392
392
  #fetch;
393
393
  #root;
394
394
  #lock;
395
+ /** Why a tool is absent lives only here: `ubix list` never names one that failed to install. */
396
+ #lastSync;
395
397
  #db;
396
398
  constructor(options = {}) {
397
399
  this.#exec = options.exec ?? spawnExec;
@@ -459,7 +461,8 @@ export class ManagedTools {
459
461
  // The only thing that changes what `list` answers; drop the memo both sides.
460
462
  this.#listed = undefined;
461
463
  try {
462
- return await this.#converge(tools, customTools, fence);
464
+ this.#lastSync = await this.#converge(tools, customTools, fence);
465
+ return this.#lastSync;
463
466
  }
464
467
  finally {
465
468
  this.#listed = undefined;
@@ -527,8 +530,12 @@ export class ManagedTools {
527
530
  }
528
531
  return base.map((entry) => {
529
532
  const state = states.find((s) => s.name === entry.name);
530
- if (!state)
531
- return entry;
533
+ if (!state) {
534
+ if (!entry.enabled)
535
+ return entry;
536
+ const synced = this.#lastSync?.entries.find((e) => e.name === entry.name);
537
+ return withBinary(entry, { error: synced?.error ?? "not installed — the last sync did not reach it" });
538
+ }
532
539
  const gone = state.installed === true && state.exists === false;
533
540
  // `npm:` lands in fnm's node prefix and `pixi:` in its own: installed,
534
541
  // but not where Pier's PATH points, and the row has to say so.
package/dist/web/auth.js CHANGED
@@ -261,7 +261,7 @@ export function noteFailure(client) {
261
261
  else
262
262
  failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
263
263
  }
264
- export const forgetFailures = (client) => void failures.delete(client);
264
+ const forgetFailures = (client) => void failures.delete(client);
265
265
  /** Hosts, not schemes: TLS commonly terminates at the reverse proxy. */
266
266
  function originMatches(origin, host) {
267
267
  if (!origin)
@@ -341,7 +341,7 @@ export function openSession(c, store, client) {
341
341
  store.revoke(previous.id);
342
342
  setSessionCookie(c, store.open(client, c.req.header("user-agent") ?? ""));
343
343
  }
344
- export const PASSWORD_OFF = "Password sign-in is off while a passkey is registered.";
344
+ const PASSWORD_OFF = "Password sign-in is off while a passkey is registered.";
345
345
  export function registerAuthRoutes(app, store,
346
346
  /** While it answers true the password is refused and the form offers only
347
347
  * the passkey; read per request, so removing the last passkey needs no restart. */
@@ -2,6 +2,9 @@
2
2
  // public route is registered before auth; every management action is after it.
3
3
  import { logger } from "../log.js";
4
4
  const log = logger("config-sync");
5
+ /** The one answer every route that writes a synced field gives while the
6
+ * subscription is on: the source owns it, and the Console says where. */
7
+ export const managedRefusal = (c) => c.json({ error: "Managed by the configuration subscription — pause it under Agent → Configuration sync to edit here" }, 409);
5
8
  export function registerConfigShareRoute(app, sync) {
6
9
  app.get("/config-sync/:token", async (c) => {
7
10
  c.header("cache-control", "no-store");
@@ -18,13 +21,9 @@ export function registerConfigShareRoute(app, sync) {
18
21
  const published = await sync.published(token);
19
22
  if (!published)
20
23
  return c.json({ error: "not found" }, 404);
21
- c.header("etag", published.etag);
22
24
  c.header("x-content-type-options", "nosniff");
23
- const tags = c.req.header("if-none-match")?.split(",").map((value) => value.trim().replace(/^W\//, ""));
24
- if (tags?.includes(published.etag) || tags?.includes("*"))
25
- return c.body(null, 304);
26
25
  c.header("content-type", "application/json; charset=utf-8");
27
- return c.body(published.body);
26
+ return c.body(published);
28
27
  }
29
28
  catch {
30
29
  // Never let the app's path logger print a capability URL.
@@ -1,9 +1,12 @@
1
1
  // The agent files a scope is configured by, through the ConfigStore. Not via
2
2
  // web/fs.ts: a scope is "global" or a cwd Pi already knows, never a browser path.
3
3
  import { isThinkingLevel } from "../core/types.js";
4
+ import { SNAPSHOT_FILES } from "../agent/types.js";
4
5
  import { normalizeModelRef } from "../settings.js";
6
+ import { managedRefusal } from "./config-sync.js";
5
7
  import { guarded } from "./route.js";
6
- export function registerConfigRoutes(app, { factory, config, onConfigWritten }) {
8
+ export function registerConfigRoutes(app, { factory, config, onConfigWritten, subscribed = () => false }) {
9
+ const managed = (scope, name) => scope.kind === "global" && subscribed() && SNAPSHOT_FILES.includes(name);
7
10
  // Only cwds Pi already knows are accepted — never an arbitrary path.
8
11
  const parseScope = async (raw) => {
9
12
  if (!raw || raw === "global")
@@ -19,7 +22,7 @@ export function registerConfigRoutes(app, { factory, config, onConfigWritten })
19
22
  return c.json({
20
23
  // Where this scope's files live on disk — the UI labels "Global" with it.
21
24
  dir: scope.kind === "global" ? config.globalDir : scope.cwd,
22
- files: await config.listFiles(scope),
25
+ files: (await config.listFiles(scope)).map((file) => ({ ...file, managed: managed(scope, file.name) })),
23
26
  });
24
27
  });
25
28
  guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
@@ -38,6 +41,8 @@ export function registerConfigRoutes(app, { factory, config, onConfigWritten })
38
41
  return c.json({ error: "content and expected content required" }, 400);
39
42
  }
40
43
  const name = c.req.param("name");
44
+ if (managed(scope, name))
45
+ return managedRefusal(c);
41
46
  await config.writeFile(scope, name, body.content, body.expected);
42
47
  onConfigWritten?.();
43
48
  return c.json({ ok: true, content: await config.readFile(scope, name) });
@@ -48,6 +53,8 @@ export function registerConfigRoutes(app, { factory, config, onConfigWritten })
48
53
  return c.json(await config.readDefaults());
49
54
  });
50
55
  guarded(app, "PUT", "/api/config/defaults", 400, async (c) => {
56
+ if (subscribed())
57
+ return managedRefusal(c);
51
58
  const defaults = parseDefaults(await c.req.json().catch(() => null));
52
59
  if (!defaults) {
53
60
  return c.json({ error: "defaultModel must be {provider, id} or null; defaultThinkingLevel a reasoning level or null" }, 400);
@@ -1,4 +1,4 @@
1
- // What git knows about a project directory, for the Files view. Every route
1
+ // What git knows about a project directory, for the Files dialog. Every route
2
2
  // runs git and nothing else; reading files is web/fs.ts.
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
@@ -11,7 +11,7 @@ const REF_RE = /^[^-\s][^\s]*$/;
11
11
  /** Output capped: a diff is display data, not an archive. */
12
12
  const git = async (root, ...args) => (await run("git", ["-C", root, ...args], { maxBuffer: MAX_DIFF_BYTES })).stdout;
13
13
  export function registerExplorerRoutes(app) {
14
- // Not a repo → { branch: null }: the Files view draws no compare, no filter.
14
+ // Not a repo → { branch: null }: the Files dialog draws no compare, no filter.
15
15
  guarded(app, "GET", "/api/explorer/git", 404, async (c) => {
16
16
  c.header("cache-control", "no-store");
17
17
  const root = await scoped(c.req.query("root"));
package/dist/web/fs.js CHANGED
@@ -51,7 +51,7 @@ export function fileHeaders(file, bytes, download = false) {
51
51
  };
52
52
  }
53
53
  export function registerFsRoutes(app) {
54
- // The Files view walks a project (`root` confines it); the cwd pickers walk
54
+ // The Files dialog walks a project (`root` confines it); the cwd pickers walk
55
55
  // from anywhere. Never contents, and never `.git`.
56
56
  guarded(app, "GET", "/api/fs/ls", 404, async (c) => {
57
57
  c.header("cache-control", "no-store");
@@ -67,6 +67,9 @@ export function registerFsRoutes(app) {
67
67
  return c.json({ path: dir, parent: dir === top || up === dir ? null : up, entries });
68
68
  });
69
69
  guarded(app, "GET", "/api/fs/file", 404, async (c) => {
70
+ // The Files viewer frames a PDF; every other route stays DENY (auth.ts).
71
+ // On every answer, a 304 included: its headers replace the cached ones.
72
+ c.header("x-frame-options", "SAMEORIGIN");
70
73
  const file = await scoped(c.req.query("root"), c.req.query("path"));
71
74
  const handle = await open(file);
72
75
  let streaming = false;
@@ -104,6 +107,18 @@ export function registerFsRoutes(app) {
104
107
  await handle.close();
105
108
  }
106
109
  });
110
+ // Which of these absolute paths exist, file or folder: a reply's file
111
+ // reference is drawn only when it opens something. A path stat cannot reach
112
+ // (missing, unreadable) is the answer `false`, not a failure.
113
+ guarded(app, "POST", "/api/fs/exists", 400, async (c) => {
114
+ const body = await c.req.json().catch(() => null);
115
+ const paths = body?.paths;
116
+ if (!Array.isArray(paths) || paths.length > 1000 || !paths.every((p) => typeof p === "string")) {
117
+ return c.json({ error: "paths required" }, 400);
118
+ }
119
+ const exists = await Promise.all(paths.map((p) => (isAbsolute(p) ? stat(p).then(() => true, () => false) : false)));
120
+ return c.json({ exists });
121
+ });
107
122
  // A name, never a path: traversal is rejected, not normalized.
108
123
  guarded(app, "POST", "/api/fs/mkdir", 400, async (c) => {
109
124
  const body = await c.req.json().catch(() => null);
@@ -1,7 +1,8 @@
1
1
  // Routes about the Pier instance itself: settings, updates, secrets control,
2
2
  // the browser's error reports. Nothing here touches a session.
3
3
  import { logger } from "../log.js";
4
- import { ACCENTS, normalizeAccent, normalizeModelMenu, normalizeModelRef, normalizePublicUrl, } from "../settings.js";
4
+ import { ACCENTS, normalizeAccent, parseModelMenu, normalizeModelRef, normalizePublicUrl, } from "../settings.js";
5
+ import { managedRefusal } from "./config-sync.js";
5
6
  import { boundHost } from "./passkeys.js";
6
7
  /** A rule that failed inside the transaction, distinct from a real fault: 400
7
8
  * for a name this Pier does not have, 409 for a collision with current state. */
@@ -16,7 +17,7 @@ const withName = (current, { name, on }) => on ? [...new Set([...current, name])
16
17
  /** A browser bug can fire in a loop, and the journal is shared. */
17
18
  const CLIENT_LOG_PER_MINUTE = 60;
18
19
  export function registerInstanceRoutes(app, deps) {
19
- const { settings, passkeys, updates, updater = null, secrets, catalog, names = [], onUnlocked, onSettingsChanged, onToolsChanged, validateCustomTools, } = deps;
20
+ const { settings, passkeys, subscribed = () => false, updates, updater = null, secrets, catalog, names = [], onUnlocked, onSettingsChanged, onToolsChanged, validateCustomTools, } = deps;
20
21
  const updateLog = logger("update");
21
22
  // A busy Pier drains first, which can take minutes, and a response held that
22
23
  // long dies at every proxy: past this cap the answer is "draining".
@@ -143,9 +144,11 @@ export function registerInstanceRoutes(app, deps) {
143
144
  writes.push(() => settings.setAccent(accent));
144
145
  }
145
146
  if (body?.modelMenu !== undefined) {
146
- const menu = normalizeModelMenu(body.modelMenu);
147
- if (menu === null)
148
- return refuse("modelMenu must be [{provider, id, note?}] (≤32 entries)");
147
+ if (subscribed())
148
+ return managedRefusal(c);
149
+ const menu = parseModelMenu(body.modelMenu);
150
+ if (typeof menu === "string")
151
+ return refuse(menu);
149
152
  writes.push(() => settings.setModelMenu(menu));
150
153
  }
151
154
  if (body?.titleModel !== undefined) {
@@ -1,6 +1,6 @@
1
1
  // The package registry (/api/packages*) through the PackageStore seam. A file
2
2
  // of its own because the registry is not agent-file editing (config.ts).
3
- import { PackageError } from "../core/types.js";
3
+ import { PackageError } from "../agent/types.js";
4
4
  const STATUS = {
5
5
  invalid: 400, missing: 404, busy: 409, refused: 409, unreachable: 502,
6
6
  };
@@ -74,7 +74,7 @@ class Refusal extends Error {
74
74
  /** The browser binds a credential to the RP ID and refuses any other host, so
75
75
  * only an https public URL can name one; a passkey on a tunnel would be a
76
76
  * lockout the moment the address changed. */
77
- export function relyingParty(publicUrl) {
77
+ function relyingParty(publicUrl) {
78
78
  if (!publicUrl.startsWith("https://")) {
79
79
  return { reason: "passkeys need an https public URL (Settings → Instance)" };
80
80
  }
@@ -186,7 +186,7 @@ const asBytes = (value, what) => {
186
186
  };
187
187
  /** COSE_Key → JWK, EC2 P-256 (ES256) or RSA (RS256). `createPublicKey`
188
188
  * validates the point / modulus; the JWK is what the store keeps. */
189
- export function coseToJwk(key) {
189
+ function coseToJwk(key) {
190
190
  const kty = key.get(1);
191
191
  const alg = key.get(3);
192
192
  const b64 = (label, what) => Buffer.from(asBytes(key.get(label), what)).toString("base64url");
@@ -211,7 +211,7 @@ export function coseToJwk(key) {
211
211
  return jwk;
212
212
  }
213
213
  /** rpIdHash(32) flags(1) signCount(4) [aaguid(16) idLen(2) id COSE_Key] [extensions]. */
214
- export function parseAuthData(data) {
214
+ function parseAuthData(data) {
215
215
  if (data.length < 37)
216
216
  throw new Refusal("authenticator data too short", 400);
217
217
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
@@ -1,8 +1,8 @@
1
1
  // HTTP boundary for provider configuration.
2
- import { isProviderApi, MODEL_EFFORTS, validateProviderSetup } from "../core/types.js";
2
+ import { isProviderApi, MODEL_EFFORTS, validateProviderSetup } from "../agent/types.js";
3
3
  import { ProviderFlows } from "./provider-flows.js";
4
- /** Shape here; the rules are core's validateProviderSetup, since web/ cannot
5
- * import agent/ and must not re-implement them. */
4
+ /** Shape here; the rules are agent/types.ts's validateProviderSetup, which
5
+ * web/ must not re-implement. */
6
6
  function setupFrom(raw) {
7
7
  if (typeof raw !== "object" || raw === null)
8
8
  return null;
@@ -0,0 +1,2 @@
1
+ import{J as e,r as t}from"./highlight-CDSk2hRJ.js";var n=2e4;function r(e){let t=e.split(`
2
+ `);return t.at(-1)===``&&t.pop(),t.map((e,t)=>({nums:[t+1],text:e,tone:``}))}function i(t,r){let i=t.length<=n?r:null,o=Math.max(2,...t.map(e=>e.nums.reduce((e,t)=>Math.max(e,String(t).length),0))),s=e(`div`,`py-2 font-mono text-[12px] leading-[1.5]`);for(let n of t){let t=e(`div`,`flex pl-2 pr-4 ${n.tone===`add`?`bg-emerald-50`:n.tone===`del`?`bg-red-50`:``}`);for(let r of n.nums){let n=e(`span`,`flex-none select-none pr-1.5 text-right text-neutral-300`,r===``?``:String(r));n.style.width=`calc(${o}ch + 0.375rem)`,t.append(n)}let r=n.nums.at(-1);typeof r==`number`&&(t.dataset.line=String(r)),t.append(a(n,i)),s.append(t)}return s}function a(n,r){let i=`min-w-0 flex-1 whitespace-pre-wrap [overflow-wrap:anywhere]`;if(!n.mark||n.mark[0]>=n.mark[1]){let e=t(n.text,r);return e.className=i,e}let[a,o]=n.mark,s=t(n.text.slice(a,o),r);return s.className=`rounded-xs ${n.tone===`add`?`bg-emerald-200/80`:`bg-red-200/70`}`,e(`span`,i,t(n.text.slice(0,a),r),s,t(n.text.slice(o),r))}export{r as n,i as t};
@@ -0,0 +1,5 @@
1
+ import{B as e,Et as t,J as n,M as r,Mt as i,T as a,U as o,V as s,X as c,Z as l,c as u,f as d,g as f,h as p,jt as m,k as h,n as ee,p as te,q as ne,rt as g,s as re,t as ie,wt as _,x as ae}from"./highlight-CDSk2hRJ.js";import{a as oe}from"./index-D0ZYdweB.js";import{n as se,t as ce}from"./code-CWT27KH1.js";var le=[[`path`,{d:`M12 5v14`}],[`path`,{d:`m19 12-7 7-7-7`}]],ue=[[`path`,{d:`M12 22v-6`}],[`path`,{d:`M12 8V2`}],[`path`,{d:`M4 12H2`}],[`path`,{d:`M10 12H8`}],[`path`,{d:`M16 12h-2`}],[`path`,{d:`M22 12h-2`}],[`path`,{d:`m15 19-3-3-3 3`}],[`path`,{d:`m15 5-3 3-3-3`}]],de=[[`path`,{d:`M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z`}]],fe=[[`path`,{d:`M15 6a9 9 0 0 0-9 9V3`}],[`circle`,{cx:`18`,cy:`6`,r:`3`}],[`circle`,{cx:`6`,cy:`18`,r:`3`}]],v=[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`}],[`path`,{d:`M3 3v5h5`}]],y=e=>{let t=new Date(e),n=e=>String(e).padStart(2,`0`);return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`},b=e=>[e.subject,e.body,[[e.author,e.email?`<${e.email}>`:``].filter(Boolean).join(` `),e.at?y(e.at):``].filter(Boolean).join(` · `)].filter(Boolean).join(`
2
+
3
+ `),x=null;function S(){x&&(x.remove(),x=null,document.removeEventListener(`pointerdown`,S,!0),window.removeEventListener(`scroll`,S,!0))}function pe(e,t){e.onmouseenter=()=>{S();let r=t();if(!r)return;x=n(`div`,`pointer-events-none fixed z-50 max-w-96 whitespace-pre-wrap rounded-lg bg-neutral-800/95 px-3 py-2 font-sans text-[11.5px] leading-snug text-neutral-100 shadow-lg`),x.textContent=r,(e.closest(`dialog[open]`)??document.body).append(x),document.addEventListener(`pointerdown`,S,!0),window.addEventListener(`scroll`,S,!0);let i=e.getBoundingClientRect();x.style.left=`${Math.max(8,Math.min(i.left,window.innerWidth-x.offsetWidth-8))}px`,x.style.top=i.bottom+6+x.offsetHeight>window.innerHeight?`${i.top-x.offsetHeight-6}px`:`${i.bottom+6}px`},e.onmouseleave=S}function C(e,t,r){let i=n(`button`,`flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left hover:bg-neutral-100`,_(m,`h-3 w-3 text-indigo-600 ${e.value===t?``:`invisible`}`),n(`span`,`inline-flex flex-none items-center gap-1.5 font-mono text-[11.5px] text-neutral-800`,e.label??e.value));return e.subject&&i.append(n(`span`,`min-w-0 truncate text-[11px] text-neutral-400`,e.subject)),e.author&&i.append(n(`span`,`ml-auto flex-none pl-1.5 text-[10.5px] text-neutral-400`,`${e.author} · ${e.at?l(e.at):``}`)),pe(i,()=>b(e)),i.onclick=()=>r(e.value),i}function me(e,t,r,i,a){let{base:o,head:s}=r,c=()=>s&&o===`${s}~1`?s:``,l=c()?`commit`:`compare`,f=t.commits[0],p=e=>[...e?[{value:``,label:`Working tree`,subject:`the files on disk, uncommitted changes included`}]:[],{...f,value:`HEAD`},...t.refs.map(e=>({value:e.name,subject:e.subject})),...t.commits.map(e=>({...e,value:e.hash}))],m=n(`div`,`flex w-[38rem] max-w-full flex-col`);function h(){let e=(e,t)=>{let r=n(`button`,`cursor-pointer rounded-full px-2.5 py-0.5 text-[11.5px] ${l===t?`bg-indigo-50 font-medium text-indigo-700`:`text-neutral-500 hover:bg-neutral-100`}`,e);return r.onclick=()=>{l=t,h()},r},r=(e,t,r,i)=>n(`div`,`flex min-w-0 flex-1 flex-col`,n(`div`,`flex-none px-2 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-neutral-400`,e),n(`div`,`min-h-0 flex-1 overflow-y-auto`,...t.map(e=>C(e,r,i)))),d=l===`compare`?n(`div`,`flex h-72 min-h-0 divide-x divide-neutral-200 pb-1`,r(`base`,p(!1),o,e=>{o=e,h(),i(o,s)}),r(`to`,p(!0),s,e=>{s=e,h(),i(o,s)})):n(`div`,`flex h-72 min-h-0 flex-col overflow-y-auto pb-1`,...t.commits.map(e=>C({value:e.hash,...e},c(),e=>{o=`${e}~1`,s=e,u(),i(o,s)}))),f=n(`div`,`flex-none border-t border-neutral-200`,C({value:a,label:`${a} ↔ Working tree`,subject:`back to the default: the main line's tip vs the files on disk`},o===a&&s===``?a:``,()=>{o=a,s=``,h(),i(o,s)}));f.querySelector(`button > span`).prepend(_(v)),m.replaceChildren(n(`div`,`flex flex-none items-center gap-1 border-b border-neutral-200 px-2 py-1.5`,e(`Compare two points`,`compare`),e(`One commit`,`commit`)),d,...l===`compare`?[f]:[])}h(),d(e,m)}var he=e=>e.slice(0,Math.max(0,e.lastIndexOf(`/`))),ge=/\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i,_e=/\.(md|markdown)$/i,ve={A:`text-emerald-600`,M:`text-amber-600`,D:`text-red-600`},ye=30,w=`pier.filesPrefs`,T=50,E=()=>{try{let e=JSON.parse(localStorage.getItem(w)??`{}`);return e&&typeof e==`object`?e:{}}catch{return{}}},be=e=>{let t=e?E()[e]:void 0;return typeof t?.cwd==`string`&&typeof t.base==`string`&&typeof t.head==`string`?{cwd:t.cwd,base:t.base,head:t.head}:null},xe=(e,t)=>{let n=E();delete n[e],n[e]=t;let r=Object.keys(n);for(let e of r.slice(0,Math.max(0,r.length-T)))delete n[e];try{localStorage.setItem(w,JSON.stringify(n))}catch(e){console.warn(`Files: could not remember this folder`,e)}},Se=`flex min-w-0 flex-none items-center gap-1.5 rounded-full px-2.5 py-1 font-mono text-[11.5px]`,Ce=(e,t=`indigo`)=>ae(e,`${Se} cursor-pointer truncate text-left ${t===`indigo`?`bg-indigo-50 font-medium text-indigo-700 hover:bg-indigo-100`:`bg-neutral-100 text-neutral-600 hover:bg-neutral-200`}`),D,O=()=>D??=N(e(`#files-dialog`)),k=(e,t,n,r)=>O()(e,t,n,r);function A(t){let n=e(`#files-dialog`);n.open?n.close():k(t)}function j(e,t){if(e&&t===e)return{root:e};let n=e&&t.startsWith(`${e}/`)?e:t.slice(0,t.lastIndexOf(`/`))||`/`,r=t.slice(n===`/`?1:n.length+1);return r?{root:n,select:r}:{root:n}}function we(e,t,n){if(!n||n.startsWith(`#`)||n.startsWith(`//`)||/^[a-z][a-z\d+.-]*:/i.test(n))return null;let r=n.replace(/[?#].*$/s,``);try{r=decodeURIComponent(r)}catch{}let i=(r.startsWith(`/`)?r===e||r.startsWith(`${e}/`)?``:e:t).split(`/`).filter(Boolean);for(let e of r.split(`/`))e===`..`?i.pop():e&&e!==`.`&&i.push(e);return`/${i.join(`/`)}`}var Te=e=>e.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu,``).replace(/\s/g,`-`);function M(e,t,n){let{root:r,select:i}=j(e?.cwd??``,t);k(e,r,i,n)}function N(e){let l=``,u=``,d={branch:null,refs:[],commits:[],worktrees:[]},m=`HEAD`,v=``,y=new Map,x=!0,S=!1,C=!1,w=[],T=new Set,E=null,D=null,O=null,k,A=n(`header`,`pagehead flex-nowrap`),M=n(`div`,`${te} flex-col items-stretch gap-1.5 px-3 py-2.5`),N=n(`div`,`min-h-0 flex-1 overflow-y-auto px-1.5 py-1.5`),Ee=n(`aside`,`${f} w-64 flex-none text-[12.5px] leading-5 max-md:h-1/2 max-md:w-full`,M,N),P=n(`div`,`${te} flex-wrap gap-2 px-4 py-2.5 font-mono text-[11.5px] text-neutral-500`),F=n(`div`,`h-full min-w-0 overflow-auto`),I=n(`div`,`pointer-events-none absolute inset-y-1 right-0.5 z-20 hidden w-2`),De=n(`div`,`relative min-h-0 flex-1`,F,I),Oe=n(`section`,`${f} min-h-0 min-w-0 flex-1`,P,De);e.append(A,n(`div`,`flex min-h-0 flex-1 gap-3 px-4 pb-4 pt-1 max-md:flex-col`,Ee,Oe));let L=(e,t)=>`/api/explorer/${e}?${new URLSearchParams({root:l,...t})}`,R=(e,t)=>`/api/fs/${e}?${new URLSearchParams({root:l,...t})}`,z=(e,t=`text-neutral-400`)=>n(`p`,`px-3 py-2 text-[12.5px] ${t}`,e),B=e=>n(`div`,`flex h-full items-center justify-center p-6`,a(e)),ke=()=>x&&d.branch!==null,Ae=e=>{let t=0;for(let n of y.keys())n.startsWith(`${e}/`)&&t++;return t};function je(e,t){let n=new Set(t.map(e=>e.name)),r=e?`${e}/`:``;for(let[e,i]of y){if(i.status!==`D`||!e.startsWith(r))continue;let a=e.slice(r.length).split(`/`)[0];n.has(a)||(n.add(a),t.push({name:a,dir:e.slice(r.length).includes(`/`)}))}let i=t;return ke()&&(i=t.filter(e=>e.dir?Ae(`${r}${e.name}`)>0:y.has(`${r}${e.name}`))),i.sort((e,t)=>Number(t.dir)-Number(e.dir)||e.name.localeCompare(t.name)),O&&he(O)===e&&!n.has(o(O))&&(F.replaceChildren(B(`No such file: ${O}`)),O=null),i.map(e=>e.dir?Ne(`${r}${e.name}`,e.name):Pe(`${r}${e.name}`,e.name))}async function Me(e,t){try{let{entries:n}=await g(R(`ls`,{path:t}),`could not list this folder`);e.replaceChildren(...je(t,n)),e.childElementCount||e.append(z(ke()?`No changes.`:`Empty.`))}catch(n){let r=je(t,[]);r.length?e.replaceChildren(...r):e.replaceChildren(z(String(n),`text-red-600`))}}function Ne(e,t){let r=Ae(e),i=[n(`span`,`truncate`,t)];r&&i.push(n(`span`,`ml-auto flex-none rounded-full bg-amber-100/80 px-1.5 font-mono text-[10px] font-semibold text-amber-700`,String(r)));let{el:a,summary:o}=ne(``,i);o.className+=` rounded-lg px-2 py-1 transition-colors hover:bg-neutral-100`;let s=n(`div`,`pl-3`);a.append(s);let c=!1,l=()=>{c||(c=!0,s.append(z(`…`)),Me(s,e))};a.ontoggle=()=>{T[a.open?`add`:`delete`](e),a.open&&l()};let u=d.branch?x:!e.includes(`/`);return e===O&&(O=null),(u&&!C||T.has(e))&&(a.open=!0,l()),a}function V(e){D?.classList.remove(`bg-indigo-50`,`font-medium`,`text-indigo-700`),D=e,e.classList.add(`bg-indigo-50`,`font-medium`,`text-indigo-700`)}function Pe(e,t){let r=y.get(e)?.status,i=n(`button`,`flex w-full cursor-pointer items-center gap-1.5 rounded-lg py-1 pl-6 pr-2 text-left transition-colors hover:bg-neutral-100`,n(`span`,`truncate`,t));return r&&i.append(n(`span`,`ml-auto flex-none font-mono text-[10.5px] font-semibold ${ve[r]??`text-neutral-500`}`,r)),i.title=e,e===E&&V(i),e===O&&(O=null,E=e,V(i),q(e)),i.onclick=()=>{V(i),E=e,q(e)},i}let H=()=>void Me(N,``),Fe=(e,t)=>[...e?[n(`span`,`flex-none font-mono text-[10.5px] font-semibold text-emerald-600`,`+${e}`)]:[],...t?[n(`span`,`flex-none font-mono text-[10.5px] font-semibold text-red-600`,`−${t}`)]:[]];function U(e,t=!1,...r){if(P.classList.toggle(`hidden`,!e),P.classList.toggle(`flex`,!!e),!e)return P.replaceChildren();let i=y.get(e),a=[n(`span`,`min-w-0 truncate`,e)];i&&(a.push(n(`span`,`flex-none font-semibold ${ve[i.status]??``}`,i.status)),a.push(...Fe(i.add,i.del)));let s=n(`div`,`ml-auto flex flex-none items-center gap-2`);if(s.append(...r),t){let t=document.createElement(`a`);t.className=`btn flex-none text-[12px]`,t.href=R(`file`,{path:e}),t.download=o(e),t.textContent=`Download`,s.append(t)}P.replaceChildren(...a,s)}function Ie(e){let t=0;for(;t<e.length;){if(e[t].tone!==`del`){t++;continue}let n=t;for(;t<e.length&&e[t].tone===`del`;)t++;let r=t;for(;t<e.length&&e[t].tone===`add`;)t++;let i=Math.min(r-n,t-r);for(let t=0;t<i;t++){let i=e[n+t],a=e[r+t],o=Math.min(i.text.length,a.text.length),s=0;for(;s<o&&i.text[s]===a.text[s];)s++;let c=0;for(;c<o-s&&i.text[i.text.length-1-c]===a.text[a.text.length-1-c];)c++;s+c!==0&&(i.mark=[s,i.text.length-c],a.mark=[s,a.text.length-c])}}}let W=Je();function G(){I.classList.add(`hidden`),I.replaceChildren(),W.set([],[])}function Le(e,t){let n=e===E?k:void 0;if(n===void 0)return t;k=void 0;let r=t.querySelector(`[data-line="${String(n)}"]`);return r?.classList.add(`bg-indigo-100`),requestAnimationFrame(()=>r?.scrollIntoView({block:`center`})),t}let Re=0,K=e=>e===Re,ze=e=>_e.test(e)&&!S&&(e!==E||k===void 0);function Be(e,t){if(!_e.test(e))return[];let n=r([[`Rendered`,`rendered`],[`Source`,`source`]],t?`rendered`:`source`,n=>{n===`rendered`!==t&&(S=n===`source`,q(e))});return n.classList.add(`font-sans`),[n]}function Ve(e,t){let n=c(e);n.className=`md mx-auto max-w-3xl px-6 py-5 text-[14px] leading-relaxed`,ie(n),s(n);let r=`${l}/${he(t)}`,i=e=>{let t=j(l,e);return`/api/fs/file?${new URLSearchParams({root:t.root,path:t.select??``})}`};for(let e of n.querySelectorAll(`img`)){let t=we(l,r,e.getAttribute(`src`)??``);t&&e.setAttribute(`src`,i(t))}for(let e of n.querySelectorAll(`a`)){let t=e.getAttribute(`href`)??``;if(t.startsWith(`#`)){e.onclick=e=>{e.preventDefault();let r=t.slice(1);try{r=decodeURIComponent(r)}catch{}r=r.toLowerCase(),[...n.querySelectorAll(`h1, h2, h3, h4, h5, h6`)].find(e=>Te(e.textContent??``)===r)?.scrollIntoView({block:`start`})};continue}let a=we(l,r,t);a&&(e.setAttribute(`href`,i(a)),e.onclick=e=>{if(e.metaKey||e.ctrlKey||e.shiftKey||e.button!==0)return;e.preventDefault();let t=j(l,a);$(Q,t.root,t.select)})}return n}function q(e){let t=++Re;return y.has(e)?Ye(e,t):He(e,t)}async function He(e,t){if(!K(t))return;G();let r=R(`file`,{path:e}),i=n(`div`,`min-w-0`);F.classList.remove(`flex`,`flex-col`);let a=ze(e);if(U(e,!0,...Be(e,a)),F.replaceChildren(i),ge.test(e)){let e=n(`img`,`max-w-full p-4`);e.src=r,e.onerror=()=>i.replaceChildren(z(`Could not load image.`,`text-red-600`)),i.append(e);return}if(e.toLowerCase().endsWith(`.pdf`)){let e=document.createElement(`iframe`);e.className=`h-full w-full`,e.src=r,F.replaceChildren(e),F.classList.add(`flex`,`flex-col`);return}i.append(z(`…`));let o=await fetch(r);if(!K(t))return;if(!o.ok){let{error:e}=await o.json().catch(()=>({}));i.replaceChildren(z(e??`HTTP ${o.status}`,`text-red-600`));return}if(!(o.headers.get(`content-type`)??``).startsWith(`text/plain`)){i.replaceChildren(z(`Binary file — use Download.`));return}let s=await o.text();if(!K(t))return;if(a)return void i.replaceChildren(Ve(s,e));let c=se(s),l=await ee(e);K(t)&&i.replaceChildren(Le(e,ce(c,l)))}function Ue(e){let t=[],n=0,r=0,i=!1,a=e.split(`
4
+ `);a.at(-1)===``&&a.pop();for(let e of a){let a=/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(e);if(a){i&&t.push({nums:[``,``],text:`⋯`,tone:``}),[n,r]=[Number(a[1]),Number(a[2])],i=!0;continue}!i||e.startsWith(`\\`)||(e.startsWith(`+`)?t.push({nums:[``,r++],text:e.slice(1),tone:`add`}):e.startsWith(`-`)?t.push({nums:[n++,``],text:e.slice(1),tone:`del`}):t.push({nums:[n++,r++],text:e.slice(1),tone:``}))}return t}function We(e){let t=[];for(let n=0;n<e.length;n++){let r=e[n].tone;if(!r)continue;let i=t.at(-1);i&&i.end===n-1?(i.end=n,(r===`add`&&i.tone===`del`||r===`del`&&i.tone===`add`)&&(i.tone=`mixed`)):t.push({start:n,end:n,tone:r})}return t}let Ge={add:`bg-emerald-400`,del:`bg-red-400`,mixed:`bg-amber-400`};function Ke(e,t,r){I.replaceChildren(...e.map(e=>{let i=n(`button`,`map-mark pointer-events-auto absolute left-1/2 w-1 -translate-x-1/2 cursor-pointer rounded-full ${Ge[e.tone]}`);return i.style.top=`${e.start/t*100}%`,i.style.height=`${Math.max(.4,(e.end-e.start+1)/t*100)}%`,i.title=`line ${qe(r,e.start)}`,i.onclick=()=>r[e.start].scrollIntoView({block:`center`}),i})),I.classList.remove(`hidden`)}let qe=(e,t)=>e[t]?.querySelector(`span`)?.textContent?.trim()||String(t+1);function Je(){let t=[],r=[],a=-1,o=n(`span`,`flex-none text-[10.5px] text-neutral-400`),s=e=>{t.length&&(a=(a+e+t.length)%t.length,r[t[a].start].scrollIntoView({block:`center`}),o.textContent=`${a+1}/${t.length}`)},c=()=>t.length>0&&e.open,l=(e,t,r,i)=>{let a=n(`button`,`icon-btn flex-none`,_(e));return a.onclick=()=>s(t),oe(a,i,r,()=>s(t),c),a};return{el:n(`div`,`flex flex-none items-center gap-1`,o,l(i,-1,`Previous change`,[`p`,`k`]),l(le,1,`Next change`,[`n`,`j`])),set(e,n){t=e,r=n,a=-1,o.textContent=`${e.length} change${e.length===1?``:`s`}`}}}async function Ye(e,t){G(),F.classList.remove(`flex`,`flex-col`);let n=y.get(e)?.status===`D`,r=!n,i=ze(e),a=Be(e,i);U(e,r,...a),F.replaceChildren(z(`…`));try{let{diff:o}=await g(L(`diff`,{base:m,head:v,file:e,context:`99999`}),`could not read this diff`);if(!K(t))return;if(!o||/^Binary files /m.test(o))return He(e,t);let s=Ue(o);if(i){let t=n?`add`:`del`,r=s.filter(e=>e.tone!==t&&e.nums.some(e=>e!==``)).map(e=>e.text).join(`
5
+ `);F.replaceChildren(Ve(r,e));return}Ie(s);let c=await ee(e);if(!K(t))return;let l=Le(e,ce(s,c)),u=[...l.children],d=We(s);W.set(d,u),U(e,r,...a,W.el),F.replaceChildren(l),Ke(d,s.length,u)}catch(n){if(!K(t))return;U(e),F.replaceChildren(z(String(n),`text-red-600`))}}let J=()=>v&&m===`${v}~1`?v:``,Xe=()=>d.commits.find(e=>e.hash===J())?.subject??``,Ze=()=>J()?`${J()} — ${Xe()||`commit`}`:`${m} ↔ ${v||`working tree`}`,Qe=(e,t,r,i)=>{let a=n(`button`,`icon-btn ${r?`bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:text-indigo-700`:``}`);return a.append(_(e)),a.title=t,a.setAttribute(`aria-label`,t),a.setAttribute(`aria-pressed`,String(r)),a.onclick=i,a};function Y(){let e=Qe(de,`Show only files that differ`,x,()=>{x=!x,Y(),H()}),t=Qe(ue,C?`Restore folders`:`Collapse all folders`,C,()=>{if(C=!C,C)w=[...T],T.clear();else for(let e of w)T.add(e);Y(),H()});if(M.classList.toggle(`hidden`,!d.branch),M.classList.toggle(`flex`,!!d.branch),!d.branch)return void M.replaceChildren();let r=ae(Ze(),`${p} font-mono`);if(J()){let e=d.commits.find(e=>e.hash===J());pe(r,()=>e?b(e):``)}else r.title=`Choose what to diff`;r.onclick=async()=>{await nt(),me(r,d,{base:m,head:v},(e,t)=>{m=e,v=t,X()},rt())};let[i,a]=[0,0];for(let e of y.values())i+=e.add,a+=e.del;M.replaceChildren(r,n(`div`,`flex items-center gap-1.5 pl-0.5`,n(`span`,`text-[11px] text-neutral-400`,`${y.size} changed file${y.size===1?``:`s`}`),...Fe(i,a),n(`span`,`ml-auto`),e,t))}let $e=()=>{u&&l&&xe(u,{cwd:l,base:m,head:v})},et=e=>{let t=e.replace(/~\d+$/,``);return!t||t===`HEAD`||d.refs.some(e=>e.name===t)||d.commits.some(e=>e.hash===t)};async function tt(){if(y=new Map,!d.branch)return;let{files:e}=await g(L(`diff`,{base:m,head:v}),`could not read what changed`);for(let t of e)y.set(t.path,{status:t.status,add:t.add,del:t.del});if(y.size<=ye)for(let e of y.keys()){let t=e.split(`/`);for(let e=1;e<t.length;e++)T.add(t.slice(0,e).join(`/`))}}async function X(){$e();try{await tt()}catch(e){Y(),N.replaceChildren(z(String(e),`text-red-600`));return}Y(),H(),E&&q(E)}function Z(){let r=Ce(``,`neutral`);r.className+=` max-w-72`,r.append(n(`span`,`truncate max-md:hidden`,l||`Choose a folder…`),n(`span`,`truncate md:hidden`,l?o(l):`Choose a folder…`)),r.title=l?`${l} — switch folder`:`Choose a folder`,r.onclick=()=>re(r,d.worktrees.map(e=>({path:e.path,...e.branch?{hint:e.branch}:{}})),l||void 0,e=>$(Q,e));let i=n(`button`,`icon-btn ml-auto`,_(t));i.type=`button`,i.title=`Close Files`,i.setAttribute(`aria-label`,`Close Files`),i.onclick=()=>e.close();let a=d.branch?[n(`span`,`${Se} bg-neutral-100 text-neutral-600`,_(fe),n(`span`,`truncate`,d.branch))]:[];A.replaceChildren(h(`Files`),r,...a,i)}async function nt(){if(l)try{d=await g(L(`git`,{}),`could not read this repository`),Z()}catch(e){F.replaceChildren(B(String(e)))}}let rt=()=>d.refs.some(e=>e.name===`main`)?`main`:d.refs.some(e=>e.name===`master`)?`master`:`HEAD`;async function it(e){if(Z(),E=null,D=null,T.clear(),O=e||null,e){x=!1;let t=e.split(`/`);for(let e=1;e<=t.length;e++)T.add(t.slice(0,e).join(`/`))}if(G(),F.classList.remove(`flex`,`flex-col`),U(),F.replaceChildren(B(`Select a file.`)),!l){d={branch:null,refs:[],commits:[],worktrees:[]},Y(),N.replaceChildren(),F.replaceChildren(B(`No folder yet — pick one from the chip above.`));return}try{d=await g(L(`git`,{}),`could not read this repository`)}catch(e){d={branch:null,refs:[],commits:[],worktrees:[]},F.replaceChildren(B(String(e)))}let t=be(u),n=t&&t.cwd===l&&et(t.base)&&et(t.head)?t:null;m=n?.base??rt(),v=n?.head??``,Z(),await X()}e.addEventListener(`close`,()=>{G(),F.classList.remove(`flex`,`flex-col`),U(),F.replaceChildren(B(`Select a file.`)),D?.classList.remove(`bg-indigo-50`,`font-medium`,`text-indigo-700`),E=null,D=null});let Q;function $(t,n,r,i){Q=t;let a=t?.id??``;e.open||e.showModal();let o=n?.startsWith(`/`)?n:(a===u?l:``)||be(a)?.cwd||t?.cwd||``;if(o===l&&a===u&&N.childElementCount&&!r){Z(),nt().then(X);return}u=a,l=o,k=i,it(r)}return $}export{k as openFiles,M as openPath,A as toggleFiles};