@tablation/crew 0.127.5 → 0.127.7

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 +757 -563
  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);
@@ -11030,10 +11563,7 @@ var SandboxRunner = class {
11030
11563
  }
11031
11564
  }
11032
11565
  };
11033
- function describeExecError(e) {
11034
- const err3 = e;
11035
- return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
11036
- }
11566
+ var describeExecError = describe;
11037
11567
  function gitOrigin(cwd) {
11038
11568
  try {
11039
11569
  return execFileSync6("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -11689,6 +12219,18 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11689
12219
  emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
11690
12220
  }
11691
12221
  }
12222
+ const forbidden = end.interrupted ? null : obs.handoff.forbidden();
12223
+ if (forbidden) {
12224
+ try {
12225
+ await raiseShipAttention({ state: new State(stateDirOfStream(id.streamPath)), emit }, seatCannotWriteItem(id.role, forbidden.detail));
12226
+ } catch {
12227
+ }
12228
+ } else if (!end.interrupted && obs.handoff.handoffLanded()) {
12229
+ try {
12230
+ clearShipAttention(new State(stateDirOfStream(id.streamPath)), seatCannotWriteKey(id.role));
12231
+ } catch {
12232
+ }
12233
+ }
11692
12234
  const before = obs.beforeFirstEdit.result();
11693
12235
  try {
11694
12236
  writeRunMeta(id, startedAt, obs.liveTicket, {
@@ -11712,6 +12254,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11712
12254
  ms: ms3,
11713
12255
  code: end.code,
11714
12256
  ...pending ? { handoff: "pending" } : {},
12257
+ ...forbidden ? { reason: "forbidden", forbidden: forbidden.detail } : {},
11715
12258
  ...end.interrupted ? { interrupted: end.interrupted } : {},
11716
12259
  ...identity,
11717
12260
  attributedTicket: attributedTicket ?? null,
@@ -11728,7 +12271,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11728
12271
  if (id.agentLog && agentLogKey) {
11729
12272
  const report = {
11730
12273
  ticketReference: attributedTicket,
11731
- outcome: end.interrupted ? "interrupted" : signedOut ? "error" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
12274
+ outcome: end.interrupted ? "interrupted" : signedOut || forbidden ? "error" : end.code === 0 || end.code === null && obs.sawResult ? "success" : "error",
11732
12275
  startedAt,
11733
12276
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
11734
12277
  cycles: obs.cycles,
@@ -11938,496 +12481,200 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
11938
12481
  o.onAdopted?.();
11939
12482
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
11940
12483
  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 } : {}
12484
+ obs.feed(tail3.poll());
12485
+ if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
12486
+ killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
12487
+ continue;
12488
+ }
12489
+ await new Promise((r) => setTimeout(r, pollMs));
12166
12490
  }
12167
- };
12491
+ }
12492
+ obs.feed(tail3.poll());
12493
+ obs.end();
12494
+ await endSink(eventsSink);
12495
+ await runner.release(runner.reattach(sc), emit);
12496
+ const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
12497
+ const { ms: ms3, ticket } = await finishRun(
12498
+ id,
12499
+ obs,
12500
+ emit,
12501
+ sc.spawnedAt,
12502
+ { code: null, ...interrupted ? { interrupted } : {} },
12503
+ o.agentLogKey
12504
+ );
12505
+ return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
12168
12506
  }
12169
- function seatAgentId(route2, role) {
12170
- return route2.resolved?.agentPersonas?.[role]?.agentId;
12507
+
12508
+ // src/interrupted.ts
12509
+ var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
12510
+ function interruptedComment(b) {
12511
+ 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.";
12512
+ return `${INTERRUPTED_MARKER}
12513
+
12514
+ 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
12515
  }
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}`);
12516
+ var handled = /* @__PURE__ */ new Set();
12517
+ var pidAlive = (pid) => {
12176
12518
  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);
12519
+ process.kill(pid, 0);
12520
+ return true;
12180
12521
  } catch (e) {
12181
- const reason = describeReadError(e);
12182
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
12522
+ return e.code === "EPERM";
12183
12523
  }
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}`);
12524
+ };
12525
+ function runFinished(streamPath) {
12526
+ const path = `${streamPath}.meta.json`;
12527
+ if (!existsSync27(path)) return false;
12190
12528
  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)";
12529
+ return JSON.parse(readFileSync27(path, "utf8")).running !== true;
12530
+ } catch {
12531
+ return true;
12226
12532
  }
12227
- return "keychain unavailable (could not write to the OS keychain)";
12228
12533
  }
12229
- async function mintMissingSeatKeys(route2, o) {
12534
+ function orphanedStreams(o) {
12535
+ const dir = join30(o.stateDir, "streams");
12536
+ if (!existsSync27(dir)) return [];
12537
+ const ownerAlive = o.isOwnerAlive ?? pidAlive;
12230
12538
  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
- }
12539
+ for (const name of readdirSync9(dir)) {
12540
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12541
+ const streamPath = join30(dir, name);
12542
+ if (runFinished(streamPath)) continue;
12543
+ const sidecar = readRunSidecar(streamPath);
12544
+ if (!sidecar || sidecar.route !== o.routeLabel) continue;
12545
+ if (ownerAlive(sidecar.ownerPid)) continue;
12546
+ out.push({ streamPath, sidecar });
12264
12547
  }
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
- }
12548
+ return out;
12549
+ }
12550
+ async function reconcileRuns(o) {
12551
+ let adopted = 0;
12552
+ let interrupted = 0;
12553
+ for (const { streamPath, sidecar } of orphanedStreams(o)) {
12554
+ if (handled.has(streamPath)) continue;
12555
+ handled.add(streamPath);
12556
+ const isAlive = o.isAlive ?? runIsAlive;
12557
+ const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
12558
+ const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
12559
+ const work = (async () => {
12302
12560
  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`
12561
+ const result = await reattachRun(streamPath, sidecar, o.emit, {
12562
+ agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
12563
+ isAlive: o.isAlive,
12564
+ pollMs: o.pollMs,
12565
+ onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
12328
12566
  });
12329
- continue;
12567
+ if (result.adopted) adopted++;
12568
+ else interrupted++;
12569
+ try {
12570
+ await o.afterRun?.(sidecar, result);
12571
+ } catch (e) {
12572
+ o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12573
+ }
12574
+ } catch (e) {
12575
+ o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12576
+ } finally {
12577
+ if (lock?.ok) lock.release();
12578
+ if (ticketLock?.ok) ticketLock.release();
12330
12579
  }
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`;
12580
+ })();
12581
+ if (isAlive(sidecar)) {
12582
+ if (o.inFlight) {
12583
+ o.inFlight.add(work);
12584
+ void work.finally(() => o.inFlight.delete(work));
12585
+ }
12586
+ } else {
12587
+ await work;
12588
+ }
12350
12589
  }
12590
+ return { adopted, interrupted };
12351
12591
  }
12352
- async function revokeSeatKeys(route2, o) {
12592
+
12593
+ // src/live-runs.ts
12594
+ import { existsSync as existsSync28, readdirSync as readdirSync10, statSync as statSync9 } from "fs";
12595
+ import { join as join31 } from "path";
12596
+ function liveRuns(stateDir, isAlive = runIsAlive) {
12597
+ const dir = join31(stateDir, "streams");
12598
+ if (!existsSync28(dir)) return [];
12353
12599
  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) }));
12600
+ for (const name of readdirSync10(dir)) {
12601
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12602
+ const streamPath = join31(dir, name);
12603
+ if (runFinished(streamPath)) continue;
12604
+ const sidecar = readRunSidecar(streamPath);
12605
+ if (!sidecar || !isAlive(sidecar)) continue;
12606
+ out.push({ ...sidecar, streamPath });
12366
12607
  }
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;
12608
+ return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
12609
+ }
12610
+ function runActivity(stateDir, isAlive = runIsAlive) {
12611
+ const activeRuns = liveRuns(stateDir, isAlive).length;
12612
+ const dir = join31(stateDir, "streams");
12613
+ let newest = 0;
12614
+ if (existsSync28(dir)) {
12615
+ for (const name of readdirSync10(dir)) {
12616
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12617
+ const streamPath = join31(dir, name);
12618
+ if (!runFinished(streamPath)) continue;
12619
+ try {
12620
+ newest = Math.max(newest, statSync9(`${streamPath}.meta.json`).mtimeMs);
12621
+ } catch {
12622
+ }
12373
12623
  }
12374
- const slug2 = slugFor(route2, agentId);
12624
+ }
12625
+ return { activeRuns, lastRunEndedAt: newest ? new Date(newest).toISOString() : null };
12626
+ }
12627
+ function siblingRuns(runs, ownTicketRecordId) {
12628
+ return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
12629
+ }
12630
+ async function settleRunEnd(o) {
12631
+ const { tracker, emit } = o;
12632
+ const all = o.runs ?? liveRuns(o.stateDir);
12633
+ const slug2 = routeSlug(o.route.route);
12634
+ const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
12635
+ const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
12636
+ const newest = (rs) => rs[rs.length - 1];
12637
+ if (o.memberId) {
12375
12638
  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" });
12639
+ if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
12640
+ else await tracker.setCrewStatus(o.memberId, "idle");
12399
12641
  } catch (e) {
12400
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
12642
+ emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
12401
12643
  }
12402
12644
  }
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`;
12645
+ try {
12646
+ if (shipSiblings.length) {
12647
+ const n = newest(shipSiblings);
12648
+ await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
12649
+ } else {
12650
+ await tracker.beatIdle(o.shipName);
12651
+ }
12652
+ } catch (e) {
12653
+ emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
12415
12654
  }
12416
12655
  }
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;
12656
+ function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
12422
12657
  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;
12658
+ return worktreeForNumber(d, n)?.path ?? null;
12428
12659
  } catch {
12429
- return void 0;
12660
+ return null;
12430
12661
  }
12662
+ }) {
12663
+ const ownRoute = run3.route === routeSlug(route2.route);
12664
+ const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
12665
+ const num = run3.ticket?.replace(/^\D+/, "") ?? "";
12666
+ return {
12667
+ role: run3.role,
12668
+ seat: ownRoute ? seatIds[run3.role] ?? null : null,
12669
+ ticket: run3.ticket,
12670
+ ticketRecordId: run3.ticketRecordId,
12671
+ repo: run3.repo ?? null,
12672
+ worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
12673
+ stream: run3.streamPath,
12674
+ pid: run3.pid,
12675
+ startedAt: run3.spawnedAt,
12676
+ deadline: run3.deadline ?? null
12677
+ };
12431
12678
  }
12432
12679
 
12433
12680
  // src/deprovision-host.ts
@@ -12571,6 +12818,10 @@ function cloneScript(o) {
12571
12818
  const mirror = `${o.mirrorDir}/${mirrorRelPath(o.repo)}`;
12572
12819
  const depsRoot = `${o.mirrorDir}/${depsRelDir(o.repo)}`;
12573
12820
  return [
12821
+ // The mirror is written by the helper container as root and mounted read-only,
12822
+ // so git sees it as owned by someone else and refuses it. Global (not `-c`) so
12823
+ // it holds for every later git call in the run that reads through the alternates.
12824
+ `git config --global --add safe.directory '*'`,
12574
12825
  `git clone --quiet --shared ${q(mirror)} ${q(o.workDir)}`,
12575
12826
  `cd ${q(o.workDir)}`,
12576
12827
  `git remote set-url origin ${q(o.origin)}`,
@@ -12805,7 +13056,10 @@ var SandboxCache = class {
12805
13056
  };
12806
13057
  function describe(e) {
12807
13058
  const err3 = e;
12808
- return (err3.stderr?.trim() || err3.message || String(e)).split("\n").slice(-3).join(" / ");
13059
+ const lines2 = (err3.stderr?.trim() || err3.message || String(e)).split("\n");
13060
+ const tail3 = lines2.slice(-3);
13061
+ const first = lines2.find((l) => /^(fatal|error):/.test(l.trim()));
13062
+ return (first && !tail3.includes(first) ? [first, ...tail3] : tail3).join(" / ");
12809
13063
  }
12810
13064
  function cacheSettingsOf(cfg) {
12811
13065
  if (!cfg?.cacheVolume) return null;
@@ -19499,100 +19753,6 @@ function formatWhoami(report) {
19499
19753
  `;
19500
19754
  }
19501
19755
 
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
19756
  // src/cli.ts
19597
19757
  import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
19598
19758
 
@@ -23504,6 +23664,14 @@ async function runOnePassUnguarded(o) {
23504
23664
  for (const role of order) {
23505
23665
  const count = live.get(role);
23506
23666
  if (count === void 0) continue;
23667
+ if (openShipAttention(o.state).some((i) => i.key === seatCannotWriteKey(role))) {
23668
+ if (await (o.seatWriteRefused ?? ((r) => seatWriteStillRefused(o.route, r, { userAgent: o.ship.userAgent })))(role)) {
23669
+ if (count === 0) skipped.push({ role, reason: "seat role cannot write the hand-off fields \u2014 see attention" });
23670
+ live.delete(role);
23671
+ continue;
23672
+ }
23673
+ clearShipAttention(o.state, seatCannotWriteKey(role));
23674
+ }
23507
23675
  if (!breaker.allows(role)) {
23508
23676
  if (count === 0) skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
23509
23677
  live.delete(role);
@@ -23838,6 +24006,17 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
23838
24006
  agentPersona: await fetchSeatPersona(route2, role, { userAgent: ship.userAgent }),
23839
24007
  resolvedAgentId: await resolveAgentId(route2, role, { userAgent: ship.userAgent })
23840
24008
  });
24009
+ const raiseIfSeatForbidden = async (e, what) => {
24010
+ if (identity.kind !== "agent" || !isForbiddenError(e)) return;
24011
+ const d = forbiddenDetail(e, route2);
24012
+ try {
24013
+ await raiseShipAttention(
24014
+ { state: ctx.state, emit, route: route2, ship },
24015
+ seatCannotWriteItem(role, `${what} refused${d.table ? ` on ${d.table}` : ""}${d.verb ? ` (no ${d.verb} access)` : ""}`)
24016
+ );
24017
+ } catch {
24018
+ }
24019
+ };
23841
24020
  emit.enter("agent", role);
23842
24021
  emit.emit(`starting agent run for ${route2.route}`);
23843
24022
  const memberId = route2.resolved?.seats[role];
@@ -23846,12 +24025,14 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
23846
24025
  await tracker.setCrewStatus(memberId, "working", workingId);
23847
24026
  } catch (e) {
23848
24027
  emit.warn(`could not set crew status: ${e.message}`, { step: "agent" });
24028
+ await raiseIfSeatForbidden(e, "status write");
23849
24029
  }
23850
24030
  }
23851
24031
  try {
23852
24032
  await tracker.beatEngaged(ship.name, route2.route, workingId);
23853
24033
  } catch (e) {
23854
24034
  emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
24035
+ await raiseIfSeatForbidden(e, "Ships heartbeat");
23855
24036
  }
23856
24037
  try {
23857
24038
  const runStart = (/* @__PURE__ */ new Date()).toISOString();
@@ -30544,6 +30725,11 @@ watermark: ${state.watermark()}
30544
30725
  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
30726
  } else if (p.state === "ok") passed++;
30546
30727
  }
30728
+ for (const w of await probeSeatWrites(route2, { userAgent: cfg.ship.userAgent })) {
30729
+ if (w.state === "forbidden") {
30730
+ problems.push({ severity: "blocker", kind: "seat_cannot_write", route: route2.route, message: seatCannotWriteMessage(w.role, w.tables), remedy: SEAT_NO_WRITE_REMEDY });
30731
+ } else if (w.state === "ok") passed++;
30732
+ }
30547
30733
  }
30548
30734
  for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
30549
30735
  if (!flag("offline")) {
@@ -30779,6 +30965,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
30779
30965
  `);
30780
30966
  }
30781
30967
  }
30968
+ for (const w of await probeSeatWrites(roleRoute, { userAgent: cfg.ship.userAgent })) {
30969
+ if (w.state === "forbidden") {
30970
+ process.stdout.write(`seat write: ERROR ${seatCannotWriteMessage(w.role, w.tables)} (READ only) \u2014 ${SEAT_NO_WRITE_REMEDY}
30971
+ `);
30972
+ process.exitCode = 1;
30973
+ } else if (w.state === "ok") process.stdout.write(`seat write: ${w.role}: ok
30974
+ `);
30975
+ }
30782
30976
  }
30783
30977
  const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
30784
30978
  if (ships.length === 0) {