@jpcasabiancai/cayenne 0.2.0 → 0.3.1

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 +1224 -180
  2. package/package.json +5 -3
package/dist/cli.js CHANGED
@@ -9,8 +9,34 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
11
 
12
+ // src/cloud.ts
13
+ function assertPublishable(key) {
14
+ if (!key) return key;
15
+ if (key.startsWith("sb_secret_"))
16
+ throw new Error("CLOUD key is a secret key; bake the publishable key");
17
+ if (key.startsWith("sb_publishable_")) return key;
18
+ const payload = key.split(".")[1];
19
+ if (!payload) throw new Error("CLOUD key is not a recognised Supabase key");
20
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
21
+ if (claims.role !== "anon") {
22
+ throw new Error(`CLOUD key has role "${claims.role}"; only the anon key may be baked in`);
23
+ }
24
+ return key;
25
+ }
26
+ var CLOUD_SUPABASE_URL, CLOUD_SUPABASE_ANON_KEY, CLOUD_APP_ORIGIN;
27
+ var init_cloud = __esm({
28
+ "src/cloud.ts"() {
29
+ "use strict";
30
+ CLOUD_SUPABASE_URL = "https://ndqrkqxwkzxgnhkvcunr.supabase.co";
31
+ CLOUD_SUPABASE_ANON_KEY = assertPublishable(
32
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5kcXJrcXh3a3p4Z25oa3ZjdW5yIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODg3MTMyNzYsImV4cCI6MjEwNDI4OTI3Nn0.Dlsxw91E1QYZ4eoRDjP80Rf4feY77Z-T-fiIwUDY2k0"
33
+ );
34
+ CLOUD_APP_ORIGIN = "https://cayenne-pink.vercel.app";
35
+ }
36
+ });
37
+
12
38
  // ../../packages/shared/src/constants.ts
13
- var PROTOCOL_VERSION, LOCAL_PORT, BROADCAST_MAX_BYTES, COMMAND_STALE_MS, PERMISSION_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS, RUNNER_ONLINE_WINDOW_MS, TERMINAL_FLUSH_MS, TERMINAL_FLUSH_BYTES, TERMINAL_RING_BYTES, TERMINAL_INPUT_MAX_BYTES, TERMINAL_REPLAY_PART_BYTES, TERMINAL_REPLAY_MAX_PER_MINUTE, TERMINAL_MAX_PER_OWNER, TERMINAL_MAX_PER_RUNNER, TERMINAL_KILL_GRACE_MS;
39
+ var PROTOCOL_VERSION, LOCAL_PORT, BROADCAST_MAX_BYTES, COMMAND_STALE_MS, PERMISSION_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS, RUNNER_ONLINE_WINDOW_MS, TERMINAL_FLUSH_MS, TERMINAL_FLUSH_BYTES, TERMINAL_LOCAL_FLUSH_MS, TERMINAL_RING_BYTES, TERMINAL_INPUT_MAX_BYTES, TERMINAL_REPLAY_PART_BYTES, TERMINAL_REPLAY_MAX_PER_MINUTE, TERMINAL_MAX_PER_OWNER, TERMINAL_MAX_PER_RUNNER, TERMINAL_KILL_GRACE_MS, LOCAL_PORT_MIN, LOCAL_PORT_MAX, LOCAL_TOKEN_CACHE_MS, LOCAL_TOKEN_NEGATIVE_CACHE_MS, LOCAL_RECHECK_MS, LOCAL_HANDSHAKE_TIMEOUT_MS, LOCAL_MAX_FRAME_BYTES, LOCAL_MAX_PENDING_UPGRADES, LOCAL_MAX_SOCKETS_PER_USER, LOCAL_MAX_SOCKETS, LOCAL_MAX_CONNECTIONS, LOCAL_GETUSER_PER_MINUTE, LOCAL_MAX_PENDING_CMDS;
14
40
  var init_constants = __esm({
15
41
  "../../packages/shared/src/constants.ts"() {
16
42
  "use strict";
@@ -23,6 +49,7 @@ var init_constants = __esm({
23
49
  RUNNER_ONLINE_WINDOW_MS = 45e3;
24
50
  TERMINAL_FLUSH_MS = 33;
25
51
  TERMINAL_FLUSH_BYTES = 16384;
52
+ TERMINAL_LOCAL_FLUSH_MS = 4;
26
53
  TERMINAL_RING_BYTES = 262144;
27
54
  TERMINAL_INPUT_MAX_BYTES = 65536;
28
55
  TERMINAL_REPLAY_PART_BYTES = 65536;
@@ -30,6 +57,19 @@ var init_constants = __esm({
30
57
  TERMINAL_MAX_PER_OWNER = 8;
31
58
  TERMINAL_MAX_PER_RUNNER = 32;
32
59
  TERMINAL_KILL_GRACE_MS = 2e3;
60
+ LOCAL_PORT_MIN = 47831;
61
+ LOCAL_PORT_MAX = 47850;
62
+ LOCAL_TOKEN_CACHE_MS = 3e5;
63
+ LOCAL_TOKEN_NEGATIVE_CACHE_MS = 1e4;
64
+ LOCAL_RECHECK_MS = LOCAL_TOKEN_CACHE_MS;
65
+ LOCAL_HANDSHAKE_TIMEOUT_MS = 5e3;
66
+ LOCAL_MAX_FRAME_BYTES = 262144;
67
+ LOCAL_MAX_PENDING_UPGRADES = 8;
68
+ LOCAL_MAX_SOCKETS_PER_USER = 4;
69
+ LOCAL_MAX_SOCKETS = 16;
70
+ LOCAL_MAX_CONNECTIONS = 32;
71
+ LOCAL_GETUSER_PER_MINUTE = 60;
72
+ LOCAL_MAX_PENDING_CMDS = 256;
33
73
  }
34
74
  });
35
75
 
@@ -284,8 +324,35 @@ var init_command = __esm({
284
324
  effort: Effort.optional(),
285
325
  model: z4.string().optional(),
286
326
  resumeProviderSessionId: z4.string().optional(),
327
+ // Duplicate a chat (D36): start from a fork of `resumeProviderSessionId` instead of appending
328
+ // to its transcript. Claude-only, no `worktree`. The runner additionally refuses a source that
329
+ // is not the caller's own Claude session on this runner, or a cwd other than the source's own directory.
330
+ forkSession: z4.boolean().optional(),
287
331
  initialPrompt: z4.string().optional(),
288
332
  title: z4.string().max(200).optional()
333
+ }).superRefine((p, ctx) => {
334
+ if (p.forkSession !== true) return;
335
+ if (p.resumeProviderSessionId === void 0) {
336
+ ctx.addIssue({
337
+ code: "custom",
338
+ message: "forkSession needs resumeProviderSessionId",
339
+ path: ["forkSession"]
340
+ });
341
+ }
342
+ if (p.worktree !== void 0) {
343
+ ctx.addIssue({
344
+ code: "custom",
345
+ message: "a forked session keeps its working directory",
346
+ path: ["worktree"]
347
+ });
348
+ }
349
+ if (p.provider === "codex") {
350
+ ctx.addIssue({
351
+ code: "custom",
352
+ message: "codex sessions cannot be forked",
353
+ path: ["provider"]
354
+ });
355
+ }
289
356
  }),
290
357
  "session.prompt": z4.object({
291
358
  text: z4.string().min(1).max(2e5),
@@ -487,34 +554,104 @@ var init_events = __esm({
487
554
  }
488
555
  });
489
556
 
490
- // ../../packages/shared/src/protocol/runner.ts
557
+ // ../../packages/shared/src/protocol/local.ts
491
558
  import { z as z7 } from "zod";
559
+ function parseLocalTopic(topic) {
560
+ const m = LOCAL_TOPIC_RE.exec(topic);
561
+ if (!m?.[1] || !m[2]) return null;
562
+ return { kind: m[1], id: m[2] };
563
+ }
564
+ function localProofMessage(nonce, runnerId, origin) {
565
+ return utf82.encode(`cayenne-local-v1
566
+ ${nonce}
567
+ ${runnerId}
568
+ ${origin}`);
569
+ }
570
+ var LOCAL_HEALTH_PATH, LOCAL_WS_PATH, LOCAL_CLOSE_NOT_READY, LOCAL_CLOSE_BAD_HANDSHAKE, LOCAL_CLOSE_UNAUTHORIZED, LOCAL_CLOSE_TOO_MANY, Base64Url, UUID, LOCAL_TOPIC_RE, LOCAL_SESSION_TOPIC_RE, LocalTopic, LocalSessionTopic, LocalHealth, utf82, LocalClientFrame, LocalRunnerFrame;
571
+ var init_local = __esm({
572
+ "../../packages/shared/src/protocol/local.ts"() {
573
+ "use strict";
574
+ init_command();
575
+ init_events();
576
+ init_terminal();
577
+ LOCAL_HEALTH_PATH = "/health";
578
+ LOCAL_WS_PATH = "/ws";
579
+ LOCAL_CLOSE_NOT_READY = 1013;
580
+ LOCAL_CLOSE_BAD_HANDSHAKE = 4400;
581
+ LOCAL_CLOSE_UNAUTHORIZED = 4401;
582
+ LOCAL_CLOSE_TOO_MANY = 4429;
583
+ Base64Url = z7.string().regex(/^[A-Za-z0-9_-]+$/);
584
+ UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
585
+ LOCAL_TOPIC_RE = new RegExp(`^(session|terminal):(${UUID})$`);
586
+ LOCAL_SESSION_TOPIC_RE = new RegExp(`^session:${UUID}$`);
587
+ LocalTopic = z7.string().regex(LOCAL_TOPIC_RE);
588
+ LocalSessionTopic = z7.string().regex(LOCAL_SESSION_TOPIC_RE);
589
+ LocalHealth = z7.object({
590
+ runnerId: z7.uuid(),
591
+ protocolVersion: z7.number().int(),
592
+ version: z7.string()
593
+ });
594
+ utf82 = new TextEncoder();
595
+ LocalClientFrame = z7.discriminatedUnion("t", [
596
+ z7.object({ t: z7.literal("hello"), nonce: Base64Url.length(43) }),
597
+ z7.object({ t: z7.literal("auth"), token: z7.string().min(1).max(8192) }),
598
+ z7.object({ t: z7.literal("cmd"), id: z7.uuid() }),
599
+ z7.object({ t: z7.literal("term"), terminalId: z7.uuid(), m: TerminalClientMessage }),
600
+ z7.object({ t: z7.literal("sub"), topic: LocalTopic }),
601
+ z7.object({ t: z7.literal("unsub"), topic: LocalTopic })
602
+ ]);
603
+ LocalRunnerFrame = z7.discriminatedUnion("t", [
604
+ z7.object({
605
+ t: z7.literal("hello"),
606
+ runnerId: z7.uuid(),
607
+ protocolVersion: z7.number().int(),
608
+ sig: Base64Url.length(86)
609
+ }),
610
+ z7.object({ t: z7.literal("ready"), userId: z7.uuid() }),
611
+ z7.object({ t: z7.literal("ack"), id: z7.uuid() }),
612
+ z7.object({
613
+ t: z7.literal("res"),
614
+ id: z7.uuid(),
615
+ status: CommandStatus.extract(["done", "failed", "cancelled"]),
616
+ result: z7.unknown().nullable(),
617
+ error: z7.string().nullable()
618
+ }),
619
+ z7.object({ t: z7.literal("cmd.err"), id: z7.uuid(), error: z7.string() }),
620
+ z7.object({ t: z7.literal("evt"), topic: LocalSessionTopic, e: SessionEvent }),
621
+ z7.object({ t: z7.literal("term"), terminalId: z7.uuid(), m: TerminalRunnerMessage }),
622
+ z7.object({ t: z7.literal("sub.err"), topic: LocalTopic, error: z7.string() })
623
+ ]);
624
+ }
625
+ });
626
+
627
+ // ../../packages/shared/src/protocol/runner.ts
628
+ import { z as z8 } from "zod";
492
629
  var ProviderModel, ProviderCapability, UNAVAILABLE, RunnerCapabilities, CODEX_TOOL_NAMES;
493
630
  var init_runner = __esm({
494
631
  "../../packages/shared/src/protocol/runner.ts"() {
495
632
  "use strict";
496
633
  init_command();
497
- ProviderModel = z7.object({
498
- id: z7.string().min(1).max(200),
499
- label: z7.string().min(1).max(200),
500
- default: z7.boolean().optional(),
634
+ ProviderModel = z8.object({
635
+ id: z8.string().min(1).max(200),
636
+ label: z8.string().min(1).max(200),
637
+ default: z8.boolean().optional(),
501
638
  /** From the app-server's `supportedReasoningEfforts`; empty means the control is hidden. */
502
- efforts: z7.array(Effort).default([]),
639
+ efforts: z8.array(Effort).default([]),
503
640
  /** The effort a new chat on this model opens at, sent back on `session.start`. */
504
641
  defaultEffort: Effort.optional()
505
642
  });
506
- ProviderCapability = z7.object({
507
- available: z7.boolean(),
508
- reason: z7.enum(["not_installed", "not_logged_in", "error"]).optional(),
509
- version: z7.string().max(50).optional(),
510
- models: z7.array(ProviderModel).max(50).default([])
643
+ ProviderCapability = z8.object({
644
+ available: z8.boolean(),
645
+ reason: z8.enum(["not_installed", "not_logged_in", "error"]).optional(),
646
+ version: z8.string().max(50).optional(),
647
+ models: z8.array(ProviderModel).max(50).default([])
511
648
  });
512
649
  UNAVAILABLE = { available: false, reason: "not_installed", models: [] };
513
- RunnerCapabilities = z7.object({
514
- kinds: z7.array(z7.string()),
515
- local_port: z7.number().int(),
516
- claude: z7.boolean(),
517
- providers: z7.object({
650
+ RunnerCapabilities = z8.object({
651
+ kinds: z8.array(z8.string()),
652
+ local_port: z8.number().int(),
653
+ claude: z8.boolean(),
654
+ providers: z8.object({
518
655
  claude: ProviderCapability.default({ available: true, models: [] }),
519
656
  codex: ProviderCapability.default(UNAVAILABLE)
520
657
  }).default({ claude: { available: true, models: [] }, codex: UNAVAILABLE }),
@@ -523,7 +660,19 @@ var init_runner = __esm({
523
660
  * config `allowMemberTerminals` names (protocol §9). Absent reads as none; a workspace admin
524
661
  * cannot grant a terminal. Optional so an older row still parses.
525
662
  */
526
- memberTerminalIds: z7.array(z7.uuid()).max(64).optional()
663
+ memberTerminalIds: z8.array(z8.uuid()).max(64).optional(),
664
+ /**
665
+ * Whether this runner honours `session.start { forkSession: true }` (D36, protocol §9). An older
666
+ * runner strips the field and then refuses the resume under R30, so a client sends
667
+ * `forkSession` only when this is true. Absent reads as false; optional so an older row parses.
668
+ */
669
+ sessionFork: z8.boolean().optional(),
670
+ /**
671
+ * The raw Ed25519 public key of the running process (32 bytes, unpadded base64url), which signs
672
+ * the local server's `hello` (protocol §6.2). New on every start, republished by every capabilities refresh that changes the row;
673
+ * a client that finds it absent never opens the fast path. Optional so an older row still parses.
674
+ */
675
+ local_pubkey: z8.string().regex(/^[A-Za-z0-9_-]{43}$/).optional()
527
676
  });
528
677
  CODEX_TOOL_NAMES = {
529
678
  command: "codex.command_execution",
@@ -534,6 +683,21 @@ var init_runner = __esm({
534
683
  }
535
684
  });
536
685
 
686
+ // ../../packages/shared/src/protocol/session.ts
687
+ import { z as z9 } from "zod";
688
+ var SlashCommand, SessionCommandsListed;
689
+ var init_session = __esm({
690
+ "../../packages/shared/src/protocol/session.ts"() {
691
+ "use strict";
692
+ SlashCommand = z9.object({
693
+ name: z9.string(),
694
+ description: z9.string(),
695
+ argumentHint: z9.string()
696
+ });
697
+ SessionCommandsListed = z9.object({ slashCommands: z9.array(SlashCommand) });
698
+ }
699
+ });
700
+
537
701
  // ../../packages/shared/src/protocol/transport.ts
538
702
  var init_transport = __esm({
539
703
  "../../packages/shared/src/protocol/transport.ts"() {
@@ -550,7 +714,9 @@ var init_protocol = __esm({
550
714
  init_events();
551
715
  init_git();
552
716
  init_github();
717
+ init_local();
553
718
  init_runner();
719
+ init_session();
554
720
  init_terminal();
555
721
  init_transport();
556
722
  }
@@ -652,31 +818,6 @@ var init_claude_dir = __esm({
652
818
  }
653
819
  });
654
820
 
655
- // src/cloud.ts
656
- function assertPublishable(key) {
657
- if (!key) return key;
658
- if (key.startsWith("sb_secret_"))
659
- throw new Error("CLOUD key is a secret key; bake the publishable key");
660
- if (key.startsWith("sb_publishable_")) return key;
661
- const payload = key.split(".")[1];
662
- if (!payload) throw new Error("CLOUD key is not a recognised Supabase key");
663
- const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
664
- if (claims.role !== "anon") {
665
- throw new Error(`CLOUD key has role "${claims.role}"; only the anon key may be baked in`);
666
- }
667
- return key;
668
- }
669
- var CLOUD_SUPABASE_URL, CLOUD_SUPABASE_ANON_KEY;
670
- var init_cloud = __esm({
671
- "src/cloud.ts"() {
672
- "use strict";
673
- CLOUD_SUPABASE_URL = "https://ndqrkqxwkzxgnhkvcunr.supabase.co";
674
- CLOUD_SUPABASE_ANON_KEY = assertPublishable(
675
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5kcXJrcXh3a3p4Z25oa3ZjdW5yIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODg3MTMyNzYsImV4cCI6MjEwNDI4OTI3Nn0.Dlsxw91E1QYZ4eoRDjP80Rf4feY77Z-T-fiIwUDY2k0"
676
- );
677
- }
678
- });
679
-
680
821
  // src/profile.ts
681
822
  import { join as join2 } from "path";
682
823
  function assertProfileName(name) {
@@ -725,7 +866,7 @@ import {
725
866
  } from "fs";
726
867
  import { homedir as homedir2 } from "os";
727
868
  import { dirname, isAbsolute as isAbsolute2, join as join3 } from "path";
728
- import { z as z8 } from "zod";
869
+ import { z as z10 } from "zod";
729
870
  function setProfileOverride(name) {
730
871
  profileOverride = name;
731
872
  }
@@ -800,22 +941,24 @@ var init_config = __esm({
800
941
  init_cloud();
801
942
  init_profile();
802
943
  MEMBER_TERMINALS_ERROR = "allowMemberTerminals must be false or a list of user ids";
803
- RunnerConfig = z8.object({
804
- supabaseUrl: z8.url(),
805
- supabaseAnonKey: z8.string().min(1),
806
- workspaceId: z8.uuid().optional(),
807
- runnerId: z8.uuid().optional(),
808
- name: z8.string().optional(),
809
- roots: z8.array(z8.string()).default([]),
810
- port: z8.number().int().default(LOCAL_PORT),
811
- allowBypassPermissions: z8.boolean().default(false),
944
+ RunnerConfig = z10.object({
945
+ supabaseUrl: z10.url(),
946
+ supabaseAnonKey: z10.string().min(1),
947
+ workspaceId: z10.uuid().optional(),
948
+ runnerId: z10.uuid().optional(),
949
+ name: z10.string().optional(),
950
+ roots: z10.array(z10.string()).default([]),
951
+ port: z10.number().int().default(LOCAL_PORT),
952
+ /** Extra page origins the local server admits, beside the built-in list (`buildOrigins`). */
953
+ localOrigins: z10.array(z10.string()).default([]),
954
+ allowBypassPermissions: z10.boolean().default(false),
812
955
  /** Absolute path to the `codex` binary, when it is not on the daemon's PATH. */
813
- codexPath: z8.string().optional(),
956
+ codexPath: z10.string().optional(),
814
957
  /**
815
958
  * The Claude Code config directory this profile's sessions run on (D33), handed to them as
816
959
  * `CLAUDE_CONFIG_DIR`. Unset means the CLI's own default, `~/.claude`.
817
960
  */
818
- claudeConfigDir: z8.string().optional(),
961
+ claudeConfigDir: z10.string().optional(),
819
962
  /**
820
963
  * Who besides the runner owner may hold a terminal here (D34, plan ruling 14): `false`, or the
821
964
  * user ids this machine's owner names. Machine-local like `allowBypassPermissions`: a workspace
@@ -823,15 +966,15 @@ var init_config = __esm({
823
966
  * `true` is refused — there is no "every member" switch. A listed member's shell runs as this
824
967
  * machine's OS user; the env allowlist is not a boundary.
825
968
  */
826
- allowMemberTerminals: z8.union(
969
+ allowMemberTerminals: z10.union(
827
970
  [
828
- z8.literal(false),
829
- z8.array(z8.uuid({ error: MEMBER_TERMINALS_ERROR }), { error: MEMBER_TERMINALS_ERROR }).max(64, { error: MEMBER_TERMINALS_ERROR })
971
+ z10.literal(false),
972
+ z10.array(z10.uuid({ error: MEMBER_TERMINALS_ERROR }), { error: MEMBER_TERMINALS_ERROR }).max(64, { error: MEMBER_TERMINALS_ERROR })
830
973
  ],
831
974
  { error: MEMBER_TERMINALS_ERROR }
832
975
  ).default(false),
833
976
  /** Minutes a terminal nobody attends stays alive before the runner kills it (features §2). */
834
- terminalIdleMinutes: z8.number().int().min(1).max(1440).default(10)
977
+ terminalIdleMinutes: z10.number().int().min(1).max(1440).default(10)
835
978
  });
836
979
  memberTerminalIdsOf = (cfg) => cfg.allowMemberTerminals === false ? [] : [...cfg.allowMemberTerminals];
837
980
  configPath = (dir) => join3(dir, "config.json");
@@ -911,24 +1054,12 @@ function grantedColumns(deps) {
911
1054
  protocol_version: PROTOCOL_VERSION,
912
1055
  status: "online",
913
1056
  last_seen_at: (/* @__PURE__ */ new Date()).toISOString(),
914
- // Spread: `deps.kinds` is the composer's list, not a value to hand to a serialiser that could
915
- // mutate it. `claude: true` stays beside `providers`: clients written
916
- // before this change look for it, and dropping it would read as "this runner cannot run
917
- // Claude".
918
- //
919
1057
  // Parsed, not merely typed: this row is the writer and every client's `RunnerCapabilities`
920
1058
  // parse is the reader. A value the reader rejects fails that parse *whole*, so one bad model
921
1059
  // reads to a member as "my Mac cannot run Codex", with nothing said anywhere. Failing here is
922
- // loud, on one machine, at the moment the value is produced.
923
- capabilities: RunnerCapabilities.parse({
924
- kinds: [...deps.kinds],
925
- local_port: cfg.port,
926
- claude: true,
927
- providers: deps.providers,
928
- // Who the runner's own config names (D34): the web shows `not-allowed` to anyone else
929
- // instead of an "Open terminal" button the runner would refuse.
930
- memberTerminalIds: memberTerminalIdsOf(cfg)
931
- }),
1060
+ // loud, on one machine, at the moment the value is produced. The parse also returns a copy, so
1061
+ // the composer's arrays are never handed to a serialiser that could mutate them.
1062
+ capabilities: RunnerCapabilities.parse(deps.capabilities),
932
1063
  // The sandbox boundary from the config, resolved and never widened here.
933
1064
  allowed_roots: publishedRoots(cfg.roots)
934
1065
  };
@@ -1088,7 +1219,7 @@ import {
1088
1219
  } from "fs";
1089
1220
  import { uptime } from "os";
1090
1221
  import { join as join5 } from "path";
1091
- import { z as z9 } from "zod";
1222
+ import { z as z11 } from "zod";
1092
1223
  function bootedAt(now = Date.now()) {
1093
1224
  return new Date(Math.round((now - uptime() * 1e3) / 1e3) * 1e3).toISOString();
1094
1225
  }
@@ -1231,13 +1362,13 @@ var LockRecord, BOOT_DRIFT_MS, ATTEMPTS, BACKOFF_MS, CLAIM_TTL_MS, errno, claimO
1231
1362
  var init_lock = __esm({
1232
1363
  "src/lock.ts"() {
1233
1364
  "use strict";
1234
- LockRecord = z9.object({
1235
- pid: z9.number().int().positive(),
1236
- profile: z9.string(),
1237
- startedAt: z9.string(),
1365
+ LockRecord = z11.object({
1366
+ pid: z11.number().int().positive(),
1367
+ profile: z11.string(),
1368
+ startedAt: z11.string(),
1238
1369
  /** `bootedAt()` when the lock was taken; a lock from another boot is stale whatever its pid. */
1239
- bootedAt: z9.string(),
1240
- version: z9.string()
1370
+ bootedAt: z11.string(),
1371
+ version: z11.string()
1241
1372
  });
1242
1373
  BOOT_DRIFT_MS = 1e4;
1243
1374
  ATTEMPTS = 8;
@@ -1339,6 +1470,44 @@ var init_version = __esm({
1339
1470
  }
1340
1471
  });
1341
1472
 
1473
+ // src/exec-path.ts
1474
+ import { accessSync, constants, statSync as statSync3 } from "fs";
1475
+ import { delimiter, isAbsolute as isAbsolute4, join as join8 } from "path";
1476
+ function findOnPath(name, path) {
1477
+ for (const dir of (path ?? "").split(delimiter)) {
1478
+ if (!isAbsolute4(dir)) continue;
1479
+ const candidate = join8(dir, name);
1480
+ try {
1481
+ if (!statSync3(candidate).isFile()) continue;
1482
+ accessSync(candidate, constants.X_OK);
1483
+ return candidate;
1484
+ } catch {
1485
+ }
1486
+ }
1487
+ return null;
1488
+ }
1489
+ function makeLocator(name, locate = findOnPath) {
1490
+ const located = /* @__PURE__ */ new Map();
1491
+ return {
1492
+ find(path) {
1493
+ const key = path ?? "";
1494
+ const cached = located.get(key);
1495
+ if (cached !== void 0) return cached;
1496
+ const found = locate(name, path);
1497
+ if (found !== null) located.set(key, found);
1498
+ return found;
1499
+ },
1500
+ forget(path) {
1501
+ located.delete(path ?? "");
1502
+ }
1503
+ };
1504
+ }
1505
+ var init_exec_path = __esm({
1506
+ "src/exec-path.ts"() {
1507
+ "use strict";
1508
+ }
1509
+ });
1510
+
1342
1511
  // src/codex/rpc.ts
1343
1512
  var RpcError, JsonRpc;
1344
1513
  var init_rpc = __esm({
@@ -1446,20 +1615,9 @@ var init_rpc = __esm({
1446
1615
 
1447
1616
  // src/codex/detect.ts
1448
1617
  import { execFile as execFile2, spawn } from "child_process";
1449
- import { accessSync, constants } from "fs";
1450
- import { delimiter, join as join8 } from "path";
1451
1618
  import { promisify } from "util";
1452
1619
  function findCodexOnPath(env) {
1453
- for (const dir of (env.PATH ?? "").split(delimiter)) {
1454
- if (!dir) continue;
1455
- const candidate = join8(dir, "codex");
1456
- try {
1457
- accessSync(candidate, constants.X_OK);
1458
- return candidate;
1459
- } catch {
1460
- }
1461
- }
1462
- return null;
1620
+ return findOnPath("codex", env.PATH);
1463
1621
  }
1464
1622
  function resolveCodexPath(cfg, env, onPath) {
1465
1623
  return env.CAYENNE_CODEX_PATH ?? cfg.codexPath ?? onPath();
@@ -1536,6 +1694,7 @@ var init_detect = __esm({
1536
1694
  "src/codex/detect.ts"() {
1537
1695
  "use strict";
1538
1696
  init_src();
1697
+ init_exec_path();
1539
1698
  init_rpc();
1540
1699
  exec = promisify(execFile2);
1541
1700
  PROBE_TIMEOUT_MS = 5e3;
@@ -1564,11 +1723,12 @@ var init_dispatcher = __esm({
1564
1723
 
1565
1724
  // src/commands/source.ts
1566
1725
  import { LRUCache } from "lru-cache";
1567
- var byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
1726
+ var STOPPING, byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
1568
1727
  var init_source = __esm({
1569
1728
  "src/commands/source.ts"() {
1570
1729
  "use strict";
1571
1730
  init_src();
1731
+ STOPPING = "runner stopping";
1572
1732
  byCreatedAt = (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id.localeCompare(b.id);
1573
1733
  GIT_READ_KINDS = /* @__PURE__ */ new Set(["git.status", "git.diff", "git.branches"]);
1574
1734
  GIT_WORKTREE_KINDS = /* @__PURE__ */ new Set([
@@ -1598,6 +1758,10 @@ var init_source = __esm({
1598
1758
  dispatch;
1599
1759
  log;
1600
1760
  seen = new LRUCache({ max: 1e3 });
1761
+ /** Local-socket hooks per row id; dropped once fired, on `stop()`, or by the function `offer` returns. */
1762
+ hooks = /* @__PURE__ */ new Map();
1763
+ /** Rows between `enqueue` and their settlement: an `offer` for one of these registers hooks only. */
1764
+ inflight = /* @__PURE__ */ new Set();
1601
1765
  queues = /* @__PURE__ */ new Map();
1602
1766
  channel = null;
1603
1767
  staleMs;
@@ -1664,6 +1828,38 @@ var init_source = __esm({
1664
1828
  this.pending = [];
1665
1829
  await this.draining;
1666
1830
  while (this.queues.size > 0) await Promise.allSettled([...this.queues.values()]);
1831
+ for (const id of [...this.hooks.keys()]) this.dropHooks(id, STOPPING);
1832
+ }
1833
+ /**
1834
+ * A pointer that arrived over the local socket (§6.2; SEC-P12, SP16). The same admission a
1835
+ * broadcast gets — dedupe by id, conditional ack, the same lane — so a row the relay already
1836
+ * delivered runs once, and a `terminal.open` pointer serialises behind the relay's copy in the
1837
+ * `terminal:<id>` lane. Hooks are registered either way so the socket hears the outcome. A row
1838
+ * this source already settled (or never claimed) is answered from the row itself, never from a
1839
+ * guess (PR15). Returns the function that drops these hooks; the connection calls it on close.
1840
+ */
1841
+ offer(row, hooks) {
1842
+ if (this.stopped) {
1843
+ hooks.onDropped?.(STOPPING);
1844
+ return () => {
1845
+ };
1846
+ }
1847
+ const list = this.hooks.get(row.id) ?? [];
1848
+ list.push(hooks);
1849
+ this.hooks.set(row.id, list);
1850
+ if (!this.seen.has(row.id)) {
1851
+ if (this.draining) this.pending.push(row);
1852
+ else this.enqueue(row);
1853
+ } else if (!this.inflight.has(row.id)) {
1854
+ void this.settleFromRow(row.id);
1855
+ }
1856
+ return () => {
1857
+ const current = this.hooks.get(row.id);
1858
+ if (!current) return;
1859
+ const at = current.indexOf(hooks);
1860
+ if (at >= 0) current.splice(at, 1);
1861
+ if (current.length === 0) this.hooks.delete(row.id);
1862
+ };
1667
1863
  }
1668
1864
  /**
1669
1865
  * Claim the row now; queue only its execution.
@@ -1681,6 +1877,7 @@ var init_source = __esm({
1681
1877
  if (this.stopped) return;
1682
1878
  if (this.seen.has(row.id)) return;
1683
1879
  this.seen.set(row.id, true);
1880
+ this.inflight.add(row.id);
1684
1881
  const ageMs = this.now() - Date.parse(row.created_at);
1685
1882
  const admitted = this.admit(row, ageMs);
1686
1883
  const key = laneOf(row);
@@ -1707,6 +1904,7 @@ var init_source = __esm({
1707
1904
  if (row.flow_run_step_id === null && ageMs > this.staleMs) {
1708
1905
  const { error: error2 } = await this.finish(row.id, "cancelled", null, "STALE");
1709
1906
  if (error2) this.release(row.id, error2.message, "stale cancel failed");
1907
+ else this.settle(row.id, { status: "cancelled", result: null, error: "STALE" });
1710
1908
  return "skip";
1711
1909
  }
1712
1910
  const { data, error } = await this.db.from("runner_commands").update({ status: "acked", acked_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", row.id).eq("status", "queued").select("id");
@@ -1716,8 +1914,10 @@ var init_source = __esm({
1716
1914
  }
1717
1915
  if (!data || data.length === 0) {
1718
1916
  this.log.info({ id: row.id }, "command no longer queued; not dispatching");
1917
+ await this.settleFromRow(row.id);
1719
1918
  return "skip";
1720
1919
  }
1920
+ for (const h of this.hooks.get(row.id) ?? []) h.onAck?.();
1721
1921
  return "dispatch";
1722
1922
  } catch (err) {
1723
1923
  this.release(row.id, err instanceof Error ? err.message : String(err), "ack failed");
@@ -1734,17 +1934,29 @@ var init_source = __esm({
1734
1934
  */
1735
1935
  release(id, err, msg) {
1736
1936
  this.seen.delete(id);
1937
+ this.inflight.delete(id);
1737
1938
  this.log.warn({ id, err }, msg);
1738
1939
  }
1739
1940
  async run(row, admitted) {
1740
1941
  if (await admitted !== "dispatch") return;
1942
+ let answered = false;
1943
+ try {
1944
+ await this.runAndFinish(row);
1945
+ answered = true;
1946
+ } finally {
1947
+ if (!answered) await this.settleFromRow(row.id);
1948
+ }
1949
+ }
1950
+ async runAndFinish(row) {
1741
1951
  let status = "done";
1742
1952
  let finished;
1953
+ let result = null;
1954
+ let message = null;
1743
1955
  try {
1744
- const result = await this.dispatch(row);
1956
+ result = await this.dispatch(row);
1745
1957
  finished = await this.finish(row.id, "done", result ?? null, null);
1746
1958
  } catch (err) {
1747
- const message = err instanceof Error ? err.message : String(err);
1959
+ message = err instanceof Error ? err.message : String(err);
1748
1960
  this.log.warn({ id: row.id, kind: row.kind, err: message }, "command failed");
1749
1961
  status = "failed";
1750
1962
  finished = await this.finish(row.id, status, null, message);
@@ -1755,6 +1967,52 @@ var init_source = __esm({
1755
1967
  "command finish write refused"
1756
1968
  );
1757
1969
  }
1970
+ if (finished.error) await this.settleFromRow(row.id);
1971
+ else {
1972
+ this.settle(row.id, {
1973
+ status,
1974
+ result: status === "done" ? result ?? null : null,
1975
+ error: status === "done" ? null : message
1976
+ });
1977
+ }
1978
+ }
1979
+ settle(id, r) {
1980
+ this.inflight.delete(id);
1981
+ const list = this.hooks.get(id);
1982
+ if (!list) return;
1983
+ this.hooks.delete(id);
1984
+ for (const h of list) h.onFinished?.(r);
1985
+ }
1986
+ /**
1987
+ * The outcome of a row this source did not finish itself — cancelled by its creator between the
1988
+ * socket's read and our ack, claimed by another process, or settled before the pointer arrived.
1989
+ * Read once; answered only when the row is terminal, otherwise the hooks are dropped and the
1990
+ * client's relay wait sees the row (PR15: never invent a status).
1991
+ */
1992
+ async settleFromRow(id) {
1993
+ this.inflight.delete(id);
1994
+ if (!this.hooks.has(id)) return;
1995
+ let reason = "row is not terminal";
1996
+ try {
1997
+ const { data } = await this.db.from("runner_commands").select("status, result, error").eq("id", id).maybeSingle();
1998
+ const status = data?.status;
1999
+ if (status === "done" || status === "failed" || status === "cancelled") {
2000
+ this.settle(id, { status, result: data?.result ?? null, error: data?.error ?? null });
2001
+ return;
2002
+ }
2003
+ if (!data) reason = "row not found";
2004
+ } catch (err) {
2005
+ this.log.warn({ id, err }, "could not re-read a command for its local pointer");
2006
+ reason = "row re-read failed";
2007
+ }
2008
+ this.dropHooks(id, reason);
2009
+ }
2010
+ /** Hooks this source will never fire `onFinished` for: told so, so a socket frees its pending slot. */
2011
+ dropHooks(id, reason) {
2012
+ const list = this.hooks.get(id);
2013
+ if (!list) return;
2014
+ this.hooks.delete(id);
2015
+ for (const h of list) h.onDropped?.(reason);
1758
2016
  }
1759
2017
  async finish(id, status, result, error) {
1760
2018
  const res = await this.db.from("runner_commands").update({ status, result, error, finished_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
@@ -1766,7 +2024,7 @@ var init_source = __esm({
1766
2024
 
1767
2025
  // src/paths.ts
1768
2026
  import { lstatSync, realpathSync as realpathSync3 } from "fs";
1769
- import { dirname as dirname3, isAbsolute as isAbsolute4, join as join9, relative, sep } from "path";
2027
+ import { dirname as dirname3, isAbsolute as isAbsolute5, join as join9, relative, sep } from "path";
1770
2028
  function assertInsideRoots(target, roots) {
1771
2029
  return resolveInsideRoots(target, roots).real;
1772
2030
  }
@@ -1784,7 +2042,7 @@ function resolveInsideRoots(target, roots) {
1784
2042
  }
1785
2043
  function assertInsideRootsOrAncestor(target, roots) {
1786
2044
  assertRoots(roots);
1787
- const absolute = isAbsolute4(target) ? target : `${process.cwd()}${sep}${target}`;
2045
+ const absolute = isAbsolute5(target) ? target : `${process.cwd()}${sep}${target}`;
1788
2046
  let walked = walk(target, absolute);
1789
2047
  if (walked.tail.includes("..")) walked = walk(target, join9(walked.real, ...walked.tail));
1790
2048
  const checked = walked.tail.length === 0 ? walked.real : join9(walked.real, ...walked.tail);
@@ -1833,7 +2091,7 @@ function matchRoot(candidate, roots) {
1833
2091
  continue;
1834
2092
  }
1835
2093
  const rel = relative(rootReal, candidate);
1836
- const inside2 = rel === "" || !rel.startsWith("..") && !isAbsolute4(rel);
2094
+ const inside2 = rel === "" || !rel.startsWith("..") && !isAbsolute5(rel);
1837
2095
  if (inside2 && (match === null || rootReal.length < match.length)) match = rootReal;
1838
2096
  }
1839
2097
  return match;
@@ -1847,7 +2105,7 @@ var init_paths = __esm({
1847
2105
  // src/git/introspect.ts
1848
2106
  import { execFile as execFile3 } from "child_process";
1849
2107
  import { readFileSync as readFileSync4, realpathSync as realpathSync4 } from "fs";
1850
- import { delimiter as delimiter2, dirname as dirname4, isAbsolute as isAbsolute5, join as join10, resolve } from "path";
2108
+ import { delimiter as delimiter2, dirname as dirname4, isAbsolute as isAbsolute6, join as join10, resolve } from "path";
1851
2109
  import { promisify as promisify2 } from "util";
1852
2110
  function parseGitHubRemote(url) {
1853
2111
  const trimmed = url.trim();
@@ -1888,17 +2146,24 @@ function gitEnv(ceiling, env = process.env) {
1888
2146
  };
1889
2147
  }
1890
2148
  function makeGitRun(opts) {
2149
+ const git = makeLocator("git", opts.locate);
1891
2150
  return async (args, cwd, ceiling) => {
2151
+ const env = gitEnv(ceiling);
2152
+ const file = git.find(env.PATH);
2153
+ if (file === null) {
2154
+ return { code: 127, stdout: "", stderr: "git not found on an absolute PATH entry" };
2155
+ }
1892
2156
  try {
1893
- const { stdout, stderr } = await exec2("git", [...HARDENING, ...args], {
2157
+ const { stdout, stderr } = await exec2(file, [...HARDENING, ...args], {
1894
2158
  cwd,
1895
- env: gitEnv(ceiling),
2159
+ env,
1896
2160
  timeout: opts.timeoutMs,
1897
2161
  maxBuffer: GIT_MAX_BUFFER
1898
2162
  });
1899
2163
  return { code: 0, stdout, stderr };
1900
2164
  } catch (err) {
1901
2165
  const e = err;
2166
+ if (e.code === "ENOENT") git.forget(env.PATH);
1902
2167
  return {
1903
2168
  code: typeof e.code === "number" ? e.code : 1,
1904
2169
  stdout: e.stdout ?? "",
@@ -1932,7 +2197,7 @@ function isLinkedWorktree(gitDir, commonDir, repoRoot) {
1932
2197
  return false;
1933
2198
  }
1934
2199
  }
1935
- async function introspectRepo(cwd, run, root) {
2200
+ async function introspectRepo(cwd, run, root, { names = true } = {}) {
1936
2201
  try {
1937
2202
  const ceiling = dirname4(root);
1938
2203
  if (ceiling.includes(delimiter2)) return { ...NONE };
@@ -1950,16 +2215,16 @@ async function introspectRepo(cwd, run, root) {
1950
2215
  } catch {
1951
2216
  return { ...NONE };
1952
2217
  }
1953
- const head = await line(["rev-parse", "--abbrev-ref", "HEAD"]);
2218
+ const head = names ? await line(["rev-parse", "--abbrev-ref", "HEAD"]) : null;
1954
2219
  const branch = head === "HEAD" ? null : head;
1955
2220
  const dirs = (await line(["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"]))?.split("\n").map((d) => d.trim());
1956
- const [gitDir, commonDir] = dirs?.length === 2 && dirs.every((d) => isAbsolute5(d)) ? dirs.map((d) => canonical(d, cwd)) : [null, null];
2221
+ const [gitDir, commonDir] = dirs?.length === 2 && dirs.every((d) => isAbsolute6(d)) ? dirs.map((d) => canonical(d, cwd)) : [null, null];
1957
2222
  if (commonDir == null || gitDir == null) return { ...NONE };
1958
2223
  if (!insideRoot(commonDir, root) && !isLinkedWorktree(gitDir, commonDir, repoRoot)) {
1959
2224
  return { ...NONE };
1960
2225
  }
1961
2226
  const isWorktree = gitDir !== commonDir;
1962
- const remote = await line(["remote", "get-url", "origin"]);
2227
+ const remote = names ? await line(["remote", "get-url", "origin"]) : null;
1963
2228
  const gh = remote === null ? null : parseGitHubRemote(remote);
1964
2229
  return {
1965
2230
  repo_root: repoRoot,
@@ -1977,6 +2242,7 @@ var init_introspect = __esm({
1977
2242
  "src/git/introspect.ts"() {
1978
2243
  "use strict";
1979
2244
  init_src();
2245
+ init_exec_path();
1980
2246
  init_paths();
1981
2247
  exec2 = promisify2(execFile3);
1982
2248
  GIT_TIMEOUT_MS = 5e3;
@@ -2016,10 +2282,10 @@ var init_introspect = __esm({
2016
2282
  });
2017
2283
 
2018
2284
  // src/git/commands.ts
2019
- import { lstatSync as lstatSync2, realpathSync as realpathSync5, statSync as statSync3 } from "fs";
2020
- import { dirname as dirname5, isAbsolute as isAbsolute6, join as join11, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
2285
+ import { lstatSync as lstatSync2, realpathSync as realpathSync5, statSync as statSync4 } from "fs";
2286
+ import { dirname as dirname5, isAbsolute as isAbsolute7, join as join11, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
2021
2287
  async function scopeRepo(cwd, run, root) {
2022
- const identity = await introspectRepo(cwd, run, root);
2288
+ const identity = await introspectRepo(cwd, run, root, { names: false });
2023
2289
  if (identity.repo_root === null) {
2024
2290
  throw new Error(`VALIDATION: ${cwd} is not inside a git repository under an allowed root`);
2025
2291
  }
@@ -2175,7 +2441,7 @@ async function gitDirs(scope, run) {
2175
2441
  );
2176
2442
  if (r.code !== 0) return null;
2177
2443
  const lines = r.stdout.split("\n").filter((l) => l !== "");
2178
- if (lines.length === 0 || !lines.every((l) => isAbsolute6(l))) return null;
2444
+ if (lines.length === 0 || !lines.every((l) => isAbsolute7(l))) return null;
2179
2445
  try {
2180
2446
  return lines.map((l) => realpathSync5.native(l));
2181
2447
  } catch {
@@ -2189,7 +2455,7 @@ function isGitDirShape(dir) {
2189
2455
  if (commondir?.isFile()) return true;
2190
2456
  const isDir = (name) => {
2191
2457
  try {
2192
- return statSync3(join11(dir, name), { throwIfNoEntry: false })?.isDirectory() === true;
2458
+ return statSync4(join11(dir, name), { throwIfNoEntry: false })?.isDirectory() === true;
2193
2459
  } catch {
2194
2460
  return false;
2195
2461
  }
@@ -2206,13 +2472,13 @@ function enclosesHome(repoRoot, home) {
2206
2472
  if (within(real, repoRoot)) return true;
2207
2473
  let root;
2208
2474
  try {
2209
- root = statSync3(repoRoot, { bigint: true });
2475
+ root = statSync4(repoRoot, { bigint: true });
2210
2476
  } catch {
2211
2477
  return false;
2212
2478
  }
2213
2479
  for (let cur = real; ; cur = dirname5(cur)) {
2214
2480
  try {
2215
- const st = statSync3(cur, { bigint: true, throwIfNoEntry: false });
2481
+ const st = statSync4(cur, { bigint: true, throwIfNoEntry: false });
2216
2482
  if (st && st.dev === root.dev && st.ino === root.ino) return true;
2217
2483
  } catch {
2218
2484
  }
@@ -2224,7 +2490,7 @@ function protectedTest(paths) {
2224
2490
  for (const path of paths) {
2225
2491
  try {
2226
2492
  const real = realpathSync5.native(path);
2227
- const st = statSync3(real, { bigint: true });
2493
+ const st = statSync4(real, { bigint: true });
2228
2494
  entries.push({ real, dev: st.dev, ino: st.ino });
2229
2495
  } catch {
2230
2496
  }
@@ -2236,7 +2502,7 @@ function protectedTest(paths) {
2236
2502
  if (cached !== void 0) return cached;
2237
2503
  let hit = false;
2238
2504
  try {
2239
- const st = statSync3(path, { bigint: true, throwIfNoEntry: false });
2505
+ const st = statSync4(path, { bigint: true, throwIfNoEntry: false });
2240
2506
  hit = st !== void 0 && entries.some((e) => e.dev === st.dev && e.ino === st.ino);
2241
2507
  } catch {
2242
2508
  }
@@ -2406,11 +2672,11 @@ import {
2406
2672
  openSync,
2407
2673
  readFileSync as readFileSync5,
2408
2674
  realpathSync as realpathSync6,
2409
- statSync as statSync4,
2675
+ statSync as statSync5,
2410
2676
  writeSync
2411
2677
  } from "fs";
2412
2678
  import { readdir } from "fs/promises";
2413
- import { dirname as dirname6, isAbsolute as isAbsolute7, join as join12, relative as relative3, sep as sep3 } from "path";
2679
+ import { dirname as dirname6, isAbsolute as isAbsolute8, join as join12, relative as relative3, sep as sep3 } from "path";
2414
2680
  import { setImmediate } from "timers/promises";
2415
2681
  function worktreePathFor(repoRoot, name) {
2416
2682
  return join12(repoRoot, WORKTREES_DIR, name);
@@ -2435,7 +2701,7 @@ async function excludeCayenneDir(scope, run) {
2435
2701
  scope.ceiling
2436
2702
  );
2437
2703
  const common = r.stdout.trim();
2438
- if (r.code !== 0 || !isAbsolute7(common)) return;
2704
+ if (r.code !== 0 || !isAbsolute8(common)) return;
2439
2705
  const dotGit = join12(scope.repoRoot, ".git");
2440
2706
  let dotGitIsRealDir;
2441
2707
  try {
@@ -2587,9 +2853,9 @@ async function worktreeIdentity(scope, run) {
2587
2853
  scope.ceiling
2588
2854
  );
2589
2855
  const gitDir = r.stdout.trim();
2590
- if (r.code !== 0 || !isAbsolute7(gitDir)) throw failed2("git rev-parse --git-dir", r.stderr);
2856
+ if (r.code !== 0 || !isAbsolute8(gitDir)) throw failed2("git rev-parse --git-dir", r.stderr);
2591
2857
  try {
2592
- const st = statSync4(realpathSync6.native(gitDir), { bigint: true });
2858
+ const st = statSync5(realpathSync6.native(gitDir), { bigint: true });
2593
2859
  return {
2594
2860
  gitDirDev: st.dev.toString(),
2595
2861
  gitDirIno: st.ino.toString(),
@@ -2624,7 +2890,7 @@ async function removeWorktree(scope, run, worktreePath, force) {
2624
2890
  throw new Error(`ROOT_NOT_ALLOWED: ${real}`);
2625
2891
  }
2626
2892
  const rel = relative3(base, real);
2627
- if (rel === "" || rel.startsWith("..") || isAbsolute7(rel) || rel.includes("/")) {
2893
+ if (rel === "" || rel.startsWith("..") || isAbsolute8(rel) || rel.includes("/")) {
2628
2894
  throw new Error(`VALIDATION: ${worktreePath} is not under ${base}`);
2629
2895
  }
2630
2896
  const inside2 = `${real}${sep3}`;
@@ -2748,7 +3014,7 @@ var init_worktree_owners = __esm({
2748
3014
  import { rmSync as rmSync4 } from "fs";
2749
3015
  import { join as join14 } from "path";
2750
3016
  function createSessionWorktrees(dir) {
2751
- const fileFor = (sessionId) => UUID.test(sessionId) ? join14(dir, `${sessionId}.json`) : null;
3017
+ const fileFor = (sessionId) => UUID2.test(sessionId) ? join14(dir, `${sessionId}.json`) : null;
2752
3018
  return {
2753
3019
  record(rec) {
2754
3020
  const file = fileFor(rec.sessionId);
@@ -2784,12 +3050,620 @@ function createSessionWorktrees(dir) {
2784
3050
  }
2785
3051
  };
2786
3052
  }
2787
- var UUID;
3053
+ var UUID2;
2788
3054
  var init_session_worktrees = __esm({
2789
3055
  "src/git/session-worktrees.ts"() {
2790
3056
  "use strict";
2791
3057
  init_worktree_owners();
2792
- UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3058
+ UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3059
+ }
3060
+ });
3061
+
3062
+ // src/local/auth.ts
3063
+ import { createHash as createHash2 } from "crypto";
3064
+ import { LRUCache as LRUCache2 } from "lru-cache";
3065
+ function claimsOf(token) {
3066
+ const payload = token.split(".")[1];
3067
+ if (!payload) return null;
3068
+ try {
3069
+ const c = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
3070
+ return typeof c.sub === "string" && typeof c.exp === "number" ? { sub: c.sub, exp: c.exp } : null;
3071
+ } catch {
3072
+ return null;
3073
+ }
3074
+ }
3075
+ function retryable(error) {
3076
+ const { name, status } = error;
3077
+ if (name === "AuthRetryableFetchError") return true;
3078
+ if (typeof status !== "number" || status === 0) return true;
3079
+ return status >= 500 || status === 429;
3080
+ }
3081
+ function createTokenVerifier({
3082
+ db,
3083
+ ttlMs = LOCAL_TOKEN_CACHE_MS,
3084
+ negativeTtlMs = LOCAL_TOKEN_NEGATIVE_CACHE_MS,
3085
+ now = Date.now,
3086
+ admit = () => true,
3087
+ log
3088
+ }) {
3089
+ const cache = new LRUCache2({ max: 1024 });
3090
+ const key = (token) => createHash2("sha256").update(token).digest("hex");
3091
+ let windowStart = now();
3092
+ let calls = 0;
3093
+ let warned2 = false;
3094
+ const budgeted = () => {
3095
+ const t = now();
3096
+ if (t - windowStart >= 6e4) {
3097
+ windowStart = t;
3098
+ calls = 0;
3099
+ warned2 = false;
3100
+ }
3101
+ if (calls < LOCAL_GETUSER_PER_MINUTE) {
3102
+ calls += 1;
3103
+ return true;
3104
+ }
3105
+ if (!warned2) {
3106
+ warned2 = true;
3107
+ log?.warn(
3108
+ { perMinute: LOCAL_GETUSER_PER_MINUTE },
3109
+ "local token verification over budget; refusing until the minute turns"
3110
+ );
3111
+ }
3112
+ return false;
3113
+ };
3114
+ const refuse = (k) => {
3115
+ cache.set(k, { userId: null, exp: 0, until: now() + negativeTtlMs });
3116
+ return { status: "refused" };
3117
+ };
3118
+ return {
3119
+ cacheKeys: () => [...cache.keys()],
3120
+ async verify(token) {
3121
+ const k = key(token);
3122
+ const hit = cache.get(k);
3123
+ if (hit && hit.until > now()) {
3124
+ if (hit.userId === null) return { status: "refused" };
3125
+ if (hit.exp * 1e3 <= now()) return refuse(k);
3126
+ return { status: "valid", userId: hit.userId, exp: hit.exp };
3127
+ }
3128
+ cache.delete(k);
3129
+ const claims = claimsOf(token);
3130
+ if (!claims || claims.exp * 1e3 <= now() || !admit(claims.sub)) return refuse(k);
3131
+ if (!budgeted()) return { status: "unavailable" };
3132
+ const user = await db.auth.getUser(token).then(
3133
+ (r) => r.error ? retryable(r.error) ? void 0 : null : r.data.user,
3134
+ () => void 0
3135
+ );
3136
+ if (user === void 0) return { status: "unavailable" };
3137
+ if (!user || user.id !== claims.sub) return refuse(k);
3138
+ cache.set(k, { userId: user.id, exp: claims.exp, until: now() + ttlMs });
3139
+ return { status: "valid", userId: user.id, exp: claims.exp };
3140
+ }
3141
+ };
3142
+ }
3143
+ var init_auth = __esm({
3144
+ "src/local/auth.ts"() {
3145
+ "use strict";
3146
+ init_src();
3147
+ }
3148
+ });
3149
+
3150
+ // src/local/identity.ts
3151
+ import { generateKeyPairSync, sign } from "crypto";
3152
+ function createLocalIdentity() {
3153
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
3154
+ const x = publicKey.export({ format: "jwk" }).x;
3155
+ if (typeof x !== "string" || x.length !== 43) throw new Error("ed25519 public key export failed");
3156
+ return {
3157
+ publicKey: x,
3158
+ sign: (message) => sign(null, message, privateKey).toString("base64url")
3159
+ };
3160
+ }
3161
+ var init_identity = __esm({
3162
+ "src/local/identity.ts"() {
3163
+ "use strict";
3164
+ }
3165
+ });
3166
+
3167
+ // src/local/origins.ts
3168
+ function normalise(origin) {
3169
+ try {
3170
+ const u = new URL(origin);
3171
+ if (u.username || u.password) return null;
3172
+ return u.origin !== "null" ? u.origin.toLowerCase() : `${u.protocol}//${u.host}`.toLowerCase();
3173
+ } catch {
3174
+ return null;
3175
+ }
3176
+ }
3177
+ function buildOrigins(extra, cloudOrigin = CLOUD_APP_ORIGIN) {
3178
+ const builtIn = cloudOrigin ? [cloudOrigin, ...SHELLS] : [...DEV, ...SHELLS];
3179
+ const out = /* @__PURE__ */ new Set();
3180
+ for (const o of [...builtIn, ...extra]) {
3181
+ const n = normalise(o);
3182
+ if (n) out.add(n);
3183
+ }
3184
+ return [...out];
3185
+ }
3186
+ function originAllowed(origin, allowed) {
3187
+ if (!origin) return false;
3188
+ const n = normalise(origin);
3189
+ return n !== null && allowed.includes(n);
3190
+ }
3191
+ var SHELLS, DEV;
3192
+ var init_origins = __esm({
3193
+ "src/local/origins.ts"() {
3194
+ "use strict";
3195
+ init_cloud();
3196
+ SHELLS = ["capacitor://localhost", "app://cayenne"];
3197
+ DEV = ["http://localhost:5173", "http://127.0.0.1:5173"];
3198
+ }
3199
+ });
3200
+
3201
+ // src/local/connection.ts
3202
+ import { randomUUID as randomUUID2 } from "crypto";
3203
+ function closeSoon(ws, code, reason) {
3204
+ if (ws.readyState === ws.CLOSED) return;
3205
+ ws.close(code, reason);
3206
+ const timer = setTimeout(() => ws.terminate(), CLOSE_GRACE_MS);
3207
+ timer.unref();
3208
+ ws.once("close", () => clearTimeout(timer));
3209
+ }
3210
+ function parseFrame(data) {
3211
+ try {
3212
+ const parsed = LocalClientFrame.safeParse(JSON.parse(String(data)));
3213
+ return parsed.success ? parsed.data : null;
3214
+ } catch {
3215
+ return null;
3216
+ }
3217
+ }
3218
+ function handleConnection(ws, who, ctx, log, now) {
3219
+ const sessions = /* @__PURE__ */ new Set();
3220
+ const terminals = /* @__PURE__ */ new Set();
3221
+ const pending = /* @__PURE__ */ new Map();
3222
+ let closed = false;
3223
+ const pendingSubs = /* @__PURE__ */ new Map();
3224
+ const conn = {
3225
+ id: randomUUID2(),
3226
+ userId: who.userId,
3227
+ tokenExp: who.tokenExp,
3228
+ sessions,
3229
+ terminals,
3230
+ send(frame) {
3231
+ if (ws.readyState !== ws.OPEN) return;
3232
+ if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
3233
+ log.warn({ conn: conn.id, userId: conn.userId }, "local client is not reading; closing");
3234
+ closeSoon(ws, LOCAL_CLOSE_NOT_READY, "too far behind");
3235
+ return;
3236
+ }
3237
+ ws.send(JSON.stringify(frame));
3238
+ },
3239
+ close(code, reason) {
3240
+ closeSoon(ws, code, reason);
3241
+ },
3242
+ recheckSessions: () => recheckSessions()
3243
+ };
3244
+ const cmdErr = (id, error) => conn.send({ t: "cmd.err", id, error });
3245
+ const onCmd = async (id) => {
3246
+ if (pending.has(id)) return;
3247
+ if (pending.size >= LOCAL_MAX_PENDING_CMDS) {
3248
+ return cmdErr(id, "VALIDATION: too many pending commands on this socket");
3249
+ }
3250
+ pending.set(id, () => {
3251
+ });
3252
+ let row;
3253
+ try {
3254
+ row = await ctx.readRow(id);
3255
+ } catch (err) {
3256
+ pending.delete(id);
3257
+ log.warn({ conn: conn.id, id, err }, "local cmd: row read failed");
3258
+ return cmdErr(id, "PROVIDER_ERROR: could not read the command");
3259
+ }
3260
+ if (closed) return;
3261
+ if (!row || row.runner_id !== ctx.runnerId || row.created_by !== conn.userId) {
3262
+ const reason = !row ? "no row" : row.runner_id !== ctx.runnerId ? "another runner" : "another creator";
3263
+ pending.delete(id);
3264
+ log.info({ conn: conn.id, userId: conn.userId, id, reason }, "local cmd refused");
3265
+ return cmdErr(id, "VALIDATION: no such command on this runner");
3266
+ }
3267
+ if (row.status === "done" || row.status === "failed" || row.status === "cancelled") {
3268
+ pending.delete(id);
3269
+ return conn.send({ t: "res", id, status: row.status, result: row.result, error: row.error });
3270
+ }
3271
+ const drop = ctx.source.offer(row, {
3272
+ onAck: () => conn.send({ t: "ack", id }),
3273
+ onFinished: (r) => {
3274
+ pending.delete(id);
3275
+ conn.send({ t: "res", id, ...r });
3276
+ },
3277
+ // The source will never settle this pointer (the row is claimed elsewhere and still running,
3278
+ // or could not be re-read): free the slot and say so without settling (ruling B10, R-B6).
3279
+ onDropped: (reason) => {
3280
+ pending.delete(id);
3281
+ log.info({ conn: conn.id, userId: conn.userId, id, reason }, "local cmd pointer dropped");
3282
+ cmdErr(id, "VALIDATION: no such command on this runner");
3283
+ }
3284
+ });
3285
+ if (pending.has(id)) pending.set(id, drop);
3286
+ };
3287
+ const onAuth = async (token) => {
3288
+ const next = await ctx.verifier.verify(token);
3289
+ if (next.status === "unavailable") {
3290
+ log.info({ conn: conn.id, userId: conn.userId }, "local auth refresh unverified; kept");
3291
+ return;
3292
+ }
3293
+ if (next.status !== "valid" || next.userId !== conn.userId || expiresAt(next.exp) <= now() || !ctx.serves(next.userId)) {
3294
+ conn.close(LOCAL_CLOSE_UNAUTHORIZED, "unauthorized");
3295
+ return;
3296
+ }
3297
+ conn.tokenExp = expiresAt(next.exp);
3298
+ conn.send({ t: "ready", userId: conn.userId });
3299
+ await recheckSessions();
3300
+ };
3301
+ const SESSION_REFUSED = "SESSION_NOT_FOUND: not hosted on this runner";
3302
+ const sessionRefusal = (s) => !s ? "no row" : s.runner_id !== ctx.runnerId ? "another runner" : s.owner_id !== conn.userId && s.visibility !== "workspace" ? "private" : null;
3303
+ const recheckSessions = async () => {
3304
+ await Promise.all(
3305
+ [...sessions].map(async (id) => {
3306
+ let s;
3307
+ try {
3308
+ s = await ctx.readSession(id);
3309
+ } catch (err) {
3310
+ log.warn({ conn: conn.id, sessionId: id, err }, "local sub recheck: session read failed");
3311
+ return;
3312
+ }
3313
+ const reason = sessionRefusal(s);
3314
+ if (!reason || !sessions.delete(id)) return;
3315
+ log.info(
3316
+ { conn: conn.id, userId: conn.userId, sessionId: id, reason },
3317
+ "local sub revoked"
3318
+ );
3319
+ conn.send({ t: "sub.err", topic: `session:${id}`, error: SESSION_REFUSED });
3320
+ })
3321
+ );
3322
+ };
3323
+ const onSub = async (topic) => {
3324
+ const parsed = parseLocalTopic(topic);
3325
+ if (!parsed) return;
3326
+ if (parsed.kind === "session") {
3327
+ const mine = {};
3328
+ pendingSubs.set(parsed.id, mine);
3329
+ const s = await ctx.readSession(parsed.id).catch(() => null);
3330
+ if (closed || pendingSubs.get(parsed.id) !== mine) return;
3331
+ pendingSubs.delete(parsed.id);
3332
+ const reason = sessionRefusal(s);
3333
+ if (reason) {
3334
+ log.info(
3335
+ { conn: conn.id, userId: conn.userId, sessionId: parsed.id, reason },
3336
+ "local sub refused"
3337
+ );
3338
+ return conn.send({ t: "sub.err", topic, error: SESSION_REFUSED });
3339
+ }
3340
+ sessions.add(parsed.id);
3341
+ return;
3342
+ }
3343
+ const t = ctx.terminals;
3344
+ if (!t?.has(parsed.id) || t.ownerOf(parsed.id) !== conn.userId || !ctx.mayHoldTerminal(conn.userId)) {
3345
+ return conn.send({
3346
+ t: "sub.err",
3347
+ topic,
3348
+ error: "TERMINAL_NOT_FOUND: not your terminal here"
3349
+ });
3350
+ }
3351
+ terminals.add(parsed.id);
3352
+ t.attachLocal(parsed.id, conn.id);
3353
+ };
3354
+ const onUnsub = (topic) => {
3355
+ const parsed = parseLocalTopic(topic);
3356
+ if (!parsed) return;
3357
+ if (parsed.kind === "session") {
3358
+ sessions.delete(parsed.id);
3359
+ pendingSubs.delete(parsed.id);
3360
+ } else if (terminals.delete(parsed.id)) ctx.terminals?.detachLocal(parsed.id, conn.id);
3361
+ };
3362
+ const onTerm = (terminalId, m) => {
3363
+ const t = ctx.terminals;
3364
+ if (!t || !terminals.has(terminalId)) {
3365
+ log.warn(
3366
+ { conn: conn.id },
3367
+ "local term frame for a terminal this socket is not subscribed to; dropped"
3368
+ );
3369
+ return;
3370
+ }
3371
+ try {
3372
+ t.receive(terminalId, m.m, "local");
3373
+ } catch (err) {
3374
+ log.warn({ conn: conn.id, terminalId, err }, "local terminal message refused");
3375
+ }
3376
+ };
3377
+ ws.on("message", (data) => {
3378
+ const f = parseFrame(data);
3379
+ if (!f) {
3380
+ log.warn({ conn: conn.id }, "local frame did not parse");
3381
+ return;
3382
+ }
3383
+ if (f.t === "hello") conn.close(LOCAL_CLOSE_BAD_HANDSHAKE, "handshake already done");
3384
+ else if (f.t === "auth") void onAuth(f.token);
3385
+ else if (f.t === "cmd") void onCmd(f.id);
3386
+ else if (f.t === "sub") void onSub(f.topic);
3387
+ else if (f.t === "unsub") onUnsub(f.topic);
3388
+ else onTerm(f.terminalId, f);
3389
+ });
3390
+ ws.on("close", () => {
3391
+ closed = true;
3392
+ for (const id of terminals) ctx.terminals?.detachLocal(id, conn.id);
3393
+ for (const drop of pending.values()) drop();
3394
+ pending.clear();
3395
+ terminals.clear();
3396
+ sessions.clear();
3397
+ pendingSubs.clear();
3398
+ });
3399
+ return conn;
3400
+ }
3401
+ function sessionFrame(sessionId, e) {
3402
+ return { t: "evt", topic: `session:${sessionId}`, e };
3403
+ }
3404
+ var MAX_BUFFERED_BYTES, CLOSE_GRACE_MS, expiresAt;
3405
+ var init_connection = __esm({
3406
+ "src/local/connection.ts"() {
3407
+ "use strict";
3408
+ init_src();
3409
+ MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
3410
+ CLOSE_GRACE_MS = 250;
3411
+ expiresAt = (exp) => exp * 1e3;
3412
+ }
3413
+ });
3414
+
3415
+ // src/local/server.ts
3416
+ import { createServer } from "http";
3417
+ import { WebSocketServer } from "ws";
3418
+ function createLocalServer({
3419
+ port,
3420
+ origins,
3421
+ log,
3422
+ handshakeTimeoutMs = LOCAL_HANDSHAKE_TIMEOUT_MS,
3423
+ recheckMs = LOCAL_RECHECK_MS,
3424
+ now = Date.now,
3425
+ maxSockets = LOCAL_MAX_SOCKETS
3426
+ }) {
3427
+ let ctx = null;
3428
+ let bound = null;
3429
+ const conns = /* @__PURE__ */ new Set();
3430
+ let pendingUpgrades = 0;
3431
+ const wss = new WebSocketServer({ noServer: true, maxPayload: LOCAL_MAX_FRAME_BYTES });
3432
+ let untapTerminals = null;
3433
+ let unwatchRoster = null;
3434
+ let recheckTimer = null;
3435
+ const hostAllowed = (host) => bound !== null && (host === `${HOST}:${bound}` || host === `localhost:${bound}`);
3436
+ const socketsOf = (userId) => {
3437
+ let n = 0;
3438
+ for (const c of conns) if (c.userId === userId) n += 1;
3439
+ return n;
3440
+ };
3441
+ const recheck = () => {
3442
+ const current = ctx;
3443
+ if (!current) return;
3444
+ const t = now();
3445
+ for (const c of [...conns]) {
3446
+ if (!current.serves(c.userId) || t >= c.tokenExp) {
3447
+ c.close(LOCAL_CLOSE_UNAUTHORIZED, "no longer authorised");
3448
+ } else {
3449
+ void c.recheckSessions();
3450
+ }
3451
+ }
3452
+ };
3453
+ const cors = (req, res) => {
3454
+ const origin = req.headers.origin;
3455
+ if (!originAllowed(origin, origins)) return false;
3456
+ res.setHeader("Access-Control-Allow-Origin", origin);
3457
+ res.setHeader("Vary", "Origin");
3458
+ return true;
3459
+ };
3460
+ const server = createServer((req, res) => {
3461
+ if (!hostAllowed(req.headers.host)) {
3462
+ res.writeHead(403).end();
3463
+ return;
3464
+ }
3465
+ if (req.url !== LOCAL_HEALTH_PATH) {
3466
+ res.writeHead(404).end();
3467
+ return;
3468
+ }
3469
+ const allowed = cors(req, res);
3470
+ if (req.method === "OPTIONS") {
3471
+ if (!allowed) {
3472
+ res.writeHead(403).end();
3473
+ return;
3474
+ }
3475
+ res.setHeader("Access-Control-Allow-Methods", "GET");
3476
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
3477
+ res.setHeader("Access-Control-Max-Age", "600");
3478
+ res.writeHead(204).end();
3479
+ return;
3480
+ }
3481
+ if (req.method !== "GET") {
3482
+ res.writeHead(405).end();
3483
+ return;
3484
+ }
3485
+ res.setHeader("Content-Type", "application/json");
3486
+ res.setHeader("Cache-Control", "no-store");
3487
+ if (!ctx) {
3488
+ res.writeHead(503).end(JSON.stringify({ error: "starting" }));
3489
+ return;
3490
+ }
3491
+ res.writeHead(200).end(
3492
+ JSON.stringify({
3493
+ runnerId: ctx.runnerId,
3494
+ protocolVersion: PROTOCOL_VERSION,
3495
+ version: ctx.version
3496
+ })
3497
+ );
3498
+ });
3499
+ server.maxConnections = LOCAL_MAX_CONNECTIONS;
3500
+ server.headersTimeout = LOCAL_HANDSHAKE_TIMEOUT_MS;
3501
+ server.requestTimeout = LOCAL_HANDSHAKE_TIMEOUT_MS;
3502
+ server.keepAliveTimeout = 1e3;
3503
+ const refuse = (socket, status) => {
3504
+ const text = status === 403 ? "Forbidden" : "Service Unavailable";
3505
+ socket.write(`HTTP/1.1 ${status} ${text}\r
3506
+ Connection: close\r
3507
+ Content-Length: 0\r
3508
+ \r
3509
+ `);
3510
+ socket.destroy();
3511
+ };
3512
+ server.on("upgrade", (req, socket, head) => {
3513
+ const origin = req.headers.origin ?? "";
3514
+ if (req.url !== LOCAL_WS_PATH || !hostAllowed(req.headers.host) || !originAllowed(origin, origins)) {
3515
+ refuse(socket, 403);
3516
+ return;
3517
+ }
3518
+ if (pendingUpgrades >= LOCAL_MAX_PENDING_UPGRADES || conns.size >= maxSockets) {
3519
+ refuse(socket, 503);
3520
+ return;
3521
+ }
3522
+ pendingUpgrades += 1;
3523
+ let released = false;
3524
+ const release = () => {
3525
+ if (released) return;
3526
+ released = true;
3527
+ pendingUpgrades -= 1;
3528
+ };
3529
+ let ws = null;
3530
+ const timer = setTimeout(() => {
3531
+ if (ws) closeSoon(ws, LOCAL_CLOSE_NOT_READY, "handshake timeout");
3532
+ else socket.destroy();
3533
+ }, handshakeTimeoutMs);
3534
+ socket.once("close", release);
3535
+ wss.handleUpgrade(req, socket, head, (admitted) => {
3536
+ ws = admitted;
3537
+ admitted.on("close", release);
3538
+ admitted.on("error", (err) => {
3539
+ log.warn({ code: err.code }, "local socket error");
3540
+ });
3541
+ const current = ctx;
3542
+ if (!current) {
3543
+ clearTimeout(timer);
3544
+ closeSoon(admitted, LOCAL_CLOSE_NOT_READY, "starting");
3545
+ return;
3546
+ }
3547
+ let state = "awaiting-hello";
3548
+ const onHandshake = async (data) => {
3549
+ const f = parseFrame(data);
3550
+ if (state === "awaiting-hello") {
3551
+ if (f?.t !== "hello") {
3552
+ closeSoon(admitted, LOCAL_CLOSE_BAD_HANDSHAKE, "expected hello");
3553
+ return;
3554
+ }
3555
+ state = "awaiting-auth";
3556
+ const sig = current.identity.sign(localProofMessage(f.nonce, current.runnerId, origin));
3557
+ admitted.send(
3558
+ JSON.stringify({
3559
+ t: "hello",
3560
+ runnerId: current.runnerId,
3561
+ protocolVersion: PROTOCOL_VERSION,
3562
+ sig
3563
+ })
3564
+ );
3565
+ return;
3566
+ }
3567
+ if (state !== "awaiting-auth" || !f || f.t !== "auth") {
3568
+ closeSoon(admitted, LOCAL_CLOSE_BAD_HANDSHAKE, "expected auth");
3569
+ return;
3570
+ }
3571
+ state = "verifying";
3572
+ const who = await current.verifier.verify(f.token);
3573
+ if (admitted.readyState !== admitted.OPEN) return;
3574
+ if (who.status !== "valid" || expiresAt(who.exp) <= now() || !current.serves(who.userId)) {
3575
+ closeSoon(admitted, LOCAL_CLOSE_UNAUTHORIZED, "unauthorized");
3576
+ return;
3577
+ }
3578
+ const tokenExp = expiresAt(who.exp);
3579
+ if (socketsOf(who.userId) >= LOCAL_MAX_SOCKETS_PER_USER) {
3580
+ closeSoon(admitted, LOCAL_CLOSE_TOO_MANY, "too many sockets for this user");
3581
+ return;
3582
+ }
3583
+ if (conns.size >= maxSockets) {
3584
+ closeSoon(admitted, LOCAL_CLOSE_TOO_MANY, "too many sockets");
3585
+ return;
3586
+ }
3587
+ state = "ready";
3588
+ clearTimeout(timer);
3589
+ release();
3590
+ admitted.off("message", listener);
3591
+ const conn = handleConnection(
3592
+ admitted,
3593
+ { userId: who.userId, tokenExp },
3594
+ current,
3595
+ log,
3596
+ now
3597
+ );
3598
+ conns.add(conn);
3599
+ admitted.on("close", () => conns.delete(conn));
3600
+ conn.send({ t: "ready", userId: who.userId });
3601
+ log.info({ conn: conn.id, userId: who.userId }, "local client attached");
3602
+ };
3603
+ const listener = (data) => {
3604
+ void onHandshake(data);
3605
+ };
3606
+ admitted.on("message", listener);
3607
+ });
3608
+ });
3609
+ return {
3610
+ get port() {
3611
+ return bound;
3612
+ },
3613
+ listen() {
3614
+ return new Promise((resolve3, reject) => {
3615
+ server.once("error", reject);
3616
+ server.listen(port, HOST, () => {
3617
+ server.off("error", reject);
3618
+ const address = server.address();
3619
+ bound = typeof address === "object" && address ? address.port : port;
3620
+ log.info({ host: HOST, port: bound }, "local server listening");
3621
+ resolve3(bound);
3622
+ });
3623
+ });
3624
+ },
3625
+ attach(next) {
3626
+ ctx = next;
3627
+ untapTerminals?.();
3628
+ untapTerminals = next.terminals?.subscribe((terminalId, m, origin) => {
3629
+ if (m.type === "replay" && origin === "relay") return;
3630
+ for (const c of conns)
3631
+ if (c.terminals.has(terminalId)) c.send({ t: "term", terminalId, m });
3632
+ }) ?? null;
3633
+ unwatchRoster?.();
3634
+ unwatchRoster = next.onRosterRefresh(recheck);
3635
+ if (recheckTimer) clearInterval(recheckTimer);
3636
+ recheckTimer = setInterval(recheck, recheckMs);
3637
+ recheckTimer.unref();
3638
+ },
3639
+ emit(sessionId, e) {
3640
+ const frame = sessionFrame(sessionId, e);
3641
+ for (const c of conns) if (c.sessions.has(sessionId)) c.send(frame);
3642
+ },
3643
+ close() {
3644
+ if (recheckTimer) clearInterval(recheckTimer);
3645
+ recheckTimer = null;
3646
+ unwatchRoster?.();
3647
+ unwatchRoster = null;
3648
+ untapTerminals?.();
3649
+ untapTerminals = null;
3650
+ for (const client of wss.clients) client.close(1001, "runner stopping");
3651
+ return new Promise((resolve3) => {
3652
+ wss.close();
3653
+ server.close(() => resolve3());
3654
+ server.closeAllConnections();
3655
+ });
3656
+ }
3657
+ };
3658
+ }
3659
+ var HOST;
3660
+ var init_server = __esm({
3661
+ "src/local/server.ts"() {
3662
+ "use strict";
3663
+ init_src();
3664
+ init_connection();
3665
+ init_origins();
3666
+ HOST = "127.0.0.1";
2793
3667
  }
2794
3668
  });
2795
3669
 
@@ -3030,21 +3904,31 @@ function checkRunsArgs(req, sha) {
3030
3904
  }
3031
3905
  return ["api", `repos/${req.owner}/${req.name}/commits/${sha}/check-runs`];
3032
3906
  }
3033
- async function runGh(args) {
3034
- try {
3035
- const { stdout, stderr } = await exec3("gh", args, {
3036
- timeout: GH_TIMEOUT_MS,
3037
- maxBuffer: MAX_OUTPUT * 2
3038
- });
3039
- return { code: 0, stdout, stderr };
3040
- } catch (err) {
3041
- const e = err;
3042
- return {
3043
- code: typeof e.code === "number" ? e.code : 1,
3044
- stdout: e.stdout ?? "",
3045
- stderr: e.stderr ?? ""
3046
- };
3047
- }
3907
+ function makeGhRun(opts = {}) {
3908
+ const gh = makeLocator("gh", opts.locate);
3909
+ const currentPath = opts.path ?? (() => process.env.PATH);
3910
+ return async (args) => {
3911
+ const path = currentPath();
3912
+ const file = gh.find(path);
3913
+ if (file === null) {
3914
+ return { code: 127, stdout: "", stderr: "gh not found on an absolute PATH entry" };
3915
+ }
3916
+ try {
3917
+ const { stdout, stderr } = await exec3(file, args, {
3918
+ timeout: GH_TIMEOUT_MS,
3919
+ maxBuffer: MAX_OUTPUT * 2
3920
+ });
3921
+ return { code: 0, stdout, stderr };
3922
+ } catch (err) {
3923
+ const e = err;
3924
+ if (e.code === "ENOENT") gh.forget(path);
3925
+ return {
3926
+ code: typeof e.code === "number" ? e.code : 1,
3927
+ stdout: e.stdout ?? "",
3928
+ stderr: e.stderr ?? ""
3929
+ };
3930
+ }
3931
+ };
3048
3932
  }
3049
3933
  function statusFrom(stderr) {
3050
3934
  const m = /\(HTTP (\d{3})\)/.exec(stderr);
@@ -3082,16 +3966,18 @@ function createGhGateway(run) {
3082
3966
  }
3083
3967
  };
3084
3968
  }
3085
- var exec3, GH_TIMEOUT_MS, MAX_OUTPUT, ALL_DOTS3, COMMIT_SHA;
3969
+ var exec3, GH_TIMEOUT_MS, MAX_OUTPUT, ALL_DOTS3, COMMIT_SHA, runGh;
3086
3970
  var init_gh = __esm({
3087
3971
  "src/github/gh.ts"() {
3088
3972
  "use strict";
3089
3973
  init_src();
3974
+ init_exec_path();
3090
3975
  exec3 = promisify3(execFile4);
3091
3976
  GH_TIMEOUT_MS = 2e4;
3092
3977
  MAX_OUTPUT = 512e3;
3093
3978
  ALL_DOTS3 = /^\.+$/;
3094
3979
  COMMIT_SHA = /^[0-9a-f]{40}$/;
3980
+ runGh = makeGhRun();
3095
3981
  }
3096
3982
  });
3097
3983
 
@@ -3125,11 +4011,12 @@ var init_refusals = __esm({
3125
4011
  });
3126
4012
 
3127
4013
  // src/sessions/handlers.ts
4014
+ import { ZodError } from "zod";
3128
4015
  function parsePayload(row, kind) {
3129
4016
  try {
3130
4017
  return parseCommand(row, kind);
3131
4018
  } catch (err) {
3132
- const message = err instanceof Error ? err.message : String(err);
4019
+ const message = err instanceof ZodError ? err.issues.map((i) => i.message).join("; ") : err instanceof Error ? err.message : String(err);
3133
4020
  throw new Error(message.startsWith("VALIDATION:") ? message : `VALIDATION: ${message}`);
3134
4021
  }
3135
4022
  }
@@ -3307,11 +4194,11 @@ var init_handlers = __esm({
3307
4194
 
3308
4195
  // src/git/watch.ts
3309
4196
  import { watch } from "fs";
3310
- import { isAbsolute as isAbsolute8, normalize, sep as sep4 } from "path";
4197
+ import { isAbsolute as isAbsolute9, normalize, sep as sep4 } from "path";
3311
4198
  function ignored(name) {
3312
4199
  if (name === null || name === "") return false;
3313
4200
  const rel = normalize(name);
3314
- if (isAbsolute8(rel) || rel === ".." || rel.startsWith(`..${sep4}`)) return true;
4201
+ if (isAbsolute9(rel) || rel === ".." || rel.startsWith(`..${sep4}`)) return true;
3315
4202
  return rel.split(/[\\/]/).some((segment) => IGNORED_NAMES.has(segment));
3316
4203
  }
3317
4204
  function watchChanges(cwd, onChange, debounceMs = CHANGES_DEBOUNCE_MS, onError = () => void 0, fsWatch = recursiveWatch) {
@@ -3513,11 +4400,11 @@ var init_pushable = __esm({
3513
4400
  });
3514
4401
 
3515
4402
  // src/claude/session.ts
3516
- import { randomUUID as randomUUID2 } from "crypto";
4403
+ import { randomUUID as randomUUID3 } from "crypto";
3517
4404
  import { EventEmitter } from "events";
3518
4405
  import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
3519
4406
  var ClaudeSession;
3520
- var init_session = __esm({
4407
+ var init_session2 = __esm({
3521
4408
  "src/claude/session.ts"() {
3522
4409
  "use strict";
3523
4410
  init_src();
@@ -3527,7 +4414,7 @@ var init_session = __esm({
3527
4414
  constructor(opts) {
3528
4415
  super();
3529
4416
  this.opts = opts;
3530
- this.providerSessionId = opts.resume ?? null;
4417
+ this.providerSessionId = opts.forkSession ? null : opts.resume ?? null;
3531
4418
  }
3532
4419
  opts;
3533
4420
  provider = "claude";
@@ -3560,6 +4447,7 @@ var init_session = __esm({
3560
4447
  // Present only when asked for, so the default call stays the CLI's own behaviour.
3561
4448
  ...this.opts.strictMcpConfig === void 0 ? {} : { strictMcpConfig: this.opts.strictMcpConfig },
3562
4449
  ...this.opts.mcpServers === void 0 ? {} : { mcpServers: this.opts.mcpServers },
4450
+ ...this.opts.forkSession ? { forkSession: true } : {},
3563
4451
  canUseTool,
3564
4452
  abortController: this.abort,
3565
4453
  stderr: (line) => {
@@ -3621,7 +4509,7 @@ var init_session = __esm({
3621
4509
  }
3622
4510
  requestPermission(toolName2, input, suggestions, toolUseId, signal) {
3623
4511
  return new Promise((resolve3) => {
3624
- const id = randomUUID2();
4512
+ const id = randomUUID3();
3625
4513
  const settle = (d) => {
3626
4514
  const entry = this.pending.get(id);
3627
4515
  if (!entry) return;
@@ -3808,7 +4696,7 @@ function scrubSecrets(text) {
3808
4696
  return SECRET_PATTERNS.reduce((acc, [pattern, to]) => acc.replace(pattern, to), text);
3809
4697
  }
3810
4698
  var STDERR_TAIL_MAX, SIGKILL_AFTER_MS, SECRET_PATTERNS, APPROVAL_METHODS, CodexSession;
3811
- var init_session2 = __esm({
4699
+ var init_session3 = __esm({
3812
4700
  "src/codex/session.ts"() {
3813
4701
  "use strict";
3814
4702
  init_src();
@@ -4154,15 +5042,15 @@ function createProviderSession(provider, opts) {
4154
5042
  var init_create = __esm({
4155
5043
  "src/providers/create.ts"() {
4156
5044
  "use strict";
4157
- init_session();
4158
5045
  init_session2();
5046
+ init_session3();
4159
5047
  }
4160
5048
  });
4161
5049
 
4162
5050
  // src/sessions/manager.ts
4163
- import { randomUUID as randomUUID3 } from "crypto";
5051
+ import { randomUUID as randomUUID4 } from "crypto";
4164
5052
  import { realpathSync as realpathSync7 } from "fs";
4165
- import { isAbsolute as isAbsolute9, sep as sep5 } from "path";
5053
+ import { isAbsolute as isAbsolute10, sep as sep5 } from "path";
4166
5054
  import { isDeepStrictEqual } from "util";
4167
5055
  function narrowToSession(suggestions) {
4168
5056
  if (!suggestions) return suggestions;
@@ -4258,10 +5146,16 @@ var init_manager = __esm({
4258
5146
  }
4259
5147
  this.assertModeAllowed(p.permissionMode);
4260
5148
  const provider = p.provider ?? "claude";
5149
+ if (p.forkSession && provider !== "claude") {
5150
+ throw new Error("VALIDATION: codex sessions cannot be forked");
5151
+ }
4261
5152
  this.assertProviderAllowed(provider, createdBy);
4262
5153
  const typed = resolveInsideRoots(p.cwd, this.deps.roots);
4263
5154
  this.assertNotRemoving(typed.real);
4264
- if (p.resumeProviderSessionId !== void 0) {
5155
+ const forking = p.forkSession === true && p.resumeProviderSessionId !== void 0;
5156
+ if (forking) {
5157
+ await this.assertForkable(p.resumeProviderSessionId, createdBy, typed.real);
5158
+ } else if (p.resumeProviderSessionId !== void 0) {
4265
5159
  await this.assertResumable(sessionId, p.resumeProviderSessionId);
4266
5160
  }
4267
5161
  let { real: cwd, root } = typed;
@@ -4269,7 +5163,7 @@ var init_manager = __esm({
4269
5163
  if (p.worktree) {
4270
5164
  cwd = await this.startWorktree(typed.real, typed.root, p.worktree, createdBy);
4271
5165
  inWorktree = true;
4272
- } else if (p.resumeProviderSessionId !== void 0) {
5166
+ } else if (p.resumeProviderSessionId !== void 0 && !forking) {
4273
5167
  const recorded = await this.recordedWorktree(sessionId);
4274
5168
  if (recorded) ({ real: cwd, root } = recorded);
4275
5169
  }
@@ -4290,6 +5184,7 @@ var init_manager = __esm({
4290
5184
  permissionMode: p.permissionMode,
4291
5185
  model: p.model,
4292
5186
  resume: p.resumeProviderSessionId,
5187
+ ...forking ? { forkSession: true } : {},
4293
5188
  ...provider === "codex" ? {
4294
5189
  codexPath: this.deps.codexPath,
4295
5190
  checkInput: this.codexGate(sessionId, cwd),
@@ -4300,7 +5195,9 @@ var init_manager = __esm({
4300
5195
  started();
4301
5196
  }
4302
5197
  if (inWorktree) await this.recordSessionWorktree(sessionId, cwd, root);
4303
- else if (p.resumeProviderSessionId === void 0) this.forgetSessionWorktree(sessionId);
5198
+ else if (p.resumeProviderSessionId === void 0 || forking) {
5199
+ this.forgetSessionWorktree(sessionId);
5200
+ }
4304
5201
  live.status = "idle";
4305
5202
  await this.updateSession(sessionId, { status: "idle", model: p.model ?? null });
4306
5203
  await this.broadcast(sessionId, {
@@ -4320,7 +5217,7 @@ var init_manager = __esm({
4320
5217
  if (p.initialPrompt) {
4321
5218
  await this.prompt(
4322
5219
  sessionId,
4323
- { text: p.initialPrompt, clientUuid: randomUUID3(), attachments: [] },
5220
+ { text: p.initialPrompt, clientUuid: randomUUID4(), attachments: [] },
4324
5221
  createdBy
4325
5222
  );
4326
5223
  }
@@ -4747,6 +5644,37 @@ var init_manager = __esm({
4747
5644
  throw new Error("VALIDATION: resumeProviderSessionId does not belong to this session");
4748
5645
  }
4749
5646
  }
5647
+ /**
5648
+ * D36, amending R30 for `forkSession: true`: the resume id may be held by another session, but
5649
+ * only one on this runner that `createdBy` owns — a member must not fork the runner owner's
5650
+ * conversation, or anyone else's. A command with no creator owns nothing. Only a Claude row with
5651
+ * a non-empty id can be a source. The new session must run where the source did, decided the way
5652
+ * a resume of the source would be: the worktree this runner recorded for it, else its canonical
5653
+ * `cwd`. Never `worktree_path`: the runner owner can rewrite that column on a member's chat, and
5654
+ * it must not steer where the member's fork runs. A source path that no longer resolves inside
5655
+ * the roots cannot be proved equal, so it is refused, not thrown through.
5656
+ */
5657
+ async assertForkable(providerSessionId, createdBy, cwd) {
5658
+ if (providerSessionId === "") {
5659
+ throw new Error("VALIDATION: forkSession source is not a session you own on this runner");
5660
+ }
5661
+ const { data, error } = await this.deps.db.from("chat_sessions").select("id, owner_id, cwd").eq("provider_session_id", providerSessionId).eq("runner_id", this.deps.runnerId).eq("provider", "claude").maybeSingle();
5662
+ if (error) throw new Error(`PROVIDER_ERROR: could not read the session: ${error.message}`);
5663
+ if (!data || createdBy === null || data.owner_id !== createdBy) {
5664
+ throw new Error("VALIDATION: forkSession source is not a session you own on this runner");
5665
+ }
5666
+ let sourceCwd = (await this.recordedWorktree(data.id))?.real ?? null;
5667
+ if (sourceCwd === null) {
5668
+ try {
5669
+ sourceCwd = resolveInsideRoots(data.cwd, this.deps.roots).real;
5670
+ } catch {
5671
+ sourceCwd = null;
5672
+ }
5673
+ }
5674
+ if (sourceCwd !== cwd) {
5675
+ throw new Error("VALIDATION: a forked session keeps its working directory");
5676
+ }
5677
+ }
4750
5678
  /**
4751
5679
  * Whether this runner may open a session on `provider` for `createdBy`.
4752
5680
  *
@@ -4797,7 +5725,7 @@ var init_manager = __esm({
4797
5725
  }
4798
5726
  /** A path as the SDK reads it: relative means relative to the session's cwd. */
4799
5727
  fromCwd(cwd, value) {
4800
- return isAbsolute9(value) ? value : `${cwd}${sep5}${value}`;
5728
+ return isAbsolute10(value) ? value : `${cwd}${sep5}${value}`;
4801
5729
  }
4802
5730
  /**
4803
5731
  * §2.9: a path in `updatedInput` passes the same boundary the session's `cwd` did, and the
@@ -5362,6 +6290,11 @@ var init_manager = __esm({
5362
6290
  }
5363
6291
  message = envelope(shrunk);
5364
6292
  }
6293
+ try {
6294
+ this.deps.tap?.(sessionId, message.payload);
6295
+ } catch (err) {
6296
+ this.deps.log.warn({ err, sessionId, type: event.type }, "session event tap threw");
6297
+ }
5365
6298
  const result = await live.channel.send(message);
5366
6299
  if (result !== "ok") {
5367
6300
  this.deps.log.warn(
@@ -5476,7 +6409,7 @@ var init_channels = __esm({
5476
6409
  // src/terminal/env.ts
5477
6410
  import { readFileSync as readFileSync7, realpathSync as realpathSync8 } from "fs";
5478
6411
  import { lstat } from "fs/promises";
5479
- import { dirname as dirname7, isAbsolute as isAbsolute10, relative as relative4 } from "path";
6412
+ import { dirname as dirname7, isAbsolute as isAbsolute11, relative as relative4 } from "path";
5480
6413
  function terminalEnv(base, { runnerOwner, claudeConfigDir }) {
5481
6414
  const env = {};
5482
6415
  for (const [key, value] of Object.entries(base)) {
@@ -5546,7 +6479,7 @@ function tryRealpath(fs, p) {
5546
6479
  }
5547
6480
  function inside(candidate, root) {
5548
6481
  const rel = relative4(root, candidate);
5549
- return rel === "" || !rel.startsWith("..") && !isAbsolute10(rel);
6482
+ return rel === "" || !rel.startsWith("..") && !isAbsolute11(rel);
5550
6483
  }
5551
6484
  var SECRET_ENV, MEMBER_ENV_ALLOW, MEMBER_ENV_KEYS, LOCALE_ENV, nodeShellFs;
5552
6485
  var init_env = __esm({
@@ -5753,6 +6686,7 @@ var init_manager2 = __esm({
5753
6686
  constructor(deps) {
5754
6687
  this.deps = deps;
5755
6688
  this.flushMs = deps.flushMs ?? TERMINAL_FLUSH_MS;
6689
+ this.localFlushMs = deps.localFlushMs ?? TERMINAL_LOCAL_FLUSH_MS;
5756
6690
  this.flushBytes = deps.flushBytes ?? TERMINAL_FLUSH_BYTES;
5757
6691
  this.ringBytes = deps.ringBytes ?? TERMINAL_RING_BYTES;
5758
6692
  this.reaper = setInterval(() => this.reap(), deps.reapIntervalMs ?? TERMINAL_REAP_INTERVAL_MS);
@@ -5769,6 +6703,7 @@ var init_manager2 = __esm({
5769
6703
  opens = /* @__PURE__ */ new Set();
5770
6704
  reaper;
5771
6705
  flushMs;
6706
+ localFlushMs;
5772
6707
  flushBytes;
5773
6708
  ringBytes;
5774
6709
  /** Set once by `shutdown()` (fix round 1 L7): `open()` checks it after every await. */
@@ -6092,9 +7027,14 @@ var init_manager2 = __esm({
6092
7027
  return;
6093
7028
  }
6094
7029
  if (term.flushTimer) return;
6095
- let others = 0;
6096
- for (const t of this.terms.values()) if (t.flushTimer !== null) others += 1;
6097
- const delay2 = others > 0 ? this.flushMs * 2 : this.flushMs;
7030
+ let delay2 = this.localFlushMs;
7031
+ if (!this.relaySuppressed(terminalId)) {
7032
+ let others = 0;
7033
+ for (const [id, t] of this.terms) {
7034
+ if (t.flushTimer !== null && !this.relaySuppressed(id)) others += 1;
7035
+ }
7036
+ delay2 = others > 0 ? this.flushMs * 2 : this.flushMs;
7037
+ }
6098
7038
  term.flushTimer = setTimeout(() => {
6099
7039
  term.flushTimer = null;
6100
7040
  this.flush(terminalId, term);
@@ -6635,13 +7575,42 @@ async function compose(cfg, lock, host, preflight) {
6635
7575
  const pty = await loadPty();
6636
7576
  const memberTerminalIds = memberTerminalIdsOf(cfg);
6637
7577
  const kinds2 = handledKinds({ terminal: pty !== null });
7578
+ const localIdentity = createLocalIdentity();
7579
+ const origins = buildOrigins(cfg.localOrigins);
7580
+ if (CLOUD_SUPABASE_URL !== "" && CLOUD_APP_ORIGIN === "") {
7581
+ logger.warn(
7582
+ { origins },
7583
+ "local origins: dev origins enabled because CAYENNE_CLOUD_APP_ORIGIN is empty"
7584
+ );
7585
+ }
7586
+ logger.info({ origins }, "local origins");
7587
+ const local = createLocalServer({ port: cfg.port, origins, log: logger });
7588
+ let localPort = 0;
7589
+ if (cfg.port < LOCAL_PORT_MIN || cfg.port > LOCAL_PORT_MAX) {
7590
+ logger.warn(
7591
+ { port: cfg.port },
7592
+ `local port ${cfg.port} is outside ${LOCAL_PORT_MIN}\u2013${LOCAL_PORT_MAX}; fast path disabled for this run`
7593
+ );
7594
+ } else {
7595
+ try {
7596
+ localPort = await local.listen();
7597
+ } catch (err) {
7598
+ logger.warn(
7599
+ { port: cfg.port, err: err instanceof Error ? err.message : String(err) },
7600
+ "local server could not bind; fast path disabled for this run"
7601
+ );
7602
+ }
7603
+ }
6638
7604
  const capabilities = () => ({
6639
7605
  kinds: [...kinds2],
6640
- local_port: cfg.port,
7606
+ local_port: localPort,
7607
+ local_pubkey: localIdentity.publicKey,
6641
7608
  claude: true,
6642
7609
  providers,
6643
7610
  // Who the runner's own config names (D34); every heartbeat refresh republishes it.
6644
- memberTerminalIds: [...memberTerminalIds]
7611
+ memberTerminalIds: [...memberTerminalIds],
7612
+ // D36: this build forks a Claude session on `session.start { forkSession: true }`.
7613
+ sessionFork: true
6645
7614
  });
6646
7615
  const registered = capabilities();
6647
7616
  let loggedCodex = null;
@@ -6654,17 +7623,27 @@ async function compose(cfg, lock, host, preflight) {
6654
7623
  }
6655
7624
  return capabilities();
6656
7625
  };
6657
- const identity = await registerRunner({
6658
- db,
6659
- cfg: registerAs === cfg.runnerId ? cfg : { ...cfg, runnerId: void 0 },
6660
- hostname: hostname(),
6661
- profile,
6662
- platform: `${process.platform}-${process.arch}`,
6663
- version: VERSION,
6664
- providers: registered.providers,
6665
- kinds: kinds2
6666
- });
6667
- lock.take(identity.runnerId);
7626
+ let identity;
7627
+ try {
7628
+ identity = await registerRunner({
7629
+ db,
7630
+ cfg: registerAs === cfg.runnerId ? cfg : { ...cfg, runnerId: void 0 },
7631
+ hostname: hostname(),
7632
+ profile,
7633
+ platform: `${process.platform}-${process.arch}`,
7634
+ version: VERSION,
7635
+ capabilities: registered
7636
+ });
7637
+ } catch (err) {
7638
+ await local.close();
7639
+ throw err;
7640
+ }
7641
+ try {
7642
+ lock.take(identity.runnerId);
7643
+ } catch (err) {
7644
+ await local.close();
7645
+ throw err;
7646
+ }
6668
7647
  let stopHeartbeat = null;
6669
7648
  let roster = null;
6670
7649
  try {
@@ -6699,11 +7678,14 @@ async function compose(cfg, lock, host, preflight) {
6699
7678
  codexAvailable: () => providers.codex.available,
6700
7679
  claudeEnv: claudeEnv(claudeConfigDir),
6701
7680
  git,
6702
- log: logger
7681
+ log: logger,
7682
+ // Every session event also reaches the loopback sockets subscribed to it (Task 10a).
7683
+ tap: (sessionId, event) => local.emit(sessionId, event)
6703
7684
  });
6704
7685
  let terminals;
7686
+ let terminalManager = null;
6705
7687
  if (pty) {
6706
- const terminalManager = new TerminalManager({
7688
+ const tm = new TerminalManager({
6707
7689
  pty,
6708
7690
  roots,
6709
7691
  env: process.env,
@@ -6726,16 +7708,17 @@ async function compose(cfg, lock, host, preflight) {
6726
7708
  memberTerminalIds,
6727
7709
  serves: (userId) => liveRoster.serves(userId),
6728
7710
  rosterHealthy: () => liveRoster.healthy(),
6729
- manager: terminalManager,
6730
- channels: createTerminalChannels({ db, manager: terminalManager, log: logger }),
7711
+ manager: tm,
7712
+ channels: createTerminalChannels({ db, manager: tm, log: logger }),
6731
7713
  log: logger
6732
7714
  });
6733
7715
  liveRoster.onRefresh(() => {
6734
- terminalManager.revalidate();
7716
+ tm.revalidate();
6735
7717
  void service2.reconcileRows();
6736
7718
  });
6737
7719
  await service2.closeOpenRows();
6738
7720
  terminals = service2;
7721
+ terminalManager = tm;
6739
7722
  }
6740
7723
  const dispatch = createDispatcher(
6741
7724
  buildHandlers(manager, {
@@ -6758,6 +7741,43 @@ async function compose(cfg, lock, host, preflight) {
6758
7741
  );
6759
7742
  stopHeartbeat = heartbeat;
6760
7743
  await source.start();
7744
+ if (localPort !== 0) {
7745
+ local.attach({
7746
+ runnerId: identity.runnerId,
7747
+ version: VERSION,
7748
+ identity: localIdentity,
7749
+ // R-B4: a token whose subject the roster does not serve (or whose payload is malformed or
7750
+ // expired) is refused inside the verifier, before any GoTrue call — junk on the loopback
7751
+ // port cannot rate-limit the real member's `/user` check.
7752
+ verifier: createTokenVerifier({
7753
+ db,
7754
+ admit: (sub) => liveRoster.serves(sub),
7755
+ log: logger
7756
+ }),
7757
+ // The roster's own predicate, read live: the owner while a member, listed members while
7758
+ // still members (PR16); every refresh re-checks every socket (SEC-P2).
7759
+ serves: (userId) => liveRoster.serves(userId),
7760
+ onRosterRefresh: (cb) => liveRoster.onRefresh(cb),
7761
+ // Exactly the manager's rule (ruling 14, R2-H1), built from the same deps it was given.
7762
+ mayHoldTerminal: (userId) => mayHoldTerminal(userId, {
7763
+ runnerUserId: identity.userId,
7764
+ memberTerminalIds,
7765
+ serves: (u) => liveRoster.serves(u),
7766
+ rosterHealthy: () => liveRoster.healthy()
7767
+ }),
7768
+ source,
7769
+ readRow: async (id) => {
7770
+ const { data } = await db.from("runner_commands").select("*").eq("id", id).maybeSingle();
7771
+ const parsed = CommandRow.safeParse(data);
7772
+ return parsed.success ? parsed.data : null;
7773
+ },
7774
+ readSession: async (id) => {
7775
+ const { data } = await db.from("chat_sessions").select("runner_id, owner_id, visibility").eq("id", id).maybeSingle();
7776
+ return data;
7777
+ },
7778
+ terminals: terminalManager
7779
+ });
7780
+ }
6761
7781
  logger.info(
6762
7782
  {
6763
7783
  runnerId: identity.runnerId,
@@ -6792,6 +7812,7 @@ async function compose(cfg, lock, host, preflight) {
6792
7812
  await stage("terminals.shutdown", async () => {
6793
7813
  await terminals?.shutdown();
6794
7814
  });
7815
+ await stage("local.close", () => local.close());
6795
7816
  await stage("roster.stop", () => liveRoster.stop());
6796
7817
  if (failures.length > 0) throw failures[0];
6797
7818
  } finally {
@@ -6858,6 +7879,7 @@ async function compose(cfg, lock, host, preflight) {
6858
7879
  "runner roster stop failed during a failed start"
6859
7880
  );
6860
7881
  }
7882
+ await local.close().catch(() => void 0);
6861
7883
  await markOffline(db, identity.runnerId);
6862
7884
  throw err;
6863
7885
  }
@@ -6892,6 +7914,7 @@ var init_start = __esm({
6892
7914
  "use strict";
6893
7915
  init_src();
6894
7916
  init_claude_dir();
7917
+ init_cloud();
6895
7918
  init_detect();
6896
7919
  init_dispatcher();
6897
7920
  init_source();
@@ -6899,6 +7922,10 @@ var init_start = __esm({
6899
7922
  init_ops();
6900
7923
  init_session_worktrees();
6901
7924
  init_worktree_owners();
7925
+ init_auth();
7926
+ init_identity();
7927
+ init_origins();
7928
+ init_server();
6902
7929
  init_lock();
6903
7930
  init_log();
6904
7931
  init_profile();
@@ -6944,6 +7971,9 @@ async function logout(db) {
6944
7971
  await db.auth.signOut();
6945
7972
  }
6946
7973
 
7974
+ // src/cli.ts
7975
+ init_cloud();
7976
+
6947
7977
  // src/commands/doctor.ts
6948
7978
  async function probeBackend(url, anonKey, fetchFn = fetch) {
6949
7979
  try {
@@ -7022,6 +8052,19 @@ async function doctor(deps) {
7022
8052
  fix: TERMINAL_FIX
7023
8053
  }
7024
8054
  );
8055
+ const devOriginsInPublishedBuild = deps.cloud.supabaseUrl !== "" && deps.cloud.appOrigin === "";
8056
+ checks.push(
8057
+ devOriginsInPublishedBuild ? {
8058
+ name: "local origins",
8059
+ ok: false,
8060
+ detail: "local origins: dev origins enabled because CAYENNE_CLOUD_APP_ORIGIN is empty",
8061
+ fix: "Rebuild the runner with CAYENNE_CLOUD_APP_ORIGIN set to the app origin (tsup.config.ts `define`)."
8062
+ } : {
8063
+ name: "local origins",
8064
+ ok: true,
8065
+ detail: deps.cloud.appOrigin || "source checkout: dev origins"
8066
+ }
8067
+ );
7025
8068
  return checks;
7026
8069
  }
7027
8070
 
@@ -7495,7 +8538,8 @@ program.command("doctor").description("Check the install and say how to fix what
7495
8538
  probe: (url) => probeBackend(url, backend?.anonKey ?? ""),
7496
8539
  runnerId,
7497
8540
  holder: runnerId ? holderOf({ root: cayenneHome(), runnerId }) : null,
7498
- pty: await loadPty() !== null
8541
+ pty: await loadPty() !== null,
8542
+ cloud: { appOrigin: CLOUD_APP_ORIGIN, supabaseUrl: CLOUD_SUPABASE_URL }
7499
8543
  });
7500
8544
  for (const c of checks) {
7501
8545
  console.log(`${c.ok ? "ok " : "FAIL"} ${c.name}: ${c.detail}`);