@tablation/crew 0.127.4 → 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 +770 -569
  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);
@@ -9683,18 +10216,28 @@ async function missingAcceptance(i, t) {
9683
10216
  if (lines2.length === 0 || !i.writer.comments) return null;
9684
10217
  const cc = i.contract.comments;
9685
10218
  const mine = (await i.writer.comments()).map((c) => c).filter((r) => r[cc.parent] === t.id && r[cc.author] === i.seatId).sort((a, b) => String(a[cc.createdAt] ?? "").localeCompare(String(b[cc.createdAt] ?? "")));
9686
- const last = mine.at(-1);
9687
- const missing = unquotedAcceptance(lines2, last ? String(last[cc.body] ?? "") : "");
9688
- return missing.length > 0 ? missing : null;
10219
+ const from = i.since ? Date.parse(i.since) : NaN;
10220
+ const recent = Number.isNaN(from) ? [] : mine.filter((r) => Date.parse(String(r[cc.createdAt] ?? "")) >= from);
10221
+ const candidates2 = recent.length > 0 ? recent : mine.slice(-1);
10222
+ if (candidates2.length === 0) return { missing: lines2, judged: null };
10223
+ let best = null;
10224
+ for (const c of candidates2) {
10225
+ const missing = unquotedAcceptance(lines2, String(c[cc.body] ?? ""));
10226
+ if (missing.length === 0) return null;
10227
+ if (!best || missing.length <= best.missing.length) best = { missing, judged: String(c[cc.createdAt] ?? "") || null };
10228
+ }
10229
+ return best;
9689
10230
  }
9690
- function acceptanceBounceComment(missing, shipName2) {
10231
+ function acceptanceBounceComment(missing, shipName2, judged) {
9691
10232
  return `${ACCEPTANCE_BOUNCED_MARKER}
9692
10233
 
9693
10234
  **Hand-off bounced: the hand-off comment does not quote every line of the ticket's Acceptance section.**
9694
10235
 
9695
10236
  ${shipName2 ? `Ship: ${shipName2}
9696
10237
 
9697
- ` : ""}Not quoted verbatim:
10238
+ ` : ""}${judged ? `Judged the comment posted at ${judged}; no comment this run quoted every line.
10239
+
10240
+ ` : "This seat has no comment on the ticket.\n\n"}Not quoted verbatim:
9698
10241
  ${missing.map((l) => `- ${l}`).join("\n")}
9699
10242
 
9700
10243
  Set back to \`in_progress\`. Post a comment that lists every Acceptance line verbatim, each followed by the test, command or manual check that exercised it in this run and its result (a line you could not exercise is listed with the reason, never omitted), then set \`fixed\` again. The branch is already pushed.`;
@@ -9715,8 +10258,8 @@ async function enforceHandoff(i) {
9715
10258
  return { kind: "unverified", why: v.why };
9716
10259
  }
9717
10260
  if (v.kind === "pushed" && !claimed) {
9718
- const missing = await missingAcceptance(i, t);
9719
- if (missing) {
10261
+ const gap = await missingAcceptance(i, t);
10262
+ if (gap) {
9720
10263
  const heldNow = i.hold ? t[i.hold.column] : void 0;
9721
10264
  if (i.hold && heldNow && heldNow !== i.hold.shipId || await takenOverSince(i, t.id)) {
9722
10265
  i.log.warn(`${t.issue_id} is held by another ship now \u2014 the acceptance check is stale, left alone`, log);
@@ -9724,10 +10267,11 @@ async function enforceHandoff(i) {
9724
10267
  }
9725
10268
  await i.writer.updateTicket(t.id, {
9726
10269
  status: i.contract.statuses.building,
9727
- ...t.assignee_id ? {} : { assignee_id: i.seatId },
10270
+ // CREW-2962: the hand-off may have assigned QA's seat; a bounce is the building seat's again.
10271
+ assignee_id: i.seatId,
9728
10272
  ...i.hold && !heldNow ? { [i.hold.column]: i.hold.shipId } : {}
9729
10273
  });
9730
- await i.writer.postEvent(t.id, acceptanceBounceComment(missing, i.shipName), i.seatId, "bounced").catch(() => {
10274
+ await i.writer.postEvent(t.id, acceptanceBounceComment(gap.missing, i.shipName, gap.judged), i.seatId, "bounced").catch(() => {
9731
10275
  i.log.warn("bounced, but the note failed to post", log);
9732
10276
  });
9733
10277
  i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: hand-off comment lacks the acceptance checklist`, log);
@@ -9781,7 +10325,7 @@ async function enforceHandoff(i) {
9781
10325
  }
9782
10326
  await i.writer.updateTicket(t.id, {
9783
10327
  status: i.contract.statuses.building,
9784
- ...t.assignee_id ? {} : { assignee_id: i.seatId },
10328
+ assignee_id: i.seatId,
9785
10329
  ...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
9786
10330
  });
9787
10331
  await i.writer.postEvent(t.id, bounceComment(v, i.shipName, pushError), i.seatId, "bounced").catch(() => {
@@ -9921,7 +10465,7 @@ ${i.shipName ? `${i.shipName}'s ` : "A "}${i.role} run set it to \`${st.handoff}
9921
10465
  i.log.warn(`${t.issue_id}: \`${v.remote}/${v.branch}\` is at ${v.origin.slice(0, 7)}, not a tip this checkout built \u2014 left alone`, at);
9922
10466
  continue;
9923
10467
  }
9924
- await i.writer.updateTicket(t.id, { status: st.building, ...t.assignee_id ? {} : { assignee_id: i.seatId } });
10468
+ await i.writer.updateTicket(t.id, { status: st.building, assignee_id: i.seatId });
9925
10469
  await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
9926
10470
  });
9927
10471
  }
@@ -11678,6 +12222,18 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11678
12222
  emit.warn(`could not save the pending hand-off: ${e.message}`, { step: "agent" });
11679
12223
  }
11680
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
+ }
11681
12237
  const before = obs.beforeFirstEdit.result();
11682
12238
  try {
11683
12239
  writeRunMeta(id, startedAt, obs.liveTicket, {
@@ -11701,6 +12257,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11701
12257
  ms: ms3,
11702
12258
  code: end.code,
11703
12259
  ...pending ? { handoff: "pending" } : {},
12260
+ ...forbidden ? { reason: "forbidden", forbidden: forbidden.detail } : {},
11704
12261
  ...end.interrupted ? { interrupted: end.interrupted } : {},
11705
12262
  ...identity,
11706
12263
  attributedTicket: attributedTicket ?? null,
@@ -11717,7 +12274,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
11717
12274
  if (id.agentLog && agentLogKey) {
11718
12275
  const report = {
11719
12276
  ticketReference: attributedTicket,
11720
- 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",
11721
12278
  startedAt,
11722
12279
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
11723
12280
  cycles: obs.cycles,
@@ -11927,496 +12484,200 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
11927
12484
  o.onAdopted?.();
11928
12485
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
11929
12486
  while (isAlive(sc)) {
11930
- obs.feed(tail3.poll());
11931
- if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
11932
- killed = await killForDeadline(() => runner.kill(runner.reattach(sc), WALL_CLOCK_REASON), sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
11933
- continue;
11934
- }
11935
- await new Promise((r) => setTimeout(r, pollMs));
11936
- }
11937
- }
11938
- obs.feed(tail3.poll());
11939
- obs.end();
11940
- await endSink(eventsSink);
11941
- await runner.release(runner.reattach(sc), emit);
11942
- const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
11943
- const { ms: ms3, ticket } = await finishRun(
11944
- id,
11945
- obs,
11946
- emit,
11947
- sc.spawnedAt,
11948
- { code: null, ...interrupted ? { interrupted } : {} },
11949
- o.agentLogKey
11950
- );
11951
- return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
11952
- }
11953
-
11954
- // src/interrupted.ts
11955
- var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
11956
- function interruptedComment(b) {
11957
- 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.";
11958
- return `${INTERRUPTED_MARKER}
11959
-
11960
- 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.`;
11961
- }
11962
- var handled = /* @__PURE__ */ new Set();
11963
- var pidAlive = (pid) => {
11964
- try {
11965
- process.kill(pid, 0);
11966
- return true;
11967
- } catch (e) {
11968
- return e.code === "EPERM";
11969
- }
11970
- };
11971
- function runFinished(streamPath) {
11972
- const path = `${streamPath}.meta.json`;
11973
- if (!existsSync27(path)) return false;
11974
- try {
11975
- return JSON.parse(readFileSync27(path, "utf8")).running !== true;
11976
- } catch {
11977
- return true;
11978
- }
11979
- }
11980
- function orphanedStreams(o) {
11981
- const dir = join30(o.stateDir, "streams");
11982
- if (!existsSync27(dir)) return [];
11983
- const ownerAlive = o.isOwnerAlive ?? pidAlive;
11984
- const out = [];
11985
- for (const name of readdirSync9(dir)) {
11986
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
11987
- const streamPath = join30(dir, name);
11988
- if (runFinished(streamPath)) continue;
11989
- const sidecar = readRunSidecar(streamPath);
11990
- if (!sidecar || sidecar.route !== o.routeLabel) continue;
11991
- if (ownerAlive(sidecar.ownerPid)) continue;
11992
- out.push({ streamPath, sidecar });
11993
- }
11994
- return out;
11995
- }
11996
- async function reconcileRuns(o) {
11997
- let adopted = 0;
11998
- let interrupted = 0;
11999
- for (const { streamPath, sidecar } of orphanedStreams(o)) {
12000
- if (handled.has(streamPath)) continue;
12001
- handled.add(streamPath);
12002
- const isAlive = o.isAlive ?? runIsAlive;
12003
- const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
12004
- const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
12005
- const work = (async () => {
12006
- try {
12007
- const result = await reattachRun(streamPath, sidecar, o.emit, {
12008
- agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
12009
- isAlive: o.isAlive,
12010
- pollMs: o.pollMs,
12011
- onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
12012
- });
12013
- if (result.adopted) adopted++;
12014
- else interrupted++;
12015
- try {
12016
- await o.afterRun?.(sidecar, result);
12017
- } catch (e) {
12018
- o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12019
- }
12020
- } catch (e) {
12021
- o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
12022
- } finally {
12023
- if (lock?.ok) lock.release();
12024
- if (ticketLock?.ok) ticketLock.release();
12025
- }
12026
- })();
12027
- if (isAlive(sidecar)) {
12028
- if (o.inFlight) {
12029
- o.inFlight.add(work);
12030
- void work.finally(() => o.inFlight.delete(work));
12031
- }
12032
- } else {
12033
- await work;
12034
- }
12035
- }
12036
- return { adopted, interrupted };
12037
- }
12038
-
12039
- // src/live-runs.ts
12040
- import { existsSync as existsSync28, readdirSync as readdirSync10, statSync as statSync9 } from "fs";
12041
- import { join as join31 } from "path";
12042
- function liveRuns(stateDir, isAlive = runIsAlive) {
12043
- const dir = join31(stateDir, "streams");
12044
- if (!existsSync28(dir)) return [];
12045
- const out = [];
12046
- for (const name of readdirSync10(dir)) {
12047
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12048
- const streamPath = join31(dir, name);
12049
- if (runFinished(streamPath)) continue;
12050
- const sidecar = readRunSidecar(streamPath);
12051
- if (!sidecar || !isAlive(sidecar)) continue;
12052
- out.push({ ...sidecar, streamPath });
12053
- }
12054
- return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
12055
- }
12056
- function runActivity(stateDir, isAlive = runIsAlive) {
12057
- const activeRuns = liveRuns(stateDir, isAlive).length;
12058
- const dir = join31(stateDir, "streams");
12059
- let newest = 0;
12060
- if (existsSync28(dir)) {
12061
- for (const name of readdirSync10(dir)) {
12062
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
12063
- const streamPath = join31(dir, name);
12064
- if (!runFinished(streamPath)) continue;
12065
- try {
12066
- newest = Math.max(newest, statSync9(`${streamPath}.meta.json`).mtimeMs);
12067
- } catch {
12068
- }
12069
- }
12070
- }
12071
- return { activeRuns, lastRunEndedAt: newest ? new Date(newest).toISOString() : null };
12072
- }
12073
- function siblingRuns(runs, ownTicketRecordId) {
12074
- return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
12075
- }
12076
- async function settleRunEnd(o) {
12077
- const { tracker, emit } = o;
12078
- const all = o.runs ?? liveRuns(o.stateDir);
12079
- const slug2 = routeSlug(o.route.route);
12080
- const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug2), o.ownTicketRecordId);
12081
- const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
12082
- const newest = (rs) => rs[rs.length - 1];
12083
- if (o.memberId) {
12084
- try {
12085
- if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
12086
- else await tracker.setCrewStatus(o.memberId, "idle");
12087
- } catch (e) {
12088
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
12089
- }
12090
- }
12091
- try {
12092
- if (shipSiblings.length) {
12093
- const n = newest(shipSiblings);
12094
- await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
12095
- } else {
12096
- await tracker.beatIdle(o.shipName);
12097
- }
12098
- } catch (e) {
12099
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
12100
- }
12101
- }
12102
- function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
12103
- try {
12104
- return worktreeForNumber(d, n)?.path ?? null;
12105
- } catch {
12106
- return null;
12107
- }
12108
- }) {
12109
- const ownRoute = run3.route === routeSlug(route2.route);
12110
- const repoDir = run3.repo ? reposOf(route2).find((t) => t.name === run3.repo)?.dir : void 0;
12111
- const num = run3.ticket?.replace(/^\D+/, "") ?? "";
12112
- return {
12113
- role: run3.role,
12114
- seat: ownRoute ? seatIds[run3.role] ?? null : null,
12115
- ticket: run3.ticket,
12116
- ticketRecordId: run3.ticketRecordId,
12117
- repo: run3.repo ?? null,
12118
- worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
12119
- stream: run3.streamPath,
12120
- pid: run3.pid,
12121
- startedAt: run3.spawnedAt,
12122
- deadline: run3.deadline ?? null
12123
- };
12124
- }
12125
-
12126
- // src/seat-keys.ts
12127
- import { hostFromUrl as hostFromUrl2 } from "@tablation/client";
12128
- var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
12129
- function keyIsAdmin(route2) {
12130
- const role = route2.resolved?.role;
12131
- return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
12132
- }
12133
- 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";
12134
- var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
12135
- 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)';
12136
- function seatKeySlug(workspaceSlug, agentId) {
12137
- return `${workspaceSlug}#agent:${agentId}`;
12138
- }
12139
- function slugFor(route2, agentId) {
12140
- const [workspaceSlug] = splitRoute(route2.route);
12141
- return seatKeySlug(workspaceSlug, agentId);
12142
- }
12143
- function withFoundSeats(route2, found, previousReleaseAgentId) {
12144
- const resolved = route2.resolved;
12145
- if (!resolved) return route2;
12146
- const releaseSeat2 = found.releaseSeat ?? resolved.releaseSeat;
12147
- const releaseAgentId = resolved.releaseAgentId ?? previousReleaseAgentId;
12148
- return {
12149
- ...route2,
12150
- resolved: {
12151
- ...resolved,
12152
- seats: found.seats,
12153
- ...releaseSeat2 ? { releaseSeat: releaseSeat2 } : {},
12154
- ...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));
12155
12493
  }
12156
- };
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 };
12157
12509
  }
12158
- function seatAgentId(route2, role) {
12159
- 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.`;
12160
12518
  }
12161
- async function resolveReleaseKey(route2, getStore = getSessionStore) {
12162
- const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
12163
- const agentId = route2.resolved?.releaseAgentId;
12164
- 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) => {
12165
12521
  try {
12166
- const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
12167
- if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
12168
- 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;
12169
12524
  } catch (e) {
12170
- const reason = describeReadError(e);
12171
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
12525
+ return e.code === "EPERM";
12172
12526
  }
12173
- }
12174
- async function resolveSeatKey(route2, role, getStore = getSessionStore) {
12175
- const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route2), reason });
12176
- if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
12177
- const agentId = seatAgentId(route2, role);
12178
- 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;
12179
12531
  try {
12180
- const session = await getStore().get(hostFromUrl2(route2.baseUrl), slugFor(route2, agentId));
12181
- if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
12182
- return ship(keyIsAdmin(route2) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
12183
- } catch (e) {
12184
- const reason = describeReadError(e);
12185
- return { kind: "ship", apiKey: resolveApiKey(route2), reason, storeError: reason };
12186
- }
12187
- }
12188
- function safeStderrLine(e) {
12189
- const err3 = e;
12190
- const raw = typeof err3?.stderr === "string" && err3.stderr.trim() ? err3.stderr : typeof err3?.message === "string" ? err3.message : "";
12191
- for (const line of raw.split("\n")) {
12192
- const t = line.trim();
12193
- if (!t || /^Command failed:/i.test(t) || /(^|\s)-w(\s|$)/.test(t)) continue;
12194
- return t.slice(0, 120);
12195
- }
12196
- return void 0;
12197
- }
12198
- function describeReadError(e) {
12199
- const fsErr = e;
12200
- if (typeof fsErr?.path === "string" && typeof fsErr.code === "string") return `key file store unreadable (${fsErr.code})`;
12201
- const code = e?.code;
12202
- const line = safeStderrLine(e);
12203
- const what = typeof code === "number" ? `security exited ${code}` : typeof code === "string" ? `security failed: ${code}` : void 0;
12204
- const detail = [what, line].filter(Boolean).join(what && line ? ": " : "");
12205
- return detail ? `OS keychain unavailable (${detail})` : "OS keychain unavailable";
12206
- }
12207
- function keychainExitCode(reason) {
12208
- const m = /security exited (\d+)/.exec(reason);
12209
- return m ? Number(m[1]) : void 0;
12210
- }
12211
- function describeStoreError(e) {
12212
- const msg = e instanceof Error ? e.message : String(e);
12213
- if (/User interaction is not allowed/i.test(msg)) {
12214
- 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;
12215
12535
  }
12216
- return "keychain unavailable (could not write to the OS keychain)";
12217
12536
  }
12218
- 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;
12219
12541
  const out = [];
12220
- if (!route2.resolved) return out;
12221
- const resolved = route2.resolved;
12222
- const doFetch = o.fetchImpl ?? fetch;
12223
- const targets = [
12224
- ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role), actorRecordId: resolved.seats[role] })),
12225
- ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId, actorRecordId: resolved.releaseSeat }] : []
12226
- ];
12227
- let store;
12228
- try {
12229
- store = (o.getStore ?? getSessionStore)();
12230
- } catch (e) {
12231
- return targets.map(({ role }) => ({
12232
- role,
12233
- action: "failed",
12234
- detail: describeStoreError(e)
12235
- }));
12236
- }
12237
- const host = hostFromUrl2(route2.baseUrl);
12238
- const base = route2.baseUrl.replace(/\/+$/, "");
12239
- if (!o.dryRun) {
12240
- const probeSlug = seatKeySlug(splitRoute(route2.route)[0], "_probe");
12241
- try {
12242
- await store.set(host, probeSlug, {
12243
- apiKey: "probe",
12244
- workspaceId: resolved.workspaceId,
12245
- identityId: "_probe",
12246
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
12247
- });
12248
- await store.clear(host, probeSlug);
12249
- } catch (e) {
12250
- const detail = `${describeStoreError(e)}; no keys minted`;
12251
- return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
12252
- }
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 });
12253
12550
  }
12254
- for (const { role, agentId, actorRecordId } of targets) {
12255
- if (!agentId) {
12256
- out.push({ role, action: "no_agent" });
12257
- continue;
12258
- }
12259
- const slug2 = slugFor(route2, agentId);
12260
- try {
12261
- if ((await store.get(host, slug2))?.apiKey) {
12262
- out.push({ role, action: "already_stored" });
12263
- continue;
12264
- }
12265
- if (o.dryRun) {
12266
- out.push({ role, action: "minted", detail: "dry run" });
12267
- continue;
12268
- }
12269
- const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
12270
- method: "POST",
12271
- headers: {
12272
- Authorization: `Bearer ${resolveApiKey(route2)}`,
12273
- "Content-Type": "application/json",
12274
- "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
12275
- },
12276
- body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}`, ...actorRecordId ? { actorRecordId } : {} })
12277
- });
12278
- if (res.status === 403) {
12279
- out.push({ role, action: "forbidden" });
12280
- break;
12281
- }
12282
- if (!res.ok) {
12283
- out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
12284
- continue;
12285
- }
12286
- const body = await res.json();
12287
- if (!body.key) {
12288
- out.push({ role, action: "failed", detail: "response carried no key" });
12289
- continue;
12290
- }
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 () => {
12291
12563
  try {
12292
- const session = {
12293
- apiKey: body.key,
12294
- workspaceId: resolved.workspaceId,
12295
- identityId: agentId,
12296
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
12297
- ...body.id ? { keyId: body.id } : {}
12298
- };
12299
- await store.set(host, slug2, session);
12300
- } catch (e) {
12301
- let revoked = false;
12302
- if (body.id) {
12303
- try {
12304
- const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
12305
- method: "DELETE",
12306
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12307
- });
12308
- revoked = del2.ok;
12309
- } catch {
12310
- revoked = false;
12311
- }
12312
- }
12313
- out.push({
12314
- role,
12315
- action: "failed",
12316
- 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 })
12317
12569
  });
12318
- 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();
12319
12582
  }
12320
- out.push({ role, action: "minted" });
12321
- } catch (e) {
12322
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
12323
- }
12324
- }
12325
- return out;
12326
- }
12327
- function describeMintOutcome(o) {
12328
- switch (o.action) {
12329
- case "minted":
12330
- return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
12331
- case "already_stored":
12332
- return `${o.role}: agent key already stored`;
12333
- case "no_agent":
12334
- return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
12335
- case "forbidden":
12336
- return FORBIDDEN_LINE;
12337
- case "failed":
12338
- 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
+ }
12339
12592
  }
12593
+ return { adopted, interrupted };
12340
12594
  }
12341
- 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 [];
12342
12602
  const out = [];
12343
- if (!route2.resolved) return out;
12344
- const resolved = route2.resolved;
12345
- const doFetch = o.fetchImpl ?? fetch;
12346
- const targets = [
12347
- ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route2, role), actorRecordId: resolved.seats[role] })),
12348
- ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId, actorRecordId: resolved.releaseSeat }] : []
12349
- ];
12350
- let store;
12351
- try {
12352
- store = (o.getStore ?? getSessionStore)();
12353
- } catch (e) {
12354
- 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 });
12355
12610
  }
12356
- const host = hostFromUrl2(route2.baseUrl);
12357
- const base = route2.baseUrl.replace(/\/+$/, "");
12358
- for (const { role, agentId } of targets) {
12359
- if (!agentId) {
12360
- out.push({ role, action: "no_agent" });
12361
- 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
+ }
12362
12626
  }
12363
- 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) {
12364
12641
  try {
12365
- const session = await store.get(host, slug2);
12366
- if (!session?.apiKey) {
12367
- out.push({ role, action: "not_stored" });
12368
- continue;
12369
- }
12370
- if (!session.keyId) {
12371
- 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" });
12372
- continue;
12373
- }
12374
- if (o.dryRun) {
12375
- out.push({ role, action: "revoked", detail: "dry run" });
12376
- continue;
12377
- }
12378
- const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
12379
- method: "DELETE",
12380
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12381
- });
12382
- if (!del2.ok && del2.status !== 404) {
12383
- out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
12384
- continue;
12385
- }
12386
- await store.clear(host, slug2);
12387
- 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");
12388
12644
  } catch (e) {
12389
- 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" });
12390
12646
  }
12391
12647
  }
12392
- return out;
12393
- }
12394
- function describeRevokeOutcome(o) {
12395
- switch (o.action) {
12396
- case "revoked":
12397
- return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
12398
- case "not_stored":
12399
- return `${o.role}: no agent key stored on this ship`;
12400
- case "no_agent":
12401
- return `${o.role}: no linked Agents row`;
12402
- case "failed":
12403
- 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" });
12404
12657
  }
12405
12658
  }
12406
- async function canMintAgentKeys(route2, o = {}) {
12407
- const resolved = route2.resolved;
12408
- if (!resolved) return void 0;
12409
- const agentId = seatAgentId(route2, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route2, r)).find(Boolean);
12410
- if (!agentId) return void 0;
12659
+ function describeRun(run3, route2, seatIds, worktreeOf = (d, n) => {
12411
12660
  try {
12412
- const res = await (o.fetchImpl ?? fetch)(`${route2.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
12413
- headers: { Authorization: `Bearer ${resolveApiKey(route2)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12414
- });
12415
- if (res.ok) return true;
12416
- return res.status === 403 ? false : void 0;
12661
+ return worktreeForNumber(d, n)?.path ?? null;
12417
12662
  } catch {
12418
- return void 0;
12663
+ return null;
12419
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
+ };
12420
12681
  }
12421
12682
 
12422
12683
  // src/deprovision-host.ts
@@ -19488,100 +19749,6 @@ function formatWhoami(report) {
19488
19749
  `;
19489
19750
  }
19490
19751
 
19491
- // src/seat-read-probe.ts
19492
- 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";
19493
- function probeTables(route2) {
19494
- const m = route2.resolved?.models;
19495
- if (!m) return [];
19496
- const r = route2.resolved;
19497
- return [
19498
- { name: "Issues", modelId: m.issues },
19499
- { name: "Comments", modelId: m.comments },
19500
- { name: "Crew", modelId: m.crew },
19501
- { name: "Ships", modelId: r?.shipsModelId ?? "" }
19502
- ].filter((t) => !!t.modelId);
19503
- }
19504
- async function probeTableRead(route2, modelId, apiKey, o = {}) {
19505
- const url = `${route2.baseUrl.replace(/\/+$/, "")}/api/data-models/${modelId}/records?limit=1`;
19506
- try {
19507
- const res = await (o.fetchImpl ?? fetch)(url, {
19508
- headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
19509
- });
19510
- if (res.ok) return { state: "ok" };
19511
- if (res.status === 403) return { state: "forbidden" };
19512
- return { state: "unknown", detail: `HTTP ${res.status}` };
19513
- } catch (e) {
19514
- return { state: "unknown", detail: e.message };
19515
- }
19516
- }
19517
- async function probeSeatReads(route2, o = {}) {
19518
- const out = [];
19519
- const roles = [...SEAT_KEY_ROLES.filter((r) => route2.resolved?.seats[r]), ...route2.resolved?.releaseSeat ? ["release"] : []];
19520
- for (const role of roles) {
19521
- const id = role === "release" ? await resolveReleaseKey(route2, o.getStore) : await resolveSeatKey(route2, role, o.getStore);
19522
- if (id.kind !== "agent") continue;
19523
- const tables = [];
19524
- for (const t of probeTables(route2)) tables.push({ table: t.name, ...await probeTableRead(route2, t.modelId, id.apiKey, o) });
19525
- const bad = tables.find((t) => t.state === "forbidden") ?? tables.find((t) => t.state === "unknown");
19526
- out.push({ role, state: bad?.state ?? (tables.length ? "ok" : "unknown"), detail: bad?.detail ?? (tables.length ? void 0 : "route has no resolved tables"), tables });
19527
- }
19528
- return out;
19529
- }
19530
- function forbiddenDetail(e, route2) {
19531
- const msg = e instanceof Error ? e.message : String(e);
19532
- const m = /^([A-Z]+) (\S+) -> 403/.exec(msg);
19533
- const verb = /No (READ|WRITE) access/i.exec(msg)?.[1]?.toUpperCase();
19534
- const modelId = /\/data-models\/([\w-]+)/i.exec(m?.[2] ?? "")?.[1];
19535
- const r = route2?.resolved;
19536
- const known = [
19537
- [r?.models?.issues, "Issues"],
19538
- [r?.models?.comments, "Comments"],
19539
- [r?.models?.crew, "Crew"],
19540
- [r?.shipsModelId, "Ships"],
19541
- [r?.locksModelId, "Locks"],
19542
- [r?.epicsModelId, "Epics"],
19543
- [r?.periscopesModelId, "Periscopes"],
19544
- [r?.reposModelId, "Repos"]
19545
- ];
19546
- const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
19547
- return { method: m?.[1], path: m?.[2], table: table2, verb };
19548
- }
19549
- function remedyFor(d, roleName = "the agent role") {
19550
- if (d.table && d.verb && !(d.table === "Issues" && d.verb === "READ")) {
19551
- return `grant ${d.verb} on ${d.table} to ${roleName}`;
19552
- }
19553
- return SEAT_NO_READ_REMEDY;
19554
- }
19555
- function describeSeatForbidden(role, tables = []) {
19556
- const bad = tables.filter((t) => t.state === "forbidden").map((t) => t.table);
19557
- const what = bad.length ? bad.join(", ") : "Issues";
19558
- const remedy = bad.length && !(bad.length === 1 && bad[0] === "Issues") ? bad.map((t) => remedyFor({ table: t, verb: "READ" })).join("; ") : SEAT_NO_READ_REMEDY;
19559
- return `seat read: ERROR ${role} agent key cannot read ${what} (403) \u2014 ${remedy}`;
19560
- }
19561
- function describeSeatTables(role, tables) {
19562
- const cells = tables.map((t) => `${t.table} ${t.state === "ok" ? "ok" : t.state === "forbidden" ? "403" : `error (${t.detail ?? "?"})`}`);
19563
- return `seat read: ${role}: ${cells.join(", ")}`;
19564
- }
19565
- function isForbiddenError(e) {
19566
- const status2 = e?.status;
19567
- if (status2 === 403) return true;
19568
- return /\b403\b/.test(e instanceof Error ? e.message : String(e));
19569
- }
19570
- var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
19571
- function seatForbiddenItem(now = /* @__PURE__ */ new Date(), e, route2) {
19572
- const d = e === void 0 ? {} : forbiddenDetail(e, route2);
19573
- const what = d.method && d.table ? `${d.method} ${d.table}${d.verb ? ` -> 403 No ${d.verb} access` : " -> 403"}` : "cannot read the Issues table (403)";
19574
- return {
19575
- kind: "seat_forbidden",
19576
- key: SEAT_FORBIDDEN_KEY,
19577
- message: `release seat: ${what} \u2014 ${remedyFor(d)}`,
19578
- since: now.toISOString()
19579
- };
19580
- }
19581
- function seatForbiddenRepoLine(scope) {
19582
- return `${scope}: skipped \u2014 release seat was refused by the tracker (see attention)`;
19583
- }
19584
-
19585
19752
  // src/cli.ts
19586
19753
  import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
19587
19754
 
@@ -23493,6 +23660,14 @@ async function runOnePassUnguarded(o) {
23493
23660
  for (const role of order) {
23494
23661
  const count = live.get(role);
23495
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
+ }
23496
23671
  if (!breaker.allows(role)) {
23497
23672
  if (count === 0) skipped.push({ role, reason: "agent recently failed to start \u2014 waiting before retry" });
23498
23673
  live.delete(role);
@@ -23827,6 +24002,17 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
23827
24002
  agentPersona: await fetchSeatPersona(route2, role, { userAgent: ship.userAgent }),
23828
24003
  resolvedAgentId: await resolveAgentId(route2, role, { userAgent: ship.userAgent })
23829
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
+ };
23830
24016
  emit.enter("agent", role);
23831
24017
  emit.emit(`starting agent run for ${route2.route}`);
23832
24018
  const memberId = route2.resolved?.seats[role];
@@ -23835,12 +24021,14 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
23835
24021
  await tracker.setCrewStatus(memberId, "working", workingId);
23836
24022
  } catch (e) {
23837
24023
  emit.warn(`could not set crew status: ${e.message}`, { step: "agent" });
24024
+ await raiseIfSeatForbidden(e, "status write");
23838
24025
  }
23839
24026
  }
23840
24027
  try {
23841
24028
  await tracker.beatEngaged(ship.name, route2.route, workingId);
23842
24029
  } catch (e) {
23843
24030
  emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
24031
+ await raiseIfSeatForbidden(e, "Ships heartbeat");
23844
24032
  }
23845
24033
  try {
23846
24034
  const runStart = (/* @__PURE__ */ new Date()).toISOString();
@@ -30533,6 +30721,11 @@ watermark: ${state.watermark()}
30533
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(/^.*?— /, "") });
30534
30722
  } else if (p.state === "ok") passed++;
30535
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
+ }
30536
30729
  }
30537
30730
  for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
30538
30731
  if (!flag("offline")) {
@@ -30768,6 +30961,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
30768
30961
  `);
30769
30962
  }
30770
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
+ }
30771
30972
  }
30772
30973
  const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
30773
30974
  if (ships.length === 0) {