@jpcasabiancai/cayenne 0.5.1 → 0.5.3

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 +350 -91
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -301,7 +301,8 @@ var init_repositories = __esm({
301
301
  });
302
302
  RepositoryList = z4.object({
303
303
  repositories: z4.array(RepositoryListItem).max(2e3),
304
- connected: z4.boolean()
304
+ connected: z4.boolean(),
305
+ reason: z4.literal("github_not_installed").optional()
305
306
  });
306
307
  REPOSITORY_LIST_ERRORS = [
307
308
  "unauthorized",
@@ -325,7 +326,12 @@ var init_repositories = __esm({
325
326
  fullName: z4.string().min(1)
326
327
  });
327
328
  RepoSessionTokenRequest = z4.strictObject({ sessionId: z4.uuid() });
328
- REPO_READ_ONLY_REASONS = ["not_connected", "no_expiry", "scope_refused"];
329
+ REPO_READ_ONLY_REASONS = [
330
+ "not_connected",
331
+ "no_expiry",
332
+ "scope_refused",
333
+ "github_not_installed"
334
+ ];
329
335
  RepoReadOnlyReason = z4.enum(REPO_READ_ONLY_REASONS);
330
336
  RepoSessionToken = z4.discriminatedUnion("connected", [
331
337
  z4.object({
@@ -354,6 +360,7 @@ var init_repositories = __esm({
354
360
  "not_recent",
355
361
  "not_found",
356
362
  "not_installed",
363
+ "github_not_installed",
357
364
  "archived",
358
365
  "repository_mismatch",
359
366
  "not_configured",
@@ -460,7 +467,7 @@ var init_skills = __esm({
460
467
 
461
468
  // ../../packages/shared/src/protocol/integrations.ts
462
469
  import { z as z6 } from "zod";
463
- var INTEGRATION_PROVIDERS, AUTH_KINDS, both, PROVIDER_META, IntegrationProviderSchema, AuthKindSchema, ProviderConfigPublic, ConnectStartRequest, ConnectStartResponse, ConnectCompleteRequest, DisconnectRequest, MessageResourceKind, ReplyRequest, ReplyResponse, ResourceList, SetSubscriptionsRequest, INBOX_ERROR_CODES, InboxErrorCode, CONVERSATION_KINDS, ConversationKind;
470
+ var INTEGRATION_PROVIDERS, AUTH_KINDS, both, PROVIDER_META, IntegrationProviderSchema, AuthKindSchema, ProviderConfigPublic, ConnectStartRequest, ConnectStartResponse, ConnectCompleteRequest, DisconnectRequest, GithubInstallationId, GithubAccountLogin, GithubAccountType, GithubAvatarUrl, GithubInstallation, GithubAvailableInstallation, GithubInstallationList, GithubBindRequest, GithubBindResponse, GithubUnbindRequest, GithubUnbindResponse, MessageResourceKind, ReplyRequest, ReplyResponse, ResourceList, SetSubscriptionsRequest, INBOX_ERROR_CODES, InboxErrorCode, CONVERSATION_KINDS, ConversationKind;
464
471
  var init_integrations = __esm({
465
472
  "../../packages/shared/src/protocol/integrations.ts"() {
466
473
  "use strict";
@@ -513,9 +520,10 @@ var init_integrations = __esm({
513
520
  github: {
514
521
  label: "GitHub",
515
522
  badge: "GH",
516
- paths: { native: { stage: 1 }, composio: { stage: 3, toolkit: "github" }, platform: null },
517
- capabilities: { messages: [], tasks: both() },
518
- webhook: { fn: "github-webhook", params: ["ws"] }
523
+ // The Cayenne GitHub App (D57), a team workspace's platform path; Composio is Stage 3.
524
+ paths: { native: null, composio: { stage: 3, toolkit: "github" }, platform: { stage: 1 } },
525
+ capabilities: { messages: [], tasks: ["composio", "platform"] },
526
+ webhook: { fn: "github-webhook", params: [] }
519
527
  },
520
528
  monday: {
521
529
  label: "Monday.com",
@@ -577,11 +585,49 @@ var init_integrations = __esm({
577
585
  ConnectStartRequest = z6.strictObject({
578
586
  workspaceId: z6.uuid(),
579
587
  provider: IntegrationProviderSchema,
580
- acknowledged: z6.boolean().optional()
588
+ acknowledged: z6.boolean().optional(),
589
+ intent: z6.enum(["connect", "install"]).optional()
590
+ }).superRefine((r, ctx) => {
591
+ if (r.intent === "install" && r.provider !== "github") {
592
+ ctx.addIssue({ code: "custom", path: ["intent"], message: "install is GitHub only" });
593
+ }
581
594
  });
582
595
  ConnectStartResponse = z6.object({ url: z6.url({ protocol: /^https$/ }) });
583
596
  ConnectCompleteRequest = z6.strictObject({ pendingId: z6.uuid() });
584
597
  DisconnectRequest = z6.strictObject({ integrationId: z6.uuid() });
598
+ GithubInstallationId = z6.number().int().positive();
599
+ GithubAccountLogin = z6.string().min(1).max(100);
600
+ GithubAccountType = z6.enum(["Organization", "User"]);
601
+ GithubAvatarUrl = z6.url({
602
+ protocol: /^https$/,
603
+ hostname: /^avatars[.]githubusercontent[.]com$/
604
+ });
605
+ GithubInstallation = z6.object({
606
+ id: z6.uuid(),
607
+ installationId: GithubInstallationId,
608
+ accountLogin: GithubAccountLogin,
609
+ accountType: GithubAccountType,
610
+ avatarUrl: GithubAvatarUrl,
611
+ addedBy: z6.object({ id: z6.uuid(), displayName: z6.string().nullable() }).nullable(),
612
+ createdAt: z6.string()
613
+ });
614
+ GithubAvailableInstallation = z6.object({
615
+ installationId: GithubInstallationId,
616
+ accountLogin: GithubAccountLogin,
617
+ accountType: GithubAccountType,
618
+ avatarUrl: GithubAvatarUrl
619
+ });
620
+ GithubInstallationList = z6.object({
621
+ bound: z6.array(GithubInstallation),
622
+ available: z6.array(GithubAvailableInstallation)
623
+ });
624
+ GithubBindRequest = z6.strictObject({
625
+ workspaceId: z6.uuid(),
626
+ installationId: GithubInstallationId
627
+ });
628
+ GithubBindResponse = z6.object({ binding: GithubInstallation });
629
+ GithubUnbindRequest = z6.strictObject({ workspaceId: z6.uuid(), bindingId: z6.uuid() });
630
+ GithubUnbindResponse = z6.object({ orphaned: z6.number().int().nonnegative() });
585
631
  MessageResourceKind = z6.enum(["clickup_team", "teams_channel", "teams_chat"]);
586
632
  ReplyRequest = z6.strictObject({
587
633
  messageId: z6.uuid(),
@@ -2126,27 +2172,45 @@ async function updateRunner(db, runnerId, columns) {
2126
2172
  }
2127
2173
  async function stamp(db, runnerId, status, what) {
2128
2174
  const { data, error } = await db.from("runners").update({ status, last_seen_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", runnerId).select("id");
2129
- if (error) logger.warn({ runnerId, err: error.message }, `${what} write failed`);
2130
- else if (!data || data.length === 0) logger.warn({ runnerId }, `${what} matched no runner row`);
2175
+ if (error) {
2176
+ logger.warn({ runnerId, err: error.message }, `${what} write failed`);
2177
+ return "error";
2178
+ }
2179
+ return data && data.length > 0 ? "ok" : "missing";
2131
2180
  }
2132
2181
  function startHeartbeat(db, runnerId, intervalMs, refresh, published, current) {
2133
2182
  const seed = published ? RunnerCapabilities.safeParse(published) : null;
2134
2183
  let last = seed?.success ? JSON.stringify(seed.data) : null;
2135
2184
  let stopped = false;
2136
2185
  let writes = Promise.resolve();
2137
- const tick = () => stamp(db, runnerId, "online", "heartbeat").catch(
2138
- (err) => logger.warn({ runnerId, err }, "heartbeat write failed")
2139
- );
2186
+ let misses = 0;
2187
+ const tick = async () => {
2188
+ let outcome;
2189
+ try {
2190
+ outcome = await stamp(db, runnerId, "online", "heartbeat");
2191
+ } catch (err) {
2192
+ logger.warn({ runnerId, err }, "heartbeat write failed");
2193
+ return;
2194
+ }
2195
+ if (outcome === "missing") {
2196
+ if (misses === 0) logger.warn({ runnerId }, "heartbeat matched no runner row");
2197
+ misses += 1;
2198
+ } else if (outcome === "ok" && misses > 0) {
2199
+ logger.info({ runnerId, misses }, "heartbeat matched its runner row again");
2200
+ misses = 0;
2201
+ }
2202
+ };
2140
2203
  const timer = setInterval(() => void tick(), intervalMs);
2141
2204
  const write = async (caps) => {
2142
2205
  const json = JSON.stringify(caps);
2143
2206
  if (stopped || json === last) return;
2144
2207
  try {
2145
- const { error } = await db.from("runners").update({ capabilities: caps }).eq("id", runnerId);
2208
+ const { data, error } = await db.from("runners").update({ capabilities: caps }).eq("id", runnerId).select("id");
2146
2209
  if (error) {
2147
2210
  logger.warn({ runnerId, err: error.message }, "capability refresh failed");
2148
2211
  return;
2149
2212
  }
2213
+ if (!data || data.length === 0) return;
2150
2214
  last = json;
2151
2215
  } catch (err) {
2152
2216
  logger.warn({ runnerId, err }, "capability refresh failed");
@@ -2182,7 +2246,9 @@ function startHeartbeat(db, runnerId, intervalMs, refresh, published, current) {
2182
2246
  };
2183
2247
  }
2184
2248
  async function markOffline(db, runnerId) {
2185
- await stamp(db, runnerId, "offline", "markOffline");
2249
+ if (await stamp(db, runnerId, "offline", "markOffline") === "missing") {
2250
+ logger.warn({ runnerId }, "markOffline matched no runner row");
2251
+ }
2186
2252
  }
2187
2253
  var UNCLAIMED_PROFILE, IDENTITY_ATTEMPTS, IDENTITY_RETRY_MS, wait, CAPABILITIES_REFRESH_MS;
2188
2254
  var init_registry2 = __esm({
@@ -2811,7 +2877,7 @@ var init_dispatcher = __esm({
2811
2877
 
2812
2878
  // src/commands/source.ts
2813
2879
  import { LRUCache } from "lru-cache";
2814
- var STOPPING, REJOIN_MIN_MS, REJOIN_MAX_MS, SOCKET_RESET_MS, DRAIN_WHILE_DOWN_MS, DRAIN_READ_TIMEOUT_MS, LOST, messageOf2, byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
2880
+ var STOPPING, REJOIN_MIN_MS, REJOIN_MAX_MS, SOCKET_RESET_MS, DRAIN_WHILE_DOWN_MS, DRAIN_READ_TIMEOUT_MS, DORMANT_PROBE_MS, LOST, messageOf2, byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
2815
2881
  var init_source = __esm({
2816
2882
  "src/commands/source.ts"() {
2817
2883
  "use strict";
@@ -2823,6 +2889,7 @@ var init_source = __esm({
2823
2889
  SOCKET_RESET_MS = 6e4;
2824
2890
  DRAIN_WHILE_DOWN_MS = 5e3;
2825
2891
  DRAIN_READ_TIMEOUT_MS = 5e3;
2892
+ DORMANT_PROBE_MS = 6e4;
2826
2893
  LOST = /* @__PURE__ */ new Set(["CHANNEL_ERROR", "TIMED_OUT", "CLOSED"]);
2827
2894
  messageOf2 = (err) => err instanceof Error ? err.message : String(err);
2828
2895
  byCreatedAt = (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id.localeCompare(b.id);
@@ -2850,6 +2917,8 @@ var init_source = __esm({
2850
2917
  this.now = opts.now ?? Date.now;
2851
2918
  this.secrets = opts.secrets ?? secrets;
2852
2919
  this.timings = opts.timings;
2920
+ this.owner = opts.owner;
2921
+ this.onOwnRowVisibility = opts.onOwnRowVisibility;
2853
2922
  }
2854
2923
  db;
2855
2924
  runnerId;
@@ -2887,11 +2956,18 @@ var init_source = __esm({
2887
2956
  downDrain = null;
2888
2957
  /** Whether the live channel's last status was `SUBSCRIBED`. */
2889
2958
  subscribed = false;
2959
+ /**
2960
+ * The last probe of this runner's own row found it invisible (S-42, 0039): no joins, no drain
2961
+ * timer, a probe every `DORMANT_PROBE_MS`. Set with one error line, cleared with one info line.
2962
+ */
2963
+ dormant = false;
2890
2964
  /** Drain reads that failed in a row; only the first of a streak is logged as an error. */
2891
2965
  drainFailures = 0;
2892
2966
  /** Broadcasts and pointers held back while a drain is in flight; see `drain()` for why. */
2893
2967
  pending = [];
2894
2968
  timings;
2969
+ owner;
2970
+ onOwnRowVisibility;
2895
2971
  /** Join `runner:{id}` at once; no connectedness check, since `subscribe` connects the socket. */
2896
2972
  async start() {
2897
2973
  this.drainWhileDown();
@@ -2963,9 +3039,15 @@ var init_source = __esm({
2963
3039
  this.attempts = 0;
2964
3040
  this.socketDownSince = null;
2965
3041
  this.subscribed = true;
3042
+ if (this.dormant) this.wake();
2966
3043
  void this.drain();
2967
3044
  } else if (LOST.has(status)) {
2968
3045
  this.subscribed = false;
3046
+ if (this.dormant) {
3047
+ this.abandon(ch);
3048
+ this.goDormant();
3049
+ return;
3050
+ }
2969
3051
  this.drainWhileDown();
2970
3052
  this.abandon(ch);
2971
3053
  this.scheduleRejoin(status);
@@ -3012,6 +3094,16 @@ var init_source = __esm({
3012
3094
  * instance is created. A socket that is down gets a `connect()` and no join: it buffers every
3013
3095
  * push while down and flushes them all on reopen, so a join per attempt through a long outage
3014
3096
  * would arrive as a burst of joins and leaves.
3097
+ *
3098
+ * On a connected socket, this runner's own row is read before the join (BACKLOG S-42, 0039). A
3099
+ * channel lost while the socket is up is either transient or a refusal: once the owner is
3100
+ * removed from the runner's workspace, Realtime closes `runner:{id}` and answers every rejoin
3101
+ * `CHANNEL_ERROR`, the same status an outage reports. The row tells the two apart without
3102
+ * matching Realtime's error text, for one REST read per attempt: `runners_select` is
3103
+ * `is_member(workspace_id)` and a runner never lives in a personal space (0034), so the row is
3104
+ * visible to the owner exactly while `owns_runner` holds. Invisible, or a row that no longer names
3105
+ * this owner in its workspace (`owner`) → dormant (see `goDormant`). A read that fails says nothing
3106
+ * either way, and never blocks recovery: the attempt joins.
3015
3107
  */
3016
3108
  async rejoin() {
3017
3109
  await this.removal;
@@ -3023,6 +3115,13 @@ var init_source = __esm({
3023
3115
  return;
3024
3116
  }
3025
3117
  this.socketDownSince = null;
3118
+ const visible = await this.ownRowVisible();
3119
+ if (this.stopped) return;
3120
+ if (visible === false) {
3121
+ this.goDormant();
3122
+ return;
3123
+ }
3124
+ if (visible && this.dormant) this.wake();
3026
3125
  const ch = this.runnerChannel();
3027
3126
  if (this.abandoned.has(ch)) {
3028
3127
  this.log.warn({ attempt: this.attempts }, "runner channel still held after leaving it");
@@ -3040,6 +3139,63 @@ var init_source = __esm({
3040
3139
  this.scheduleRejoin("JOIN_FAILED");
3041
3140
  }
3042
3141
  }
3142
+ /**
3143
+ * Whether the owner this daemon logs in as can see this runner's row, still naming them in the
3144
+ * workspace it registered in: `true`, `false`, or `null` when the read failed, threw or timed out
3145
+ * (bounded like a drain read). Never rejects.
3146
+ */
3147
+ async ownRowVisible() {
3148
+ try {
3149
+ const { data, error } = await this.db.from("runners").select("id, user_id, workspace_id").eq("id", this.runnerId).abortSignal(AbortSignal.timeout(DRAIN_READ_TIMEOUT_MS)).maybeSingle();
3150
+ if (!error) {
3151
+ if (data === null) return false;
3152
+ const { owner } = this;
3153
+ return !owner || data.user_id === owner.userId && data.workspace_id === owner.workspaceId;
3154
+ }
3155
+ this.log.warn({ err: error.message }, "could not read this runner's own row; rejoining");
3156
+ } catch (err) {
3157
+ this.log.warn({ err: messageOf2(err) }, "could not read this runner's own row; rejoining");
3158
+ }
3159
+ return null;
3160
+ }
3161
+ /**
3162
+ * Stop asking for commands nobody may send (S-42, 0039): no join (Realtime would refuse it), no
3163
+ * drain timer (RLS hides every row), and one error line on entering, not one per probe. The next
3164
+ * attempt is a probe `DORMANT_PROBE_MS` away, in the rejoin slot so `stop()` clears it, and it
3165
+ * does not move the backoff.
3166
+ */
3167
+ goDormant() {
3168
+ this.stopDrainWhileDown();
3169
+ if (!this.dormant) {
3170
+ this.dormant = true;
3171
+ this.log.error(
3172
+ { runnerId: this.runnerId },
3173
+ "this runner's own row is not visible to its owner, or no longer names this owner in its workspace: the owner left the runner's workspace, or the row was deleted or replaced; commands are paused until it is visible again"
3174
+ );
3175
+ this.tell(false);
3176
+ }
3177
+ if (this.rejoinTimer) return;
3178
+ this.rejoinTimer = setTimeout(() => {
3179
+ this.rejoinTimer = null;
3180
+ void this.rejoin();
3181
+ }, DORMANT_PROBE_MS);
3182
+ }
3183
+ /** The row is visible again: rejoin from the backoff's first step, draining meanwhile. */
3184
+ wake() {
3185
+ this.dormant = false;
3186
+ this.attempts = 0;
3187
+ this.log.info({ runnerId: this.runnerId }, "runner row visible again; rejoining");
3188
+ this.drainWhileDown();
3189
+ this.tell(true);
3190
+ }
3191
+ /** `onOwnRowVisibility`, kept from ever breaking the source. */
3192
+ tell(visible) {
3193
+ try {
3194
+ this.onOwnRowVisibility?.(visible);
3195
+ } catch (err) {
3196
+ this.log.warn({ visible, err: messageOf2(err) }, "own-row visibility listener threw");
3197
+ }
3198
+ }
3043
3199
  /**
3044
3200
  * Ask a down socket to connect. Once attempts have found it down for `SOCKET_RESET_MS`, tear it
3045
3201
  * down first: `connect()` returns early while the client still holds a conn, and a conn whose
@@ -3774,13 +3930,14 @@ function writeShim(path, body) {
3774
3930
  function shellQuote(value) {
3775
3931
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
3776
3932
  }
3777
- var READ_ONLY_NOT_CONNECTED, READ_ONLY_NO_EXPIRY, READ_ONLY_SCOPE_REFUSED, READ_ONLY_UNAVAILABLE, CREDENTIAL_HELPER, NO_SSH_TEXT, READ_ONLY_IDENTITY, GH_SHIM, NO_SSH_SHIM, GIT_NOT_FOUND_TEXT, GH_CONFIG, READONLY_BIN, NO_SSH, GIT_HOME, GIT_BIN, NETRC_NAME, CURLRC_NAME, NETRC_VALUE_KEYS;
3933
+ var READ_ONLY_NOT_CONNECTED, READ_ONLY_NO_EXPIRY, READ_ONLY_GITHUB_NOT_INSTALLED, READ_ONLY_SCOPE_REFUSED, READ_ONLY_UNAVAILABLE, CREDENTIAL_HELPER, NO_SSH_TEXT, READ_ONLY_IDENTITY, GH_SHIM, NO_SSH_SHIM, GIT_NOT_FOUND_TEXT, GH_CONFIG, READONLY_BIN, NO_SSH, GIT_HOME, GIT_BIN, NETRC_NAME, CURLRC_NAME, NETRC_VALUE_KEYS;
3778
3934
  var init_credentials = __esm({
3779
3935
  "src/repos/credentials.ts"() {
3780
3936
  "use strict";
3781
3937
  init_introspect();
3782
3938
  READ_ONLY_NOT_CONNECTED = "Cayenne: this repository chat is read-only because you have not connected GitHub. Connect it in Settings \u2192 Integrations, then send your next message here.";
3783
- READ_ONLY_NO_EXPIRY = `Cayenne: this repository chat is read-only on this runner because your GitHub token never expires, and Cayenne lends only expiring tokens to another member's runner. Ask a workspace admin to turn on "Expire user authorization tokens" for the workspace's GitHub App, reconnect GitHub in Settings \u2192 Integrations, then send your next message here.`;
3939
+ READ_ONLY_NO_EXPIRY = "Cayenne: this repository chat is read-only on this runner because your GitHub token never expires, and Cayenne lends only expiring tokens to another member's runner. Reconnect GitHub in Settings \u2192 Integrations to get a token that expires, then send your next message here.";
3940
+ READ_ONLY_GITHUB_NOT_INSTALLED = "Cayenne: this repository chat is read-only because this repository's GitHub organisation is not connected to this workspace. Add it in Settings \u2192 Integrations, then send your next message here.";
3784
3941
  READ_ONLY_SCOPE_REFUSED = "Cayenne: this repository chat is read-only because GitHub would not give your token access to this repository; your GitHub account may not have access to it. Send another message here once it does.";
3785
3942
  READ_ONLY_UNAVAILABLE = "Cayenne: this repository chat could not get your GitHub token, so pushing is off for now. Send another message to retry.";
3786
3943
  CREDENTIAL_HELPER = String.raw`!f() { test "$1" = get || exit 0; if [ -n "$GH_TOKEN" ]; then echo username=x-access-token; echo "password=$GH_TOKEN"; else printf '%s\n' "$CAYENNE_READ_ONLY" >&2; exit 1; fi; }; f`;
@@ -6093,13 +6250,16 @@ async function failureCode(error, signal) {
6093
6250
  function cloneRefusal(code, slug) {
6094
6251
  switch (code) {
6095
6252
  case "not_installed":
6096
- return `VALIDATION: ${slug} is not in the workspace's GitHub App installation`;
6253
+ return `VALIDATION: ${slug} is not in the Cayenne GitHub App's installation`;
6254
+ // D57: the repository's installation is not bound to this workspace, or there is none.
6255
+ case "github_not_installed":
6256
+ return `VALIDATION: ${slug}'s GitHub organisation is not connected to this workspace; add it in Settings \u2192 Integrations`;
6097
6257
  case "repository_mismatch":
6098
6258
  return `VALIDATION: ${slug} was renamed or replaced on GitHub; pick it again`;
6099
6259
  case "bad_default_branch":
6100
6260
  return `VALIDATION: ${slug}'s default branch is not a branch name Cayenne accepts`;
6101
6261
  case "permissions_pending":
6102
- return "PROVIDER_ERROR: the workspace GitHub App needs Contents access; an admin must approve the new permissions on GitHub";
6262
+ return "PROVIDER_ERROR: the Cayenne GitHub App needs Contents access; an admin must approve the new permissions on GitHub";
6103
6263
  // Also a private key GitHub refuses or a deleted App: only an admin can fix either.
6104
6264
  case "not_configured":
6105
6265
  return "PROVIDER_ERROR: GitHub is not set up correctly for this workspace; ask an admin";
@@ -6211,10 +6371,12 @@ var init_roster = __esm({
6211
6371
  });
6212
6372
  return this.inflight;
6213
6373
  }
6214
- /** The runner owner while a member, or a listed member who still is one. */
6374
+ /**
6375
+ * The runner owner, or a listed member who is still a workspace member — and nobody at all
6376
+ * while the owner is not a member of the runner's workspace (0039).
6377
+ */
6215
6378
  serves(userId) {
6216
- if (userId === this.deps.runnerUserId) return this.ownerIsMember;
6217
- return this.allowedMemberIds.includes(userId);
6379
+ return this.ownerIsMember && (userId === this.deps.runnerUserId || this.allowedMemberIds.includes(userId));
6218
6380
  }
6219
6381
  /**
6220
6382
  * False once `ROSTER_FAIL_CLOSED_AFTER` refresh intervals have passed with no successful read,
@@ -6240,16 +6402,20 @@ var init_roster = __esm({
6240
6402
  if (channel) await this.deps.db.removeChannel(channel);
6241
6403
  }
6242
6404
  async load() {
6243
- const { db, runnerId, workspaceId, runnerUserId, log } = this.deps;
6405
+ const { db, runnerId, workspaceId, runnerUserId } = this.deps;
6244
6406
  const runner = await this.withTimeout(
6245
- db.from("runners").select("allowed_member_ids").eq("id", runnerId).single(),
6407
+ db.from("runners").select("allowed_member_ids, user_id, workspace_id").eq("id", runnerId).maybeSingle(),
6246
6408
  `could not read allowed_member_ids for runner ${runnerId}: timed out`
6247
6409
  );
6248
- if (runner.error || !runner.data) {
6410
+ if (runner.error) {
6249
6411
  throw new Error(
6250
- `could not read allowed_member_ids for runner ${runnerId}: ${runner.error?.message ?? "no row"}`
6412
+ `could not read allowed_member_ids for runner ${runnerId}: ${runner.error.message}`
6251
6413
  );
6252
6414
  }
6415
+ if (!runner.data || runner.data.user_id !== runnerUserId || runner.data.workspace_id !== workspaceId) {
6416
+ this.apply([], false, []);
6417
+ return;
6418
+ }
6253
6419
  const listed = runner.data.allowed_member_ids;
6254
6420
  const members = await this.withTimeout(
6255
6421
  db.from("workspace_members").select("user_id").eq("workspace_id", workspaceId).in("user_id", [runnerUserId, ...listed]),
@@ -6261,15 +6427,18 @@ var init_roster = __esm({
6261
6427
  );
6262
6428
  }
6263
6429
  const current = new Set(members.data.map((m) => m.user_id));
6430
+ const ownerIsMember = current.has(runnerUserId);
6431
+ const served = ownerIsMember ? listed.filter((id) => id !== runnerUserId && current.has(id)) : [];
6432
+ this.apply(listed, ownerIsMember, served);
6433
+ }
6434
+ /** Record one successful read: the lists spliced in place, the time, and a line on a change. */
6435
+ apply(listed, ownerIsMember, served) {
6436
+ const { runnerId, log } = this.deps;
6264
6437
  const before = [...this.allowedMemberIds];
6265
6438
  const beforeOwnerIsMember = this.ownerIsMember;
6266
6439
  this.listed = [...listed];
6267
- this.ownerIsMember = current.has(runnerUserId);
6268
- this.allowedMemberIds.splice(
6269
- 0,
6270
- this.allowedMemberIds.length,
6271
- ...listed.filter((id) => id !== runnerUserId && current.has(id))
6272
- );
6440
+ this.ownerIsMember = ownerIsMember;
6441
+ this.allowedMemberIds.splice(0, this.allowedMemberIds.length, ...served);
6273
6442
  this.lastSuccessAt = performance.now();
6274
6443
  if (!sameIds(before, this.allowedMemberIds) || beforeOwnerIsMember !== this.ownerIsMember) {
6275
6444
  log.info(
@@ -7411,6 +7580,18 @@ var init_service = __esm({
7411
7580
  }
7412
7581
  });
7413
7582
 
7583
+ // src/providers/env.ts
7584
+ function definedEnv(env) {
7585
+ const out = {};
7586
+ for (const [key, value] of Object.entries(env)) if (value !== void 0) out[key] = value;
7587
+ return out;
7588
+ }
7589
+ var init_env = __esm({
7590
+ "src/providers/env.ts"() {
7591
+ "use strict";
7592
+ }
7593
+ });
7594
+
7414
7595
  // src/claude/auth-mode.ts
7415
7596
  function authModeFrom(info) {
7416
7597
  if (info.apiProvider && info.apiProvider !== "firstParty") return "third_party";
@@ -7572,17 +7753,13 @@ async function askGuard(guard, toolName2, input) {
7572
7753
  return GUARD_FAILED;
7573
7754
  }
7574
7755
  }
7575
- function definedEnv(env) {
7576
- const out = {};
7577
- for (const [key, value] of Object.entries(env)) if (value !== void 0) out[key] = value;
7578
- return out;
7579
- }
7580
7756
  var SESSION_FLAG_SETTINGS, GUARD_FAILED, ASK_TOOL_REASON, ClaudeSession;
7581
7757
  var init_session2 = __esm({
7582
7758
  "src/claude/session.ts"() {
7583
7759
  "use strict";
7584
7760
  init_src();
7585
7761
  init_log();
7762
+ init_env();
7586
7763
  init_auth_mode();
7587
7764
  init_normalize();
7588
7765
  init_pushable();
@@ -7970,6 +8147,7 @@ var init_session3 = __esm({
7970
8147
  "src/codex/session.ts"() {
7971
8148
  "use strict";
7972
8149
  init_src();
8150
+ init_env();
7973
8151
  init_attachments2();
7974
8152
  init_normalize2();
7975
8153
  init_policy();
@@ -8027,8 +8205,12 @@ var init_session3 = __esm({
8027
8205
  questionCounts = /* @__PURE__ */ new Map();
8028
8206
  start() {
8029
8207
  if (this.child) throw new Error("VALIDATION: session already started");
8030
- const spawnFn = this.opts.spawnFn ?? ((p, a) => spawn2(p, a, { stdio: "pipe" }));
8031
- const child = spawnFn(this.opts.codexPath, ["app-server"]);
8208
+ const spawnFn = this.opts.spawnFn ?? ((p, a, env) => spawn2(p, a, { stdio: "pipe", env }));
8209
+ const child = spawnFn(
8210
+ this.opts.codexPath,
8211
+ ["app-server"],
8212
+ definedEnv({ ...process.env, ...this.opts.env })
8213
+ );
8032
8214
  this.child = child;
8033
8215
  child.stderr?.setEncoding("utf8");
8034
8216
  child.stderr?.on("data", (line) => {
@@ -9743,6 +9925,9 @@ function worthRespawning(current, next) {
9743
9925
  if (next.state !== "ok") return next.state === "none" && current.state !== "none";
9744
9926
  return current.state !== "ok" || current.token !== next.token;
9745
9927
  }
9928
+ function refusedByPostgres(code) {
9929
+ return typeof code === "string" && /^[0-9A-Z]{5}$/.test(code) && code !== UNIQUE_VIOLATION && code !== "40003" && !code.startsWith("08") && !code.startsWith("57P");
9930
+ }
9746
9931
  function assertTaskMode(mode) {
9747
9932
  if (TASK_REFUSED_MODES.has(mode)) {
9748
9933
  throw new Error(`VALIDATION: a task session cannot run in ${mode}`);
@@ -9780,7 +9965,7 @@ function shrink(event) {
9780
9965
  return null;
9781
9966
  }
9782
9967
  }
9783
- var READ_ONLY_TEXT, REFRESH_WITHIN_MS, betweenTurns, UNREADABLE_MARK_CREDS, roundUsd, ORPHANED_STATUSES, RESTARTABLE_STATUSES, isAtOrBelow3, DENY_MESSAGE_MAX, EXIT_ERROR_MAX, INITIAL_PROMPT_MAX, TASK_SKILL_LOOKUP_MS, TASK_REFUSED_MODES, OWNER_DRIVEN_KINDS, REPO_OWNER_ONLY, REPO_NO_DUPLICATE, PROVIDER_SESSION_ID, REPO_WORKTREE_GONE, TASK_ASK_TOOLS, MEMORY_PREAMBLE, MEMORY_SOURCE, wrapMemory, PATH_INPUT_TOOLS, PATH_INPUT_KEYS, isRecord, errorCode, SessionManager;
9968
+ var READ_ONLY_TEXT, REFRESH_WITHIN_MS, betweenTurns, UNREADABLE_MARK_CREDS, roundUsd, UNIQUE_VIOLATION, ROW_WRITE_TIMEOUT_MS, ORPHANED_STATUSES, RESTARTABLE_STATUSES, isAtOrBelow3, DENY_MESSAGE_MAX, EXIT_ERROR_MAX, INITIAL_PROMPT_MAX, TASK_SKILL_LOOKUP_MS, TASK_REFUSED_MODES, OWNER_DRIVEN_KINDS, REPO_OWNER_ONLY, REPO_NO_DUPLICATE, PROVIDER_SESSION_ID, REPO_WORKTREE_GONE, TASK_ASK_TOOLS, MEMORY_PREAMBLE, MEMORY_SOURCE, wrapMemory, PATH_INPUT_TOOLS, PATH_INPUT_KEYS, isRecord, errorCode, SessionManager;
9784
9969
  var init_manager = __esm({
9785
9970
  "src/sessions/manager.ts"() {
9786
9971
  "use strict";
@@ -9812,7 +9997,8 @@ var init_manager = __esm({
9812
9997
  READ_ONLY_TEXT = {
9813
9998
  not_connected: READ_ONLY_NOT_CONNECTED,
9814
9999
  no_expiry: READ_ONLY_NO_EXPIRY,
9815
- scope_refused: READ_ONLY_SCOPE_REFUSED
10000
+ scope_refused: READ_ONLY_SCOPE_REFUSED,
10001
+ github_not_installed: READ_ONLY_GITHUB_NOT_INSTALLED
9816
10002
  };
9817
10003
  REFRESH_WITHIN_MS = 10 * 6e4;
9818
10004
  betweenTurns = (live) => (live.status === "idle" || live.status === "error") && !live.stopping;
@@ -9821,6 +10007,8 @@ var init_manager = __esm({
9821
10007
  readOnlyText: READ_ONLY_UNAVAILABLE
9822
10008
  };
9823
10009
  roundUsd = (usd) => Math.round(usd * 1e6) / 1e6;
10010
+ UNIQUE_VIOLATION = "23505";
10011
+ ROW_WRITE_TIMEOUT_MS = 3e4;
9824
10012
  ORPHANED_STATUSES = ["running", "awaiting_permission"];
9825
10013
  RESTARTABLE_STATUSES = /* @__PURE__ */ new Set(["idle", ...ORPHANED_STATUSES]);
9826
10014
  isAtOrBelow3 = (p, dir) => p === dir || p.startsWith(dir.endsWith(sep16) ? dir : `${dir}${sep16}`);
@@ -10486,8 +10674,9 @@ var init_manager = __esm({
10486
10674
  /**
10487
10675
  * The runner's own re-check of who queued a `session.*` command, after the RPC's.
10488
10676
  *
10489
- * `created_by` must be a member this runner serves: the runner owner, or one of
10490
- * `runners.allowed_member_ids` as loaded at registration. The session owner gets no separate
10677
+ * `created_by` must be a member this runner serves, as the live roster decides (`serves`): the
10678
+ * runner owner or one of `runners.allowed_member_ids` still in the workspace, and nobody while
10679
+ * the owner is not a member of the runner's workspace (0039). The session owner gets no separate
10491
10680
  * branch: `sessions_insert` already required `can_command_runner`, so every owner was on the
10492
10681
  * list when the session was created, and an owner off it now has been de-listed since. A
10493
10682
  * command with no creator is a flow's (data model §2), which phase 1A does not run.
@@ -10522,11 +10711,12 @@ var init_manager = __esm({
10522
10711
  /**
10523
10712
  * Whether this creator may command this machine at all.
10524
10713
  *
10525
- * The same membership test `assertMayCommand` runs (the runner owner, or one of
10526
- * `runners.allowed_member_ids`), given its own name because not every command is
10527
- * session-scoped: `github.request` addresses a repository, and `requireSession` would
10528
- * reject it outright. One roster, one decision (`serves`); this and `assertMayCommand`
10529
- * only differ in the noun they name in the refusal, never in who they let through.
10714
+ * The same membership test `assertMayCommand` runs (the roster's `serves`: the runner owner or
10715
+ * a listed member, and nobody while the owner is out of the runner's workspace, 0039), given
10716
+ * its own name because not every command is session-scoped: `github.request` addresses a
10717
+ * repository, and `requireSession` would reject it outright. One roster, one decision
10718
+ * (`serves`); this and `assertMayCommand` only differ in the noun they name in the refusal,
10719
+ * never in who they let through.
10530
10720
  */
10531
10721
  assertMayUseRunner(createdBy) {
10532
10722
  if (createdBy === null || !this.serves(createdBy)) {
@@ -10789,7 +10979,9 @@ var init_manager = __esm({
10789
10979
  }
10790
10980
  /**
10791
10981
  * Put rows on this runner that claim a turn in progress back to `idle`, for the chats that have
10792
- * no live session here — at boot every one of them, since nothing is live yet. Only
10982
+ * no live session here — at boot every one of them, since nothing is live yet, and again when
10983
+ * the runner owner is back in the runner's workspace (S-42, `cayenne start`), minus the sessions
10984
+ * live by then. Only
10793
10985
  * `running` and `awaiting_permission`: those are the states a dead provider cannot leave, while
10794
10986
  * `starting` is still owed a `session.start` and `error` is the member's to read. Their pending
10795
10987
  * permission prompts are retired first (`expireOrphanedPermissions`). A failed write is logged,
@@ -10800,7 +10992,11 @@ var init_manager = __esm({
10800
10992
  if (ids?.length === 0) return;
10801
10993
  await this.expireOrphanedPermissions(ids);
10802
10994
  let write = this.deps.db.from("chat_sessions").update({ status: "idle" }).eq("runner_id", this.deps.runnerId).in("status", ORPHANED_STATUSES);
10803
- if (ids) write = write.in("id", ids);
10995
+ if (ids) {
10996
+ write = write.in("id", ids);
10997
+ } else if (this.live.size > 0) {
10998
+ write = write.not("id", "in", `(${[...this.live.keys()].join(",")})`);
10999
+ }
10804
11000
  const { data, error } = await write.select("id");
10805
11001
  if (error) {
10806
11002
  this.deps.log.warn({ err: error.message }, "settling orphaned session rows failed");
@@ -10900,12 +11096,17 @@ var init_manager = __esm({
10900
11096
  this.assertCodexOwner(sessionId, createdBy);
10901
11097
  return { slashCommands: await this.provider(() => live.session.supportedCommands()) };
10902
11098
  }
11099
+ /**
11100
+ * `stop` every live session, as `session.stop` would. Used at daemon shutdown and when the runner
11101
+ * owner leaves the runner's workspace (S-42): it sets no flag, so the manager serves again after.
11102
+ */
10903
11103
  async shutdown() {
10904
11104
  await Promise.allSettled([...this.live.keys()].map((id) => this.stop(id)));
10905
11105
  }
10906
11106
  // --- internals ---
11107
+ /** The roster decides, the runner owner included: under 0039 an owner out of the workspace is refused. */
10907
11108
  serves(userId) {
10908
- return userId === this.deps.runnerUserId || this.deps.allowedMemberIds.includes(userId);
11109
+ return this.deps.serves(userId);
10909
11110
  }
10910
11111
  /**
10911
11112
  * Write this session's skills and name the tree the SDK should load (D38, lane 6 §4).
@@ -11583,7 +11784,7 @@ var init_manager = __esm({
11583
11784
  const create = this.deps.createSession ?? createProviderSession;
11584
11785
  let session;
11585
11786
  try {
11586
- session = create(provider, this.withProviderEnv(provider, opts, place.repo));
11787
+ session = create(provider, this.withProviderEnv(opts, place.repo));
11587
11788
  } catch (err) {
11588
11789
  if (this.live.get(sessionId)?.channel !== channel) {
11589
11790
  await this.deps.db.removeChannel(channel);
@@ -11610,6 +11811,7 @@ var init_manager = __esm({
11610
11811
  stopping: false,
11611
11812
  dropping: null,
11612
11813
  inflight: /* @__PURE__ */ new Set(),
11814
+ writes: Promise.resolve(),
11613
11815
  changesVersion: this.nextChangesSeed(),
11614
11816
  watcher: null,
11615
11817
  docs: null,
@@ -11751,13 +11953,15 @@ var init_manager = __esm({
11751
11953
  return this.changesHigh;
11752
11954
  }
11753
11955
  /**
11754
- * The account pairing rides on the session's own env, never on `process.env` (D33). Merged over
11755
- * the env the settings policy already set (D39's `CLAUDE.md` switch), never instead of it. A
11756
- * repository chat's credential environment (D50, `repositoryChatEnv`) goes last, so nothing
11757
- * before it can put a credential helper, a token or a `gh` login back.
11956
+ * The account pairing rides on the session's own env, never on `process.env` (D33). Both
11957
+ * providers get it: a Codex session is owner-only, and a `claude` it runs uses the runner's
11958
+ * account, as in the owner's terminal (D55, amending D33). Merged over the env the
11959
+ * settings policy already set (D39's `CLAUDE.md` switch), never instead of it. A repository
11960
+ * chat's credential environment (D50, `repositoryChatEnv`) goes last, so nothing before it can
11961
+ * put a credential helper, a token or a `gh` login back.
11758
11962
  */
11759
- withProviderEnv(provider, opts, repo) {
11760
- const extra = provider === "claude" ? this.deps.claudeEnv : void 0;
11963
+ withProviderEnv(opts, repo) {
11964
+ const extra = this.deps.claudeEnv;
11761
11965
  const repoEnv = repo ? this.repositoryEnv(repo.creds) : void 0;
11762
11966
  if ((!extra || Object.keys(extra).length === 0) && !repoEnv) return opts;
11763
11967
  return { ...opts, env: { ...opts.env, ...extra, ...repoEnv } };
@@ -11901,13 +12105,15 @@ var init_manager = __esm({
11901
12105
  );
11902
12106
  session.on("result", (r) => {
11903
12107
  this.deps.timings?.finished(sessionId);
12108
+ const lastSeq = live.writes.then(() => live.seq);
11904
12109
  guard(async () => {
11905
12110
  if (live.provider === "claude") await this.recordCost(sessionId, live, r);
12111
+ const seq = await lastSeq;
11906
12112
  const status = r.isError ? "error" : "idle";
11907
12113
  live.status = status;
11908
12114
  await this.updateSession(sessionId, { status, last_message_at: (/* @__PURE__ */ new Date()).toISOString() });
11909
12115
  const { authMode: _authMode, ...event } = r;
11910
- await this.broadcast(sessionId, { type: "result", seq: live.seq, ...event });
12116
+ await this.broadcast(sessionId, { type: "result", seq, ...event });
11911
12117
  await this.broadcast(sessionId, {
11912
12118
  type: "session.status",
11913
12119
  status,
@@ -12015,35 +12221,55 @@ var init_manager = __esm({
12015
12221
  );
12016
12222
  });
12017
12223
  }
12224
+ /**
12225
+ * Write one `chat_messages` row and broadcast its `message` twin, in this session's write order.
12226
+ *
12227
+ * The step is queued on `live.writes` before this method first awaits, so two calls made back to
12228
+ * back (the SDK hands a short turn's reply and result row over on one tick) insert and broadcast
12229
+ * in call order: insert N and its broadcast both happen before insert N+1 starts. A caller on
12230
+ * the prompt path may therefore wait for earlier rows' inserts — a database wait. It never
12231
+ * waits on a relay send: `broadcast` returns once the relay has the event queued.
12232
+ *
12233
+ * Rejects with `PROVIDER_ERROR: persist failed: …` for this row only; the chain goes on.
12234
+ */
12018
12235
  async persist(sessionId, live, role, content, providerUuid, parentToolUseId, raw) {
12019
12236
  const safeContent = this.secrets.redact(content);
12020
12237
  const safeRaw = this.secrets.redact(raw);
12021
- live.seq += 1;
12022
- const { data, error } = await this.deps.db.from("chat_messages").insert({
12023
- session_id: sessionId,
12024
- // Overwritten from the session by stamp_message_workspace; the insert policy still needs
12025
- // is_member(workspace_id) to see a non-null value.
12026
- workspace_id: this.deps.workspaceId,
12027
- seq: live.seq,
12028
- role,
12029
- content: safeContent,
12030
- provider_uuid: providerUuid,
12031
- parent_tool_use_id: parentToolUseId,
12032
- raw: safeRaw
12033
- }).select(
12034
- "id, session_id, workspace_id, seq, role, content, provider_uuid, parent_tool_use_id, created_at"
12035
- ).single();
12036
- if (error || !data) {
12037
- throw new Error(`PROVIDER_ERROR: persist failed: ${error?.message ?? "no row"}`);
12038
- }
12039
- const row = {
12040
- ...data,
12041
- seq: live.seq,
12042
- role,
12043
- content: safeContent
12044
- };
12045
- await this.broadcast(sessionId, { type: "message", message: row });
12046
- return row;
12238
+ const step = live.writes.then(async () => {
12239
+ const seq = live.seq + 1;
12240
+ live.seq = seq;
12241
+ const { data, error } = await this.deps.db.from("chat_messages").insert({
12242
+ session_id: sessionId,
12243
+ // Overwritten from the session by stamp_message_workspace; the insert policy still needs
12244
+ // is_member(workspace_id) to see a non-null value.
12245
+ workspace_id: this.deps.workspaceId,
12246
+ seq,
12247
+ role,
12248
+ content: safeContent,
12249
+ provider_uuid: providerUuid,
12250
+ parent_tool_use_id: parentToolUseId,
12251
+ raw: safeRaw
12252
+ }).select(
12253
+ "id, session_id, workspace_id, seq, role, content, provider_uuid, parent_tool_use_id, created_at"
12254
+ ).abortSignal(AbortSignal.timeout(ROW_WRITE_TIMEOUT_MS)).single();
12255
+ if (error || !data) {
12256
+ if (refusedByPostgres(error?.code)) live.seq = seq - 1;
12257
+ throw new Error(`PROVIDER_ERROR: persist failed: ${error?.message ?? "no row"}`);
12258
+ }
12259
+ const row = {
12260
+ ...data,
12261
+ seq,
12262
+ role,
12263
+ content: safeContent
12264
+ };
12265
+ await this.broadcast(sessionId, { type: "message", message: row });
12266
+ return row;
12267
+ });
12268
+ live.writes = step.then(
12269
+ () => void 0,
12270
+ () => void 0
12271
+ );
12272
+ return step;
12047
12273
  }
12048
12274
  /**
12049
12275
  * The highest `chat_messages.seq` this session has, or 0. A failed read is fatal rather than
@@ -12703,7 +12929,7 @@ function inside3(candidate, root) {
12703
12929
  return rel === "" || !rel.startsWith("..") && !isAbsolute21(rel);
12704
12930
  }
12705
12931
  var SECRET_ENV, MEMBER_ENV_ALLOW, MEMBER_ENV_KEYS, LOCALE_ENV, nodeShellFs;
12706
- var init_env = __esm({
12932
+ var init_env2 = __esm({
12707
12933
  "src/terminal/env.ts"() {
12708
12934
  "use strict";
12709
12935
  init_src();
@@ -12888,7 +13114,7 @@ var init_manager2 = __esm({
12888
13114
  "use strict";
12889
13115
  init_src();
12890
13116
  init_paths();
12891
- init_env();
13117
+ init_env2();
12892
13118
  init_process_group();
12893
13119
  init_refusals();
12894
13120
  init_ring();
@@ -14080,8 +14306,8 @@ async function compose(cfg, lock, host, preflight) {
14080
14306
  runnerId: identity.runnerId,
14081
14307
  workspaceId: identity.workspaceId,
14082
14308
  runnerUserId: identity.userId,
14083
- // The roster's own array, mutated in place: `serves` reads the live list with no restart.
14084
- allowedMemberIds: liveRoster.allowedMemberIds,
14309
+ // The roster decides who may command, the owner included (0039): live, with no restart.
14310
+ serves: (userId) => liveRoster.serves(userId),
14085
14311
  worktreeOwners,
14086
14312
  sessionWorktrees,
14087
14313
  // And which sessions were started from a task, so their secret-file guard outlives a restart.
@@ -14114,6 +14340,30 @@ async function compose(cfg, lock, host, preflight) {
14114
14340
  tap: (sessionId, event) => local.emit(sessionId, event),
14115
14341
  timings
14116
14342
  });
14343
+ let ownerServed = liveRoster.serves(identity.userId);
14344
+ let ownerTransition = Promise.resolve();
14345
+ liveRoster.onRefresh(() => {
14346
+ const served = liveRoster.serves(identity.userId);
14347
+ if (served === ownerServed) return;
14348
+ ownerServed = served;
14349
+ const line = { runnerId: identity.runnerId };
14350
+ ownerTransition = ownerTransition.then(async () => {
14351
+ try {
14352
+ if (served) {
14353
+ logger.info(line, "runner owner served again; settling chats left mid-turn");
14354
+ await manager.settleOrphans();
14355
+ } else {
14356
+ logger.warn(line, "runner owner no longer served; stopping every live session");
14357
+ await manager.shutdown();
14358
+ }
14359
+ } catch (err) {
14360
+ logger.warn(
14361
+ { ...line, served, err: err instanceof Error ? err.message : String(err) },
14362
+ "runner owner transition failed"
14363
+ );
14364
+ }
14365
+ });
14366
+ });
14117
14367
  let terminals;
14118
14368
  let terminalManager = null;
14119
14369
  if (pty) {
@@ -14168,7 +14418,15 @@ async function compose(cfg, lock, host, preflight) {
14168
14418
  terminals
14169
14419
  })
14170
14420
  );
14171
- const source = new CommandSource(db, identity.runnerId, dispatch, logger, { timings });
14421
+ const source = new CommandSource(db, identity.runnerId, dispatch, logger, {
14422
+ timings,
14423
+ // S-42 review C: a row under this id counts as this runner's only while it names this owner
14424
+ // in this workspace.
14425
+ owner: { userId: identity.userId, workspaceId: identity.workspaceId },
14426
+ // S-42 review A: the source hears of a removal or a re-add within a probe; the roster, left
14427
+ // alone, would at its next 60 s timer (a removal changes no runner row). Refresh it now.
14428
+ onOwnRowVisibility: () => void liveRoster.refresh()
14429
+ });
14172
14430
  const heartbeat = startHeartbeat(
14173
14431
  db,
14174
14432
  identity.runnerId,
@@ -14223,7 +14481,8 @@ async function compose(cfg, lock, host, preflight) {
14223
14481
  log: logger
14224
14482
  }),
14225
14483
  // The roster's own predicate, read live: the owner while a member, listed members while
14226
- // still members (PR16); every refresh re-checks every socket (SEC-P2).
14484
+ // still members (PR16), nobody while the owner is out (0039); every refresh re-checks
14485
+ // every socket (SEC-P2).
14227
14486
  serves: (userId) => liveRoster.serves(userId),
14228
14487
  onRosterRefresh: (cb) => liveRoster.onRefresh(cb),
14229
14488
  // Exactly the manager's rule (ruling 14, R2-H1), built from the same deps it was given.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpcasabiancai/cayenne",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "files": [
5
5
  "dist/cli.js"
6
6
  ],