@tablation/crew 0.96.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +253 -46
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -684,11 +684,17 @@ var claudePair = {
684
684
  },
685
685
  removePair: (o) => removeClaudeMcp({ bin: o.bin ?? "claude", dryRun: o.dryRun })
686
686
  };
687
+ var inferenceBaseUrl = (baseUrl, workspaceId) => `${baseUrl.replace(/\/+$/, "")}/api/workspaces/${workspaceId}/inference`;
688
+ function workspaceSettings(baseUrl) {
689
+ const blank = BILLING_VARS_TO_UNSET.filter((v) => v !== "ANTHROPIC_AUTH_TOKEN");
690
+ return JSON.stringify({ env: { ...Object.fromEntries(blank.map((v) => [v, ""])), ANTHROPIC_BASE_URL: baseUrl } });
691
+ }
687
692
  function subscriptionSettings() {
688
693
  return JSON.stringify({ env: Object.fromEntries(BILLING_VARS_TO_UNSET.map((v) => [v, ""])) });
689
694
  }
690
695
  function describeClaudeAuth(spec, env = process.env, home = homedir3()) {
691
696
  if (spec.auth === "subscription") return "subscription (settings override)";
697
+ if (spec.auth === "workspace") return `workspace${spec.inference ? ` (${new URL(spec.inference.baseUrl).host})` : " (endpoint resolved per route at run time)"}`;
692
698
  let fromSettings;
693
699
  try {
694
700
  const parsed = JSON.parse(readFileSync6(join6(home, ".claude", "settings.json"), "utf8"));
@@ -702,6 +708,9 @@ function describeClaudeAuth(spec, env = process.env, home = homedir3()) {
702
708
  const shell = BILLING_VARS_TO_UNSET.find((v) => v.startsWith("ANTHROPIC_API") && env[v]);
703
709
  return shell ? `inherit: ${shell} in env (unset at exec)` : "inherit: subscription login";
704
710
  }
711
+ function withoutProviderVars(env) {
712
+ return Object.fromEntries(Object.entries(env).filter(([k]) => !BILLING_VARS_TO_UNSET.includes(k) && k !== "ANTHROPIC_BASE_URL" && !/^(CLAUDE_CODE_USE_|ANTHROPIC_(VERTEX|BEDROCK|FOUNDRY))/.test(k)));
713
+ }
705
714
  var claudeAdapter = {
706
715
  harness: "claude",
707
716
  displayName: "Claude Code",
@@ -757,6 +766,7 @@ var claudeAdapter = {
757
766
  }
758
767
  })] : [],
759
768
  ...spec.auth === "subscription" ? ["--settings", subscriptionSettings()] : [],
769
+ ...spec.auth === "workspace" && spec.inference ? ["--settings", workspaceSettings(spec.inference.baseUrl)] : [],
760
770
  ...spec.args
761
771
  ];
762
772
  return wrapInvocation(spec, { model: o.model, args });
@@ -767,7 +777,11 @@ var claudeAdapter = {
767
777
  // blocks at all — without it, Agent Log Cycles reporting has nothing
768
778
  // to report (ISSUE-558).
769
779
  ...spec.maxThinkingTokens > 0 ? { MAX_THINKING_TOKENS: String(spec.maxThinkingTokens) } : {},
770
- ...spec.env
780
+ // CREW-2796: under `workspace` the only route to a model is the endpoint. The provider
781
+ // variables are already unset at exec (`unsetEnv`), and a spec-level `env:` entry must not
782
+ // smuggle one back in.
783
+ ...spec.auth === "workspace" ? withoutProviderVars(spec.env) : spec.env,
784
+ ...spec.auth === "workspace" && spec.inference ? { ANTHROPIC_BASE_URL: spec.inference.baseUrl, ANTHROPIC_AUTH_TOKEN: spec.inference.token } : {}
771
785
  };
772
786
  },
773
787
  provider: () => "anthropic",
@@ -3168,10 +3182,10 @@ function parseToolchains(raw, base, missing, file) {
3168
3182
  const maxRunMinutes = parseMaxRunMinutes(entryRaw.maxRunMinutes, `${where}.maxRunMinutes`, missing);
3169
3183
  let auth;
3170
3184
  if (entryRaw.auth !== void 0) {
3171
- if (entryRaw.auth !== "subscription" && entryRaw.auth !== "inherit") {
3172
- missing.add(`${where}.auth must be subscription|inherit (got "${entryRaw.auth}")`);
3185
+ if (entryRaw.auth !== "subscription" && entryRaw.auth !== "inherit" && entryRaw.auth !== "workspace") {
3186
+ missing.add(`${where}.auth must be subscription|inherit|workspace (got "${entryRaw.auth}")`);
3173
3187
  } else if (harness !== "claude") {
3174
- missing.add(`${where}.auth is only supported on the claude harness`);
3188
+ missing.add(`${where}.auth is only supported on the claude harness (${entryRaw.auth === "workspace" ? "workspace is not available for codex or gemini yet" : "got " + harness})`);
3175
3189
  } else {
3176
3190
  auth = entryRaw.auth;
3177
3191
  }
@@ -4939,6 +4953,16 @@ function agentLogRefusedItem(route2, role, status2, nowIso = (/* @__PURE__ */ ne
4939
4953
  message: `${route2}: the tracker refused the ${role} seat's run report (${status2}) \u2014 runs and ticket spend are not being recorded; check the seat key's role`
4940
4954
  };
4941
4955
  }
4956
+ var workspaceAuthKey = (role) => `workspace_auth:${role}`;
4957
+ var workspaceAllowanceKey = (role) => `workspace_allowance:${role}`;
4958
+ function workspaceAllowanceItem(route2, role, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
4959
+ return {
4960
+ kind: "workspace_allowance",
4961
+ key: workspaceAllowanceKey(role),
4962
+ since: nowIso,
4963
+ message: `${route2}: the ${role} seat is paused \u2014 the workspace is past its AI allowance; it resumes on its own once the limit is raised`
4964
+ };
4965
+ }
4942
4966
  var diskLowKey = (repo) => `disk_low:${repo}`;
4943
4967
  var LOW_DISK_BYTES = 2 * 1024 ** 3;
4944
4968
  var gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
@@ -7278,6 +7302,18 @@ var Tracker = class {
7278
7302
  if (!row) return;
7279
7303
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
7280
7304
  }
7305
+ /**
7306
+ * Writes (or, with `null`, clears) this ship's `relay_url` (CREW-2803). A
7307
+ * no-op when there is no row for this ship or the Ships table has not taken
7308
+ * the column (probed by the key's absence from the row), so the tunnel
7309
+ * still runs on an install that predates it.
7310
+ */
7311
+ async updateRelayUrl(shipName2, url) {
7312
+ const row = await this.myShipRow(shipName2);
7313
+ if (!row || !("relay_url" in row)) return;
7314
+ if ((row.relay_url ?? null) === url) return;
7315
+ await this.client.records.update(this.route.resolved.shipsModelId, row.id, { relay_url: url });
7316
+ }
7281
7317
  /** The other half of `beatEngaged` — clears engagement back to idle. */
7282
7318
  async beatIdle(shipName2) {
7283
7319
  const row = await this.myShipRow(shipName2);
@@ -9367,6 +9403,37 @@ var AgentError = class extends Error {
9367
9403
  };
9368
9404
  var AgentSpawnError = class extends AgentError {
9369
9405
  };
9406
+ var WorkspaceAuthRefused = class extends AgentSpawnError {
9407
+ };
9408
+ var WorkspaceAllowanceExhausted = class extends AgentError {
9409
+ };
9410
+ var ALLOWANCE_REFUSAL_MARKER = "AI_ALLOWANCE_EXCEEDED";
9411
+ function streamShowsAllowanceRefusal(streamPath) {
9412
+ let raw;
9413
+ try {
9414
+ raw = readFileSync19(streamPath, "utf8");
9415
+ } catch {
9416
+ return false;
9417
+ }
9418
+ if (!raw.includes(ALLOWANCE_REFUSAL_MARKER)) return false;
9419
+ for (const line of raw.split("\n")) {
9420
+ if (!line.includes(ALLOWANCE_REFUSAL_MARKER)) continue;
9421
+ let ev;
9422
+ try {
9423
+ ev = JSON.parse(line);
9424
+ } catch {
9425
+ continue;
9426
+ }
9427
+ if (typeof ev !== "object" || ev === null) continue;
9428
+ const e = ev;
9429
+ if (e.type === "result" && e.is_error === true) {
9430
+ if (typeof e.result === "string" && e.result.includes(ALLOWANCE_REFUSAL_MARKER)) return true;
9431
+ } else if (e.type === "assistant" && (e.isApiErrorMessage === true || e.error !== void 0)) {
9432
+ if (JSON.stringify(e.message ?? "").includes(ALLOWANCE_REFUSAL_MARKER)) return true;
9433
+ }
9434
+ }
9435
+ return false;
9436
+ }
9370
9437
  var digestPathFor = (stateDir, routeLabel, role, runId) => join22(stateDir, `digest-${routeLabel}-${role}${runId ? `-${runId}` : ""}.md`);
9371
9438
  function planAgentRun(o) {
9372
9439
  const briefPath = join22(o.route.promptsDir, "personas", `lane-${o.role}.md`);
@@ -9411,7 +9478,17 @@ function planAgentRun(o) {
9411
9478
  const streamsDir = join22(o.stateDir, "streams");
9412
9479
  const base = `${routeLabel}-${o.role}-${o.cycle}${o.runId ? `-${o.runId}` : ""}`;
9413
9480
  const seat = resolveSeat(o.ship, o.route, o.role, o.agentPersona ?? (o.agentModel ? { model: o.agentModel } : {}), o.repo);
9414
- const { toolchain, model } = seat;
9481
+ const { model } = seat;
9482
+ let toolchain = seat.toolchain;
9483
+ if (toolchain.auth === "workspace") {
9484
+ if (!o.apiKey || o.seatKind === "ship") {
9485
+ throw new WorkspaceAuthRefused(`${o.role}: toolchain ${toolchain.name} has auth: workspace, which needs the seat's own agent key \u2014 this seat would fall back to the ship key (run \`crew agents sync\`)`);
9486
+ }
9487
+ if (!o.route.resolved) {
9488
+ throw new WorkspaceAuthRefused(`${o.role}: toolchain ${toolchain.name} has auth: workspace, but route ${o.route.route} is not connected (run \`crew connect\`)`);
9489
+ }
9490
+ toolchain = { ...toolchain, inference: { baseUrl: inferenceBaseUrl(o.route.baseUrl, o.route.resolved.workspaceId), token: o.apiKey } };
9491
+ }
9415
9492
  const adapter = adapterFor(toolchain.harness);
9416
9493
  const invocation = adapter.invocation(toolchain, {
9417
9494
  role: o.role,
@@ -9456,6 +9533,7 @@ function planAgentRun(o) {
9456
9533
  args: invocation.args,
9457
9534
  toolchain: toolchain.name,
9458
9535
  harness: toolchain.harness,
9536
+ workspaceAuth: toolchain.auth === "workspace",
9459
9537
  provider: toolchainProvider(toolchain),
9460
9538
  maxRunMinutes: resolveMaxRunMinutes(o.route.maxRunMinutes, toolchain.maxRunMinutes),
9461
9539
  resolution: { toolchain: seat.toolchainWhy, model: seat.modelWhy },
@@ -18145,6 +18223,26 @@ function sshKeygenAvailable() {
18145
18223
  }
18146
18224
  }
18147
18225
 
18226
+ // src/relay-probe.ts
18227
+ async function probeRelayControl(relayUrl, apiKey, userAgent, fetchFn = fetch, timeoutMs = 1e4) {
18228
+ const started = Date.now();
18229
+ try {
18230
+ const res = await fetchFn(`${relayUrl.replace(/\/+$/, "")}/control/status`, {
18231
+ headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": userAgent },
18232
+ signal: AbortSignal.timeout(timeoutMs)
18233
+ });
18234
+ const latencyMs = Date.now() - started;
18235
+ await res.arrayBuffer().catch(() => void 0);
18236
+ if (res.ok) return { ok: true, status: res.status, latencyMs };
18237
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
18238
+ } catch (e) {
18239
+ return { ok: false, error: e.message };
18240
+ }
18241
+ }
18242
+ function describeRelayProbe(url, p) {
18243
+ return p.ok ? `relay control: OK ${url}/control/status (${p.status}, ${p.latencyMs} ms)` : `relay control: FAILED ${url}/control/status \u2014 ${p.error}`;
18244
+ }
18245
+
18148
18246
  // src/passenger-containers.ts
18149
18247
  import { execFileSync as execFileSync9 } from "child_process";
18150
18248
  import { createHash as createHash7 } from "crypto";
@@ -18362,15 +18460,21 @@ function generateTunnelSlug() {
18362
18460
  for (let i = 0; i < SLUG_LENGTH; i++) out += SLUG_ALPHABET[randomInt(SLUG_ALPHABET.length)];
18363
18461
  return out;
18364
18462
  }
18365
- function bindAddrFor(o) {
18366
- const base = `workspace-${o.workspaceId}-slug-${o.slug}-key-${o.apiKey}`;
18367
- return o.mcpSecret ? `${base}-secret-${o.mcpSecret}` : base;
18463
+ function bindAddrFor(o, path) {
18464
+ const base = `workspace-${o.workspaceId}-slug-${o.slug}${path ? `-path-${path}` : ""}-key-${o.apiKey}`;
18465
+ return o.mcpSecret && !path ? `${base}-secret-${o.mcpSecret}` : base;
18368
18466
  }
18369
- function publicUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18467
+ function relayBaseUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18370
18468
  const domain = relayPublicDomain ?? relayHost.replace(/^.*@/, "");
18371
- return `https://${slug}.${domain}${relayHttpPort === 443 ? "" : `:${relayHttpPort}`}/mcp`;
18469
+ return `https://${slug}.${domain}${relayHttpPort === 443 ? "" : `:${relayHttpPort}`}`;
18470
+ }
18471
+ function publicUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18472
+ return `${relayBaseUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain)}/mcp`;
18372
18473
  }
18373
18474
  function sshArgsFor(o) {
18475
+ const forwards = [];
18476
+ if (o.localPort !== void 0) forwards.push("-R", `${bindAddrFor(o)}:0:127.0.0.1:${o.localPort}`);
18477
+ if (o.controlSocket) forwards.push("-R", `${bindAddrFor(o, "control")}:0:${o.controlSocket}`);
18374
18478
  return [
18375
18479
  "-N",
18376
18480
  "-o",
@@ -18385,8 +18489,7 @@ function sshArgsFor(o) {
18385
18489
  o.privateKeyPath,
18386
18490
  "-p",
18387
18491
  String(o.relayPort),
18388
- "-R",
18389
- `${bindAddrFor(o)}:0:127.0.0.1:${o.localPort}`,
18492
+ ...forwards,
18390
18493
  o.relayHost
18391
18494
  ];
18392
18495
  }
@@ -18457,16 +18560,19 @@ function isPidAlive(pid) {
18457
18560
  return false;
18458
18561
  }
18459
18562
  }
18563
+ var CONTROL_ONLY_HASH = "control-only";
18460
18564
  function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, stateDir, mcpSecrets = /* @__PURE__ */ new Map(), deps = {}) {
18461
18565
  const alive = deps.isPidAlive ?? isPidAlive;
18462
18566
  const plannedIds = new Set(plans.map((p) => p.workspaceId));
18463
18567
  for (const plan of plans) {
18464
18568
  const existing = readPersistedTunnel(stateDir, plan.workspaceId);
18569
+ const mountsHash = plan.mountsHash ?? CONTROL_ONLY_HASH;
18570
+ const controlUnchanged = existing?.controlSocket === deps.controlSocket;
18465
18571
  const currentSecret = mcpSecrets.get(plan.workspaceId);
18466
18572
  const secretUnchanged = (existing?.mcpSecret ?? void 0) === (currentSecret ?? void 0);
18467
18573
  const slug = existing?.slug ?? generateTunnelSlug();
18468
18574
  const slugUnchanged = existing?.liveSlug === slug;
18469
- if (existing && alive(existing.pid) && existing.mountsHash === plan.mountsHash && secretUnchanged && slugUnchanged) {
18575
+ if (existing && alive(existing.pid) && existing.mountsHash === mountsHash && secretUnchanged && slugUnchanged && controlUnchanged) {
18470
18576
  if (existing.status === "connecting") {
18471
18577
  const settled = { ...existing, status: "connected" };
18472
18578
  writePersistedTunnel(stateDir, settled);
@@ -18493,6 +18599,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
18493
18599
  slug,
18494
18600
  apiKey,
18495
18601
  localPort: plan.port,
18602
+ controlSocket: deps.controlSocket,
18496
18603
  privateKeyPath,
18497
18604
  mcpSecret: currentSecret,
18498
18605
  spawnFn: deps.spawnFn,
@@ -18502,9 +18609,10 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
18502
18609
  });
18503
18610
  const started = {
18504
18611
  pid: tunnel.pid ?? -1,
18505
- containerName: plan.containerName,
18612
+ containerName: plan.containerName ?? "",
18506
18613
  workspaceId: plan.workspaceId,
18507
- mountsHash: plan.mountsHash,
18614
+ mountsHash,
18615
+ controlSocket: deps.controlSocket,
18508
18616
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
18509
18617
  status: settledStatus,
18510
18618
  mcpSecret: currentSecret,
@@ -18870,6 +18978,7 @@ import { join as join42 } from "path";
18870
18978
  import { randomBytes as randomBytes2 } from "crypto";
18871
18979
  var DEFAULT_FLOOR_MS = 5e3;
18872
18980
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
18981
+ var ALLOWANCE_PAUSE_MS = 5 * 60 * 1e3;
18873
18982
  var defaultSleep = (ms3) => new Promise((resolve6) => setTimeout(resolve6, ms3));
18874
18983
  async function runDaemonLoop(o) {
18875
18984
  const floor = o.floorMs ?? DEFAULT_FLOOR_MS;
@@ -19003,6 +19112,10 @@ var SpawnBreaker = class {
19003
19112
  this.states.set(role, { failures, retryAt: this.now() + retryInMs, lastMessage: message });
19004
19113
  return { parked, firstOfMessage: prev?.lastMessage !== message, failures, retryInMs };
19005
19114
  }
19115
+ /** CREW-2796: holds `role` back for `ms` without counting a failed start (an allowance pause). */
19116
+ pause(role, ms3) {
19117
+ this.states.set(role, { failures: 0, retryAt: this.now() + ms3, lastMessage: "paused" });
19118
+ }
19006
19119
  /** A run for `role` spawned: forget its earlier failed starts. */
19007
19120
  recordSuccess(role) {
19008
19121
  this.states.delete(role);
@@ -19130,8 +19243,27 @@ async function runOnePassUnguarded(o) {
19130
19243
  const run2 = spawn9(role, decision, ctx, { ready }).then(() => {
19131
19244
  breaker.recordSuccess(role);
19132
19245
  clearShipAttention(o.state, `role_parked:${role}`);
19246
+ clearShipAttention(o.state, workspaceAuthKey(role));
19247
+ clearShipAttention(o.state, workspaceAllowanceKey(role));
19133
19248
  }).catch(async (e) => {
19134
19249
  const message = e.message;
19250
+ if (e instanceof WorkspaceAllowanceExhausted) {
19251
+ breaker.pause(role, ALLOWANCE_PAUSE_MS);
19252
+ emit.warn(`${role} paused: ${message}`, { step: "agent", role });
19253
+ await raiseShipAttention({ state: o.state, emit, route: o.route, ship: o.ship }, workspaceAllowanceItem(o.route.route, role));
19254
+ return;
19255
+ }
19256
+ if (e instanceof WorkspaceAuthRefused) {
19257
+ breaker.recordFailure(role, message);
19258
+ emit.warn(`${role} agent refused: ${message}`, { step: "agent", role });
19259
+ await raiseShipAttention({ state: o.state, emit, route: o.route, ship: o.ship }, {
19260
+ kind: "workspace_auth",
19261
+ key: workspaceAuthKey(role),
19262
+ message: `${o.route.route}: ${message}`,
19263
+ since: (/* @__PURE__ */ new Date()).toISOString()
19264
+ });
19265
+ return;
19266
+ }
19135
19267
  if (!(e instanceof AgentSpawnError)) {
19136
19268
  emit.warn(`${role} agent run failed: ${message}`, { step: "agent", role });
19137
19269
  return;
@@ -19398,6 +19530,7 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
19398
19530
  roster: rosterFor(decision, route2, role),
19399
19531
  environment: await buildEnvironment(route2, ship, ticketHint),
19400
19532
  apiKey: identity.apiKey,
19533
+ seatKind: identity.kind,
19401
19534
  cycle: emit.cycle,
19402
19535
  runId,
19403
19536
  ticket: ticketHint,
@@ -19442,6 +19575,9 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
19442
19575
  await postRunKilled(route2, tracker, workingId, memberId, result.killed.limitMinutes, emit);
19443
19576
  } else {
19444
19577
  await guardRunHandoff(route2, ship, tracker, role, workingId, emit, runStart);
19578
+ if (plan.workspaceAuth && streamShowsAllowanceRefusal(plan.streamPath)) {
19579
+ throw new WorkspaceAllowanceExhausted("the inference endpoint refused the run: the workspace is past its AI allowance");
19580
+ }
19445
19581
  }
19446
19582
  } finally {
19447
19583
  await settleRunEnd({
@@ -20484,6 +20620,11 @@ if (command === "passenger-sync-daemon") {
20484
20620
  }, parseTicketNumber = function(input) {
20485
20621
  const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(input.trim());
20486
20622
  return m ? m[1] : null;
20623
+ }, routeRelayUrl = function(route3) {
20624
+ const ws = route3.resolved?.workspaceId;
20625
+ const slug = ws ? readPersistedTunnel(cfg.ship.stateDir, ws)?.slug : void 0;
20626
+ if (!cfg.ship.relayHost || !slug) return null;
20627
+ return relayBaseUrlFor(slug, cfg.ship.relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain);
20487
20628
  }, passengerApiKeys = function(routes) {
20488
20629
  const keys = /* @__PURE__ */ new Map();
20489
20630
  for (const r of routes) {
@@ -20531,7 +20672,7 @@ if (command === "passenger-sync-daemon") {
20531
20672
  const unsafe = baseBranchUnsafe(out);
20532
20673
  return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
20533
20674
  };
20534
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth;
20675
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, routeRelayUrl2 = routeRelayUrl, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth;
20535
20676
  let cfg;
20536
20677
  let firstRun;
20537
20678
  let firstRunFromFlags = false;
@@ -20664,7 +20805,10 @@ if (command === "passenger-sync-daemon") {
20664
20805
  async function syncHostPassengers() {
20665
20806
  if (dryRun) return;
20666
20807
  const passengerRoutes = cfg.routes.filter((r) => r.enabled && r.hostPassengers);
20667
- if (passengerRoutes.length === 0) return;
20808
+ if (passengerRoutes.length === 0) {
20809
+ await syncShipTunnels([], /* @__PURE__ */ new Map(), []);
20810
+ return;
20811
+ }
20668
20812
  if (!dockerAvailable()) {
20669
20813
  const pathProblem = dockerPathProblem(cfg.ship, COMMON_DOCKER_DIRS);
20670
20814
  emit.warn(
@@ -20706,37 +20850,64 @@ if (command === "passenger-sync-daemon") {
20706
20850
  { step: "passengers", data: { ...result } }
20707
20851
  );
20708
20852
  }
20709
- if (cfg.ship.relayHost) {
20710
- try {
20711
- const { privateKeyPath } = ensureShipSshKeypair(cfg.ship.stateDir);
20712
- const plans = planContainers(cfg);
20713
- const mcpSecrets = new Map([...syncEndpoints].map(([workspaceId, e]) => [workspaceId, e.secret]));
20714
- const relayHost = cfg.ship.relayHost;
20715
- const reconcile = endpointReconcileDeps(passengerRoutes, relayHost);
20716
- const pending = [];
20717
- syncPassengerTunnels(plans, relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, mcpSecrets, {
20718
- apiKeys: passengerApiKeys(passengerRoutes),
20719
- onStatus: (workspaceId, status2, info) => {
20720
- const owningRoute = passengerRoutes.find((r) => r.resolved?.workspaceId === workspaceId);
20721
- if (!owningRoute) return;
20722
- pending.push(
20723
- new Tracker(owningRoute, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
20724
- emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" });
20725
- })
20726
- );
20727
- if (status2 === "disconnected") pending.push(unregisterEndpoint(workspaceId, info, reconcile));
20728
- }
20729
- });
20730
- for (const plan of plans) pending.push(reconcileEndpoint(plan.workspaceId, reconcile));
20731
- await Promise.all(pending);
20732
- } catch (e) {
20733
- emit.warn(`passenger tunnel sync failed: ${e.message}`, { step: "passengers" });
20734
- }
20735
- }
20853
+ await syncShipTunnels(planContainers(cfg), new Map([...syncEndpoints].map(([workspaceId, e]) => [workspaceId, e.secret])), passengerRoutes);
20736
20854
  } catch (e) {
20737
20855
  emit.warn(`passenger container sync failed: ${e.message}`, { step: "passengers" });
20738
20856
  }
20739
20857
  }
20858
+ async function syncShipTunnels(containerPlans, mcpSecrets, passengerRoutes) {
20859
+ if (!cfg.ship.relayHost) return;
20860
+ const relayHost = cfg.ship.relayHost;
20861
+ try {
20862
+ const { privateKeyPath } = ensureShipSshKeypair(cfg.ship.stateDir);
20863
+ const controlRoutes = cfg.routes.filter((r) => r.enabled && r.resolved?.workspaceId);
20864
+ const planned = new Set(containerPlans.map((p) => p.workspaceId));
20865
+ const plans = [...containerPlans];
20866
+ for (const r of controlRoutes) {
20867
+ const ws = r.resolved.workspaceId;
20868
+ if (planned.has(ws)) continue;
20869
+ planned.add(ws);
20870
+ plans.push({ workspaceId: ws });
20871
+ }
20872
+ const apiRoutes = controlRoutes;
20873
+ const reconcile = endpointReconcileDeps(passengerRoutes, relayHost);
20874
+ const pending = [];
20875
+ syncPassengerTunnels(plans, relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, mcpSecrets, {
20876
+ apiKeys: passengerApiKeys(apiRoutes),
20877
+ controlSocket: controlSocketFor(cfg.ship.stateDir),
20878
+ onStatus: (workspaceId, status2, info) => {
20879
+ const owningRoute = apiRoutes.find((r) => r.resolved?.workspaceId === workspaceId);
20880
+ if (!owningRoute) return;
20881
+ pending.push(
20882
+ new Tracker(owningRoute, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
20883
+ emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" });
20884
+ })
20885
+ );
20886
+ if (status2 === "disconnected") pending.push(unregisterEndpoint(workspaceId, info, reconcile));
20887
+ }
20888
+ });
20889
+ for (const plan of containerPlans) pending.push(reconcileEndpoint(plan.workspaceId, reconcile));
20890
+ for (const plan of plans) pending.push(reconcileRelayUrl(plan.workspaceId, apiRoutes));
20891
+ await Promise.all(pending);
20892
+ } catch (e) {
20893
+ emit.warn(`ship tunnel sync failed: ${e.message}`, { step: "passengers" });
20894
+ }
20895
+ }
20896
+ async function reconcileRelayUrl(workspaceId, routes) {
20897
+ if (!cfg.ship.relayHost) return;
20898
+ const persisted = readPersistedTunnel(cfg.ship.stateDir, workspaceId);
20899
+ if (!persisted?.slug || persisted.status !== "connected") return;
20900
+ const url = relayBaseUrlFor(persisted.slug, cfg.ship.relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain);
20901
+ if (persisted.relayUrl === url) return;
20902
+ const route3 = routes.find((r) => r.resolved?.workspaceId === workspaceId);
20903
+ if (!route3) return;
20904
+ try {
20905
+ await new Tracker(route3, cfg.ship).updateRelayUrl(cfg.ship.name, url);
20906
+ updatePersistedTunnel(cfg.ship.stateDir, workspaceId, { relayUrl: url });
20907
+ } catch (e) {
20908
+ emit.warn(`could not write relay_url: ${e.message}`, { step: "passengers" });
20909
+ }
20910
+ }
20740
20911
  async function releaseFleet(opts = {}) {
20741
20912
  if (releaseRefused()) return;
20742
20913
  precheckTickets.clear();
@@ -23753,6 +23924,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23753
23924
  const rotatePending = [];
23754
23925
  syncPassengerTunnels(plans, cfg.ship.relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, /* @__PURE__ */ new Map(), {
23755
23926
  apiKeys: passengerApiKeys([route2]),
23927
+ controlSocket: controlSocketFor(cfg.ship.stateDir),
23756
23928
  onStatus: (wsId, status2, info) => {
23757
23929
  rotatePending.push(
23758
23930
  new Tracker(route2, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
@@ -24329,6 +24501,14 @@ ${where.join("\n")}`);
24329
24501
  const url = mcpUrlFor(value("base-url") ?? cfg.routes[0]?.baseUrl ?? DEFAULT_BASE_URL);
24330
24502
  const disconnectHarnesses = /* @__PURE__ */ new Set([...shipPair(cfg.ship), ...shipToolchains(cfg.ship).map((t) => t.harness).filter(isPairHarness)]);
24331
24503
  const removePairEntries = (mcpUrl, dry) => [...disconnectHarnesses].map((h) => pairInstallerFor(h).removePair({ url: mcpUrl, bin: cfg.ship.agent.bin, dryRun: dry }));
24504
+ if (!dryRun) {
24505
+ for (const r of cfg.routes.filter((x) => x.resolved?.shipsModelId)) {
24506
+ await new Tracker(r, cfg.ship).updateRelayUrl(cfg.ship.name, null).catch((e) => {
24507
+ process.stderr.write(`could not clear relay_url for ${r.route}: ${e.message}
24508
+ `);
24509
+ });
24510
+ }
24511
+ }
24332
24512
  if (!flag("reset")) {
24333
24513
  for (const line of removePairEntries(url, dryRun)) process.stderr.write(`${line}
24334
24514
  `);
@@ -24763,7 +24943,9 @@ ${where.join("\n")}`);
24763
24943
  attention: openShipAttention(state),
24764
24944
  // Additive (CREW-2495): this ship's Ships-row name and whether it has drifted from `name`.
24765
24945
  rowName: nameSync.rowName ?? null,
24766
- nameDrift: nameSync.drift
24946
+ nameDrift: nameSync.drift,
24947
+ // Additive (CREW-2803): the relay URL this route's tunnel is reachable at, or null.
24948
+ relayUrl: routeRelayUrl(route2)
24767
24949
  },
24768
24950
  // Additive (CREW-1402): what the operator owes. `version` stays 1.
24769
24951
  todo: todo.map((i) => ({ ticket: displayKey(i.ticket), title: i.ticket.title ?? null, reasons: i.reasons })),
@@ -24853,7 +25035,8 @@ ${owed.map((l) => ` ${l}
24853
25035
  route: ${route2.route} -> ${route2.dir}
24854
25036
  enabled: ${route2.enabled}
24855
25037
  periscopes: ${periscopeSummary(cfg)}
24856
- ` + periscopes.map((p) => `periscope: ${p.ticket} ${p.repo} ${p.url} ${p.state}${p.serving ? "" : " (not serving)"} expires ${p.expiresAt}
25038
+ ` + (routeRelayUrl(route2) ? `relay: ${routeRelayUrl(route2)}
25039
+ ` : "") + periscopes.map((p) => `periscope: ${p.ticket} ${p.repo} ${p.url} ${p.state}${p.serving ? "" : " (not serving)"} expires ${p.expiresAt}
24857
25040
  `).join("") + `crew: ${state.isPaused() ? "paused" : "active"}
24858
25041
  release: ${state.releaseSkipReason() ? "paused" : "active"}
24859
25042
  watermark: ${state.watermark()}
@@ -25445,11 +25628,34 @@ ${foreign.join("\n")}
25445
25628
  );
25446
25629
  }
25447
25630
  }
25631
+ if (cfg.ship.relayHost && !flag("offline")) {
25632
+ const relayUrl = routeRelayUrl(route2);
25633
+ if (!relayUrl) {
25634
+ process.stdout.write("relay control: no tunnel URL yet (the first `crew passengers` cycle opens it)\n");
25635
+ } else {
25636
+ let key;
25637
+ try {
25638
+ key = resolveApiKey(route2);
25639
+ } catch {
25640
+ key = void 0;
25641
+ }
25642
+ process.stdout.write(`${key ? describeRelayProbe(relayUrl, await probeRelayControl(relayUrl, key, cfg.ship.userAgent)) : "relay control: could not check (no API key for this route)"}
25643
+ `);
25644
+ }
25645
+ }
25448
25646
  for (const l of doctorSelftestLines(readSelftest(cfg.ship.stateDir), shipToolchains(cfg.ship).map((t) => t.name))) {
25449
25647
  process.stdout.write(`${l}
25450
25648
  `);
25451
25649
  }
25452
25650
  for (const t of shipToolchains(cfg.ship).filter((t2) => t2.harness === "claude")) {
25651
+ if (t.auth === "workspace") {
25652
+ for (const r of cfg.routes.filter((r2) => r2.enabled)) {
25653
+ const inference = r.resolved ? { baseUrl: inferenceBaseUrl(r.baseUrl, r.resolved.workspaceId), token: "" } : void 0;
25654
+ process.stdout.write(`auth: ${t.name}: ${r.route}: ${describeClaudeAuth({ ...t, inference })}
25655
+ `);
25656
+ }
25657
+ continue;
25658
+ }
25453
25659
  process.stdout.write(`auth: ${t.name}: ${describeClaudeAuth(t)}
25454
25660
  `);
25455
25661
  }
@@ -25466,6 +25672,7 @@ ${foreign.join("\n")}
25466
25672
  }
25467
25673
  var requireArmed2;
25468
25674
  var parseTicketNumber2;
25675
+ var routeRelayUrl2;
25469
25676
  var passengerApiKeys2;
25470
25677
  var endpointApiFor2;
25471
25678
  var endpointReconcileDeps2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.96.0",
3
+ "version": "0.98.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",