@tablation/crew 0.127.5 → 0.127.6

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 (2) hide show
  1. package/dist/cli.js +748 -558
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6979,6 +6979,502 @@ var SignedOutError = class extends Error {
6979
6979
  };
6980
6980
  var SIGNED_OUT_PAUSE_MS = 15 * 60 * 1e3;
6981
6981
 
6982
+ // src/seat-keys.ts
6983
+ import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
6984
+ var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
6985
+ function keyIsAdmin(route2) {
6986
+ const role = route2.resolved?.role;
6987
+ return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
6988
+ }
6989
+ var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
6990
+ var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
6991
+ var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
6992
+ function seatKeySlug(workspaceSlug, agentId) {
6993
+ return `${workspaceSlug}#agent:${agentId}`;
6994
+ }
6995
+ function slugFor(route2, agentId) {
6996
+ const [workspaceSlug] = splitRoute(route2.route);
6997
+ return seatKeySlug(workspaceSlug, agentId);
6998
+ }
6999
+ function withFoundSeats(route2, found, previousReleaseAgentId) {
7000
+ const resolved = route2.resolved;
7001
+ if (!resolved) return route2;
7002
+ const releaseSeat2 = found.releaseSeat ?? resolved.releaseSeat;
7003
+ const releaseAgentId = resolved.releaseAgentId ?? previousReleaseAgentId;
7004
+ return {
7005
+ ...route2,
7006
+ resolved: {
7007
+ ...resolved,
7008
+ seats: found.seats,
7009
+ ...releaseSeat2 ? { releaseSeat: releaseSeat2 } : {},
7010
+ ...releaseAgentId ? { releaseAgentId } : {}
7011
+ }
7012
+ };
7013
+ }
7014
+ function seatAgentId(route2, role) {
7015
+ return route2.resolved?.agentPersonas?.[role]?.agentId;
7016
+ }
7017
+ async function resolveReleaseKey(route2, getStore = getSessionStore) {
7018
+ const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
7019
+ const agentId = route2.resolved?.releaseAgentId;
7020
+ if (!agentId) return ship(keyIsAdmin(route2) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
7021
+ try {
7022
+ const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
7023
+ if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
7024
+ return ship(keyIsAdmin(route2) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
7025
+ } catch (e) {
7026
+ const reason = describeReadError(e);
7027
+ return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
7028
+ }
7029
+ }
7030
+ async function resolveSeatKey(route2, role, getStore = getSessionStore) {
7031
+ const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
7032
+ if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
7033
+ const agentId = seatAgentId(route2, role);
7034
+ if (!agentId) return ship(keyIsAdmin(route2) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
7035
+ try {
7036
+ const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
7037
+ if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
7038
+ return ship(keyIsAdmin(route2) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
7039
+ } catch (e) {
7040
+ const reason = describeReadError(e);
7041
+ return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
7042
+ }
7043
+ }
7044
+ function safeStderrLine(e) {
7045
+ const err3 = e;
7046
+ const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
7047
+ for (const line of raw.split("\n")) {
7048
+ const t = line.trim();
7049
+ if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
7050
+ return t.slice(0, 120);
7051
+ }
7052
+ return void 0;
7053
+ }
7054
+ function describeReadError(e) {
7055
+ const fsErr = e;
7056
+ if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
7057
+ const code = e?.code;
7058
+ const line = safeStderrLine(e);
7059
+ const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
7060
+ const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
7061
+ return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
7062
+ }
7063
+ function keychainExitCode(reason) {
7064
+ const m = /security exited (\d+)/.exec(reason);
7065
+ return m ? Number(m[1]) : void 0;
7066
+ }
7067
+ function describeStoreError(e) {
7068
+ const msg = e instanceof Error ? e.message : String(e);
7069
+ if (/User interaction is not allowed/i.test(msg)) {
7070
+ return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
7071
+ }
7072
+ return "keychain unavailable (could not write to the OS keychain)";
7073
+ }
7074
+ async function mintMissingSeatKeys(route2, o) {
7075
+ const out = [];
7076
+ if (!route2.resolved) return out;
7077
+ const resolved = route2.resolved;
7078
+ const doFetch = o.fetchImpl ?? fetch;
7079
+ const targets = [
7080
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role), actorRecordId: resolved.seats[role] })),
7081
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId, actorRecordId: resolved.releaseSeat }] : []
7082
+ ];
7083
+ let store;
7084
+ try {
7085
+ store = (o.getStore ?? getSessionStore)();
7086
+ } catch (e) {
7087
+ return targets.map(({ role }) => ({
7088
+ role,
7089
+ action: "failed",
7090
+ detail: describeStoreError(e)
7091
+ }));
7092
+ }
7093
+ const host = hostFromUrl2(route2.baseUrl);
7094
+ const base = route2.baseUrl.replace(/\/+$/, "");
7095
+ if (!o.dryRun) {
7096
+ const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
7097
+ try {
7098
+ await store.set(host, probeSlug, {
7099
+ apiKey: "probe",
7100
+ workspaceId: resolved.workspaceId,
7101
+ identityId: "_probe",
7102
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
7103
+ });
7104
+ await store.clear(host, probeSlug);
7105
+ } catch (e) {
7106
+ const detail = `${describeStoreError(e)}; no keys minted`;
7107
+ return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
7108
+ }
7109
+ }
7110
+ for (const { role, agentId, actorRecordId } of targets) {
7111
+ if (!agentId) {
7112
+ out.push({ role, action: "no_agent" });
7113
+ continue;
7114
+ }
7115
+ const slug2 = slugFor(route2, agentId);
7116
+ try {
7117
+ if ((await store.get(host, slug2))?.apiKey) {
7118
+ out.push({ role, action: "already_stored" });
7119
+ continue;
7120
+ }
7121
+ if (o.dryRun) {
7122
+ out.push({ role, action: "minted", detail: "dry run" });
7123
+ continue;
7124
+ }
7125
+ const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
7126
+ method: "POST",
7127
+ headers: {
7128
+ Authorization: `Bearer ${resolveApiKey(route2)}`,
7129
+ "Content-Type": "application/json",
7130
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
7131
+ },
7132
+ body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}`, ...actorRecordId ? { actorRecordId } : {} })
7133
+ });
7134
+ if (res.status === 403) {
7135
+ out.push({ role, action: "forbidden" });
7136
+ break;
7137
+ }
7138
+ if (!res.ok) {
7139
+ out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
7140
+ continue;
7141
+ }
7142
+ const body = await res.json();
7143
+ if (!body.key) {
7144
+ out.push({ role, action: "failed", detail: "response carried no key" });
7145
+ continue;
7146
+ }
7147
+ try {
7148
+ const session = {
7149
+ apiKey: body.key,
7150
+ workspaceId: resolved.workspaceId,
7151
+ identityId: agentId,
7152
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7153
+ ...body.id ? { keyId: body.id } : {}
7154
+ };
7155
+ await store.set(host, slug2, session);
7156
+ } catch (e) {
7157
+ let revoked = false;
7158
+ if (body.id) {
7159
+ try {
7160
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
7161
+ method: "DELETE",
7162
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
7163
+ });
7164
+ revoked = del2.ok;
7165
+ } catch {
7166
+ revoked = false;
7167
+ }
7168
+ }
7169
+ out.push({
7170
+ role,
7171
+ action: "failed",
7172
+ detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
7173
+ });
7174
+ continue;
7175
+ }
7176
+ out.push({ role, action: "minted" });
7177
+ } catch (e) {
7178
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
7179
+ }
7180
+ }
7181
+ return out;
7182
+ }
7183
+ function describeMintOutcome(o) {
7184
+ switch (o.action) {
7185
+ case "minted":
7186
+ return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
7187
+ case "already_stored":
7188
+ return `${o.role}: agent key already stored`;
7189
+ case "no_agent":
7190
+ return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
7191
+ case "forbidden":
7192
+ return FORBIDDEN_LINE;
7193
+ case "failed":
7194
+ return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
7195
+ }
7196
+ }
7197
+ async function revokeSeatKeys(route2, o) {
7198
+ const out = [];
7199
+ if (!route2.resolved) return out;
7200
+ const resolved = route2.resolved;
7201
+ const doFetch = o.fetchImpl ?? fetch;
7202
+ const targets = [
7203
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role), actorRecordId: resolved.seats[role] })),
7204
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId, actorRecordId: resolved.releaseSeat }] : []
7205
+ ];
7206
+ let store;
7207
+ try {
7208
+ store = (o.getStore ?? getSessionStore)();
7209
+ } catch (e) {
7210
+ return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
7211
+ }
7212
+ const host = hostFromUrl2(route2.baseUrl);
7213
+ const base = route2.baseUrl.replace(/\/+$/, "");
7214
+ for (const { role, agentId } of targets) {
7215
+ if (!agentId) {
7216
+ out.push({ role, action: "no_agent" });
7217
+ continue;
7218
+ }
7219
+ const slug2 = slugFor(route2, agentId);
7220
+ try {
7221
+ const session = await store.get(host, slug2);
7222
+ if (!session?.apiKey) {
7223
+ out.push({ role, action: "not_stored" });
7224
+ continue;
7225
+ }
7226
+ if (!session.keyId) {
7227
+ out.push({ role, action: "failed", detail: "no key id was recorded when this key was minted \u2014 revoke it from the seat's Agents row" });
7228
+ continue;
7229
+ }
7230
+ if (o.dryRun) {
7231
+ out.push({ role, action: "revoked", detail: "dry run" });
7232
+ continue;
7233
+ }
7234
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
7235
+ method: "DELETE",
7236
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
7237
+ });
7238
+ if (!del2.ok && del2.status !== 404) {
7239
+ out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
7240
+ continue;
7241
+ }
7242
+ await store.clear(host, slug2);
7243
+ out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
7244
+ } catch (e) {
7245
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
7246
+ }
7247
+ }
7248
+ return out;
7249
+ }
7250
+ function describeRevokeOutcome(o) {
7251
+ switch (o.action) {
7252
+ case "revoked":
7253
+ return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
7254
+ case "not_stored":
7255
+ return `${o.role}: no agent key stored on this ship`;
7256
+ case "no_agent":
7257
+ return `${o.role}: no linked Agents row`;
7258
+ case "failed":
7259
+ return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
7260
+ }
7261
+ }
7262
+ async function canMintAgentKeys(route2, o = {}) {
7263
+ const resolved = route2.resolved;
7264
+ if (!resolved) return void 0;
7265
+ const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
7266
+ if (!agentId) return void 0;
7267
+ try {
7268
+ const res = await (o.fetchImpl ?? fetch)(`${route2.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
7269
+ headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
7270
+ });
7271
+ if (res.ok) return true;
7272
+ return res.status === 403 ? false : void 0;
7273
+ } catch {
7274
+ return void 0;
7275
+ }
7276
+ }
7277
+
7278
+ // src/seat-read-probe.ts
7279
+ var SEAT_NO_READ_REMEDY = "the project has no default role for agent keys \u2014 update the Issues app's template, then re-mint with `crew agents sync --revoke-seat-keys`, or assign the Crew seat role to the agent members";
7280
+ function probeTables(route2) {
7281
+ const m = route2.resolved?.models;
7282
+ if (!m) return [];
7283
+ const r = route2.resolved;
7284
+ return [
7285
+ { name: "Issues", modelId: m.issues },
7286
+ { name: "Comments", modelId: m.comments },
7287
+ { name: "Crew", modelId: m.crew },
7288
+ { name: "Ships", modelId: r?.shipsModelId ?? "" }
7289
+ ].filter((t) => !!t.modelId);
7290
+ }
7291
+ async function probeTableRead(route2, modelId, apiKey, o = {}) {
7292
+ const url = `${route2.baseUrl.replace(/\/+$/, "")}/api/data-models/${modelId}/records?limit=1`;
7293
+ try {
7294
+ const res = await (o.fetchImpl ?? fetch)(url, {
7295
+ headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
7296
+ });
7297
+ if (res.ok) return { state: "ok" };
7298
+ if (res.status === 403) return { state: "forbidden" };
7299
+ return { state: "unknown", detail: `HTTP ${res.status}` };
7300
+ } catch (e) {
7301
+ return { state: "unknown", detail: e.message };
7302
+ }
7303
+ }
7304
+ async function probeSeatReads(route2, o = {}) {
7305
+ const out = [];
7306
+ const roles = [...SEAT_KEY_ROLES.filter((r) => route2.resolved?.seats[r]), ...route2.resolved?.releaseSeat ? ["release"] : []];
7307
+ for (const role of roles) {
7308
+ const id = role === "release" ? await resolveReleaseKey(route2, o.getStore) : await resolveSeatKey(route2, role, o.getStore);
7309
+ if (id.kind !== "agent") continue;
7310
+ const tables = [];
7311
+ for (const t of probeTables(route2)) tables.push({ table: t.name, ...await probeTableRead(route2, t.modelId, id.apiKey, o) });
7312
+ const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
7313
+ out.push({ role, state: bad?.state ?? (tables.length ? "ok" : "unknown"), detail: bad?.detail ?? (tables.length ? void 0 : "route has no resolved tables"), tables });
7314
+ }
7315
+ return out;
7316
+ }
7317
+ function forbiddenDetail(e, route2) {
7318
+ const msg = e instanceof Error ? e.message : String(e);
7319
+ const m = /^([A-Z]+) (\S+) -> 403/.exec(msg);
7320
+ const verb = /No (READ|WRITE) access/i.exec(msg)?.[1]?.toUpperCase();
7321
+ const modelId = /\/data-models\/([\w-]+)/i.exec(m?.[2] ?? "")?.[1];
7322
+ const r = route2?.resolved;
7323
+ const known = [
7324
+ [r?.models?.issues, "Issues"],
7325
+ [r?.models?.comments, "Comments"],
7326
+ [r?.models?.crew, "Crew"],
7327
+ [r?.shipsModelId, "Ships"],
7328
+ [r?.locksModelId, "Locks"],
7329
+ [r?.epicsModelId, "Epics"],
7330
+ [r?.periscopesModelId, "Periscopes"],
7331
+ [r?.reposModelId, "Repos"]
7332
+ ];
7333
+ const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
7334
+ return { method: m?.[1], path: m?.[2], table: table2, verb };
7335
+ }
7336
+ function remedyFor(d, roleName = "the agent role") {
7337
+ if (d.table && d.verb && !(d.table === "Issues" && d.verb === "READ")) {
7338
+ return `grant ${d.verb} on ${d.table} to ${roleName}`;
7339
+ }
7340
+ return SEAT_NO_READ_REMEDY;
7341
+ }
7342
+ function describeSeatForbidden(role, tables = []) {
7343
+ const bad = tables.filter((t) => t.state === "forbidden").map((t) => t.table);
7344
+ const what = bad.length ? bad.join(", ") : "Issues";
7345
+ const remedy = bad.length && !(bad.length === 1 && bad[0] === "Issues") ? bad.map((t) => remedyFor({ table: t, verb: "READ" })).join("; ") : SEAT_NO_READ_REMEDY;
7346
+ return `seat read: ERROR ${role} agent key cannot read ${what} (403) \u2014 ${remedy}`;
7347
+ }
7348
+ function describeSeatTables(role, tables) {
7349
+ const cells = tables.map((t) => `${t.table} ${t.state === "ok" ? "ok" : t.state === "forbidden" ? "403" : `error (${t.detail ?? "?"})`}`);
7350
+ return `seat read: ${role}: ${cells.join(", ")}`;
7351
+ }
7352
+ function isForbiddenError(e) {
7353
+ const status2 = e?.status;
7354
+ if (status2 === 403) return true;
7355
+ return /\b403\b/.test(e instanceof Error ? e.message : String(e));
7356
+ }
7357
+ var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
7358
+ function seatForbiddenItem(now = /* @__PURE__ */ new Date(), e, route2) {
7359
+ const d = e === void 0 ? {} : forbiddenDetail(e, route2);
7360
+ const what = d.method && d.table ? `${d.method} ${d.table}${d.verb ? ` -> 403 No ${d.verb} access` : " -> 403"}` : "cannot read the Issues table (403)";
7361
+ return {
7362
+ kind: "seat_forbidden",
7363
+ key: SEAT_FORBIDDEN_KEY,
7364
+ message: `release seat: ${what} \u2014 ${remedyFor(d)}`,
7365
+ since: now.toISOString()
7366
+ };
7367
+ }
7368
+ function seatForbiddenRepoLine(scope) {
7369
+ return `${scope}: skipped \u2014 release seat was refused by the tracker (see attention)`;
7370
+ }
7371
+ var SEAT_WRITE_ISSUE_FIELDS = [
7372
+ "status",
7373
+ "assignee_id",
7374
+ "held_by_ship_id",
7375
+ "work_started_at",
7376
+ "commit_sha",
7377
+ "merged_at",
7378
+ "resolved_at",
7379
+ "released_version",
7380
+ "released_at",
7381
+ "resolution_note",
7382
+ "agent_runs",
7383
+ "tokens_in",
7384
+ "tokens_out",
7385
+ "cache_read_tokens",
7386
+ "cache_write_tokens",
7387
+ "cost_usd"
7388
+ ];
7389
+ var canWrite = (level) => level === "WRITE" || level === "DELETE";
7390
+ function refusedIssueFields(body, columns = SEAT_WRITE_ISSUE_FIELDS) {
7391
+ const fields = body?.fields;
7392
+ if (!fields || typeof fields !== "object") return [];
7393
+ return columns.filter((c) => c in fields && !canWrite(fields[c]));
7394
+ }
7395
+ async function getJson(url, apiKey, o) {
7396
+ const res = await (o.fetchImpl ?? fetch)(url, {
7397
+ headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
7398
+ });
7399
+ if (!res.ok) return { status: res.status };
7400
+ try {
7401
+ return { status: res.status, body: await res.json() };
7402
+ } catch {
7403
+ return { status: 0 };
7404
+ }
7405
+ }
7406
+ function writeTables(route2) {
7407
+ const r = route2.resolved;
7408
+ return [
7409
+ { name: "Crew", modelId: r?.models?.crew ?? "" },
7410
+ { name: "Ships", modelId: r?.shipsModelId ?? "" },
7411
+ { name: "Locks", modelId: r?.locksModelId ?? "" },
7412
+ { name: "Periscopes", modelId: r?.periscopesModelId ?? "" }
7413
+ ].filter((t) => !!t.modelId);
7414
+ }
7415
+ async function probeKeyWrites(route2, apiKey, o = {}) {
7416
+ const base = route2.baseUrl.replace(/\/+$/, "");
7417
+ const out = [];
7418
+ const issues = route2.resolved?.models?.issues;
7419
+ const guard = async (table2, fn) => {
7420
+ try {
7421
+ out.push(await fn());
7422
+ } catch (e) {
7423
+ out.push({ table: table2, state: "unknown", fields: [], detail: e.message });
7424
+ }
7425
+ };
7426
+ if (issues) {
7427
+ await guard("Issues", async () => {
7428
+ const r = await getJson(`${base}/api/data-models/${issues}/field-access`, apiKey, o);
7429
+ if (r.status === 403) return { table: "Issues", state: "forbidden", fields: [] };
7430
+ if (!r.body) return { table: "Issues", state: "unknown", fields: [], detail: `HTTP ${r.status}` };
7431
+ const fields = refusedIssueFields(r.body);
7432
+ return { table: "Issues", state: fields.length ? "forbidden" : "ok", fields };
7433
+ });
7434
+ }
7435
+ for (const t of writeTables(route2)) {
7436
+ await guard(t.name, async () => {
7437
+ const r = await getJson(`${base}/api/data-models/${t.modelId}/access`, apiKey, o);
7438
+ if (r.status === 403) return { table: t.name, state: "forbidden", fields: [] };
7439
+ if (!r.body) return { table: t.name, state: "unknown", fields: [], detail: `HTTP ${r.status}` };
7440
+ return { table: t.name, state: canWrite(r.body.access) ? "ok" : "forbidden", fields: [] };
7441
+ });
7442
+ }
7443
+ return out;
7444
+ }
7445
+ async function probeSeatWrites(route2, o = {}) {
7446
+ const out = [];
7447
+ const all = [...SEAT_KEY_ROLES.filter((r) => route2.resolved?.seats[r]), ...route2.resolved?.releaseSeat ? ["release"] : []];
7448
+ const roles = o.only ? all.filter((r) => r === o.only) : all;
7449
+ for (const role of roles) {
7450
+ const id = role === "release" ? await resolveReleaseKey(route2, o.getStore) : await resolveSeatKey(route2, role, o.getStore);
7451
+ if (id.kind !== "agent") continue;
7452
+ const tables = await probeKeyWrites(route2, id.apiKey, o);
7453
+ const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
7454
+ out.push({ role, state: bad?.state ?? "ok", tables });
7455
+ }
7456
+ return out;
7457
+ }
7458
+ function describeWriteRefusals(tables) {
7459
+ return tables.filter((t) => t.state === "forbidden").map((t) => t.fields.length ? `${t.table} (${t.fields.join(", ")})` : t.table).join(", ");
7460
+ }
7461
+ var SEAT_NO_WRITE_REMEDY = "raise the grants on that role to WRITE, or point the project's default agent role at the Issues app template's";
7462
+ function seatCannotWriteMessage(role, tables) {
7463
+ return `${role} agent key cannot write ${describeWriteRefusals(tables)}`;
7464
+ }
7465
+ var seatCannotWriteKey = (role) => `seat_cannot_write:${role}`;
7466
+ function seatCannotWriteItem(role, detail, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
7467
+ return { kind: "seat_cannot_write", key: seatCannotWriteKey(role), since: nowIso, message: `${role} seat: ${detail} (403) \u2014 ${SEAT_NO_WRITE_REMEDY}` };
7468
+ }
7469
+ async function seatWriteStillRefused(route2, role, o = {}) {
7470
+ try {
7471
+ const [r] = await probeSeatWrites(route2, { ...o, only: role });
7472
+ return !!r && r.state !== "ok";
7473
+ } catch {
7474
+ return true;
7475
+ }
7476
+ }
7477
+
6982
7478
  // src/environment.ts
6983
7479
  import { basename as basename6 } from "path";
6984
7480
 
@@ -8223,6 +8719,22 @@ function isTransportFailure(isError, text2) {
8223
8719
  }
8224
8720
  return TRANSPORT_FAILURE.test(text2);
8225
8721
  }
8722
+ var FORBIDDEN_WRITE = /\b403\b[^]*?No WRITE access/i;
8723
+ function isForbiddenWrite(isError, text2) {
8724
+ if (FORBIDDEN_WRITE.test(text2)) return true;
8725
+ if (!isError) {
8726
+ try {
8727
+ const doc = JSON.parse(text2);
8728
+ return !!doc && typeof doc === "object" && doc.statusCode === 403;
8729
+ } catch {
8730
+ }
8731
+ }
8732
+ return false;
8733
+ }
8734
+ function forbiddenDetail403(text2) {
8735
+ const m = /No WRITE access to (?:"[^"]+"|this model|[\w-]+)/i.exec(text2);
8736
+ return m ? m[0] : "a hand-off write was refused";
8737
+ }
8226
8738
  var HandoffTranscriptTracker = class {
8227
8739
  open = /* @__PURE__ */ new Map();
8228
8740
  /** Per ticket record: the last hand-off status write's outcome. */
@@ -8230,6 +8742,9 @@ var HandoffTranscriptTracker = class {
8230
8742
  /** Per ticket record: the last hand-off comment that failed after the status write's turn. */
8231
8743
  comment = /* @__PURE__ */ new Map();
8232
8744
  landed = /* @__PURE__ */ new Set();
8745
+ /** Per ticket record: the last hand-off status write, when the tracker refused it with 403. */
8746
+ forbiddenWrites = /* @__PURE__ */ new Map();
8747
+ statusLanded = false;
8233
8748
  lastText = "";
8234
8749
  resultLine = "";
8235
8750
  feed(parsed) {
@@ -8250,7 +8765,16 @@ var HandoffTranscriptTracker = class {
8250
8765
  const w = this.open.get(b.tool_use_id);
8251
8766
  if (!w) continue;
8252
8767
  this.open.delete(b.tool_use_id);
8253
- const failed = isTransportFailure(b.is_error === true, resultText(b.content));
8768
+ const text2 = resultText(b.content);
8769
+ if (w.kind === "handoff") {
8770
+ const refused = isForbiddenWrite(b.is_error === true, text2);
8771
+ if (refused) this.forbiddenWrites.set(w.recordId, forbiddenDetail403(text2));
8772
+ else {
8773
+ this.forbiddenWrites.delete(w.recordId);
8774
+ if (!isTransportFailure(b.is_error === true, text2)) this.statusLanded = true;
8775
+ }
8776
+ }
8777
+ const failed = isTransportFailure(b.is_error === true, text2);
8254
8778
  if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
8255
8779
  else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
8256
8780
  else if (!failed) {
@@ -8260,6 +8784,15 @@ var HandoffTranscriptTracker = class {
8260
8784
  }
8261
8785
  }
8262
8786
  }
8787
+ /** The newest hand-off write the tracker refused with 403 and no later success for that ticket, else null. */
8788
+ forbidden() {
8789
+ const last = [...this.forbiddenWrites].at(-1);
8790
+ return last ? { ticketRecordId: last[0], detail: last[1] } : null;
8791
+ }
8792
+ /** A hand-off status write went through, so the seat can write what a hand-off needs. */
8793
+ handoffLanded() {
8794
+ return this.statusLanded && this.forbiddenWrites.size === 0;
8795
+ }
8263
8796
  /** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
8264
8797
  pending() {
8265
8798
  const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
@@ -11689,6 +12222,18 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11689
12222
  emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
11690
12223
  }
11691
12224
  }
12225
+ const forbidden = end.interrupted ? null : obs.handoff.forbidden();
12226
+ if (forbidden) {
12227
+ try {
12228
+ await raiseShipAttention({ state: new State(stateDirOfStream(id.streamPath)), emit }, seatCannotWriteItem(id.role, forbidden.detail));
12229
+ } catch {
12230
+ }
12231
+ } else if (!end.interrupted && obs.handoff.handoffLanded()) {
12232
+ try {
12233
+ clearShipAttention(new State(stateDirOfStream(id.streamPath)), seatCannotWriteKey(id.role));
12234
+ } catch {
12235
+ }
12236
+ }
11692
12237
  const before = obs.beforeFirstEdit.result();
11693
12238
  try {
11694
12239
  writeRunMeta(id, startedAt, obs.liveTicket, {
@@ -11712,6 +12257,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11712
12257
  ms: ms3,
11713
12258
  code: end.code,
11714
12259
  ...pending ? { handoff: "pending" } : {},
12260
+ ...forbidden ? { reason: "forbidden", forbidden: forbidden.detail } : {},
11715
12261
  ...end.interrupted ? { interrupted: end.interrupted } : {},
11716
12262
  ...identity,
11717
12263
  attributedTicket: attributedTicket ?? null,
@@ -11728,7 +12274,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11728
12274
  if (id.agentLog && agentLogKey) {
11729
12275
  const report = {
11730
12276
  ticketReference: attributedTicket,
11731
- outcome: end.interrupted ? "interrupted" : signedOut ? "error" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
12277
+ outcome: end.interrupted ? "interrupted" : signedOut || forbidden ? "error" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
11732
12278
  startedAt,
11733
12279
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
11734
12280
  cycles: obs.cycles,
@@ -11938,496 +12484,200 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
11938
12484
  o.onAdopted?.();
11939
12485
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
11940
12486
  while (isAlive(sc)) {
11941
- obs.feed(tail3.poll());
11942
- if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
11943
- killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
11944
- continue;
11945
- }
11946
- await new Promise((r) => setTimeout(r, pollMs));
11947
- }
11948
- }
11949
- obs.feed(tail3.poll());
11950
- obs.end();
11951
- await endSink(eventsSink);
11952
- await runner.release(runner.reattach(sc), emit);
11953
- const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
11954
- const { ms: ms3, ticket } = await finishRun(
11955
- id,
11956
- obs,
11957
- emit,
11958
- sc.spawnedAt,
11959
- { code: null, ...interrupted ? { interrupted } : {} },
11960
- o.agentLogKey
11961
- );
11962
- return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
11963
- }
11964
-
11965
- // src/interrupted.ts
11966
- var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
11967
- function interruptedComment(b) {
11968
- const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
11969
- return `${INTERRUPTED_MARKER}
11970
-
11971
- The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
11972
- }
11973
- var handled = /* @__PURE__ */ new Set();
11974
- var pidAlive = (pid) => {
11975
- try {
11976
- process.kill(pid, 0);
11977
- return true;
11978
- } catch (e) {
11979
- return e.code === "EPERM";
11980
- }
11981
- };
11982
- function runFinished(streamPath) {
11983
- const path = `${streamPath}.meta.json`;
11984
- if (!existsSync27(path)) return false;
11985
- try {
11986
- return JSON.parse(readFileSync27(path, "utf8")).running !== true;
11987
- } catch {
11988
- return true;
11989
- }
11990
- }
11991
- function orphanedStreams(o) {
11992
- const dir = join30(o.stateDir, "streams");
11993
- if (!existsSync27(dir)) return [];
11994
- const ownerAlive = o.isOwnerAlive ?? pidAlive;
11995
- const out = [];
11996
- for (const name of readdirSync9(dir)) {
11997
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
11998
- const streamPath = join30(dir, name);
11999
- if (runFinished(streamPath)) continue;
12000
- const sidecar = readRunSidecar(streamPath);
12001
- if (!sidecar || sidecar.route !== o.routeLabel) continue;
12002
- if (ownerAlive(sidecar.ownerPid)) continue;
12003
- out.push({ streamPath, sidecar });
12004
- }
12005
- return out;
12006
- }
12007
- async function reconcileRuns(o) {
12008
- let adopted = 0;
12009
- let interrupted = 0;
12010
- for (const { streamPath, sidecar } of orphanedStreams(o)) {
12011
- if (handled.has(streamPath)) continue;
12012
- handled.add(streamPath);
12013
- const isAlive = o.isAlive ?? runIsAlive;
12014
- const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
12015
- const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
12016
- const work = (async () => {
12017
- try {
12018
- const result = await reattachRun(streamPath, sidecar, o.emit, {
12019
- agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
12020
- isAlive: o.isAlive,
12021
- pollMs: o.pollMs,
12022
- onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
12023
- });
12024
- if (result.adopted) adopted++;
12025
- else interrupted++;
12026
- try {
12027
- await o.afterRun?.(sidecar, result);
12028
- } catch (e) {
12029
- o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12030
- }
12031
- } catch (e) {
12032
- o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12033
- } finally {
12034
- if (lock?.ok) lock.release();
12035
- if (ticketLock?.ok) ticketLock.release();
12036
- }
12037
- })();
12038
- if (isAlive(sidecar)) {
12039
- if (o.inFlight) {
12040
- o.inFlight.add(work);
12041
- void work.finally(() => o.inFlight.delete(work));
12042
- }
12043
- } else {
12044
- await work;
12045
- }
12046
- }
12047
- return { adopted, interrupted };
12048
- }
12049
-
12050
- // src/live-runs.ts
12051
- import { existsSync as existsSync28, readdirSync as readdirSync10, statSync as statSync9 } from "fs";
12052
- import { join as join31 } from "path";
12053
- function liveRuns(stateDir, isAlive = runIsAlive) {
12054
- const dir = join31(stateDir, "streams");
12055
- if (!existsSync28(dir)) return [];
12056
- const out = [];
12057
- for (const name of readdirSync10(dir)) {
12058
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12059
- const streamPath = join31(dir, name);
12060
- if (runFinished(streamPath)) continue;
12061
- const sidecar = readRunSidecar(streamPath);
12062
- if (!sidecar || !isAlive(sidecar)) continue;
12063
- out.push({ ...sidecar, streamPath });
12064
- }
12065
- return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
12066
- }
12067
- function runActivity(stateDir, isAlive = runIsAlive) {
12068
- const activeRuns = liveRuns(stateDir, isAlive).length;
12069
- const dir = join31(stateDir, "streams");
12070
- let newest = 0;
12071
- if (existsSync28(dir)) {
12072
- for (const name of readdirSync10(dir)) {
12073
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12074
- const streamPath = join31(dir, name);
12075
- if (!runFinished(streamPath)) continue;
12076
- try {
12077
- newest = Math.max(newest, statSync9(`${streamPath}.meta.json`).mtimeMs);
12078
- } catch {
12079
- }
12080
- }
12081
- }
12082
- return { activeRuns, lastRunEndedAt: newest ? new Date(newest).toISOString() : null };
12083
- }
12084
- function siblingRuns(runs, ownTicketRecordId) {
12085
- return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
12086
- }
12087
- async function settleRunEnd(o) {
12088
- const { tracker, emit } = o;
12089
- const all = o.runs ?? liveRuns(o.stateDir);
12090
- const slug2 = routeSlug(o.route.route);
12091
- const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
12092
- const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
12093
- const newest = (rs) => rs[rs.length - 1];
12094
- if (o.memberId) {
12095
- try {
12096
- if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
12097
- else await tracker.setCrewStatus(o.memberId, "idle");
12098
- } catch (e) {
12099
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
12100
- }
12101
- }
12102
- try {
12103
- if (shipSiblings.length) {
12104
- const n = newest(shipSiblings);
12105
- await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
12106
- } else {
12107
- await tracker.beatIdle(o.shipName);
12108
- }
12109
- } catch (e) {
12110
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
12111
- }
12112
- }
12113
- function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
12114
- try {
12115
- return worktreeForNumber(d, n)?.path ?? null;
12116
- } catch {
12117
- return null;
12118
- }
12119
- }) {
12120
- const ownRoute = run3.route === routeSlug(route2.route);
12121
- const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
12122
- const num = run3.ticket?.replace(/^\D+/, "") ?? "";
12123
- return {
12124
- role: run3.role,
12125
- seat: ownRoute ? seatIds[run3.role] ?? null : null,
12126
- ticket: run3.ticket,
12127
- ticketRecordId: run3.ticketRecordId,
12128
- repo: run3.repo ?? null,
12129
- worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
12130
- stream: run3.streamPath,
12131
- pid: run3.pid,
12132
- startedAt: run3.spawnedAt,
12133
- deadline: run3.deadline ?? null
12134
- };
12135
- }
12136
-
12137
- // src/seat-keys.ts
12138
- import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
12139
- var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
12140
- function keyIsAdmin(route2) {
12141
- const role = route2.resolved?.role;
12142
- return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
12143
- }
12144
- var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
12145
- var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
12146
- var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
12147
- function seatKeySlug(workspaceSlug, agentId) {
12148
- return `${workspaceSlug}#agent:${agentId}`;
12149
- }
12150
- function slugFor(route2, agentId) {
12151
- const [workspaceSlug] = splitRoute(route2.route);
12152
- return seatKeySlug(workspaceSlug, agentId);
12153
- }
12154
- function withFoundSeats(route2, found, previousReleaseAgentId) {
12155
- const resolved = route2.resolved;
12156
- if (!resolved) return route2;
12157
- const releaseSeat2 = found.releaseSeat ?? resolved.releaseSeat;
12158
- const releaseAgentId = resolved.releaseAgentId ?? previousReleaseAgentId;
12159
- return {
12160
- ...route2,
12161
- resolved: {
12162
- ...resolved,
12163
- seats: found.seats,
12164
- ...releaseSeat2 ? { releaseSeat: releaseSeat2 } : {},
12165
- ...releaseAgentId ? { releaseAgentId } : {}
12487
+ obs.feed(tail3.poll());
12488
+ if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
12489
+ killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
12490
+ continue;
12491
+ }
12492
+ await new Promise((r) => setTimeout(r, pollMs));
12166
12493
  }
12167
- };
12494
+ }
12495
+ obs.feed(tail3.poll());
12496
+ obs.end();
12497
+ await endSink(eventsSink);
12498
+ await runner.release(runner.reattach(sc), emit);
12499
+ const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
12500
+ const { ms: ms3, ticket } = await finishRun(
12501
+ id,
12502
+ obs,
12503
+ emit,
12504
+ sc.spawnedAt,
12505
+ { code: null, ...interrupted ? { interrupted } : {} },
12506
+ o.agentLogKey
12507
+ );
12508
+ return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
12168
12509
  }
12169
- function seatAgentId(route2, role) {
12170
- return route2.resolved?.agentPersonas?.[role]?.agentId;
12510
+
12511
+ // src/interrupted.ts
12512
+ var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
12513
+ function interruptedComment(b) {
12514
+ const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
12515
+ return `${INTERRUPTED_MARKER}
12516
+
12517
+ The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
12171
12518
  }
12172
- async function resolveReleaseKey(route2, getStore = getSessionStore) {
12173
- const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
12174
- const agentId = route2.resolved?.releaseAgentId;
12175
- if (!agentId) return ship(keyIsAdmin(route2) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
12519
+ var handled = /* @__PURE__ */ new Set();
12520
+ var pidAlive = (pid) => {
12176
12521
  try {
12177
- const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
12178
- if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
12179
- return ship(keyIsAdmin(route2) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
12522
+ process.kill(pid, 0);
12523
+ return true;
12180
12524
  } catch (e) {
12181
- const reason = describeReadError(e);
12182
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
12525
+ return e.code === "EPERM";
12183
12526
  }
12184
- }
12185
- async function resolveSeatKey(route2, role, getStore = getSessionStore) {
12186
- const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
12187
- if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
12188
- const agentId = seatAgentId(route2, role);
12189
- if (!agentId) return ship(keyIsAdmin(route2) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
12527
+ };
12528
+ function runFinished(streamPath) {
12529
+ const path = `${streamPath}.meta.json`;
12530
+ if (!existsSync27(path)) return false;
12190
12531
  try {
12191
- const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
12192
- if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
12193
- return ship(keyIsAdmin(route2) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
12194
- } catch (e) {
12195
- const reason = describeReadError(e);
12196
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
12197
- }
12198
- }
12199
- function safeStderrLine(e) {
12200
- const err3 = e;
12201
- const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
12202
- for (const line of raw.split("\n")) {
12203
- const t = line.trim();
12204
- if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
12205
- return t.slice(0, 120);
12206
- }
12207
- return void 0;
12208
- }
12209
- function describeReadError(e) {
12210
- const fsErr = e;
12211
- if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
12212
- const code = e?.code;
12213
- const line = safeStderrLine(e);
12214
- const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
12215
- const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
12216
- return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
12217
- }
12218
- function keychainExitCode(reason) {
12219
- const m = /security exited (\d+)/.exec(reason);
12220
- return m ? Number(m[1]) : void 0;
12221
- }
12222
- function describeStoreError(e) {
12223
- const msg = e instanceof Error ? e.message : String(e);
12224
- if (/User interaction is not allowed/i.test(msg)) {
12225
- return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
12532
+ return JSON.parse(readFileSync27(path, "utf8")).running !== true;
12533
+ } catch {
12534
+ return true;
12226
12535
  }
12227
- return "keychain unavailable (could not write to the OS keychain)";
12228
12536
  }
12229
- async function mintMissingSeatKeys(route2, o) {
12537
+ function orphanedStreams(o) {
12538
+ const dir = join30(o.stateDir, "streams");
12539
+ if (!existsSync27(dir)) return [];
12540
+ const ownerAlive = o.isOwnerAlive ?? pidAlive;
12230
12541
  const out = [];
12231
- if (!route2.resolved) return out;
12232
- const resolved = route2.resolved;
12233
- const doFetch = o.fetchImpl ?? fetch;
12234
- const targets = [
12235
- ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role), actorRecordId: resolved.seats[role] })),
12236
- ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId, actorRecordId: resolved.releaseSeat }] : []
12237
- ];
12238
- let store;
12239
- try {
12240
- store = (o.getStore ?? getSessionStore)();
12241
- } catch (e) {
12242
- return targets.map(({ role }) => ({
12243
- role,
12244
- action: "failed",
12245
- detail: describeStoreError(e)
12246
- }));
12247
- }
12248
- const host = hostFromUrl2(route2.baseUrl);
12249
- const base = route2.baseUrl.replace(/\/+$/, "");
12250
- if (!o.dryRun) {
12251
- const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
12252
- try {
12253
- await store.set(host, probeSlug, {
12254
- apiKey: "probe",
12255
- workspaceId: resolved.workspaceId,
12256
- identityId: "_probe",
12257
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
12258
- });
12259
- await store.clear(host, probeSlug);
12260
- } catch (e) {
12261
- const detail = `${describeStoreError(e)}; no keys minted`;
12262
- return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
12263
- }
12542
+ for (const name of readdirSync9(dir)) {
12543
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12544
+ const streamPath = join30(dir, name);
12545
+ if (runFinished(streamPath)) continue;
12546
+ const sidecar = readRunSidecar(streamPath);
12547
+ if (!sidecar || sidecar.route !== o.routeLabel) continue;
12548
+ if (ownerAlive(sidecar.ownerPid)) continue;
12549
+ out.push({ streamPath, sidecar });
12264
12550
  }
12265
- for (const { role, agentId, actorRecordId } of targets) {
12266
- if (!agentId) {
12267
- out.push({ role, action: "no_agent" });
12268
- continue;
12269
- }
12270
- const slug2 = slugFor(route2, agentId);
12271
- try {
12272
- if ((await store.get(host, slug2))?.apiKey) {
12273
- out.push({ role, action: "already_stored" });
12274
- continue;
12275
- }
12276
- if (o.dryRun) {
12277
- out.push({ role, action: "minted", detail: "dry run" });
12278
- continue;
12279
- }
12280
- const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
12281
- method: "POST",
12282
- headers: {
12283
- Authorization: `Bearer ${resolveApiKey(route2)}`,
12284
- "Content-Type": "application/json",
12285
- "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
12286
- },
12287
- body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}`, ...actorRecordId ? { actorRecordId } : {} })
12288
- });
12289
- if (res.status === 403) {
12290
- out.push({ role, action: "forbidden" });
12291
- break;
12292
- }
12293
- if (!res.ok) {
12294
- out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
12295
- continue;
12296
- }
12297
- const body = await res.json();
12298
- if (!body.key) {
12299
- out.push({ role, action: "failed", detail: "response carried no key" });
12300
- continue;
12301
- }
12551
+ return out;
12552
+ }
12553
+ async function reconcileRuns(o) {
12554
+ let adopted = 0;
12555
+ let interrupted = 0;
12556
+ for (const { streamPath, sidecar } of orphanedStreams(o)) {
12557
+ if (handled.has(streamPath)) continue;
12558
+ handled.add(streamPath);
12559
+ const isAlive = o.isAlive ?? runIsAlive;
12560
+ const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
12561
+ const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
12562
+ const work = (async () => {
12302
12563
  try {
12303
- const session = {
12304
- apiKey: body.key,
12305
- workspaceId: resolved.workspaceId,
12306
- identityId: agentId,
12307
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
12308
- ...body.id ? { keyId: body.id } : {}
12309
- };
12310
- await store.set(host, slug2, session);
12311
- } catch (e) {
12312
- let revoked = false;
12313
- if (body.id) {
12314
- try {
12315
- const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
12316
- method: "DELETE",
12317
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12318
- });
12319
- revoked = del2.ok;
12320
- } catch {
12321
- revoked = false;
12322
- }
12323
- }
12324
- out.push({
12325
- role,
12326
- action: "failed",
12327
- detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
12564
+ const result = await reattachRun(streamPath, sidecar, o.emit, {
12565
+ agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
12566
+ isAlive: o.isAlive,
12567
+ pollMs: o.pollMs,
12568
+ onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
12328
12569
  });
12329
- continue;
12570
+ if (result.adopted) adopted++;
12571
+ else interrupted++;
12572
+ try {
12573
+ await o.afterRun?.(sidecar, result);
12574
+ } catch (e) {
12575
+ o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12576
+ }
12577
+ } catch (e) {
12578
+ o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12579
+ } finally {
12580
+ if (lock?.ok) lock.release();
12581
+ if (ticketLock?.ok) ticketLock.release();
12330
12582
  }
12331
- out.push({ role, action: "minted" });
12332
- } catch (e) {
12333
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
12334
- }
12335
- }
12336
- return out;
12337
- }
12338
- function describeMintOutcome(o) {
12339
- switch (o.action) {
12340
- case "minted":
12341
- return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
12342
- case "already_stored":
12343
- return `${o.role}: agent key already stored`;
12344
- case "no_agent":
12345
- return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
12346
- case "forbidden":
12347
- return FORBIDDEN_LINE;
12348
- case "failed":
12349
- return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
12583
+ })();
12584
+ if (isAlive(sidecar)) {
12585
+ if (o.inFlight) {
12586
+ o.inFlight.add(work);
12587
+ void work.finally(() => o.inFlight.delete(work));
12588
+ }
12589
+ } else {
12590
+ await work;
12591
+ }
12350
12592
  }
12593
+ return { adopted, interrupted };
12351
12594
  }
12352
- async function revokeSeatKeys(route2, o) {
12595
+
12596
+ // src/live-runs.ts
12597
+ import { existsSync as existsSync28, readdirSync as readdirSync10, statSync as statSync9 } from "fs";
12598
+ import { join as join31 } from "path";
12599
+ function liveRuns(stateDir, isAlive = runIsAlive) {
12600
+ const dir = join31(stateDir, "streams");
12601
+ if (!existsSync28(dir)) return [];
12353
12602
  const out = [];
12354
- if (!route2.resolved) return out;
12355
- const resolved = route2.resolved;
12356
- const doFetch = o.fetchImpl ?? fetch;
12357
- const targets = [
12358
- ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role), actorRecordId: resolved.seats[role] })),
12359
- ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId, actorRecordId: resolved.releaseSeat }] : []
12360
- ];
12361
- let store;
12362
- try {
12363
- store = (o.getStore ?? getSessionStore)();
12364
- } catch (e) {
12365
- return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
12603
+ for (const name of readdirSync10(dir)) {
12604
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12605
+ const streamPath = join31(dir, name);
12606
+ if (runFinished(streamPath)) continue;
12607
+ const sidecar = readRunSidecar(streamPath);
12608
+ if (!sidecar || !isAlive(sidecar)) continue;
12609
+ out.push({ ...sidecar, streamPath });
12366
12610
  }
12367
- const host = hostFromUrl2(route2.baseUrl);
12368
- const base = route2.baseUrl.replace(/\/+$/, "");
12369
- for (const { role, agentId } of targets) {
12370
- if (!agentId) {
12371
- out.push({ role, action: "no_agent" });
12372
- continue;
12611
+ return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
12612
+ }
12613
+ function runActivity(stateDir, isAlive = runIsAlive) {
12614
+ const activeRuns = liveRuns(stateDir, isAlive).length;
12615
+ const dir = join31(stateDir, "streams");
12616
+ let newest = 0;
12617
+ if (existsSync28(dir)) {
12618
+ for (const name of readdirSync10(dir)) {
12619
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12620
+ const streamPath = join31(dir, name);
12621
+ if (!runFinished(streamPath)) continue;
12622
+ try {
12623
+ newest = Math.max(newest, statSync9(`${streamPath}.meta.json`).mtimeMs);
12624
+ } catch {
12625
+ }
12373
12626
  }
12374
- const slug2 = slugFor(route2, agentId);
12627
+ }
12628
+ return { activeRuns, lastRunEndedAt: newest ? new Date(newest).toISOString() : null };
12629
+ }
12630
+ function siblingRuns(runs, ownTicketRecordId) {
12631
+ return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
12632
+ }
12633
+ async function settleRunEnd(o) {
12634
+ const { tracker, emit } = o;
12635
+ const all = o.runs ?? liveRuns(o.stateDir);
12636
+ const slug2 = routeSlug(o.route.route);
12637
+ const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
12638
+ const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
12639
+ const newest = (rs) => rs[rs.length - 1];
12640
+ if (o.memberId) {
12375
12641
  try {
12376
- const session = await store.get(host, slug2);
12377
- if (!session?.apiKey) {
12378
- out.push({ role, action: "not_stored" });
12379
- continue;
12380
- }
12381
- if (!session.keyId) {
12382
- out.push({ role, action: "failed", detail: "no key id was recorded when this key was minted \u2014 revoke it from the seat's Agents row" });
12383
- continue;
12384
- }
12385
- if (o.dryRun) {
12386
- out.push({ role, action: "revoked", detail: "dry run" });
12387
- continue;
12388
- }
12389
- const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
12390
- method: "DELETE",
12391
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12392
- });
12393
- if (!del2.ok && del2.status !== 404) {
12394
- out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
12395
- continue;
12396
- }
12397
- await store.clear(host, slug2);
12398
- out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
12642
+ if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
12643
+ else await tracker.setCrewStatus(o.memberId, "idle");
12399
12644
  } catch (e) {
12400
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
12645
+ emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
12401
12646
  }
12402
12647
  }
12403
- return out;
12404
- }
12405
- function describeRevokeOutcome(o) {
12406
- switch (o.action) {
12407
- case "revoked":
12408
- return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
12409
- case "not_stored":
12410
- return `${o.role}: no agent key stored on this ship`;
12411
- case "no_agent":
12412
- return `${o.role}: no linked Agents row`;
12413
- case "failed":
12414
- return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
12648
+ try {
12649
+ if (shipSiblings.length) {
12650
+ const n = newest(shipSiblings);
12651
+ await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
12652
+ } else {
12653
+ await tracker.beatIdle(o.shipName);
12654
+ }
12655
+ } catch (e) {
12656
+ emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
12415
12657
  }
12416
12658
  }
12417
- async function canMintAgentKeys(route2, o = {}) {
12418
- const resolved = route2.resolved;
12419
- if (!resolved) return void 0;
12420
- const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
12421
- if (!agentId) return void 0;
12659
+ function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
12422
12660
  try {
12423
- const res = await (o.fetchImpl ?? fetch)(`${route2.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
12424
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12425
- });
12426
- if (res.ok) return true;
12427
- return res.status === 403 ? false : void 0;
12661
+ return worktreeForNumber(d, n)?.path ?? null;
12428
12662
  } catch {
12429
- return void 0;
12663
+ return null;
12430
12664
  }
12665
+ }) {
12666
+ const ownRoute = run3.route === routeSlug(route2.route);
12667
+ const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
12668
+ const num = run3.ticket?.replace(/^\D+/, "") ?? "";
12669
+ return {
12670
+ role: run3.role,
12671
+ seat: ownRoute ? seatIds[run3.role] ?? null : null,
12672
+ ticket: run3.ticket,
12673
+ ticketRecordId: run3.ticketRecordId,
12674
+ repo: run3.repo ?? null,
12675
+ worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
12676
+ stream: run3.streamPath,
12677
+ pid: run3.pid,
12678
+ startedAt: run3.spawnedAt,
12679
+ deadline: run3.deadline ?? null
12680
+ };
12431
12681
  }
12432
12682
 
12433
12683
  // src/deprovision-host.ts
@@ -19499,100 +19749,6 @@ function formatWhoami(report) {
19499
19749
  `;
19500
19750
  }
19501
19751
 
19502
- // src/seat-read-probe.ts
19503
- var SEAT_NO_READ_REMEDY = "the project has no default role for agent keys \u2014 update the Issues app's template, then re-mint with `crew agents sync --revoke-seat-keys`, or assign the Crew seat role to the agent members";
19504
- function probeTables(route2) {
19505
- const m = route2.resolved?.models;
19506
- if (!m) return [];
19507
- const r = route2.resolved;
19508
- return [
19509
- { name: "Issues", modelId: m.issues },
19510
- { name: "Comments", modelId: m.comments },
19511
- { name: "Crew", modelId: m.crew },
19512
- { name: "Ships", modelId: r?.shipsModelId ?? "" }
19513
- ].filter((t) => !!t.modelId);
19514
- }
19515
- async function probeTableRead(route2, modelId, apiKey, o = {}) {
19516
- const url = `${route2.baseUrl.replace(/\/+$/, "")}/api/data-models/${modelId}/records?limit=1`;
19517
- try {
19518
- const res = await (o.fetchImpl ?? fetch)(url, {
19519
- headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
19520
- });
19521
- if (res.ok) return { state: "ok" };
19522
- if (res.status === 403) return { state: "forbidden" };
19523
- return { state: "unknown", detail: `HTTP ${res.status}` };
19524
- } catch (e) {
19525
- return { state: "unknown", detail: e.message };
19526
- }
19527
- }
19528
- async function probeSeatReads(route2, o = {}) {
19529
- const out = [];
19530
- const roles = [...SEAT_KEY_ROLES.filter((r) => route2.resolved?.seats[r]), ...route2.resolved?.releaseSeat ? ["release"] : []];
19531
- for (const role of roles) {
19532
- const id = role === "release" ? await resolveReleaseKey(route2, o.getStore) : await resolveSeatKey(route2, role, o.getStore);
19533
- if (id.kind !== "agent") continue;
19534
- const tables = [];
19535
- for (const t of probeTables(route2)) tables.push({ table: t.name, ...await probeTableRead(route2, t.modelId, id.apiKey, o) });
19536
- const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
19537
- out.push({ role, state: bad?.state ?? (tables.length ? "ok" : "unknown"), detail: bad?.detail ?? (tables.length ? void 0 : "route has no resolved tables"), tables });
19538
- }
19539
- return out;
19540
- }
19541
- function forbiddenDetail(e, route2) {
19542
- const msg = e instanceof Error ? e.message : String(e);
19543
- const m = /^([A-Z]+) (\S+) -> 403/.exec(msg);
19544
- const verb = /No (READ|WRITE) access/i.exec(msg)?.[1]?.toUpperCase();
19545
- const modelId = /\/data-models\/([\w-]+)/i.exec(m?.[2] ?? "")?.[1];
19546
- const r = route2?.resolved;
19547
- const known = [
19548
- [r?.models?.issues, "Issues"],
19549
- [r?.models?.comments, "Comments"],
19550
- [r?.models?.crew, "Crew"],
19551
- [r?.shipsModelId, "Ships"],
19552
- [r?.locksModelId, "Locks"],
19553
- [r?.epicsModelId, "Epics"],
19554
- [r?.periscopesModelId, "Periscopes"],
19555
- [r?.reposModelId, "Repos"]
19556
- ];
19557
- const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
19558
- return { method: m?.[1], path: m?.[2], table: table2, verb };
19559
- }
19560
- function remedyFor(d, roleName = "the agent role") {
19561
- if (d.table && d.verb && !(d.table === "Issues" && d.verb === "READ")) {
19562
- return `grant ${d.verb} on ${d.table} to ${roleName}`;
19563
- }
19564
- return SEAT_NO_READ_REMEDY;
19565
- }
19566
- function describeSeatForbidden(role, tables = []) {
19567
- const bad = tables.filter((t) => t.state === "forbidden").map((t) => t.table);
19568
- const what = bad.length ? bad.join(", ") : "Issues";
19569
- const remedy = bad.length && !(bad.length === 1 && bad[0] === "Issues") ? bad.map((t) => remedyFor({ table: t, verb: "READ" })).join("; ") : SEAT_NO_READ_REMEDY;
19570
- return `seat read: ERROR ${role} agent key cannot read ${what} (403) \u2014 ${remedy}`;
19571
- }
19572
- function describeSeatTables(role, tables) {
19573
- const cells = tables.map((t) => `${t.table} ${t.state === "ok" ? "ok" : t.state === "forbidden" ? "403" : `error (${t.detail ?? "?"})`}`);
19574
- return `seat read: ${role}: ${cells.join(", ")}`;
19575
- }
19576
- function isForbiddenError(e) {
19577
- const status2 = e?.status;
19578
- if (status2 === 403) return true;
19579
- return /\b403\b/.test(e instanceof Error ? e.message : String(e));
19580
- }
19581
- var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
19582
- function seatForbiddenItem(now = /* @__PURE__ */ new Date(), e, route2) {
19583
- const d = e === void 0 ? {} : forbiddenDetail(e, route2);
19584
- const what = d.method && d.table ? `${d.method} ${d.table}${d.verb ? ` -> 403 No ${d.verb} access` : " -> 403"}` : "cannot read the Issues table (403)";
19585
- return {
19586
- kind: "seat_forbidden",
19587
- key: SEAT_FORBIDDEN_KEY,
19588
- message: `release seat: ${what} \u2014 ${remedyFor(d)}`,
19589
- since: now.toISOString()
19590
- };
19591
- }
19592
- function seatForbiddenRepoLine(scope) {
19593
- return `${scope}: skipped \u2014 release seat was refused by the tracker (see attention)`;
19594
- }
19595
-
19596
19752
  // src/cli.ts
19597
19753
  import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
19598
19754
 
@@ -23504,6 +23660,14 @@ async function runOnePassUnguarded(o) {
23504
23660
  for (const role of order) {
23505
23661
  const count = live.get(role);
23506
23662
  if (count === void 0) continue;
23663
+ if (openShipAttention(o.state).some((i) => i.key === seatCannotWriteKey(role))) {
23664
+ if (await (o.seatWriteRefused ?? ((r) => seatWriteStillRefused(o.route, r, { userAgent: o.ship.userAgent })))(role)) {
23665
+ if (count === 0) skipped.push({ role, reason: "seat role cannot write the hand-off fields \u2014 see attention" });
23666
+ live.delete(role);
23667
+ continue;
23668
+ }
23669
+ clearShipAttention(o.state, seatCannotWriteKey(role));
23670
+ }
23507
23671
  if (!breaker.allows(role)) {
23508
23672
  if (count === 0) skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
23509
23673
  live.delete(role);
@@ -23838,6 +24002,17 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
23838
24002
  agentPersona: await fetchSeatPersona(route2, role, { userAgent: ship.userAgent }),
23839
24003
  resolvedAgentId: await resolveAgentId(route2, role, { userAgent: ship.userAgent })
23840
24004
  });
24005
+ const raiseIfSeatForbidden = async (e, what) => {
24006
+ if (identity.kind !== "agent" || !isForbiddenError(e)) return;
24007
+ const d = forbiddenDetail(e, route2);
24008
+ try {
24009
+ await raiseShipAttention(
24010
+ { state: ctx.state, emit, route: route2, ship },
24011
+ seatCannotWriteItem(role, `${what} refused${d.table ? ` on ${d.table}` : ""}${d.verb ? ` (no ${d.verb} access)` : ""}`)
24012
+ );
24013
+ } catch {
24014
+ }
24015
+ };
23841
24016
  emit.enter("agent", role);
23842
24017
  emit.emit(`starting agent run for ${route2.route}`);
23843
24018
  const memberId = route2.resolved?.seats[role];
@@ -23846,12 +24021,14 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
23846
24021
  await tracker.setCrewStatus(memberId, "working", workingId);
23847
24022
  } catch (e) {
23848
24023
  emit.warn(`could not set crew status: ${e.message}`, { step: "agent" });
24024
+ await raiseIfSeatForbidden(e, "status write");
23849
24025
  }
23850
24026
  }
23851
24027
  try {
23852
24028
  await tracker.beatEngaged(ship.name, route2.route, workingId);
23853
24029
  } catch (e) {
23854
24030
  emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
24031
+ await raiseIfSeatForbidden(e, "Ships heartbeat");
23855
24032
  }
23856
24033
  try {
23857
24034
  const runStart = (/* @__PURE__ */ new Date()).toISOString();
@@ -30544,6 +30721,11 @@ watermark: ${state.watermark()}
30544
30721
  problems.push({ severity: "blocker", kind: "seat_cannot_read_issues", route: route2.route, message: `${p.role} agent key refused on ${p.tables.filter((t) => t.state === "forbidden").map((t) => t.table).join(", ")} (403)`, remedy: describeSeatForbidden(p.role, p.tables).replace(/^.*?— /, "") });
30545
30722
  } else if (p.state === "ok") passed++;
30546
30723
  }
30724
+ for (const w of await probeSeatWrites(route2, { userAgent: cfg.ship.userAgent })) {
30725
+ if (w.state === "forbidden") {
30726
+ problems.push({ severity: "blocker", kind: "seat_cannot_write", route: route2.route, message: seatCannotWriteMessage(w.role, w.tables), remedy: SEAT_NO_WRITE_REMEDY });
30727
+ } else if (w.state === "ok") passed++;
30728
+ }
30547
30729
  }
30548
30730
  for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
30549
30731
  if (!flag("offline")) {
@@ -30779,6 +30961,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
30779
30961
  `);
30780
30962
  }
30781
30963
  }
30964
+ for (const w of await probeSeatWrites(roleRoute, { userAgent: cfg.ship.userAgent })) {
30965
+ if (w.state === "forbidden") {
30966
+ process.stdout.write(`seat write: ERROR ${seatCannotWriteMessage(w.role, w.tables)} (READ only) \u2014 ${SEAT_NO_WRITE_REMEDY}
30967
+ `);
30968
+ process.exitCode = 1;
30969
+ } else if (w.state === "ok") process.stdout.write(`seat write: ${w.role}: ok
30970
+ `);
30971
+ }
30782
30972
  }
30783
30973
  const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
30784
30974
  if (ships.length === 0) {