@spencerboucher/switchyard 0.3.0 → 0.4.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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/switchyard.mjs +1243 -742
  3. package/package.json +1 -1
  4. package/web-dist/assets/{AutomationView-Bt_v8JDx.js → AutomationView-BhEfjAtI.js} +2 -2
  5. package/web-dist/assets/{AutomationView-Bt_v8JDx.js.map → AutomationView-BhEfjAtI.js.map} +1 -1
  6. package/web-dist/assets/{InteractionCard-BRI9KgrO.js → InteractionCard-DxEZUNrD.js} +2 -2
  7. package/web-dist/assets/{InteractionCard-BRI9KgrO.js.map → InteractionCard-DxEZUNrD.js.map} +1 -1
  8. package/web-dist/assets/Menu-C4GUY_ym.js +2 -0
  9. package/web-dist/assets/Menu-C4GUY_ym.js.map +1 -0
  10. package/web-dist/assets/{Overlay-B9h5oAe6.js → Overlay-C-GqmwUn.js} +2 -2
  11. package/web-dist/assets/{Overlay-B9h5oAe6.js.map → Overlay-C-GqmwUn.js.map} +1 -1
  12. package/web-dist/assets/{attention-VkuGuoSf.js → attention-Dzqdk9Gz.js} +2 -2
  13. package/web-dist/assets/{attention-VkuGuoSf.js.map → attention-Dzqdk9Gz.js.map} +1 -1
  14. package/web-dist/assets/{automations-BY88rXFv.js → automations-DzTTwxBl.js} +2 -2
  15. package/web-dist/assets/{automations-BY88rXFv.js.map → automations-DzTTwxBl.js.map} +1 -1
  16. package/web-dist/assets/{board-OzxtnPgh.js → board-C5mG_8rg.js} +2 -2
  17. package/web-dist/assets/{board-OzxtnPgh.js.map → board-C5mG_8rg.js.map} +1 -1
  18. package/web-dist/assets/code-9shyh1Gw.js +8 -0
  19. package/web-dist/assets/code-9shyh1Gw.js.map +1 -0
  20. package/web-dist/assets/{describe-CRXLn3O6.js → describe-BbCZVvJP.js} +2 -2
  21. package/web-dist/assets/{describe-CRXLn3O6.js.map → describe-BbCZVvJP.js.map} +1 -1
  22. package/web-dist/assets/dist-PaQekTP8.js +56 -0
  23. package/web-dist/assets/dist-PaQekTP8.js.map +1 -0
  24. package/web-dist/assets/{enroll-C9-fBIKO.js → enroll-DWJANJtw.js} +2 -2
  25. package/web-dist/assets/{enroll-C9-fBIKO.js.map → enroll-DWJANJtw.js.map} +1 -1
  26. package/web-dist/assets/index-ByiuDoG0.css +2 -0
  27. package/web-dist/assets/{index-B11DJFHW.js → index-afOJ8NRR.js} +3 -3
  28. package/web-dist/assets/{index-B11DJFHW.js.map → index-afOJ8NRR.js.map} +1 -1
  29. package/web-dist/assets/{more-BUvq82hI.js → more-B6qxC6M-.js} +2 -2
  30. package/web-dist/assets/{more-BUvq82hI.js.map → more-B6qxC6M-.js.map} +1 -1
  31. package/web-dist/assets/{now-DR2wt1DG.js → now-BOWxgbt3.js} +2 -2
  32. package/web-dist/assets/{now-DR2wt1DG.js.map → now-BOWxgbt3.js.map} +1 -1
  33. package/web-dist/assets/settings-BpkmIv_4.js +8 -0
  34. package/web-dist/assets/settings-BpkmIv_4.js.map +1 -0
  35. package/web-dist/assets/{send-CFwekPNi.js → sliders-horizontal-S3tmwxsP.js} +2 -2
  36. package/web-dist/assets/sliders-horizontal-S3tmwxsP.js.map +1 -0
  37. package/web-dist/assets/{useNow-ChB9z-N6.js → useNow-D9Zxr5Jb.js} +2 -2
  38. package/web-dist/assets/{useNow-ChB9z-N6.js.map → useNow-D9Zxr5Jb.js.map} +1 -1
  39. package/web-dist/index.html +2 -2
  40. package/web-dist/sw.js +1 -1
  41. package/web-dist/sw.js.map +1 -1
  42. package/web-dist/assets/Menu-Cqil5qVF.js +0 -2
  43. package/web-dist/assets/Menu-Cqil5qVF.js.map +0 -1
  44. package/web-dist/assets/code-BR4PKt8F.js +0 -8
  45. package/web-dist/assets/code-BR4PKt8F.js.map +0 -1
  46. package/web-dist/assets/dist-C4YmDHPG.js +0 -56
  47. package/web-dist/assets/dist-C4YmDHPG.js.map +0 -1
  48. package/web-dist/assets/index-DGGOgcLZ.css +0 -2
  49. package/web-dist/assets/send-CFwekPNi.js.map +0 -1
  50. package/web-dist/assets/settings-C4tDpSCU.js +0 -7
  51. package/web-dist/assets/settings-C4tDpSCU.js.map +0 -1
@@ -295,6 +295,19 @@ var RuntimeCapabilities = z3.object({
295
295
  costAccounting: z3.boolean(),
296
296
  unsupported: z3.array(z3.object({ capability: z3.string(), reason: z3.string() }))
297
297
  });
298
+ var ProjectScan = z3.object({
299
+ state: z3.enum(["running", "done", "failed"]),
300
+ startedAt: Iso,
301
+ finishedAt: Iso.nullable(),
302
+ /** Repositories found across all folders. */
303
+ repositories: z3.number().int(),
304
+ /** New projects created by this scan. */
305
+ projectsCreated: z3.number().int(),
306
+ /** New checkouts (including the ones of new projects). */
307
+ checkoutsAdded: z3.number().int(),
308
+ /** "<folder>: <message>" per folder that failed; for state "failed", the reason last. */
309
+ errors: z3.array(z3.string())
310
+ });
298
311
  var Host = z3.object({
299
312
  id: Id,
300
313
  name: z3.string(),
@@ -306,7 +319,10 @@ var Host = z3.object({
306
319
  connectivity: Connectivity,
307
320
  lastSeenAt: Iso.nullable(),
308
321
  revokedAt: Iso.nullable(),
309
- createdAt: Iso
322
+ createdAt: Iso,
323
+ /** Folders on the host whose git repositories become projects (absolute or ~/...). */
324
+ projectFolders: z3.array(z3.string()).optional(),
325
+ projectScan: ProjectScan.nullable().optional()
310
326
  });
311
327
  var WorktreeDir = z3.string().min(1).refine((d) => !d.startsWith("/") && !d.includes("\\"), "must be a relative path").refine((d) => !d.split("/").includes(".."), "must stay inside the repository").refine((d) => d.split("/").every((s) => s.length > 0 && s !== "."), "must be a plain relative path");
312
328
  var Project = z3.object({
@@ -817,6 +833,23 @@ var UpdateProjectRequest = z4.object({
817
833
  worktreeDir: WorktreeDir.optional()
818
834
  });
819
835
  var AddCheckoutRequest = z4.object({ hostId: z4.string(), path: z4.string().min(1) });
836
+ function isProjectFolderPath(s) {
837
+ if (s.includes("\0"))
838
+ return false;
839
+ return s === "~" || s.startsWith("~/") || s.startsWith("/");
840
+ }
841
+ var ProjectFolder = z4.string().trim().min(1).max(1024).refine(isProjectFolderPath, "must be an absolute path or start with ~/");
842
+ var UpdateHostRequest = z4.object({
843
+ projectFolders: z4.array(ProjectFolder).max(20).optional()
844
+ });
845
+ var ProjectScanResult = ProjectScan.extend({
846
+ added: z4.array(z4.object({
847
+ projectId: z4.string(),
848
+ name: z4.string(),
849
+ path: z4.string(),
850
+ newProject: z4.boolean()
851
+ }))
852
+ });
820
853
  var ProfileInput = z4.object({
821
854
  name: z4.string().min(1),
822
855
  provider: z4.string().nullable().default(null),
@@ -1208,6 +1241,7 @@ var HostCommand = z7.discriminatedUnion("type", [
1208
1241
  z7.object({ type: z7.literal("session.readNative"), sessionFile: z7.string() }),
1209
1242
  z7.object({ type: z7.literal("session.snapshot"), sessionId: z7.string() }),
1210
1243
  z7.object({ type: z7.literal("checkout.inspect"), path: z7.string() }),
1244
+ z7.object({ type: z7.literal("projects.scan"), folder: z7.string().min(1) }),
1211
1245
  z7.object({
1212
1246
  type: z7.literal("workspace.discover"),
1213
1247
  checkoutPath: z7.string(),
@@ -1297,6 +1331,12 @@ var CheckoutInfo = z7.object({
1297
1331
  repoIdentity: z7.string(),
1298
1332
  defaultBranch: z7.string()
1299
1333
  });
1334
+ var ScannedRepository = CheckoutInfo.extend({ name: z7.string() });
1335
+ var ProjectFolderScan = z7.object({
1336
+ /** The folder, ~ expanded and realpath'd. */
1337
+ folder: z7.string(),
1338
+ repositories: z7.array(ScannedRepository)
1339
+ });
1300
1340
  var DiffFile = z7.object({
1301
1341
  path: z7.string(),
1302
1342
  oldPath: z7.string().nullable(),
@@ -5125,176 +5165,675 @@ function occurrenceLabel(o) {
5125
5165
  }
5126
5166
  }
5127
5167
 
5128
- // packages/coordinator/dist/push.js
5129
- import webpush from "web-push";
5130
- var webPushSender = async (subscription, payload, vapid) => {
5131
- await webpush.sendNotification(subscription, JSON.stringify(payload), {
5132
- vapidDetails: {
5133
- subject: vapid.subject,
5134
- publicKey: vapid.publicKey,
5135
- privateKey: vapid.privateKey
5136
- },
5137
- TTL: 24 * 3600,
5138
- urgency: payload.type === "show" ? "high" : "normal"
5139
- });
5140
- };
5141
- var VAPID_KEY = "vapid";
5142
- var SETTINGS_KEY = "notificationSettings";
5143
- var DEFAULT_NOTIFICATION_SETTINGS = {
5144
- quietHours: null,
5145
- privatePreviews: false,
5146
- preferredDeviceId: null
5147
- };
5148
- var PushService = class {
5149
- ctx;
5150
- sender;
5151
- constructor(ctx, sender = webPushSender) {
5152
- this.ctx = ctx;
5153
- this.sender = sender;
5154
- }
5155
- /** VAPID keys, generated once and stored in `settings`. */
5156
- vapid() {
5157
- const existing = this.ctx.store.getSetting(VAPID_KEY, null);
5158
- if (existing)
5159
- return existing;
5160
- const keys = webpush.generateVAPIDKeys();
5161
- this.ctx.store.setSetting(VAPID_KEY, keys);
5162
- return keys;
5163
- }
5164
- subject() {
5165
- const url = this.ctx.config.publicUrl;
5166
- return url.startsWith("https://") ? url : "mailto:switchyard@localhost";
5167
- }
5168
- settings() {
5169
- return {
5170
- ...DEFAULT_NOTIFICATION_SETTINGS,
5171
- ...this.ctx.store.getSetting(SETTINGS_KEY, {})
5172
- };
5173
- }
5174
- updateSettings(patch) {
5175
- const next = NotificationSettings.parse({ ...this.settings(), ...patch });
5176
- if (next.quietHours) {
5177
- try {
5178
- new Intl.DateTimeFormat("en-US", { timeZone: next.quietHours.timezone });
5179
- } catch {
5180
- throw new SwitchyardError("invalid_request", `Unknown timezone "${next.quietHours.timezone}"`);
5181
- }
5182
- }
5183
- if (next.preferredDeviceId)
5184
- this.ctx.store.devices.require(next.preferredDeviceId);
5185
- this.ctx.store.setSetting(SETTINGS_KEY, next);
5186
- return next;
5187
- }
5188
- subscriptions(deviceId) {
5189
- return this.ctx.store.listPushSubscriptions(deviceId);
5190
- }
5191
- /** Store a browser subscription for a device and record its permission state. */
5192
- subscribe(deviceId, sub, permission) {
5193
- const { store } = this.ctx;
5194
- return store.tx(() => {
5195
- if (permission === "granted") {
5196
- store.upsertPushSubscription({
5197
- deviceId,
5198
- endpoint: sub.endpoint,
5199
- subscription: { endpoint: sub.endpoint, keys: sub.keys },
5200
- createdAt: store.nowIso()
5201
- });
5202
- } else {
5203
- store.deletePushSubscription(sub.endpoint);
5204
- }
5205
- return this.syncDevice(deviceId, permission);
5206
- });
5207
- }
5208
- unsubscribe(deviceId, endpoint) {
5209
- const { store } = this.ctx;
5210
- return store.tx(() => {
5211
- if (endpoint) {
5212
- const own = store.listPushSubscriptions(deviceId).some((s) => s.endpoint === endpoint);
5213
- if (own)
5214
- store.deletePushSubscription(endpoint);
5215
- } else {
5216
- store.deletePushSubscriptionsForDevice(deviceId);
5217
- }
5218
- return this.syncDevice(deviceId);
5219
- });
5220
- }
5221
- /** Mirror subscription presence into `device.push`. */
5222
- syncDevice(deviceId, permission) {
5223
- const { store } = this.ctx;
5224
- const d = store.devices.require(deviceId);
5225
- const push = {
5226
- subscribed: store.listPushSubscriptions(deviceId).length > 0,
5227
- permission: permission ?? d.push.permission
5228
- };
5229
- if (JSON.stringify(push) === JSON.stringify(d.push))
5230
- return d;
5231
- return store.putDevice({ ...d, push });
5232
- }
5233
- /** Send to every subscription of a device. 404/410 deletes the subscription. */
5234
- async sendToDevice(deviceId, payload) {
5235
- const subs = this.ctx.store.listPushSubscriptions(deviceId);
5236
- const result = { sent: 0, removed: 0, failed: 0 };
5237
- if (subs.length === 0)
5238
- return result;
5239
- const vapid = { ...this.vapid(), subject: this.subject() };
5240
- for (const s of subs) {
5241
- try {
5242
- await this.sender(s.subscription, payload, vapid);
5243
- result.sent++;
5244
- } catch (err) {
5245
- const status = err.statusCode;
5246
- if (status === 404 || status === 410) {
5247
- this.ctx.store.tx(() => {
5248
- this.ctx.store.deletePushSubscription(s.endpoint);
5249
- if (this.ctx.store.devices.get(deviceId))
5250
- this.syncDevice(deviceId);
5251
- });
5252
- result.removed++;
5253
- } else {
5254
- result.failed++;
5255
- this.ctx.log.warn({ err, deviceId, status }, "push delivery failed");
5256
- }
5257
- }
5258
- }
5259
- return result;
5260
- }
5261
- };
5262
-
5263
- // packages/coordinator/dist/routes/automations.js
5264
- import { createHash as createHash2 } from "node:crypto";
5168
+ // packages/coordinator/dist/workspaces.js
5265
5169
  import { z as z8 } from "zod";
5266
- var ListQuery = z8.object({ projectId: z8.string().optional() });
5267
- var HistoryQuery = z8.object({ limit: z8.coerce.number().int().min(1).max(1e3).default(100) });
5268
- var WEBHOOK_BODY_LIMIT = 256 * 1024;
5269
- function registerAutomationRoutes(app, ctx) {
5270
- const { store } = ctx;
5271
- const svc = () => requireService(ctx, "automations");
5272
- const idOf = (req) => req.params.id;
5273
- const view = (id) => {
5274
- const a = store.automations.require(id);
5275
- return { ...a, summary: svc().describe(a) };
5276
- };
5277
- app.get("/api/automations", async (req) => {
5278
- const q = parse(ListQuery, req.query, "query");
5279
- const list = q.projectId ? store.automations.list("project_id = ?", [q.projectId], "created_at") : store.automations.list(void 0, [], "created_at");
5280
- return list.map((a) => ({ ...a, summary: svc().describe(a) }));
5170
+ var DEFAULT_WORKTREE_DIR = ".worktrees";
5171
+ function worktreeDirOf(project) {
5172
+ return project?.worktreeDir || DEFAULT_WORKTREE_DIR;
5173
+ }
5174
+ function predictedWorktreePath(checkout, project, branch) {
5175
+ return `${checkout.path}/${worktreeDirOf(project)}/${branchSlug(branch)}`;
5176
+ }
5177
+ var ZERO_GIT = { dirty: false, changedFiles: 0, ahead: 0, behind: 0, unmerged: false };
5178
+ var NO_SETUP = { state: "none", log: "" };
5179
+ var SetupState2 = z8.object({
5180
+ state: z8.enum(["none", "running", "succeeded", "failed"]),
5181
+ log: z8.string()
5182
+ });
5183
+ function stripRef(branch) {
5184
+ return branch?.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : branch;
5185
+ }
5186
+ async function inspectCheckout(ctx, hostId, path) {
5187
+ const r = await ctx.hostHub.request(hostId, { type: "checkout.inspect", path });
5188
+ return CheckoutInfo.parse(r);
5189
+ }
5190
+ function recordCreatedWorkspace(ctx, project, checkout, info) {
5191
+ return ctx.store.tx(() => {
5192
+ const existing = ctx.store.workspaceByPath(checkout.hostId, info.path);
5193
+ const buffered = ctx.hostHub.takeBufferedWorkspace(checkout.hostId, info.path);
5194
+ const git = GitState.safeParse(buffered?.git ?? info.git);
5195
+ const setup = SetupState2.safeParse(buffered?.setup ?? info.setup);
5196
+ const ws = {
5197
+ id: existing?.id ?? newId("wsp"),
5198
+ checkoutId: checkout.id,
5199
+ projectId: project.id,
5200
+ hostId: checkout.hostId,
5201
+ path: info.path,
5202
+ branch: info.branch,
5203
+ baseBranch: info.baseBranch,
5204
+ provenance: "managed",
5205
+ git: git.success ? git.data : existing?.git ?? ZERO_GIT,
5206
+ setup: setup.success ? setup.data : existing?.setup ?? NO_SETUP,
5207
+ archivedAt: null,
5208
+ updatedAt: ctx.store.nowIso()
5209
+ };
5210
+ return ctx.store.putWorkspace(ws);
5281
5211
  });
5282
- app.post("/api/automations", async (req, reply) => {
5283
- const a = svc().create(validated(req.body));
5284
- return reply.code(201).send(view(a.id));
5212
+ }
5213
+ async function refreshCheckoutWorkspaces(ctx, checkout) {
5214
+ const raw = await ctx.hostHub.request(checkout.hostId, {
5215
+ type: "workspace.discover",
5216
+ checkoutPath: checkout.path,
5217
+ worktreeDir: worktreeDirOf(ctx.store.projects.get(checkout.projectId))
5285
5218
  });
5286
- app.get("/api/automations/:id", async (req) => view(idOf(req)));
5287
- app.patch("/api/automations/:id", async (req) => {
5288
- const body = req.body ?? {};
5289
- if (typeof body !== "object" || Array.isArray(body)) {
5290
- throw new SwitchyardError("invalid_request", "Expected an object");
5219
+ const list = DiscoveredWorktree.array().parse(Array.isArray(raw) ? raw : raw?.worktrees ?? []);
5220
+ return ctx.store.tx(() => {
5221
+ const now = ctx.store.nowIso();
5222
+ const seen = /* @__PURE__ */ new Set();
5223
+ const out = [];
5224
+ for (const wt of list) {
5225
+ if (wt.bare || wt.prunable)
5226
+ continue;
5227
+ seen.add(wt.path);
5228
+ const existing = ctx.store.workspaceByPath(checkout.hostId, wt.path);
5229
+ const next = {
5230
+ id: existing?.id ?? newId("wsp"),
5231
+ checkoutId: checkout.id,
5232
+ projectId: checkout.projectId,
5233
+ hostId: checkout.hostId,
5234
+ path: wt.path,
5235
+ branch: stripRef(wt.branch),
5236
+ baseBranch: existing?.baseBranch ?? (wt.provenance === "main" ? null : checkout.defaultBranch),
5237
+ provenance: existing?.provenance === "managed" ? "managed" : wt.provenance,
5238
+ git: wt.git ?? existing?.git ?? ZERO_GIT,
5239
+ setup: existing?.setup ?? NO_SETUP,
5240
+ archivedAt: null,
5241
+ updatedAt: existing?.updatedAt ?? now
5242
+ };
5243
+ const changed = !existing || JSON.stringify({ ...existing, updatedAt: "" }) !== JSON.stringify({ ...next, updatedAt: "" });
5244
+ out.push(changed ? ctx.store.putWorkspace({ ...next, updatedAt: now }) : existing);
5291
5245
  }
5292
- svc().update(idOf(req), body);
5293
- return view(idOf(req));
5246
+ for (const ws of ctx.store.workspaces.list("checkout_id = ? AND archived_at IS NULL", [
5247
+ checkout.id
5248
+ ])) {
5249
+ if (!seen.has(ws.path))
5250
+ ctx.store.putWorkspace({ ...ws, archivedAt: now, updatedAt: now });
5251
+ }
5252
+ return out;
5294
5253
  });
5295
- app.delete("/api/automations/:id", async (req) => {
5296
- svc().delete(idOf(req));
5297
- return { ok: true };
5254
+ }
5255
+ async function refreshWorkspaceStatus(ctx, ws) {
5256
+ const raw = await ctx.hostHub.request(ws.hostId, {
5257
+ type: "workspace.status",
5258
+ path: ws.path,
5259
+ base: ws.baseBranch
5260
+ });
5261
+ const git = GitState.parse(raw?.git ?? raw);
5262
+ return ctx.store.tx(() => {
5263
+ const cur = ctx.store.workspaces.require(ws.id);
5264
+ if (JSON.stringify(cur.git) === JSON.stringify(git))
5265
+ return cur;
5266
+ return ctx.store.putWorkspace({ ...cur, git, updatedAt: ctx.store.nowIso() });
5267
+ });
5268
+ }
5269
+ function baseFor(ctx, ws) {
5270
+ return ws.baseBranch ?? ctx.store.checkouts.get(ws.checkoutId)?.defaultBranch ?? null;
5271
+ }
5272
+
5273
+ // packages/coordinator/dist/sessions/service.js
5274
+ var LIVE_RUNTIMES2 = ["starting", "idle", "running", "waiting_input"];
5275
+ function requireUsableHost(ctx, hostId) {
5276
+ const host = ctx.store.hosts.require(hostId);
5277
+ if (host.revokedAt)
5278
+ throw new SwitchyardError("forbidden", `Host ${host.name} was revoked`);
5279
+ if (!ctx.hostHub.isConnected(hostId)) {
5280
+ throw new SwitchyardError("host_unreachable", `Host ${host.name} is offline`, { hostId });
5281
+ }
5282
+ if (!host.piCompatible) {
5283
+ throw new SwitchyardError("pi_incompatible", `Pi ${host.piVersion ?? "(not found)"} on ${host.name} is outside the tested range`, { hostId });
5284
+ }
5285
+ }
5286
+ function requireConnected(ctx, hostId) {
5287
+ const host = ctx.store.hosts.require(hostId);
5288
+ if (!ctx.hostHub.isConnected(hostId)) {
5289
+ throw new SwitchyardError("host_unreachable", `Host ${host.name} is offline`, { hostId });
5290
+ }
5291
+ }
5292
+ function resolveProfile(ctx, profileId, project) {
5293
+ const id = profileId ?? project?.defaultProfileId ?? null;
5294
+ if (id)
5295
+ return ctx.store.profiles.require(id);
5296
+ const first = ctx.store.profiles.list(void 0, [], "created_at")[0];
5297
+ if (!first)
5298
+ throw new SwitchyardError("invalid_request", "No Pi profile configured");
5299
+ return first;
5300
+ }
5301
+ function titleFrom(prompt, fallback = "New session") {
5302
+ const line = prompt.trim().split("\n")[0]?.trim() ?? "";
5303
+ if (!line)
5304
+ return fallback;
5305
+ return line.length > 80 ? `${line.slice(0, 77)}...` : line;
5306
+ }
5307
+ function appendNotice(ctx, session, level, text) {
5308
+ return ctx.store.tx(() => {
5309
+ const item = {
5310
+ id: newId("itm"),
5311
+ sessionId: session.id,
5312
+ order: ctx.store.maxItemOrder(session.id) + 1,
5313
+ at: ctx.store.nowIso(),
5314
+ kind: "notice",
5315
+ data: { level, text }
5316
+ };
5317
+ ctx.store.upsertItem(item);
5318
+ ctx.store.appendEvent("transcript.item", { sessionId: session.id, hostId: session.hostId, projectId: session.projectId ?? void 0 }, item);
5319
+ return item;
5320
+ });
5321
+ }
5322
+ function failSession(ctx, sessionId, message) {
5323
+ ctx.store.tx(() => {
5324
+ const s = ctx.store.sessions.get(sessionId);
5325
+ if (!s)
5326
+ return;
5327
+ ctx.store.putSession({
5328
+ ...s,
5329
+ runtime: "failed",
5330
+ runtimeDetail: message,
5331
+ lastActivityAt: ctx.store.nowIso()
5332
+ });
5333
+ appendNotice(ctx, s, "error", message);
5334
+ });
5335
+ }
5336
+ async function startOnHost(ctx, session, opts) {
5337
+ const cmd = {
5338
+ type: "session.start",
5339
+ sessionId: session.id,
5340
+ title: session.title,
5341
+ workspacePath: session.cwd,
5342
+ projectId: session.projectId,
5343
+ profile: session.profileSnapshot,
5344
+ nativeHandle: opts.nativeHandle,
5345
+ initialPrompt: opts.initialPrompt,
5346
+ dispatchKey: opts.dispatchKey ?? null,
5347
+ controlRevision: opts.controlRevision ?? 1,
5348
+ controlMode: opts.controlMode ?? "human",
5349
+ origin: opts.origin ?? "human",
5350
+ readOnly: session.readOnly
5351
+ };
5352
+ const result = await ctx.hostHub.request(session.hostId, cmd);
5353
+ return ctx.store.tx(() => {
5354
+ const s = ctx.store.sessions.require(session.id);
5355
+ const handle = result?.nativeHandle ?? null;
5356
+ if (handle && JSON.stringify(handle) !== JSON.stringify(s.nativeHandle)) {
5357
+ return ctx.store.putSession({ ...s, nativeHandle: handle });
5358
+ }
5359
+ return s;
5360
+ });
5361
+ }
5362
+ async function createWorkspace(ctx, project, checkout, branch, base) {
5363
+ requireConnected(ctx, checkout.hostId);
5364
+ const baseBranch = base ?? checkout.defaultBranch;
5365
+ const result = await ctx.hostHub.request(checkout.hostId, {
5366
+ type: "workspace.create",
5367
+ checkoutPath: checkout.path,
5368
+ branch,
5369
+ base: baseBranch,
5370
+ setupCommand: project.setupCommand,
5371
+ worktreeDir: worktreeDirOf(project)
5372
+ });
5373
+ const predicted = predictedWorktreePath(checkout, project, branch);
5374
+ return recordCreatedWorkspace(ctx, project, checkout, {
5375
+ path: typeof result?.path === "string" ? result.path : predicted,
5376
+ branch: typeof result?.branch === "string" ? result.branch : branch,
5377
+ baseBranch,
5378
+ git: result?.git,
5379
+ setup: result?.setup
5380
+ });
5381
+ }
5382
+ function createSession(ctx, input) {
5383
+ const project = ctx.store.projects.require(input.projectId);
5384
+ const profile = resolveProfile(ctx, input.profileId, project);
5385
+ let hostId;
5386
+ let workspace = null;
5387
+ let checkout = null;
5388
+ if ("existingId" in input.workspace) {
5389
+ workspace = ctx.store.workspaces.require(input.workspace.existingId);
5390
+ if (workspace.projectId !== project.id) {
5391
+ throw new SwitchyardError("invalid_request", "Workspace belongs to another project");
5392
+ }
5393
+ if (workspace.archivedAt)
5394
+ throw new SwitchyardError("conflict", "Workspace is archived");
5395
+ hostId = workspace.hostId;
5396
+ } else {
5397
+ checkout = ctx.store.checkouts.require(input.workspace.checkoutId);
5398
+ if (checkout.projectId !== project.id) {
5399
+ throw new SwitchyardError("invalid_request", "Checkout belongs to another project");
5400
+ }
5401
+ hostId = checkout.hostId;
5402
+ }
5403
+ requireUsableHost(ctx, hostId);
5404
+ const now = ctx.store.nowIso();
5405
+ const host = ctx.store.hosts.require(hostId);
5406
+ const session = {
5407
+ id: newId("ses"),
5408
+ title: input.title?.trim() || titleFrom(input.prompt),
5409
+ subtitle: null,
5410
+ hostId,
5411
+ workspaceId: workspace?.id ?? null,
5412
+ projectId: project.id,
5413
+ cwd: workspace?.path ?? predictedWorktreePath(checkout, project, input.workspace.newBranch),
5414
+ nativeHandle: null,
5415
+ profileSnapshot: profile,
5416
+ runtime: "starting",
5417
+ runtimeDetail: null,
5418
+ connectivity: host.connectivity,
5419
+ runId: null,
5420
+ taskId: null,
5421
+ provenance: "created",
5422
+ readOnly: false,
5423
+ lastActivityAt: now,
5424
+ archivedAt: null,
5425
+ queue: { steering: [], followUp: [] },
5426
+ stats: null,
5427
+ lastAssistantText: null,
5428
+ createdAt: now
5429
+ };
5430
+ ctx.store.putSession(session);
5431
+ const choice = input.workspace;
5432
+ const started = (async () => {
5433
+ try {
5434
+ let s = session;
5435
+ if (!("existingId" in choice)) {
5436
+ const ws = await createWorkspace(ctx, project, checkout, choice.newBranch, choice.base);
5437
+ s = ctx.store.tx(() => {
5438
+ const cur = ctx.store.sessions.require(session.id);
5439
+ return ctx.store.putSession({ ...cur, workspaceId: ws.id, cwd: ws.path });
5440
+ });
5441
+ }
5442
+ await startOnHost(ctx, s, {
5443
+ initialPrompt: input.prompt.trim() ? input.prompt : null,
5444
+ nativeHandle: null
5445
+ });
5446
+ } catch (err) {
5447
+ const message = err instanceof Error ? err.message : String(err);
5448
+ ctx.log.warn({ err, sessionId: session.id }, "session start failed");
5449
+ failSession(ctx, session.id, `Could not start the session: ${message}`);
5450
+ }
5451
+ })();
5452
+ return { session, started };
5453
+ }
5454
+
5455
+ // packages/coordinator/dist/project-folders.js
5456
+ var PROJECT_SCAN_INTERVAL_MS = 10 * 6e4;
5457
+ var PROJECT_SCAN_MIN_AGE_ON_CONNECT_MS = 6e4;
5458
+ var PROJECT_SCAN_TIMEOUT_MS = 12e4;
5459
+ function normalizeProjectFolders(list) {
5460
+ const out = [];
5461
+ for (const raw of list) {
5462
+ let f = raw.trim();
5463
+ while (f.length > 1 && f.endsWith("/"))
5464
+ f = f.slice(0, -1);
5465
+ if (f && !out.includes(f))
5466
+ out.push(f);
5467
+ }
5468
+ return out;
5469
+ }
5470
+ function uniqueProjectName(existing, base, hostName) {
5471
+ const taken = new Set(existing.map((n) => n.toLowerCase()));
5472
+ if (!taken.has(base.toLowerCase()))
5473
+ return base;
5474
+ const withHost = `${base} (${hostName})`;
5475
+ if (!taken.has(withHost.toLowerCase()))
5476
+ return withHost;
5477
+ for (let i = 2; ; i++) {
5478
+ const candidate = `${withHost} ${i}`;
5479
+ if (!taken.has(candidate.toLowerCase()))
5480
+ return candidate;
5481
+ }
5482
+ }
5483
+ function newProject(input) {
5484
+ return {
5485
+ id: newId("prj"),
5486
+ name: input.name,
5487
+ repoIdentity: input.repoIdentity,
5488
+ defaultProfileId: input.defaultProfileId ?? null,
5489
+ defaultHostId: input.hostId,
5490
+ maxConcurrentRuns: input.maxConcurrentRuns ?? 1,
5491
+ automationEnabled: true,
5492
+ setupCommand: input.setupCommand ?? null,
5493
+ checks: input.checks ?? [],
5494
+ workspaceSharing: "single_writer",
5495
+ worktreeDir: input.worktreeDir ?? DEFAULT_WORKTREE_DIR,
5496
+ createdAt: input.now,
5497
+ updatedAt: input.now
5498
+ };
5499
+ }
5500
+ function setScan(ctx, hostId, scan) {
5501
+ ctx.store.tx(() => {
5502
+ const h = ctx.store.hosts.require(hostId);
5503
+ ctx.store.putHost({ ...h, projectScan: scan });
5504
+ });
5505
+ }
5506
+ function isTooOld(err) {
5507
+ return err instanceof SwitchyardError && err.code === "invalid_request" && err.message.startsWith("Invalid command");
5508
+ }
5509
+ async function scanHostProjectFolders(ctx, hostId) {
5510
+ const host = ctx.store.hosts.require(hostId);
5511
+ const folders = host.projectFolders ?? [];
5512
+ if (folders.length === 0) {
5513
+ throw new SwitchyardError("invalid_request", `${host.name} has no project folders. Add one first.`);
5514
+ }
5515
+ requireConnected(ctx, hostId);
5516
+ const scan = {
5517
+ state: "running",
5518
+ startedAt: ctx.store.nowIso(),
5519
+ finishedAt: null,
5520
+ repositories: 0,
5521
+ projectsCreated: 0,
5522
+ checkoutsAdded: 0,
5523
+ errors: []
5524
+ };
5525
+ const added = [];
5526
+ setScan(ctx, hostId, scan);
5527
+ try {
5528
+ const newCheckouts = [];
5529
+ for (const folder of folders) {
5530
+ let result;
5531
+ try {
5532
+ const raw = await ctx.hostHub.request(hostId, { type: "projects.scan", folder }, { timeoutMs: PROJECT_SCAN_TIMEOUT_MS });
5533
+ result = ProjectFolderScan.parse(raw);
5534
+ } catch (err) {
5535
+ if (isTooOld(err)) {
5536
+ throw new SwitchyardError("unsupported_capability", `${host.name} runs Switchyard ${host.agentVersion}, which cannot scan project folders. Update its host agent.`);
5537
+ }
5538
+ if (err instanceof SwitchyardError && err.code === "host_unreachable")
5539
+ throw err;
5540
+ scan.errors.push(`${folder}: ${err instanceof Error ? err.message : String(err)}`);
5541
+ continue;
5542
+ }
5543
+ scan.repositories += result.repositories.length;
5544
+ for (const repo of result.repositories) {
5545
+ const r = ctx.store.tx(() => {
5546
+ const known = ctx.store.checkouts.list("host_id = ? AND path = ?", [
5547
+ hostId,
5548
+ repo.root
5549
+ ])[0];
5550
+ if (known)
5551
+ return null;
5552
+ const now = ctx.store.nowIso();
5553
+ let project = ctx.store.projects.list("repo_identity = ?", [repo.repoIdentity])[0];
5554
+ let created = false;
5555
+ if (!project) {
5556
+ const names = ctx.store.projects.list().map((p) => p.name);
5557
+ project = ctx.store.putProject(newProject({
5558
+ name: uniqueProjectName(names, repo.name, host.name),
5559
+ repoIdentity: repo.repoIdentity,
5560
+ hostId,
5561
+ now
5562
+ }));
5563
+ created = true;
5564
+ }
5565
+ const checkout = ctx.store.putCheckout({
5566
+ id: newId("chk"),
5567
+ projectId: project.id,
5568
+ hostId,
5569
+ path: repo.root,
5570
+ commonDir: repo.commonDir,
5571
+ defaultBranch: repo.defaultBranch,
5572
+ createdAt: now
5573
+ });
5574
+ return { project, checkout, created };
5575
+ });
5576
+ if (!r)
5577
+ continue;
5578
+ scan.checkoutsAdded++;
5579
+ if (r.created)
5580
+ scan.projectsCreated++;
5581
+ newCheckouts.push(r.checkout);
5582
+ added.push({
5583
+ projectId: r.project.id,
5584
+ name: r.project.name,
5585
+ path: repo.root,
5586
+ newProject: r.created
5587
+ });
5588
+ }
5589
+ }
5590
+ for (const checkout of newCheckouts) {
5591
+ try {
5592
+ await refreshCheckoutWorkspaces(ctx, checkout);
5593
+ } catch (err) {
5594
+ ctx.log.warn({ err, checkoutId: checkout.id }, "workspace discovery failed");
5595
+ }
5596
+ }
5597
+ const done = { ...scan, state: "done", finishedAt: ctx.store.nowIso() };
5598
+ setScan(ctx, hostId, done);
5599
+ return { ...done, added };
5600
+ } catch (err) {
5601
+ const message = err instanceof Error ? err.message : String(err);
5602
+ setScan(ctx, hostId, {
5603
+ ...scan,
5604
+ state: "failed",
5605
+ finishedAt: ctx.store.nowIso(),
5606
+ errors: [...scan.errors, message]
5607
+ });
5608
+ throw err;
5609
+ }
5610
+ }
5611
+ var ProjectFolderService = class {
5612
+ ctx;
5613
+ opts;
5614
+ inflight = /* @__PURE__ */ new Map();
5615
+ timer = null;
5616
+ onConnected = ({ hostId }) => {
5617
+ const host = this.ctx.store.hosts.get(hostId);
5618
+ if (!host || host.revokedAt || !(host.projectFolders ?? []).length)
5619
+ return;
5620
+ const last = host.projectScan?.startedAt ? Date.parse(host.projectScan.startedAt) : null;
5621
+ const now = Date.parse(this.ctx.store.nowIso());
5622
+ if (last !== null && now - last < PROJECT_SCAN_MIN_AGE_ON_CONNECT_MS)
5623
+ return;
5624
+ this.background(hostId);
5625
+ };
5626
+ constructor(ctx, opts) {
5627
+ this.ctx = ctx;
5628
+ this.opts = opts;
5629
+ }
5630
+ /** Scan a host now; joins a scan already running for it. */
5631
+ scan(hostId) {
5632
+ const running = this.inflight.get(hostId);
5633
+ if (running)
5634
+ return running;
5635
+ const p = scanHostProjectFolders(this.ctx, hostId).finally(() => this.inflight.delete(hostId));
5636
+ this.inflight.set(hostId, p);
5637
+ return p;
5638
+ }
5639
+ /** Scan without waiting; failures are logged (and recorded on the host). */
5640
+ background(hostId) {
5641
+ this.scan(hostId).catch((err) => this.ctx.log.warn({ err, hostId }, "project folder scan failed"));
5642
+ }
5643
+ start() {
5644
+ this.ctx.hub.on("host.connected", this.onConnected);
5645
+ if (this.opts.intervalMs > 0) {
5646
+ this.timer = setInterval(() => this.tick(), this.opts.intervalMs);
5647
+ this.timer.unref();
5648
+ }
5649
+ }
5650
+ stop() {
5651
+ if (this.timer)
5652
+ clearInterval(this.timer);
5653
+ this.timer = null;
5654
+ this.ctx.hub.off("host.connected", this.onConnected);
5655
+ }
5656
+ tick() {
5657
+ for (const h of this.ctx.store.hosts.list()) {
5658
+ if (h.revokedAt || !(h.projectFolders ?? []).length)
5659
+ continue;
5660
+ if (!this.ctx.hostHub.isConnected(h.id))
5661
+ continue;
5662
+ this.background(h.id);
5663
+ }
5664
+ }
5665
+ };
5666
+
5667
+ // packages/coordinator/dist/push.js
5668
+ import webpush from "web-push";
5669
+ var webPushSender = async (subscription, payload, vapid) => {
5670
+ await webpush.sendNotification(subscription, JSON.stringify(payload), {
5671
+ vapidDetails: {
5672
+ subject: vapid.subject,
5673
+ publicKey: vapid.publicKey,
5674
+ privateKey: vapid.privateKey
5675
+ },
5676
+ TTL: 24 * 3600,
5677
+ urgency: payload.type === "show" ? "high" : "normal"
5678
+ });
5679
+ };
5680
+ var VAPID_KEY = "vapid";
5681
+ var SETTINGS_KEY = "notificationSettings";
5682
+ var DEFAULT_NOTIFICATION_SETTINGS = {
5683
+ quietHours: null,
5684
+ privatePreviews: false,
5685
+ preferredDeviceId: null
5686
+ };
5687
+ var PushService = class {
5688
+ ctx;
5689
+ sender;
5690
+ constructor(ctx, sender = webPushSender) {
5691
+ this.ctx = ctx;
5692
+ this.sender = sender;
5693
+ }
5694
+ /** VAPID keys, generated once and stored in `settings`. */
5695
+ vapid() {
5696
+ const existing = this.ctx.store.getSetting(VAPID_KEY, null);
5697
+ if (existing)
5698
+ return existing;
5699
+ const keys = webpush.generateVAPIDKeys();
5700
+ this.ctx.store.setSetting(VAPID_KEY, keys);
5701
+ return keys;
5702
+ }
5703
+ subject() {
5704
+ const url = this.ctx.config.publicUrl;
5705
+ return url.startsWith("https://") ? url : "mailto:switchyard@localhost";
5706
+ }
5707
+ settings() {
5708
+ return {
5709
+ ...DEFAULT_NOTIFICATION_SETTINGS,
5710
+ ...this.ctx.store.getSetting(SETTINGS_KEY, {})
5711
+ };
5712
+ }
5713
+ updateSettings(patch) {
5714
+ const next = NotificationSettings.parse({ ...this.settings(), ...patch });
5715
+ if (next.quietHours) {
5716
+ try {
5717
+ new Intl.DateTimeFormat("en-US", { timeZone: next.quietHours.timezone });
5718
+ } catch {
5719
+ throw new SwitchyardError("invalid_request", `Unknown timezone "${next.quietHours.timezone}"`);
5720
+ }
5721
+ }
5722
+ if (next.preferredDeviceId)
5723
+ this.ctx.store.devices.require(next.preferredDeviceId);
5724
+ this.ctx.store.setSetting(SETTINGS_KEY, next);
5725
+ return next;
5726
+ }
5727
+ subscriptions(deviceId) {
5728
+ return this.ctx.store.listPushSubscriptions(deviceId);
5729
+ }
5730
+ /** Store a browser subscription for a device and record its permission state. */
5731
+ subscribe(deviceId, sub, permission) {
5732
+ const { store } = this.ctx;
5733
+ return store.tx(() => {
5734
+ if (permission === "granted") {
5735
+ store.upsertPushSubscription({
5736
+ deviceId,
5737
+ endpoint: sub.endpoint,
5738
+ subscription: { endpoint: sub.endpoint, keys: sub.keys },
5739
+ createdAt: store.nowIso()
5740
+ });
5741
+ } else {
5742
+ store.deletePushSubscription(sub.endpoint);
5743
+ }
5744
+ return this.syncDevice(deviceId, permission);
5745
+ });
5746
+ }
5747
+ unsubscribe(deviceId, endpoint) {
5748
+ const { store } = this.ctx;
5749
+ return store.tx(() => {
5750
+ if (endpoint) {
5751
+ const own = store.listPushSubscriptions(deviceId).some((s) => s.endpoint === endpoint);
5752
+ if (own)
5753
+ store.deletePushSubscription(endpoint);
5754
+ } else {
5755
+ store.deletePushSubscriptionsForDevice(deviceId);
5756
+ }
5757
+ return this.syncDevice(deviceId);
5758
+ });
5759
+ }
5760
+ /** Mirror subscription presence into `device.push`. */
5761
+ syncDevice(deviceId, permission) {
5762
+ const { store } = this.ctx;
5763
+ const d = store.devices.require(deviceId);
5764
+ const push = {
5765
+ subscribed: store.listPushSubscriptions(deviceId).length > 0,
5766
+ permission: permission ?? d.push.permission
5767
+ };
5768
+ if (JSON.stringify(push) === JSON.stringify(d.push))
5769
+ return d;
5770
+ return store.putDevice({ ...d, push });
5771
+ }
5772
+ /** Send to every subscription of a device. 404/410 deletes the subscription. */
5773
+ async sendToDevice(deviceId, payload) {
5774
+ const subs = this.ctx.store.listPushSubscriptions(deviceId);
5775
+ const result = { sent: 0, removed: 0, failed: 0 };
5776
+ if (subs.length === 0)
5777
+ return result;
5778
+ const vapid = { ...this.vapid(), subject: this.subject() };
5779
+ for (const s of subs) {
5780
+ try {
5781
+ await this.sender(s.subscription, payload, vapid);
5782
+ result.sent++;
5783
+ } catch (err) {
5784
+ const status = err.statusCode;
5785
+ if (status === 404 || status === 410) {
5786
+ this.ctx.store.tx(() => {
5787
+ this.ctx.store.deletePushSubscription(s.endpoint);
5788
+ if (this.ctx.store.devices.get(deviceId))
5789
+ this.syncDevice(deviceId);
5790
+ });
5791
+ result.removed++;
5792
+ } else {
5793
+ result.failed++;
5794
+ this.ctx.log.warn({ err, deviceId, status }, "push delivery failed");
5795
+ }
5796
+ }
5797
+ }
5798
+ return result;
5799
+ }
5800
+ };
5801
+
5802
+ // packages/coordinator/dist/routes/automations.js
5803
+ import { createHash as createHash2 } from "node:crypto";
5804
+ import { z as z9 } from "zod";
5805
+ var ListQuery = z9.object({ projectId: z9.string().optional() });
5806
+ var HistoryQuery = z9.object({ limit: z9.coerce.number().int().min(1).max(1e3).default(100) });
5807
+ var WEBHOOK_BODY_LIMIT = 256 * 1024;
5808
+ function registerAutomationRoutes(app, ctx) {
5809
+ const { store } = ctx;
5810
+ const svc = () => requireService(ctx, "automations");
5811
+ const idOf = (req) => req.params.id;
5812
+ const view = (id) => {
5813
+ const a = store.automations.require(id);
5814
+ return { ...a, summary: svc().describe(a) };
5815
+ };
5816
+ app.get("/api/automations", async (req) => {
5817
+ const q = parse(ListQuery, req.query, "query");
5818
+ const list = q.projectId ? store.automations.list("project_id = ?", [q.projectId], "created_at") : store.automations.list(void 0, [], "created_at");
5819
+ return list.map((a) => ({ ...a, summary: svc().describe(a) }));
5820
+ });
5821
+ app.post("/api/automations", async (req, reply) => {
5822
+ const a = svc().create(validated(req.body));
5823
+ return reply.code(201).send(view(a.id));
5824
+ });
5825
+ app.get("/api/automations/:id", async (req) => view(idOf(req)));
5826
+ app.patch("/api/automations/:id", async (req) => {
5827
+ const body = req.body ?? {};
5828
+ if (typeof body !== "object" || Array.isArray(body)) {
5829
+ throw new SwitchyardError("invalid_request", "Expected an object");
5830
+ }
5831
+ svc().update(idOf(req), body);
5832
+ return view(idOf(req));
5833
+ });
5834
+ app.delete("/api/automations/:id", async (req) => {
5835
+ svc().delete(idOf(req));
5836
+ return { ok: true };
5298
5837
  });
5299
5838
  app.get("/api/automations/:id/preview", async (req) => svc().preview(store.automations.require(idOf(req))));
5300
5839
  app.post("/api/automations/preview", async (req) => {
@@ -5343,455 +5882,168 @@ function registerAutomationRoutes(app, ctx) {
5343
5882
  if (!a.enabled)
5344
5883
  throw new SwitchyardError("conflict", "This automation is disabled");
5345
5884
  const idem = headerValue(req.headers["idempotency-key"]) ?? headerValue(req.headers["x-github-delivery"]);
5346
- const payload = req.body ?? {};
5347
- const key = idem ? `hook:${idem}` : derivedWebhookKey(payload, store.now());
5348
- const match = filtersMatch(a.filters, payload);
5349
- const r = svc().fire(a.id, match.ok ? { key, scheduledFor: null, payload } : { key, scheduledFor: null, record: { state: "filtered", reason: match.reason } });
5350
- return reply.code(202).send({
5351
- occurrenceId: r.occurrence.id,
5352
- state: r.occurrence.state,
5353
- ...r.duplicate ? { duplicate: true } : {}
5354
- });
5355
- });
5356
- }
5357
- var WEBHOOK_DEDUPE_WINDOW_MS = 10 * 6e4;
5358
- function derivedWebhookKey(payload, now) {
5359
- const bucket = Math.floor(now.getTime() / WEBHOOK_DEDUPE_WINDOW_MS);
5360
- const digest = createHash2("sha256").update(JSON.stringify(payload)).digest("hex");
5361
- return `hook:body:${digest}:${bucket}`;
5362
- }
5363
- function headerValue(v) {
5364
- const s = Array.isArray(v) ? v[0] : v;
5365
- return s?.trim() ? s.trim().slice(0, 200) : null;
5366
- }
5367
- function validated(body) {
5368
- return parse(AutomationInput, body);
5369
- }
5370
-
5371
- // packages/coordinator/dist/routes/push.js
5372
- import { z as z9 } from "zod";
5373
- var DeleteSubscription = z9.object({ endpoint: z9.string().optional() });
5374
- function registerPushRoutes(app, ctx) {
5375
- const push = () => requireService(ctx, "push");
5376
- app.get("/api/push/vapid-public-key", async () => ({ publicKey: push().vapid().publicKey }));
5377
- app.post("/api/push/subscriptions", async (req, reply) => {
5378
- const body = parse(PushSubscriptionRequest, req.body);
5379
- const device = deviceOf(req);
5380
- const updated = push().subscribe(device.id, { endpoint: body.endpoint, keys: body.keys }, body.permission);
5381
- return reply.code(201).send(updated);
5382
- });
5383
- app.delete("/api/push/subscriptions", async (req) => {
5384
- const body = parse(DeleteSubscription, req.body ?? {});
5385
- return push().unsubscribe(deviceOf(req).id, body.endpoint ?? null);
5386
- });
5387
- app.post("/api/push/test", async (req) => {
5388
- const device = deviceOf(req);
5389
- if (push().subscriptions(device.id).length === 0) {
5390
- throw new SwitchyardError("conflict", "This device has no push subscription");
5391
- }
5392
- const r = await push().sendToDevice(device.id, {
5393
- type: "show",
5394
- title: "Switchyard",
5395
- body: "Test notification from Switchyard",
5396
- tag: "switchyard-test",
5397
- url: "/settings",
5398
- renotify: true
5399
- });
5400
- return { ok: r.sent > 0, ...r };
5401
- });
5402
- app.get("/api/me/notification-settings", async () => push().settings());
5403
- app.patch("/api/me/notification-settings", async (req) => {
5404
- const body = parse(NotificationSettings.partial(), req.body);
5405
- return push().updateSettings(body);
5406
- });
5407
- }
5408
-
5409
- // packages/coordinator/dist/routes/tasks.js
5410
- import { z as z11 } from "zod";
5411
-
5412
- // packages/coordinator/dist/sessions/control.js
5413
- function requireRun(ctx, session) {
5414
- const run = ctx.store.runForSession(session);
5415
- if (!run) {
5416
- throw new SwitchyardError("invalid_request", "This session is not controlled by an automation run");
5417
- }
5418
- return run;
5419
- }
5420
- async function setControl(ctx, sessionId, mode) {
5421
- const { session, run } = ctx.store.tx(() => {
5422
- const session2 = ctx.store.sessions.require(sessionId);
5423
- const run2 = requireRun(ctx, session2);
5424
- const now = ctx.store.nowIso();
5425
- const updated = ctx.store.putRun({ ...run2, controlMode: mode, controlRevision: run2.controlRevision + 1, updatedAt: now }, session2.projectId ?? void 0);
5426
- return { session: session2, run: updated };
5427
- });
5428
- ctx.hub.emit("control.changed", { run, session, mode });
5429
- if (mode === "human")
5430
- getService(ctx, "dispatcher")?.onTakeover(run.id);
5431
- if (ctx.hostHub.isConnected(session.hostId)) {
5432
- try {
5433
- await ctx.hostHub.request(session.hostId, {
5434
- type: "session.setControl",
5435
- sessionId: session.id,
5436
- controlRevision: run.controlRevision,
5437
- mode
5438
- });
5439
- } catch (err) {
5440
- ctx.log.warn({ err, sessionId }, "session.setControl failed");
5441
- }
5442
- }
5443
- return { session, run };
5444
- }
5445
- function takeover(ctx, sessionId) {
5446
- return setControl(ctx, sessionId, "human");
5447
- }
5448
- async function takeoverRun(ctx, runId) {
5449
- const run = ctx.store.runs.require(runId);
5450
- if (run.sessionId && ctx.store.sessions.get(run.sessionId)?.runId === run.id) {
5451
- return (await takeover(ctx, run.sessionId)).run;
5452
- }
5453
- const updated = ctx.store.tx(() => {
5454
- const cur = ctx.store.runs.require(runId);
5455
- const task = ctx.store.tasks.get(cur.taskId);
5456
- return ctx.store.putRun({
5457
- ...cur,
5458
- controlMode: "human",
5459
- controlRevision: cur.controlRevision + 1,
5460
- updatedAt: ctx.store.nowIso()
5461
- }, task?.projectId);
5462
- });
5463
- getService(ctx, "dispatcher")?.onTakeover(updated.id);
5464
- return updated;
5465
- }
5466
- async function returnToAutomation(ctx, sessionId) {
5467
- const result = await setControl(ctx, sessionId, "automation");
5468
- const session = ctx.store.sessions.require(sessionId);
5469
- if (session.runtime === "idle") {
5470
- const runs = getService(ctx, "runs");
5471
- void runs?.onSessionSettled({
5472
- session,
5473
- aborted: false,
5474
- lastAssistantText: session.lastAssistantText
5475
- }).catch((err) => ctx.log.error({ err, sessionId }, "re-evaluation failed"));
5476
- }
5477
- getService(ctx, "dispatcher")?.schedule();
5478
- return result;
5479
- }
5480
- async function stopSession(ctx, sessionId) {
5481
- const session = ctx.store.sessions.require(sessionId);
5482
- const pre = ctx.store.runForSession(session);
5483
- if (pre && (pre.state === "dispatching" || pre.state === "uncertain")) {
5484
- throw new SwitchyardError("conflict", "The launch is not confirmed yet; wait for the host or pause automation instead");
5485
- }
5486
- await ctx.hostHub.request(session.hostId, {
5487
- type: "session.abort",
5488
- sessionId,
5489
- clearQueue: true
5490
- });
5491
- const run = ctx.store.runForSession(session);
5492
- if (run && !["succeeded", "failed", "canceled", "interrupted"].includes(run.state)) {
5493
- ctx.store.tx(() => {
5494
- const now = ctx.store.nowIso();
5495
- ctx.store.putRun({ ...run, state: "interrupted", updatedAt: now }, session.projectId ?? void 0);
5496
- const task = ctx.store.tasks.get(run.taskId);
5497
- if (task && task.currentRunId === run.id) {
5498
- ctx.store.putTask({
5499
- ...task,
5500
- blocked: { reason: "Stopped by user" },
5501
- updatedAt: now,
5502
- lastActivityAt: now
5503
- });
5504
- }
5505
- });
5506
- }
5507
- return ctx.store.sessions.require(sessionId);
5508
- }
5509
-
5510
- // packages/coordinator/dist/workspaces.js
5511
- import { z as z10 } from "zod";
5512
- var DEFAULT_WORKTREE_DIR = ".worktrees";
5513
- function worktreeDirOf(project) {
5514
- return project?.worktreeDir || DEFAULT_WORKTREE_DIR;
5515
- }
5516
- function predictedWorktreePath(checkout, project, branch) {
5517
- return `${checkout.path}/${worktreeDirOf(project)}/${branchSlug(branch)}`;
5518
- }
5519
- var ZERO_GIT = { dirty: false, changedFiles: 0, ahead: 0, behind: 0, unmerged: false };
5520
- var NO_SETUP = { state: "none", log: "" };
5521
- var SetupState2 = z10.object({
5522
- state: z10.enum(["none", "running", "succeeded", "failed"]),
5523
- log: z10.string()
5524
- });
5525
- function stripRef(branch) {
5526
- return branch?.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : branch;
5527
- }
5528
- async function inspectCheckout(ctx, hostId, path) {
5529
- const r = await ctx.hostHub.request(hostId, { type: "checkout.inspect", path });
5530
- return CheckoutInfo.parse(r);
5531
- }
5532
- function recordCreatedWorkspace(ctx, project, checkout, info) {
5533
- return ctx.store.tx(() => {
5534
- const existing = ctx.store.workspaceByPath(checkout.hostId, info.path);
5535
- const buffered = ctx.hostHub.takeBufferedWorkspace(checkout.hostId, info.path);
5536
- const git = GitState.safeParse(buffered?.git ?? info.git);
5537
- const setup = SetupState2.safeParse(buffered?.setup ?? info.setup);
5538
- const ws = {
5539
- id: existing?.id ?? newId("wsp"),
5540
- checkoutId: checkout.id,
5541
- projectId: project.id,
5542
- hostId: checkout.hostId,
5543
- path: info.path,
5544
- branch: info.branch,
5545
- baseBranch: info.baseBranch,
5546
- provenance: "managed",
5547
- git: git.success ? git.data : existing?.git ?? ZERO_GIT,
5548
- setup: setup.success ? setup.data : existing?.setup ?? NO_SETUP,
5549
- archivedAt: null,
5550
- updatedAt: ctx.store.nowIso()
5551
- };
5552
- return ctx.store.putWorkspace(ws);
5553
- });
5554
- }
5555
- async function refreshCheckoutWorkspaces(ctx, checkout) {
5556
- const raw = await ctx.hostHub.request(checkout.hostId, {
5557
- type: "workspace.discover",
5558
- checkoutPath: checkout.path,
5559
- worktreeDir: worktreeDirOf(ctx.store.projects.get(checkout.projectId))
5560
- });
5561
- const list = DiscoveredWorktree.array().parse(Array.isArray(raw) ? raw : raw?.worktrees ?? []);
5562
- return ctx.store.tx(() => {
5563
- const now = ctx.store.nowIso();
5564
- const seen = /* @__PURE__ */ new Set();
5565
- const out = [];
5566
- for (const wt of list) {
5567
- if (wt.bare || wt.prunable)
5568
- continue;
5569
- seen.add(wt.path);
5570
- const existing = ctx.store.workspaceByPath(checkout.hostId, wt.path);
5571
- const next = {
5572
- id: existing?.id ?? newId("wsp"),
5573
- checkoutId: checkout.id,
5574
- projectId: checkout.projectId,
5575
- hostId: checkout.hostId,
5576
- path: wt.path,
5577
- branch: stripRef(wt.branch),
5578
- baseBranch: existing?.baseBranch ?? (wt.provenance === "main" ? null : checkout.defaultBranch),
5579
- provenance: existing?.provenance === "managed" ? "managed" : wt.provenance,
5580
- git: wt.git ?? existing?.git ?? ZERO_GIT,
5581
- setup: existing?.setup ?? NO_SETUP,
5582
- archivedAt: null,
5583
- updatedAt: existing?.updatedAt ?? now
5584
- };
5585
- const changed = !existing || JSON.stringify({ ...existing, updatedAt: "" }) !== JSON.stringify({ ...next, updatedAt: "" });
5586
- out.push(changed ? ctx.store.putWorkspace({ ...next, updatedAt: now }) : existing);
5587
- }
5588
- for (const ws of ctx.store.workspaces.list("checkout_id = ? AND archived_at IS NULL", [
5589
- checkout.id
5590
- ])) {
5591
- if (!seen.has(ws.path))
5592
- ctx.store.putWorkspace({ ...ws, archivedAt: now, updatedAt: now });
5593
- }
5594
- return out;
5885
+ const payload = req.body ?? {};
5886
+ const key = idem ? `hook:${idem}` : derivedWebhookKey(payload, store.now());
5887
+ const match = filtersMatch(a.filters, payload);
5888
+ const r = svc().fire(a.id, match.ok ? { key, scheduledFor: null, payload } : { key, scheduledFor: null, record: { state: "filtered", reason: match.reason } });
5889
+ return reply.code(202).send({
5890
+ occurrenceId: r.occurrence.id,
5891
+ state: r.occurrence.state,
5892
+ ...r.duplicate ? { duplicate: true } : {}
5893
+ });
5595
5894
  });
5596
5895
  }
5597
- async function refreshWorkspaceStatus(ctx, ws) {
5598
- const raw = await ctx.hostHub.request(ws.hostId, {
5599
- type: "workspace.status",
5600
- path: ws.path,
5601
- base: ws.baseBranch
5602
- });
5603
- const git = GitState.parse(raw?.git ?? raw);
5604
- return ctx.store.tx(() => {
5605
- const cur = ctx.store.workspaces.require(ws.id);
5606
- if (JSON.stringify(cur.git) === JSON.stringify(git))
5607
- return cur;
5608
- return ctx.store.putWorkspace({ ...cur, git, updatedAt: ctx.store.nowIso() });
5609
- });
5896
+ var WEBHOOK_DEDUPE_WINDOW_MS = 10 * 6e4;
5897
+ function derivedWebhookKey(payload, now) {
5898
+ const bucket = Math.floor(now.getTime() / WEBHOOK_DEDUPE_WINDOW_MS);
5899
+ const digest = createHash2("sha256").update(JSON.stringify(payload)).digest("hex");
5900
+ return `hook:body:${digest}:${bucket}`;
5610
5901
  }
5611
- function baseFor(ctx, ws) {
5612
- return ws.baseBranch ?? ctx.store.checkouts.get(ws.checkoutId)?.defaultBranch ?? null;
5902
+ function headerValue(v) {
5903
+ const s = Array.isArray(v) ? v[0] : v;
5904
+ return s?.trim() ? s.trim().slice(0, 200) : null;
5905
+ }
5906
+ function validated(body) {
5907
+ return parse(AutomationInput, body);
5613
5908
  }
5614
5909
 
5615
- // packages/coordinator/dist/sessions/service.js
5616
- var LIVE_RUNTIMES2 = ["starting", "idle", "running", "waiting_input"];
5617
- function requireUsableHost(ctx, hostId) {
5618
- const host = ctx.store.hosts.require(hostId);
5619
- if (host.revokedAt)
5620
- throw new SwitchyardError("forbidden", `Host ${host.name} was revoked`);
5621
- if (!ctx.hostHub.isConnected(hostId)) {
5622
- throw new SwitchyardError("host_unreachable", `Host ${host.name} is offline`, { hostId });
5623
- }
5624
- if (!host.piCompatible) {
5625
- throw new SwitchyardError("pi_incompatible", `Pi ${host.piVersion ?? "(not found)"} on ${host.name} is outside the tested range`, { hostId });
5626
- }
5910
+ // packages/coordinator/dist/routes/push.js
5911
+ import { z as z10 } from "zod";
5912
+ var DeleteSubscription = z10.object({ endpoint: z10.string().optional() });
5913
+ function registerPushRoutes(app, ctx) {
5914
+ const push = () => requireService(ctx, "push");
5915
+ app.get("/api/push/vapid-public-key", async () => ({ publicKey: push().vapid().publicKey }));
5916
+ app.post("/api/push/subscriptions", async (req, reply) => {
5917
+ const body = parse(PushSubscriptionRequest, req.body);
5918
+ const device = deviceOf(req);
5919
+ const updated = push().subscribe(device.id, { endpoint: body.endpoint, keys: body.keys }, body.permission);
5920
+ return reply.code(201).send(updated);
5921
+ });
5922
+ app.delete("/api/push/subscriptions", async (req) => {
5923
+ const body = parse(DeleteSubscription, req.body ?? {});
5924
+ return push().unsubscribe(deviceOf(req).id, body.endpoint ?? null);
5925
+ });
5926
+ app.post("/api/push/test", async (req) => {
5927
+ const device = deviceOf(req);
5928
+ if (push().subscriptions(device.id).length === 0) {
5929
+ throw new SwitchyardError("conflict", "This device has no push subscription");
5930
+ }
5931
+ const r = await push().sendToDevice(device.id, {
5932
+ type: "show",
5933
+ title: "Switchyard",
5934
+ body: "Test notification from Switchyard",
5935
+ tag: "switchyard-test",
5936
+ url: "/settings",
5937
+ renotify: true
5938
+ });
5939
+ return { ok: r.sent > 0, ...r };
5940
+ });
5941
+ app.get("/api/me/notification-settings", async () => push().settings());
5942
+ app.patch("/api/me/notification-settings", async (req) => {
5943
+ const body = parse(NotificationSettings.partial(), req.body);
5944
+ return push().updateSettings(body);
5945
+ });
5627
5946
  }
5628
- function requireConnected(ctx, hostId) {
5629
- const host = ctx.store.hosts.require(hostId);
5630
- if (!ctx.hostHub.isConnected(hostId)) {
5631
- throw new SwitchyardError("host_unreachable", `Host ${host.name} is offline`, { hostId });
5947
+
5948
+ // packages/coordinator/dist/routes/tasks.js
5949
+ import { z as z11 } from "zod";
5950
+
5951
+ // packages/coordinator/dist/sessions/control.js
5952
+ function requireRun(ctx, session) {
5953
+ const run = ctx.store.runForSession(session);
5954
+ if (!run) {
5955
+ throw new SwitchyardError("invalid_request", "This session is not controlled by an automation run");
5632
5956
  }
5957
+ return run;
5633
5958
  }
5634
- function resolveProfile(ctx, profileId, project) {
5635
- const id = profileId ?? project?.defaultProfileId ?? null;
5636
- if (id)
5637
- return ctx.store.profiles.require(id);
5638
- const first = ctx.store.profiles.list(void 0, [], "created_at")[0];
5639
- if (!first)
5640
- throw new SwitchyardError("invalid_request", "No Pi profile configured");
5641
- return first;
5642
- }
5643
- function titleFrom(prompt, fallback = "New session") {
5644
- const line = prompt.trim().split("\n")[0]?.trim() ?? "";
5645
- if (!line)
5646
- return fallback;
5647
- return line.length > 80 ? `${line.slice(0, 77)}...` : line;
5648
- }
5649
- function appendNotice(ctx, session, level, text) {
5650
- return ctx.store.tx(() => {
5651
- const item = {
5652
- id: newId("itm"),
5653
- sessionId: session.id,
5654
- order: ctx.store.maxItemOrder(session.id) + 1,
5655
- at: ctx.store.nowIso(),
5656
- kind: "notice",
5657
- data: { level, text }
5658
- };
5659
- ctx.store.upsertItem(item);
5660
- ctx.store.appendEvent("transcript.item", { sessionId: session.id, hostId: session.hostId, projectId: session.projectId ?? void 0 }, item);
5661
- return item;
5959
+ async function setControl(ctx, sessionId, mode) {
5960
+ const { session, run } = ctx.store.tx(() => {
5961
+ const session2 = ctx.store.sessions.require(sessionId);
5962
+ const run2 = requireRun(ctx, session2);
5963
+ const now = ctx.store.nowIso();
5964
+ const updated = ctx.store.putRun({ ...run2, controlMode: mode, controlRevision: run2.controlRevision + 1, updatedAt: now }, session2.projectId ?? void 0);
5965
+ return { session: session2, run: updated };
5662
5966
  });
5967
+ ctx.hub.emit("control.changed", { run, session, mode });
5968
+ if (mode === "human")
5969
+ getService(ctx, "dispatcher")?.onTakeover(run.id);
5970
+ if (ctx.hostHub.isConnected(session.hostId)) {
5971
+ try {
5972
+ await ctx.hostHub.request(session.hostId, {
5973
+ type: "session.setControl",
5974
+ sessionId: session.id,
5975
+ controlRevision: run.controlRevision,
5976
+ mode
5977
+ });
5978
+ } catch (err) {
5979
+ ctx.log.warn({ err, sessionId }, "session.setControl failed");
5980
+ }
5981
+ }
5982
+ return { session, run };
5663
5983
  }
5664
- function failSession(ctx, sessionId, message) {
5665
- ctx.store.tx(() => {
5666
- const s = ctx.store.sessions.get(sessionId);
5667
- if (!s)
5668
- return;
5669
- ctx.store.putSession({
5670
- ...s,
5671
- runtime: "failed",
5672
- runtimeDetail: message,
5673
- lastActivityAt: ctx.store.nowIso()
5674
- });
5675
- appendNotice(ctx, s, "error", message);
5676
- });
5984
+ function takeover(ctx, sessionId) {
5985
+ return setControl(ctx, sessionId, "human");
5677
5986
  }
5678
- async function startOnHost(ctx, session, opts) {
5679
- const cmd = {
5680
- type: "session.start",
5681
- sessionId: session.id,
5682
- title: session.title,
5683
- workspacePath: session.cwd,
5684
- projectId: session.projectId,
5685
- profile: session.profileSnapshot,
5686
- nativeHandle: opts.nativeHandle,
5687
- initialPrompt: opts.initialPrompt,
5688
- dispatchKey: opts.dispatchKey ?? null,
5689
- controlRevision: opts.controlRevision ?? 1,
5690
- controlMode: opts.controlMode ?? "human",
5691
- origin: opts.origin ?? "human",
5692
- readOnly: session.readOnly
5693
- };
5694
- const result = await ctx.hostHub.request(session.hostId, cmd);
5695
- return ctx.store.tx(() => {
5696
- const s = ctx.store.sessions.require(session.id);
5697
- const handle = result?.nativeHandle ?? null;
5698
- if (handle && JSON.stringify(handle) !== JSON.stringify(s.nativeHandle)) {
5699
- return ctx.store.putSession({ ...s, nativeHandle: handle });
5700
- }
5701
- return s;
5987
+ async function takeoverRun(ctx, runId) {
5988
+ const run = ctx.store.runs.require(runId);
5989
+ if (run.sessionId && ctx.store.sessions.get(run.sessionId)?.runId === run.id) {
5990
+ return (await takeover(ctx, run.sessionId)).run;
5991
+ }
5992
+ const updated = ctx.store.tx(() => {
5993
+ const cur = ctx.store.runs.require(runId);
5994
+ const task = ctx.store.tasks.get(cur.taskId);
5995
+ return ctx.store.putRun({
5996
+ ...cur,
5997
+ controlMode: "human",
5998
+ controlRevision: cur.controlRevision + 1,
5999
+ updatedAt: ctx.store.nowIso()
6000
+ }, task?.projectId);
5702
6001
  });
6002
+ getService(ctx, "dispatcher")?.onTakeover(updated.id);
6003
+ return updated;
5703
6004
  }
5704
- async function createWorkspace(ctx, project, checkout, branch, base) {
5705
- requireConnected(ctx, checkout.hostId);
5706
- const baseBranch = base ?? checkout.defaultBranch;
5707
- const result = await ctx.hostHub.request(checkout.hostId, {
5708
- type: "workspace.create",
5709
- checkoutPath: checkout.path,
5710
- branch,
5711
- base: baseBranch,
5712
- setupCommand: project.setupCommand,
5713
- worktreeDir: worktreeDirOf(project)
5714
- });
5715
- const predicted = predictedWorktreePath(checkout, project, branch);
5716
- return recordCreatedWorkspace(ctx, project, checkout, {
5717
- path: typeof result?.path === "string" ? result.path : predicted,
5718
- branch: typeof result?.branch === "string" ? result.branch : branch,
5719
- baseBranch,
5720
- git: result?.git,
5721
- setup: result?.setup
5722
- });
6005
+ async function returnToAutomation(ctx, sessionId) {
6006
+ const result = await setControl(ctx, sessionId, "automation");
6007
+ const session = ctx.store.sessions.require(sessionId);
6008
+ if (session.runtime === "idle") {
6009
+ const runs = getService(ctx, "runs");
6010
+ void runs?.onSessionSettled({
6011
+ session,
6012
+ aborted: false,
6013
+ lastAssistantText: session.lastAssistantText
6014
+ }).catch((err) => ctx.log.error({ err, sessionId }, "re-evaluation failed"));
6015
+ }
6016
+ getService(ctx, "dispatcher")?.schedule();
6017
+ return result;
5723
6018
  }
5724
- function createSession(ctx, input) {
5725
- const project = ctx.store.projects.require(input.projectId);
5726
- const profile = resolveProfile(ctx, input.profileId, project);
5727
- let hostId;
5728
- let workspace = null;
5729
- let checkout = null;
5730
- if ("existingId" in input.workspace) {
5731
- workspace = ctx.store.workspaces.require(input.workspace.existingId);
5732
- if (workspace.projectId !== project.id) {
5733
- throw new SwitchyardError("invalid_request", "Workspace belongs to another project");
5734
- }
5735
- if (workspace.archivedAt)
5736
- throw new SwitchyardError("conflict", "Workspace is archived");
5737
- hostId = workspace.hostId;
5738
- } else {
5739
- checkout = ctx.store.checkouts.require(input.workspace.checkoutId);
5740
- if (checkout.projectId !== project.id) {
5741
- throw new SwitchyardError("invalid_request", "Checkout belongs to another project");
5742
- }
5743
- hostId = checkout.hostId;
6019
+ async function stopSession(ctx, sessionId) {
6020
+ const session = ctx.store.sessions.require(sessionId);
6021
+ const pre = ctx.store.runForSession(session);
6022
+ if (pre && (pre.state === "dispatching" || pre.state === "uncertain")) {
6023
+ throw new SwitchyardError("conflict", "The launch is not confirmed yet; wait for the host or pause automation instead");
5744
6024
  }
5745
- requireUsableHost(ctx, hostId);
5746
- const now = ctx.store.nowIso();
5747
- const host = ctx.store.hosts.require(hostId);
5748
- const session = {
5749
- id: newId("ses"),
5750
- title: input.title?.trim() || titleFrom(input.prompt),
5751
- subtitle: null,
5752
- hostId,
5753
- workspaceId: workspace?.id ?? null,
5754
- projectId: project.id,
5755
- cwd: workspace?.path ?? predictedWorktreePath(checkout, project, input.workspace.newBranch),
5756
- nativeHandle: null,
5757
- profileSnapshot: profile,
5758
- runtime: "starting",
5759
- runtimeDetail: null,
5760
- connectivity: host.connectivity,
5761
- runId: null,
5762
- taskId: null,
5763
- provenance: "created",
5764
- readOnly: false,
5765
- lastActivityAt: now,
5766
- archivedAt: null,
5767
- queue: { steering: [], followUp: [] },
5768
- stats: null,
5769
- lastAssistantText: null,
5770
- createdAt: now
5771
- };
5772
- ctx.store.putSession(session);
5773
- const choice = input.workspace;
5774
- const started = (async () => {
5775
- try {
5776
- let s = session;
5777
- if (!("existingId" in choice)) {
5778
- const ws = await createWorkspace(ctx, project, checkout, choice.newBranch, choice.base);
5779
- s = ctx.store.tx(() => {
5780
- const cur = ctx.store.sessions.require(session.id);
5781
- return ctx.store.putSession({ ...cur, workspaceId: ws.id, cwd: ws.path });
6025
+ await ctx.hostHub.request(session.hostId, {
6026
+ type: "session.abort",
6027
+ sessionId,
6028
+ clearQueue: true
6029
+ });
6030
+ const run = ctx.store.runForSession(session);
6031
+ if (run && !["succeeded", "failed", "canceled", "interrupted"].includes(run.state)) {
6032
+ ctx.store.tx(() => {
6033
+ const now = ctx.store.nowIso();
6034
+ ctx.store.putRun({ ...run, state: "interrupted", updatedAt: now }, session.projectId ?? void 0);
6035
+ const task = ctx.store.tasks.get(run.taskId);
6036
+ if (task && task.currentRunId === run.id) {
6037
+ ctx.store.putTask({
6038
+ ...task,
6039
+ blocked: { reason: "Stopped by user" },
6040
+ updatedAt: now,
6041
+ lastActivityAt: now
5782
6042
  });
5783
6043
  }
5784
- await startOnHost(ctx, s, {
5785
- initialPrompt: input.prompt.trim() ? input.prompt : null,
5786
- nativeHandle: null
5787
- });
5788
- } catch (err) {
5789
- const message = err instanceof Error ? err.message : String(err);
5790
- ctx.log.warn({ err, sessionId: session.id }, "session start failed");
5791
- failSession(ctx, session.id, `Could not start the session: ${message}`);
5792
- }
5793
- })();
5794
- return { session, started };
6044
+ });
6045
+ }
6046
+ return ctx.store.sessions.require(sessionId);
5795
6047
  }
5796
6048
 
5797
6049
  // packages/coordinator/dist/routes/tasks.js
@@ -7553,13 +7805,17 @@ function installBuiltins(app, ctx, opts = {}) {
7553
7805
  const notifications = new NotificationRouter(ctx, push, {
7554
7806
  desktopRecentMs: opts.desktopRecentMs ?? 5 * 6e4
7555
7807
  });
7808
+ const projectFolders = new ProjectFolderService(ctx, {
7809
+ intervalMs: opts.projectScanIntervalMs ?? PROJECT_SCAN_INTERVAL_MS
7810
+ });
7556
7811
  const services = {
7557
7812
  dispatcher,
7558
7813
  runs,
7559
7814
  automations,
7560
7815
  scheduler,
7561
7816
  push,
7562
- notifications
7817
+ notifications,
7818
+ projectFolders
7563
7819
  };
7564
7820
  for (const [name, svc] of Object.entries(services))
7565
7821
  ctx.services.set(name, svc);
@@ -7575,8 +7831,10 @@ function installBuiltins(app, ctx, opts = {}) {
7575
7831
  notifications.start();
7576
7832
  dispatcher.start();
7577
7833
  scheduler.start();
7834
+ projectFolders.start();
7578
7835
  },
7579
7836
  stop() {
7837
+ projectFolders.stop();
7580
7838
  scheduler.stop();
7581
7839
  dispatcher.stop();
7582
7840
  notifications.stop();
@@ -8286,12 +8544,12 @@ var HostHub = class {
8286
8544
  const commandId = opts.commandId ?? newId("cmd");
8287
8545
  const existing = this.pending.get(commandId);
8288
8546
  if (existing) {
8289
- return new Promise((resolve12, reject) => {
8547
+ return new Promise((resolve13, reject) => {
8290
8548
  const prevResolve = existing.resolve;
8291
8549
  const prevReject = existing.reject;
8292
8550
  existing.resolve = (r) => {
8293
8551
  prevResolve(r);
8294
- resolve12(r);
8552
+ resolve13(r);
8295
8553
  };
8296
8554
  existing.reject = (e) => {
8297
8555
  prevReject(e);
@@ -8305,7 +8563,7 @@ var HostHub = class {
8305
8563
  command: parsed
8306
8564
  });
8307
8565
  const timeoutMs = opts.timeoutMs ?? this.opts.commandTimeoutMs;
8308
- return new Promise((resolve12, reject) => {
8566
+ return new Promise((resolve13, reject) => {
8309
8567
  const timer = setTimeout(() => {
8310
8568
  this.pending.delete(commandId);
8311
8569
  reject(new SwitchyardError("timeout", `Host did not answer ${parsed.type} in time`, {
@@ -8314,7 +8572,7 @@ var HostHub = class {
8314
8572
  }));
8315
8573
  }, timeoutMs);
8316
8574
  timer.unref();
8317
- this.pending.set(commandId, { hostId, frame, resolve: resolve12, reject, timer });
8575
+ this.pending.set(commandId, { hostId, frame, resolve: resolve13, reject, timer });
8318
8576
  conn.socket.send(frame);
8319
8577
  });
8320
8578
  }
@@ -9719,6 +9977,31 @@ function registerHostRoutes(app, ctx) {
9719
9977
  const host = ctx.store.hosts.require(id);
9720
9978
  return { ...host, adapters: ctx.store.adaptersByHost()[id] ?? [] };
9721
9979
  });
9980
+ app.patch("/api/hosts/:id", async (req) => {
9981
+ const { id } = req.params;
9982
+ const body = parse(UpdateHostRequest, req.body);
9983
+ let scan = false;
9984
+ const host = ctx.store.tx(() => {
9985
+ const h = ctx.store.hosts.require(id);
9986
+ if (h.revokedAt)
9987
+ throw new SwitchyardError("forbidden", "This host was revoked");
9988
+ const next = { ...h };
9989
+ if (body.projectFolders !== void 0) {
9990
+ const folders = normalizeProjectFolders(body.projectFolders);
9991
+ scan = folders.length > 0 && JSON.stringify(folders) !== JSON.stringify(h.projectFolders ?? []);
9992
+ next.projectFolders = folders;
9993
+ }
9994
+ return ctx.store.putHost(next);
9995
+ });
9996
+ if (scan && ctx.hostHub.isConnected(id)) {
9997
+ requireService(ctx, "projectFolders").background(id);
9998
+ }
9999
+ return host;
10000
+ });
10001
+ app.post("/api/hosts/:id/scan-projects", async (req) => {
10002
+ const { id } = req.params;
10003
+ return requireService(ctx, "projectFolders").scan(id);
10004
+ });
9722
10005
  const revoke = async (req) => {
9723
10006
  const { id } = req.params;
9724
10007
  const host = ctx.store.tx(() => {
@@ -9928,21 +10211,17 @@ function registerProjectRoutes(app, ctx) {
9928
10211
  if (dupe) {
9929
10212
  throw new SwitchyardError("conflict", `Project ${dupe.name} already tracks ${info.repoIdentity}; add a checkout to it instead`, { projectId: dupe.id });
9930
10213
  }
9931
- const project2 = {
9932
- id: newId("prj"),
10214
+ const project2 = newProject({
9933
10215
  name: body.name,
9934
10216
  repoIdentity: info.repoIdentity,
9935
- defaultProfileId: body.defaultProfileId ?? null,
9936
- defaultHostId: body.hostId,
9937
- maxConcurrentRuns: body.maxConcurrentRuns ?? 1,
9938
- automationEnabled: true,
9939
- setupCommand: body.setupCommand ?? null,
9940
- checks: body.checks ?? [],
9941
- workspaceSharing: "single_writer",
9942
- worktreeDir: body.worktreeDir ?? DEFAULT_WORKTREE_DIR,
9943
- createdAt: now,
9944
- updatedAt: now
9945
- };
10217
+ hostId: body.hostId,
10218
+ now,
10219
+ defaultProfileId: body.defaultProfileId,
10220
+ setupCommand: body.setupCommand,
10221
+ checks: body.checks,
10222
+ maxConcurrentRuns: body.maxConcurrentRuns,
10223
+ worktreeDir: body.worktreeDir
10224
+ });
9946
10225
  const checkout2 = {
9947
10226
  id: newId("chk"),
9948
10227
  projectId: project2.id,
@@ -12563,7 +12842,7 @@ function tailBytes(text, maxBytes) {
12563
12842
  return buf.subarray(start).toString("utf8");
12564
12843
  }
12565
12844
  function spawnShell(cwd, cmd, opts) {
12566
- return new Promise((resolve12) => {
12845
+ return new Promise((resolve13) => {
12567
12846
  let settled = false;
12568
12847
  let timedOut = false;
12569
12848
  let exit = null;
@@ -12581,7 +12860,7 @@ function spawnShell(cwd, cmd, opts) {
12581
12860
  clearTimeout(timeout);
12582
12861
  clearTimeout(killTimer);
12583
12862
  clearTimeout(closeTimer);
12584
- resolve12(outcome);
12863
+ resolve13(outcome);
12585
12864
  };
12586
12865
  const timeout = setTimeout(() => {
12587
12866
  timedOut = true;
@@ -13306,6 +13585,81 @@ exit code ${outcome.signal ?? "null"}` : `exit code ${outcome.code ?? outcome.si
13306
13585
  return final;
13307
13586
  }
13308
13587
 
13588
+ // packages/host-agent/dist/project-folders.js
13589
+ import { lstat as lstat3, readdir as readdir3, realpath as realpath3, stat as stat3 } from "node:fs/promises";
13590
+ import { homedir as homedir3 } from "node:os";
13591
+ import { isAbsolute as isAbsolute3, join as join11 } from "node:path";
13592
+ function expandHome2(folder, home = homedir3()) {
13593
+ if (folder === "~")
13594
+ return home;
13595
+ if (folder.startsWith("~/"))
13596
+ return join11(home, folder.slice(2));
13597
+ return folder;
13598
+ }
13599
+ async function mapLimit(items, limit, fn) {
13600
+ const out = new Array(items.length);
13601
+ let next = 0;
13602
+ const worker = async () => {
13603
+ while (next < items.length) {
13604
+ const i = next++;
13605
+ out[i] = await fn(items[i]);
13606
+ }
13607
+ };
13608
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
13609
+ return out;
13610
+ }
13611
+ async function isDirectory(path) {
13612
+ try {
13613
+ return (await stat3(path)).isDirectory();
13614
+ } catch {
13615
+ return false;
13616
+ }
13617
+ }
13618
+ async function scanProjectFolder(folder, opts = {}) {
13619
+ const p = expandHome2(folder.trim(), opts.home);
13620
+ if (!isAbsolute3(p)) {
13621
+ throw new SwitchyardError("invalid_request", "Project folders must be absolute or start with ~/");
13622
+ }
13623
+ let st;
13624
+ try {
13625
+ st = await stat3(p);
13626
+ } catch {
13627
+ throw new SwitchyardError("not_found", `${folder} does not exist on this host`);
13628
+ }
13629
+ if (!st.isDirectory()) {
13630
+ throw new SwitchyardError("invalid_request", `${folder} is not a directory`);
13631
+ }
13632
+ const root = await realpath3(p);
13633
+ const entries = await readdir3(root, { withFileTypes: true });
13634
+ const candidates = [];
13635
+ for (const e of entries) {
13636
+ if (e.name.startsWith("."))
13637
+ continue;
13638
+ if (e.isDirectory() || e.isSymbolicLink() && await isDirectory(join11(root, e.name))) {
13639
+ candidates.push(e.name);
13640
+ }
13641
+ }
13642
+ const found = await mapLimit(candidates, opts.concurrency ?? 8, async (name) => {
13643
+ const dir = join11(root, name);
13644
+ try {
13645
+ await lstat3(join11(dir, ".git"));
13646
+ } catch {
13647
+ return null;
13648
+ }
13649
+ const info = await inspectCheckout2(dir).catch(() => null);
13650
+ if (!info)
13651
+ return null;
13652
+ if (info.root !== await realpath3(dir).catch(() => ""))
13653
+ return null;
13654
+ const ownGitDir = await realpath3(join11(info.root, ".git")).catch(() => "");
13655
+ if (info.commonDir !== ownGitDir)
13656
+ return null;
13657
+ return { ...info, name };
13658
+ });
13659
+ const repositories = found.filter((r) => r !== null).sort((a, b) => a.name.localeCompare(b.name));
13660
+ return { folder: root, repositories };
13661
+ }
13662
+
13309
13663
  // packages/host-agent/dist/commands.js
13310
13664
  function toErrorBody(err) {
13311
13665
  if (err instanceof SwitchyardError)
@@ -13478,6 +13832,8 @@ var CommandRouter = class {
13478
13832
  return sup.snapshot(c.sessionId);
13479
13833
  case "checkout.inspect":
13480
13834
  return inspectCheckout2(c.path);
13835
+ case "projects.scan":
13836
+ return scanProjectFolder(c.folder);
13481
13837
  case "workspace.discover":
13482
13838
  return {
13483
13839
  worktrees: await discoverWorktrees(c.checkoutPath, { worktreeDir: c.worktreeDir })
@@ -13511,8 +13867,8 @@ var CommandRouter = class {
13511
13867
 
13512
13868
  // packages/host-agent/dist/config.js
13513
13869
  import { chmodSync as chmodSync3, existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
13514
- import { homedir as homedir3, hostname as hostname2 } from "node:os";
13515
- import { dirname as dirname5, join as join11 } from "node:path";
13870
+ import { homedir as homedir4, hostname as hostname2 } from "node:os";
13871
+ import { dirname as dirname5, join as join12 } from "node:path";
13516
13872
  import { z as z20 } from "zod";
13517
13873
  var HostConfigSchema = z20.object({
13518
13874
  coordinatorUrl: z20.string().min(1),
@@ -13520,28 +13876,28 @@ var HostConfigSchema = z20.object({
13520
13876
  credential: z20.string().min(1),
13521
13877
  name: z20.string().min(1)
13522
13878
  });
13523
- function defaultConfigPath(env = process.env, home = homedir3()) {
13879
+ function defaultConfigPath(env = process.env, home = homedir4()) {
13524
13880
  const fromEnv = env.SWITCHYARD_HOST_CONFIG?.trim();
13525
13881
  if (fromEnv)
13526
13882
  return fromEnv;
13527
13883
  const xdg = env.XDG_CONFIG_HOME?.trim();
13528
- return join11(xdg || join11(home, ".config"), "switchyard", "host.json");
13884
+ return join12(xdg || join12(home, ".config"), "switchyard", "host.json");
13529
13885
  }
13530
- function defaultStateDir(env = process.env, platform = process.platform, home = homedir3()) {
13886
+ function defaultStateDir(env = process.env, platform = process.platform, home = homedir4()) {
13531
13887
  const fromEnv = env.SWITCHYARD_STATE_DIR?.trim();
13532
13888
  if (fromEnv)
13533
13889
  return fromEnv;
13534
13890
  if (platform === "darwin")
13535
- return join11(home, "Library", "Application Support", "switchyard");
13891
+ return join12(home, "Library", "Application Support", "switchyard");
13536
13892
  const xdg = env.XDG_STATE_HOME?.trim();
13537
- return join11(xdg || join11(home, ".local", "state"), "switchyard");
13893
+ return join12(xdg || join12(home, ".local", "state"), "switchyard");
13538
13894
  }
13539
13895
  function statePaths(stateDir) {
13540
13896
  const p = {
13541
13897
  root: stateDir,
13542
- db: join11(stateDir, "host.db"),
13543
- runtime: join11(stateDir, "runtime"),
13544
- logs: join11(stateDir, "logs")
13898
+ db: join12(stateDir, "host.db"),
13899
+ runtime: join12(stateDir, "runtime"),
13900
+ logs: join12(stateDir, "logs")
13545
13901
  };
13546
13902
  for (const d of [p.root, p.runtime, p.logs])
13547
13903
  mkdirSync5(d, { recursive: true, mode: 448 });
@@ -14126,16 +14482,16 @@ var CoordinatorLink = class {
14126
14482
  this.ready = false;
14127
14483
  if (!ws)
14128
14484
  return;
14129
- await new Promise((resolve12) => {
14485
+ await new Promise((resolve13) => {
14130
14486
  if (ws.readyState === WebSocket2.CLOSED)
14131
- return resolve12();
14487
+ return resolve13();
14132
14488
  const timer = setTimeout(() => {
14133
14489
  ws.terminate();
14134
- resolve12();
14490
+ resolve13();
14135
14491
  }, 2e3);
14136
14492
  ws.once("close", () => {
14137
14493
  clearTimeout(timer);
14138
- resolve12();
14494
+ resolve13();
14139
14495
  });
14140
14496
  try {
14141
14497
  ws.close(1e3, "host agent stopping");
@@ -14455,7 +14811,7 @@ function isSupportedVersion(version) {
14455
14811
  async function detectPi(piBin = "pi", env = {}) {
14456
14812
  let out;
14457
14813
  try {
14458
- out = await new Promise((resolve12, reject) => {
14814
+ out = await new Promise((resolve13, reject) => {
14459
14815
  execFile2(piBin, ["--version"], {
14460
14816
  timeout: 3e4,
14461
14817
  env: {
@@ -14465,7 +14821,7 @@ async function detectPi(piBin = "pi", env = {}) {
14465
14821
  PI_TELEMETRY: "0",
14466
14822
  ...env
14467
14823
  }
14468
- }, (err, stdout, stderr) => err ? reject(err) : resolve12(`${stdout}
14824
+ }, (err, stdout, stderr) => err ? reject(err) : resolve13(`${stdout}
14469
14825
  ${stderr}`));
14470
14826
  });
14471
14827
  } catch (err) {
@@ -14492,9 +14848,9 @@ ${stderr}`));
14492
14848
 
14493
14849
  // packages/pi-runtime/dist/importable.js
14494
14850
  import { accessSync, constants, readFileSync as readFileSync6, realpathSync } from "node:fs";
14495
- import { open, readdir as readdir3, readFile as readFile6, stat as stat3 } from "node:fs/promises";
14496
- import { homedir as homedir4 } from "node:os";
14497
- import { delimiter, dirname as dirname7, join as join12, resolve as resolve10 } from "node:path";
14851
+ import { open, readdir as readdir4, readFile as readFile6, stat as stat4 } from "node:fs/promises";
14852
+ import { homedir as homedir5 } from "node:os";
14853
+ import { delimiter, dirname as dirname7, join as join13, resolve as resolve10 } from "node:path";
14498
14854
  import { pathToFileURL as pathToFileURL2 } from "node:url";
14499
14855
 
14500
14856
  // packages/pi-runtime/dist/transcript.js
@@ -14664,8 +15020,8 @@ ${output}`.trimEnd() }, at);
14664
15020
  // packages/pi-runtime/dist/importable.js
14665
15021
  var INACTIVE_AFTER_MS = 12e4;
14666
15022
  function defaultSessionsRoot() {
14667
- const agentDir = process.env.PI_CODING_AGENT_DIR ?? join12(homedir4(), ".pi", "agent");
14668
- return join12(agentDir, "sessions");
15023
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? join13(homedir5(), ".pi", "agent");
15024
+ return join13(agentDir, "sessions");
14669
15025
  }
14670
15026
  async function readFirstLine(file) {
14671
15027
  const fh = await open(file, "r");
@@ -14713,12 +15069,12 @@ function readSessionNameSync(file) {
14713
15069
  async function findJsonl(dir, depth, out) {
14714
15070
  let entries;
14715
15071
  try {
14716
- entries = await readdir3(dir, { withFileTypes: true });
15072
+ entries = await readdir4(dir, { withFileTypes: true });
14717
15073
  } catch {
14718
15074
  return;
14719
15075
  }
14720
15076
  for (const entry of entries) {
14721
- const full = join12(dir, entry.name);
15077
+ const full = join13(dir, entry.name);
14722
15078
  if (entry.isDirectory() && depth > 0)
14723
15079
  await findJsonl(full, depth - 1, out);
14724
15080
  else if (entry.isFile() && entry.name.endsWith(".jsonl"))
@@ -14737,7 +15093,7 @@ async function listImportable(sessionsRoot = defaultSessionsRoot(), now = Date.n
14737
15093
  const header = JSON.parse(first);
14738
15094
  if (header?.type !== "session" || typeof header.id !== "string")
14739
15095
  continue;
14740
- const st = await stat3(file);
15096
+ const st = await stat4(file);
14741
15097
  const content = await readFile6(file, "utf8");
14742
15098
  result.push({
14743
15099
  sessionFile: file,
@@ -14761,7 +15117,7 @@ function findOnPath(bin) {
14761
15117
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
14762
15118
  if (!dir)
14763
15119
  continue;
14764
- const candidate = join12(dir, bin);
15120
+ const candidate = join13(dir, bin);
14765
15121
  try {
14766
15122
  accessSync(candidate, constants.X_OK);
14767
15123
  return candidate;
@@ -14794,14 +15150,14 @@ function resolvePiPackageRoot(piBin = "pi") {
14794
15150
  let dir = dirname7(start);
14795
15151
  for (let i = 0; i < 8; i++) {
14796
15152
  try {
14797
- const pkg = JSON.parse(readFileSync6(join12(dir, "package.json"), "utf8"));
15153
+ const pkg = JSON.parse(readFileSync6(join13(dir, "package.json"), "utf8"));
14798
15154
  if (pkg?.name === "@earendil-works/pi-coding-agent")
14799
15155
  return dir;
14800
15156
  } catch {
14801
15157
  }
14802
- const nested = join12(dir, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
15158
+ const nested = join13(dir, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
14803
15159
  try {
14804
- const pkg = JSON.parse(readFileSync6(join12(nested, "package.json"), "utf8"));
15160
+ const pkg = JSON.parse(readFileSync6(join13(nested, "package.json"), "utf8"));
14805
15161
  if (pkg?.name === "@earendil-works/pi-coding-agent")
14806
15162
  return nested;
14807
15163
  } catch {
@@ -14821,7 +15177,7 @@ function loadPiEntryParser(piBin = "pi") {
14821
15177
  if (!root)
14822
15178
  return null;
14823
15179
  try {
14824
- const mod = await import(pathToFileURL2(join12(root, "dist", "index.js")).href);
15180
+ const mod = await import(pathToFileURL2(join13(root, "dist", "index.js")).href);
14825
15181
  return typeof mod.parseSessionEntries === "function" ? mod.parseSessionEntries : null;
14826
15182
  } catch {
14827
15183
  return null;
@@ -14978,7 +15334,7 @@ function serializeJsonl(value) {
14978
15334
  `;
14979
15335
  }
14980
15336
  function writeJsonl(stream, value) {
14981
- return new Promise((resolve12, reject) => {
15337
+ return new Promise((resolve13, reject) => {
14982
15338
  if (stream.destroyed || stream.writableEnded) {
14983
15339
  reject(new Error("stream is closed"));
14984
15340
  return;
@@ -14994,7 +15350,7 @@ function writeJsonl(stream, value) {
14994
15350
  return;
14995
15351
  }
14996
15352
  if (ok) {
14997
- resolve12();
15353
+ resolve13();
14998
15354
  return;
14999
15355
  }
15000
15356
  const cleanup = () => {
@@ -15004,7 +15360,7 @@ function writeJsonl(stream, value) {
15004
15360
  };
15005
15361
  const onDrain = () => {
15006
15362
  cleanup();
15007
- resolve12();
15363
+ resolve13();
15008
15364
  };
15009
15365
  const onClose = () => {
15010
15366
  cleanup();
@@ -15023,7 +15379,7 @@ import { EventEmitter as EventEmitter3 } from "node:events";
15023
15379
  import { chmodSync as chmodSync4, existsSync as existsSync10, mkdirSync as mkdirSync7, rmSync as rmSync2 } from "node:fs";
15024
15380
  import { createServer } from "node:net";
15025
15381
  import { tmpdir as tmpdir2 } from "node:os";
15026
- import { join as join13 } from "node:path";
15382
+ import { join as join14 } from "node:path";
15027
15383
  import { fileURLToPath as fileURLToPath2 } from "node:url";
15028
15384
  function bridgeExtensionPath() {
15029
15385
  const sibling = fileURLToPath2(new URL("./bridge/switchyard-bridge.js", import.meta.url));
@@ -15043,11 +15399,11 @@ var MAX_SOCKET_PATH = 100;
15043
15399
  function bridgeSocketPath(runtimeDir) {
15044
15400
  const name = `bridge-${randomUUID().slice(0, 8)}.sock`;
15045
15401
  let dir = runtimeDir;
15046
- if (join13(dir, name).length > MAX_SOCKET_PATH) {
15047
- dir = join13("/tmp", `switchyard-${process.getuid?.() ?? "u"}`);
15402
+ if (join14(dir, name).length > MAX_SOCKET_PATH) {
15403
+ dir = join14("/tmp", `switchyard-${process.getuid?.() ?? "u"}`);
15048
15404
  }
15049
15405
  mkdirSync7(dir, { recursive: true, mode: 448 });
15050
- return join13(dir, name);
15406
+ return join14(dir, name);
15051
15407
  }
15052
15408
  var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
15053
15409
  child;
@@ -15067,10 +15423,10 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
15067
15423
  this.child = child;
15068
15424
  this.server = server;
15069
15425
  this.bridgeSocket = socketPath;
15070
- this.exitPromise = new Promise((resolve12) => {
15426
+ this.exitPromise = new Promise((resolve13) => {
15071
15427
  child.once("exit", (code, signal) => {
15072
15428
  this.exitInfo = { code, signal, requested: this.closeRequested };
15073
- resolve12(this.exitInfo);
15429
+ resolve13(this.exitInfo);
15074
15430
  });
15075
15431
  });
15076
15432
  JsonlReader.attach(child.stdout, (record) => this.onRecord(record), (err, line) => this.emit("protocol_error", err, line));
@@ -15100,18 +15456,18 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
15100
15456
  let proc = null;
15101
15457
  const early = [];
15102
15458
  if (useBridge) {
15103
- socketPath = bridgeSocketPath(opts.runtimeDir ?? join13(tmpdir2(), "switchyard"));
15459
+ socketPath = bridgeSocketPath(opts.runtimeDir ?? join14(tmpdir2(), "switchyard"));
15104
15460
  server = createServer((socket) => {
15105
15461
  if (proc)
15106
15462
  proc.attachBridgeSocket(socket);
15107
15463
  else
15108
15464
  early.push(socket);
15109
15465
  });
15110
- await new Promise((resolve12, reject) => {
15466
+ await new Promise((resolve13, reject) => {
15111
15467
  server.once("error", reject);
15112
15468
  server.listen(socketPath, () => {
15113
15469
  server.off("error", reject);
15114
- resolve12();
15470
+ resolve13();
15115
15471
  });
15116
15472
  });
15117
15473
  try {
@@ -15137,10 +15493,10 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
15137
15493
  stdio: ["pipe", "pipe", "pipe"]
15138
15494
  });
15139
15495
  try {
15140
- await new Promise((resolve12, reject) => {
15496
+ await new Promise((resolve13, reject) => {
15141
15497
  child.once("spawn", () => {
15142
15498
  child.off("error", reject);
15143
- resolve12();
15499
+ resolve13();
15144
15500
  });
15145
15501
  child.once("error", reject);
15146
15502
  });
@@ -15177,12 +15533,12 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
15177
15533
  return Promise.reject(new PiRpcError(cmd.type, "Pi is not running"));
15178
15534
  }
15179
15535
  const id = `sy-${++this.nextId}`;
15180
- return new Promise((resolve12, reject) => {
15536
+ return new Promise((resolve13, reject) => {
15181
15537
  const timer = setTimeout(() => {
15182
15538
  this.pending.delete(id);
15183
15539
  reject(new PiRpcError(cmd.type, `Pi did not answer ${cmd.type} within ${timeoutMs} ms`));
15184
15540
  }, timeoutMs);
15185
- this.pending.set(id, { command: cmd.type, resolve: resolve12, reject, timer });
15541
+ this.pending.set(id, { command: cmd.type, resolve: resolve13, reject, timer });
15186
15542
  writeJsonl(this.child.stdin, { ...cmd, id }).catch((err) => {
15187
15543
  const p = this.pending.get(id);
15188
15544
  if (!p)
@@ -15211,7 +15567,7 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
15211
15567
  }
15212
15568
  const graceMs = opts.graceMs ?? 1e4;
15213
15569
  const termMs = opts.termMs ?? 3e3;
15214
- const timeout = (ms) => new Promise((resolve12) => setTimeout(() => resolve12(null), ms).unref());
15570
+ const timeout = (ms) => new Promise((resolve13) => setTimeout(() => resolve13(null), ms).unref());
15215
15571
  let info = await Promise.race([this.exitPromise, timeout(graceMs)]);
15216
15572
  if (info)
15217
15573
  return info;
@@ -15989,7 +16345,7 @@ ${output}`.trimEnd() }));
15989
16345
 
15990
16346
  // packages/host-agent/dist/supervisor.js
15991
16347
  import { existsSync as existsSync12, realpathSync as realpathSync2, statSync } from "node:fs";
15992
- import { join as join14, sep as sep3 } from "node:path";
16348
+ import { join as join15, sep as sep3 } from "node:path";
15993
16349
  var RESTART_NOTICE = "Host agent restarted; resume to continue";
15994
16350
  var QUEUED_ANSWER_SUMMARY = "Answered from your earlier form";
15995
16351
  var PI_CLOSED_SUMMARY = "Pi closed this question (timeout or abort)";
@@ -16447,7 +16803,7 @@ var SessionSupervisor = class {
16447
16803
  }
16448
16804
  async importable() {
16449
16805
  const env = { ...process.env, ...this.opts.env ?? {} };
16450
- const root = join14(piAgentDir(env), "sessions");
16806
+ const root = join15(piAgentDir(env), "sessions");
16451
16807
  const list = await listImportable(root);
16452
16808
  const liveFiles = new Set([...this.live.values()].flatMap((l) => l.rec.nativeHandle ? [realpathOr2(l.rec.nativeHandle.sessionFile)] : []));
16453
16809
  return {
@@ -17280,7 +17636,7 @@ function defaultDoctorDeps(ctx) {
17280
17636
  return {
17281
17637
  nodeVersion: process.versions.node,
17282
17638
  detectPi: () => detectPi(ctx.env.SWITCHYARD_PI_BIN ?? "pi"),
17283
- gitVersion: () => new Promise((resolve12) => execFile3("git", ["--version"], (err, stdout) => resolve12(err ? null : stdout.trim()))),
17639
+ gitVersion: () => new Promise((resolve13) => execFile3("git", ["--version"], (err, stdout) => resolve13(err ? null : stdout.trim()))),
17284
17640
  hostConfigPaths: [defaultConfigPath(ctx.env), standaloneHostPaths(dataDir).configPath],
17285
17641
  readFile: (p) => existsSync13(p) ? readFileSync8(p, "utf8") : null
17286
17642
  };
@@ -17436,14 +17792,14 @@ function printDoctor(ctx, results) {
17436
17792
  // packages/cli/dist/commands/host.js
17437
17793
  import { existsSync as existsSync15, mkdirSync as mkdirSync9 } from "node:fs";
17438
17794
  import { hostname as hostname3 } from "node:os";
17439
- import { dirname as dirname9, join as join16, resolve as resolve11 } from "node:path";
17795
+ import { dirname as dirname9, join as join17, resolve as resolve12 } from "node:path";
17440
17796
  import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
17441
17797
 
17442
17798
  // packages/cli/dist/commands/service.js
17443
17799
  import { execFile as execFile4 } from "node:child_process";
17444
17800
  import { existsSync as existsSync14, mkdirSync as mkdirSync8, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
17445
- import { homedir as homedir5 } from "node:os";
17446
- import { dirname as dirname8, join as join15 } from "node:path";
17801
+ import { homedir as homedir6 } from "node:os";
17802
+ import { dirname as dirname8, join as join16, resolve as resolve11 } from "node:path";
17447
17803
  var LAUNCHD_LABEL = "com.switchyard.host";
17448
17804
  var SYSTEMD_UNIT = "switchyard-host.service";
17449
17805
  var TEMPLATES = {
@@ -17453,7 +17809,7 @@ var TEMPLATES = {
17453
17809
  <plist version="1.0">
17454
17810
  <dict>
17455
17811
  <key>Label</key>
17456
- <string>com.switchyard.host</string>
17812
+ <string>{{LABEL}}</string>
17457
17813
  <key>ProgramArguments</key>
17458
17814
  <!-- Through zsh (every Mac has /bin/zsh) so the agent and the Pi sessions it starts get the
17459
17815
  environment ~/.zshenv sets for a terminal, like provider settings; PATH below is the base. -->
@@ -17467,6 +17823,8 @@ var TEMPLATES = {
17467
17823
  <string>run</string>
17468
17824
  <string>--config</string>
17469
17825
  <string>{{CONFIG}}</string>
17826
+ <string>--state-dir</string>
17827
+ <string>{{STATE_DIR}}</string>
17470
17828
  </array>
17471
17829
  <key>EnvironmentVariables</key>
17472
17830
  <dict>
@@ -17501,7 +17859,7 @@ Wants=network-online.target
17501
17859
 
17502
17860
  [Service]
17503
17861
  Type=simple
17504
- ExecStart="{{NODE}}" "{{CLI}}" host run --config "{{CONFIG}}"
17862
+ ExecStart="{{NODE}}" "{{CLI}}" host run --config "{{CONFIG}}" --state-dir "{{STATE_DIR}}"
17505
17863
  Environment="PATH={{PATH}}"
17506
17864
  WorkingDirectory={{HOME}}
17507
17865
  Restart=on-failure
@@ -17514,8 +17872,8 @@ WantedBy=default.target
17514
17872
  `
17515
17873
  };
17516
17874
  var TEMPLATE_FILES = {
17517
- launchd: join15("launchd", `${LAUNCHD_LABEL}.plist`),
17518
- systemd: join15("systemd", SYSTEMD_UNIT)
17875
+ launchd: join16("launchd", `${LAUNCHD_LABEL}.plist`),
17876
+ systemd: join16("systemd", SYSTEMD_UNIT)
17519
17877
  };
17520
17878
  function xmlEscape(s) {
17521
17879
  return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
@@ -17527,12 +17885,12 @@ function systemdEscape(s, exec = true) {
17527
17885
  return exec ? out.replaceAll("$", "$$$$") : out;
17528
17886
  }
17529
17887
  function loadTemplate(kind, dir = deployDir()) {
17530
- const p = join15(dir, TEMPLATE_FILES[kind]);
17888
+ const p = join16(dir, TEMPLATE_FILES[kind]);
17531
17889
  return existsSync14(p) ? readFileSync9(p, "utf8") : TEMPLATES[kind];
17532
17890
  }
17533
17891
  function renderService(kind, vars, template = TEMPLATES[kind]) {
17534
17892
  for (const [k, v] of Object.entries(vars)) {
17535
- if (k !== "path" && !v.startsWith("/")) {
17893
+ if (k !== "path" && k !== "label" && !v.startsWith("/")) {
17536
17894
  throw new CliError(`Service file needs an absolute ${k} path, got "${v}"`);
17537
17895
  }
17538
17896
  }
@@ -17540,7 +17898,7 @@ function renderService(kind, vars, template = TEMPLATES[kind]) {
17540
17898
  if (kind === "systemd" && /\s/.test(vars.home)) {
17541
17899
  throw new CliError("The home directory path contains whitespace; edit the unit by hand");
17542
17900
  }
17543
- return template.replaceAll("{{NODE}}", esc(vars.node)).replaceAll("{{CLI}}", esc(vars.cli)).replaceAll("{{CONFIG}}", esc(vars.config)).replaceAll("{{HOME}}", kind === "launchd" ? esc(vars.home) : vars.home).replaceAll("{{PATH}}", kind === "launchd" ? esc(vars.path) : systemdEscape(vars.path, false)).replaceAll("{{LOG_DIR}}", esc(vars.logDir));
17901
+ return template.replaceAll("{{NODE}}", esc(vars.node)).replaceAll("{{CLI}}", esc(vars.cli)).replaceAll("{{LABEL}}", esc(vars.label)).replaceAll("{{CONFIG}}", esc(vars.config)).replaceAll("{{STATE_DIR}}", esc(vars.stateDir)).replaceAll("{{HOME}}", kind === "launchd" ? esc(vars.home) : vars.home).replaceAll("{{PATH}}", kind === "launchd" ? esc(vars.path) : systemdEscape(vars.path, false)).replaceAll("{{LOG_DIR}}", esc(vars.logDir));
17544
17902
  }
17545
17903
  function serviceKindFor(platform) {
17546
17904
  if (platform === "darwin")
@@ -17549,17 +17907,27 @@ function serviceKindFor(platform) {
17549
17907
  return "systemd";
17550
17908
  throw new CliError(`Service install is supported on macOS and Linux, not ${platform}`);
17551
17909
  }
17552
- function serviceFilePath(kind, home = homedir5()) {
17553
- return kind === "launchd" ? join15(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`) : join15(home, ".config", "systemd", "user", SYSTEMD_UNIT);
17910
+ function serviceNames(instance) {
17911
+ if (instance === void 0)
17912
+ return { label: LAUNCHD_LABEL, unit: SYSTEMD_UNIT };
17913
+ if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(instance)) {
17914
+ throw new CliError("Instance names use lowercase letters, digits and dashes (at most 32)");
17915
+ }
17916
+ return { label: `${LAUNCHD_LABEL}.${instance}`, unit: `switchyard-host-${instance}.service` };
17917
+ }
17918
+ function serviceFilePath(kind, home = homedir6(), instance) {
17919
+ const { label, unit } = serviceNames(instance);
17920
+ return kind === "launchd" ? join16(home, "Library", "LaunchAgents", `${label}.plist`) : join16(home, ".config", "systemd", "user", unit);
17554
17921
  }
17555
- function loadCommands(kind, file, uid) {
17922
+ function loadCommands(kind, file, uid, instance) {
17923
+ const { label, unit } = serviceNames(instance);
17556
17924
  return kind === "launchd" ? [
17557
- ["launchctl", "bootout", `gui/${uid}/${LAUNCHD_LABEL}`],
17925
+ ["launchctl", "bootout", `gui/${uid}/${label}`],
17558
17926
  ["launchctl", "bootstrap", `gui/${uid}`, file]
17559
17927
  ] : [
17560
17928
  ["systemctl", "--user", "daemon-reload"],
17561
- ["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT],
17562
- ["systemctl", "--user", "restart", SYSTEMD_UNIT]
17929
+ ["systemctl", "--user", "enable", "--now", unit],
17930
+ ["systemctl", "--user", "restart", unit]
17563
17931
  ];
17564
17932
  }
17565
17933
  function stableNodePath(execPath, exists2 = existsSync14) {
@@ -17574,27 +17942,34 @@ function stableNodePath(execPath, exists2 = existsSync14) {
17574
17942
  function defaultInstallDeps() {
17575
17943
  return {
17576
17944
  platform: process.platform,
17577
- home: homedir5(),
17945
+ home: homedir6(),
17578
17946
  uid: process.getuid?.() ?? 0,
17579
- run: (cmd) => new Promise((resolve12) => execFile4(cmd[0], cmd.slice(1), (err, stdout, stderr) => resolve12({ ok: !err, output: `${stdout}${stderr}`.trim() })))
17947
+ run: (cmd) => new Promise((resolve13) => execFile4(cmd[0], cmd.slice(1), (err, stdout, stderr) => resolve13({ ok: !err, output: `${stdout}${stderr}`.trim() })))
17580
17948
  };
17581
17949
  }
17582
17950
  async function installService(ctx, flags, deps = defaultInstallDeps()) {
17583
17951
  const kind = serviceKindFor(deps.platform);
17952
+ const names = serviceNames(flags.instance);
17953
+ if (flags.instance !== void 0 && !(flags.config && flags.stateDir)) {
17954
+ throw new CliError("--instance needs --config and --state-dir, so the second host agent keeps its own enrollment and state");
17955
+ }
17584
17956
  const config = flags.config ?? defaultConfigPath(ctx.env, deps.home);
17585
17957
  if (!existsSync14(config) && !flags.print) {
17586
17958
  throw new CliError(`No host config at ${config}. Enroll first: switchyard host enroll --coordinator <url> --token <t>`);
17587
17959
  }
17588
- const logDir = join15(defaultStateDir(ctx.env, deps.platform, deps.home), "logs");
17960
+ const stateDir = flags.stateDir ? resolve11(flags.stateDir) : defaultStateDir(ctx.env, deps.platform, deps.home);
17961
+ const logDir = join16(stateDir, "logs");
17589
17962
  const content = renderService(kind, {
17590
17963
  node: stableNodePath(process.execPath),
17591
17964
  cli: cliEntry(),
17592
17965
  config,
17966
+ stateDir,
17967
+ label: names.label,
17593
17968
  home: deps.home,
17594
17969
  path: ctx.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
17595
17970
  logDir
17596
17971
  }, loadTemplate(kind));
17597
- const file = serviceFilePath(kind, deps.home);
17972
+ const file = serviceFilePath(kind, deps.home, flags.instance);
17598
17973
  if (flags.print) {
17599
17974
  ctx.out(content.trimEnd());
17600
17975
  return { file, content };
@@ -17605,18 +17980,22 @@ async function installService(ctx, flags, deps = defaultInstallDeps()) {
17605
17980
  ctx.out(`Wrote ${file}`);
17606
17981
  if (flags.load === false) {
17607
17982
  ctx.out("Not loaded (--no-load). Load it with:");
17608
- for (const c of loadCommands(kind, file, deps.uid).slice(1))
17983
+ for (const c of loadCommands(kind, file, deps.uid, flags.instance).slice(1))
17609
17984
  ctx.out(` ${c.join(" ")}`);
17610
17985
  return { file, content };
17611
17986
  }
17612
- for (const cmd of loadCommands(kind, file, deps.uid)) {
17987
+ for (const cmd of loadCommands(kind, file, deps.uid, flags.instance)) {
17613
17988
  ctx.out(`$ ${cmd.join(" ")}`);
17614
- const r = await deps.run(cmd);
17989
+ let r = await deps.run(cmd);
17990
+ for (let i = 0; !r.ok && cmd[1] === "bootstrap" && i < 10; i++) {
17991
+ await new Promise((resolve13) => setTimeout(resolve13, deps.retryDelayMs ?? 500));
17992
+ r = await deps.run(cmd);
17993
+ }
17615
17994
  if (!r.ok && cmd[1] !== "bootout") {
17616
17995
  throw new CliError(`${cmd.join(" ")} failed${r.output ? `: ${r.output}` : ""}`);
17617
17996
  }
17618
17997
  }
17619
- ctx.out(`The host agent runs as ${kind === "launchd" ? LAUNCHD_LABEL : SYSTEMD_UNIT}; logs: ${logDir}`);
17998
+ ctx.out(`The host agent runs as ${kind === "launchd" ? names.label : names.unit}; logs: ${logDir}`);
17620
17999
  return { file, content };
17621
18000
  }
17622
18001
 
@@ -17654,10 +18033,10 @@ async function hostRun(ctx, flags) {
17654
18033
  }
17655
18034
  function hostBackup(ctx, out, flags) {
17656
18035
  const stateDir = flags.stateDir ?? defaultStateDir(ctx.env);
17657
- const src = join16(stateDir, "host.db");
18036
+ const src = join17(stateDir, "host.db");
17658
18037
  if (!existsSync15(src))
17659
18038
  throw new CliError(`No host database at ${src}`);
17660
- const dest = resolve11(out);
18039
+ const dest = resolve12(out);
17661
18040
  if (existsSync15(dest))
17662
18041
  throw new CliError(`${dest} already exists`);
17663
18042
  mkdirSync9(dirname9(dest), { recursive: true });
@@ -17731,6 +18110,161 @@ async function hostStatus(ctx, flags) {
17731
18110
  ctx.out(`Service: ${svc.installed ? `installed (${svc.file})` : "not installed"}`);
17732
18111
  }
17733
18112
 
18113
+ // packages/cli/dist/context.js
18114
+ function defaultCtx() {
18115
+ return {
18116
+ out: (l) => process.stdout.write(`${l}
18117
+ `),
18118
+ err: (l) => process.stderr.write(`${l}
18119
+ `),
18120
+ env: process.env,
18121
+ fetch: (input, init2) => fetch(input, init2)
18122
+ };
18123
+ }
18124
+ function connect(ctx, flags) {
18125
+ const conn = resolveConnection(flags, ctx.env, ctx.paths);
18126
+ return {
18127
+ conn,
18128
+ api: new ApiClient({ coordinatorUrl: conn.url, credential: conn.credential, fetch: ctx.fetch })
18129
+ };
18130
+ }
18131
+ function table2(headers, rows) {
18132
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
18133
+ const fmt = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
18134
+ return [fmt(headers), ...rows.map(fmt)];
18135
+ }
18136
+ function printList(ctx, json, data, headers, rows, empty) {
18137
+ if (json) {
18138
+ ctx.out(JSON.stringify(data, null, 2));
18139
+ return;
18140
+ }
18141
+ if (rows.length === 0) {
18142
+ ctx.out(empty);
18143
+ return;
18144
+ }
18145
+ for (const l of table2(headers, rows))
18146
+ ctx.out(l);
18147
+ }
18148
+ function ago(iso, now = Date.now()) {
18149
+ if (!iso)
18150
+ return "-";
18151
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
18152
+ if (s < 60)
18153
+ return `${s}s ago`;
18154
+ if (s < 3600)
18155
+ return `${Math.round(s / 60)}m ago`;
18156
+ if (s < 86400)
18157
+ return `${Math.round(s / 3600)}h ago`;
18158
+ return `${Math.round(s / 86400)}d ago`;
18159
+ }
18160
+
18161
+ // packages/cli/dist/commands/hosts.js
18162
+ async function resolveHost(api, ref) {
18163
+ const hosts = (await api.get("/api/hosts")).filter((h) => !h.revokedAt);
18164
+ const exact = hosts.find((h) => h.id === ref);
18165
+ if (exact)
18166
+ return exact;
18167
+ const named = hosts.filter((h) => h.name.toLowerCase() === ref.toLowerCase());
18168
+ if (named.length === 1)
18169
+ return named[0];
18170
+ if (named.length > 1)
18171
+ throw new CliError(`Several hosts are named "${ref}"; use the id`);
18172
+ const known = hosts.map((h) => `${h.name} (${h.id})`).join(", ") || "none";
18173
+ throw new CliError(`Unknown host "${ref}". Hosts: ${known}`);
18174
+ }
18175
+ function scanLine(scan, now = Date.now()) {
18176
+ if (!scan)
18177
+ return ["Not scanned yet."];
18178
+ if (scan.state === "running")
18179
+ return [`Scanning since ${ago(scan.startedAt, now)}.`];
18180
+ if (scan.state === "failed") {
18181
+ return [
18182
+ `Last scan failed ${ago(scan.finishedAt, now)}: ${scan.errors.at(-1) ?? "unknown error"}`
18183
+ ];
18184
+ }
18185
+ return [
18186
+ `Last scanned ${ago(scan.finishedAt, now)}: ${scan.repositories} repositories, ${scan.checkoutsAdded} new checkouts, ${scan.projectsCreated} new projects.`,
18187
+ ...scan.errors.map((e) => `! ${e}`)
18188
+ ];
18189
+ }
18190
+ async function hostsList(ctx, flags) {
18191
+ const { api } = connect(ctx, flags);
18192
+ const hosts = (await api.get("/api/hosts")).filter((h) => !h.revokedAt);
18193
+ printList(ctx, flags.json, hosts, ["ID", "NAME", "STATUS", "AGENT", "PROJECT FOLDERS"], hosts.map((h) => [
18194
+ h.id,
18195
+ h.name,
18196
+ h.connectivity,
18197
+ h.agentVersion,
18198
+ (h.projectFolders ?? []).join(", ") || "-"
18199
+ ]), "No hosts.");
18200
+ }
18201
+ async function hostsFolders(ctx, ref, flags) {
18202
+ const { api } = connect(ctx, flags);
18203
+ const changing = !!(flags.set || flags.add || flags.remove || flags.clear);
18204
+ if (flags.set && (flags.add || flags.remove || flags.clear) || flags.clear && (flags.add || flags.remove)) {
18205
+ throw new CliError("Use --set or --clear on its own, or --add/--remove");
18206
+ }
18207
+ const host = await resolveHost(api, ref);
18208
+ const current = host.projectFolders ?? [];
18209
+ if (!changing) {
18210
+ if (flags.json)
18211
+ ctx.out(JSON.stringify(host, null, 2));
18212
+ else {
18213
+ if (current.length === 0)
18214
+ ctx.out("No project folders.");
18215
+ for (const f of current)
18216
+ ctx.out(f);
18217
+ for (const l of scanLine(host.projectScan))
18218
+ ctx.out(l);
18219
+ }
18220
+ return host;
18221
+ }
18222
+ let next;
18223
+ if (flags.set)
18224
+ next = [...flags.set];
18225
+ else if (flags.clear)
18226
+ next = [];
18227
+ else {
18228
+ next = [...current];
18229
+ for (const f of flags.remove ?? []) {
18230
+ if (!next.includes(f))
18231
+ throw new CliError(`${f} is not a project folder of ${host.name}`);
18232
+ next = next.filter((x) => x !== f);
18233
+ }
18234
+ for (const f of flags.add ?? [])
18235
+ if (!next.includes(f))
18236
+ next.push(f);
18237
+ }
18238
+ const updated = await api.request("PATCH", `/api/hosts/${encodeURIComponent(host.id)}`, {
18239
+ projectFolders: next
18240
+ });
18241
+ if (flags.json) {
18242
+ ctx.out(JSON.stringify(updated, null, 2));
18243
+ return updated;
18244
+ }
18245
+ const folders = updated.projectFolders ?? [];
18246
+ ctx.out(`Project folders on ${updated.name}: ${folders.join(", ") || "none"}`);
18247
+ if (folders.length > 0 && updated.connectivity === "connected") {
18248
+ ctx.out(`Scanning in the background; \`switchyard hosts scan ${updated.name}\` waits for the result.`);
18249
+ }
18250
+ return updated;
18251
+ }
18252
+ async function hostsScan(ctx, ref, flags) {
18253
+ const { api } = connect(ctx, flags);
18254
+ const host = await resolveHost(api, ref);
18255
+ const r = await api.post(`/api/hosts/${encodeURIComponent(host.id)}/scan-projects`);
18256
+ if (flags.json) {
18257
+ ctx.out(JSON.stringify(r, null, 2));
18258
+ return r;
18259
+ }
18260
+ ctx.out(`Scanned ${host.name}: ${r.repositories} repositories, ${r.checkoutsAdded} new checkouts, ${r.projectsCreated} new projects`);
18261
+ for (const a of r.added)
18262
+ ctx.out(`+ ${a.name} ${a.path}${a.newProject ? " (new project)" : ""}`);
18263
+ for (const e of r.errors)
18264
+ ctx.out(`! ${e}`);
18265
+ return r;
18266
+ }
18267
+
17734
18268
  // packages/cli/dist/commands/up.js
17735
18269
  import { spawn as spawn3 } from "node:child_process";
17736
18270
  import { existsSync as existsSync16 } from "node:fs";
@@ -17861,54 +18395,6 @@ async function up(ctx, flags) {
17861
18395
  await s.stop();
17862
18396
  }
17863
18397
 
17864
- // packages/cli/dist/context.js
17865
- function defaultCtx() {
17866
- return {
17867
- out: (l) => process.stdout.write(`${l}
17868
- `),
17869
- err: (l) => process.stderr.write(`${l}
17870
- `),
17871
- env: process.env,
17872
- fetch: (input, init2) => fetch(input, init2)
17873
- };
17874
- }
17875
- function connect(ctx, flags) {
17876
- const conn = resolveConnection(flags, ctx.env, ctx.paths);
17877
- return {
17878
- conn,
17879
- api: new ApiClient({ coordinatorUrl: conn.url, credential: conn.credential, fetch: ctx.fetch })
17880
- };
17881
- }
17882
- function table2(headers, rows) {
17883
- const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
17884
- const fmt = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
17885
- return [fmt(headers), ...rows.map(fmt)];
17886
- }
17887
- function printList(ctx, json, data, headers, rows, empty) {
17888
- if (json) {
17889
- ctx.out(JSON.stringify(data, null, 2));
17890
- return;
17891
- }
17892
- if (rows.length === 0) {
17893
- ctx.out(empty);
17894
- return;
17895
- }
17896
- for (const l of table2(headers, rows))
17897
- ctx.out(l);
17898
- }
17899
- function ago(iso, now = Date.now()) {
17900
- if (!iso)
17901
- return "-";
17902
- const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
17903
- if (s < 60)
17904
- return `${s}s ago`;
17905
- if (s < 3600)
17906
- return `${Math.round(s / 60)}m ago`;
17907
- if (s < 86400)
17908
- return `${Math.round(s / 3600)}h ago`;
17909
- return `${Math.round(s / 86400)}d ago`;
17910
- }
17911
-
17912
18398
  // packages/cli/dist/commands/work.js
17913
18399
  function byId2(list) {
17914
18400
  return new Map(list.map((x) => [x.id, x]));
@@ -18111,7 +18597,7 @@ function buildProgram(ctx = defaultCtx()) {
18111
18597
  const host = program.command("host").description("Host agent on this machine");
18112
18598
  host.command("enroll").description("Enroll this machine as a host (writes ~/.config/switchyard/host.json)").requiredOption("--coordinator <url>", "coordinator URL").option("--token <token>", "host enrollment token (switchyard pair --host)").option("--secret <secret>", "shared host enrollment secret (SWITCHYARD_HOST_ENROLL_SECRET)").option("--name <name>", "host name (default: hostname)").option("--config <path>", "host.json path").action((o) => hostEnroll(ctx, o));
18113
18599
  host.command("run").description("Run the host agent in the foreground (waits for host.json when not enrolled)").option("--config <path>", "host.json path (env SWITCHYARD_HOST_CONFIG)").option("--state-dir <dir>", "state dir (env SWITCHYARD_STATE_DIR)").action((o) => hostRun(ctx, o));
18114
- host.command("install-service").description("Install the host agent as a launchd agent (macOS) or systemd user unit (Linux)").option("--config <path>", "host.json path").option("--print", "print the service file without writing it").option("--no-load", "write the file but do not load it").action(async (o) => {
18600
+ host.command("install-service").description("Install the host agent as a launchd agent (macOS) or systemd user unit (Linux)").option("--config <path>", "host.json path").option("--state-dir <dir>", "state dir (default: the platform default)").option("--instance <name>", "run a second host agent beside the default one, e.g. dev; needs --config and --state-dir").option("--print", "print the service file without writing it").option("--no-load", "write the file but do not load it").action(async (o) => {
18115
18601
  await installService(ctx, o);
18116
18602
  });
18117
18603
  host.command("backup <out>").description("Consistent backup of the host database").option("--state-dir <dir>", "state dir (env SWITCHYARD_STATE_DIR)").action((out, o) => hostBackup(ctx, out, o));
@@ -18131,6 +18617,21 @@ function buildProgram(ctx = defaultCtx()) {
18131
18617
  if (code !== 0)
18132
18618
  throw new CliError("doctor found failures", code);
18133
18619
  });
18620
+ const hosts = program.command("hosts").description("Hosts and their project folders");
18621
+ connectionOptions(hosts.command("list").description("List hosts")).option("--json", "JSON output").action((o) => hostsList(ctx, { ...connFlags(o), json: o.json }));
18622
+ connectionOptions(hosts.command("folders <host>").description("Show or change a host's project folders (absolute or ~/... on the host; quote ~ so your shell does not expand it)")).option("--set <folder...>", "replace the folders").option("--add <folder...>", "add folders").option("--remove <folder...>", "remove folders").option("--clear", "remove every folder").option("--json", "JSON output").action(async (ref, o) => {
18623
+ await hostsFolders(ctx, ref, {
18624
+ ...connFlags(o),
18625
+ set: o.set,
18626
+ add: o.add,
18627
+ remove: o.remove,
18628
+ clear: o.clear,
18629
+ json: o.json
18630
+ });
18631
+ });
18632
+ connectionOptions(hosts.command("scan <host>").description("Scan a host's project folders now and add new repositories as projects")).option("--json", "JSON output").action(async (ref, o) => {
18633
+ await hostsScan(ctx, ref, { ...connFlags(o), json: o.json });
18634
+ });
18134
18635
  const sessions = program.command("sessions").description("Sessions");
18135
18636
  connectionOptions(sessions.command("list").description("List sessions")).option("--project <project>", "project id or name").option("--json", "JSON output").action((o) => sessionsList(ctx, { ...connFlags(o), json: o.json, project: o.project }));
18136
18637
  connectionOptions(sessions.command("import <sessionFile>").description("Import an inactive Pi session file from a host")).option("--host <hostId>", "host holding the file (default: the only host)").option("--project <project>", "project id or name").option("--json", "JSON output").action(async (file, o) => {