@jpcasabiancai/cayenne 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1122 -171
  2. package/package.json +3 -1
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
 
@@ -487,34 +527,104 @@ var init_events = __esm({
487
527
  }
488
528
  });
489
529
 
490
- // ../../packages/shared/src/protocol/runner.ts
530
+ // ../../packages/shared/src/protocol/local.ts
491
531
  import { z as z7 } from "zod";
532
+ function parseLocalTopic(topic) {
533
+ const m = LOCAL_TOPIC_RE.exec(topic);
534
+ if (!m?.[1] || !m[2]) return null;
535
+ return { kind: m[1], id: m[2] };
536
+ }
537
+ function localProofMessage(nonce, runnerId, origin) {
538
+ return utf82.encode(`cayenne-local-v1
539
+ ${nonce}
540
+ ${runnerId}
541
+ ${origin}`);
542
+ }
543
+ 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;
544
+ var init_local = __esm({
545
+ "../../packages/shared/src/protocol/local.ts"() {
546
+ "use strict";
547
+ init_command();
548
+ init_events();
549
+ init_terminal();
550
+ LOCAL_HEALTH_PATH = "/health";
551
+ LOCAL_WS_PATH = "/ws";
552
+ LOCAL_CLOSE_NOT_READY = 1013;
553
+ LOCAL_CLOSE_BAD_HANDSHAKE = 4400;
554
+ LOCAL_CLOSE_UNAUTHORIZED = 4401;
555
+ LOCAL_CLOSE_TOO_MANY = 4429;
556
+ Base64Url = z7.string().regex(/^[A-Za-z0-9_-]+$/);
557
+ UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
558
+ LOCAL_TOPIC_RE = new RegExp(`^(session|terminal):(${UUID})$`);
559
+ LOCAL_SESSION_TOPIC_RE = new RegExp(`^session:${UUID}$`);
560
+ LocalTopic = z7.string().regex(LOCAL_TOPIC_RE);
561
+ LocalSessionTopic = z7.string().regex(LOCAL_SESSION_TOPIC_RE);
562
+ LocalHealth = z7.object({
563
+ runnerId: z7.uuid(),
564
+ protocolVersion: z7.number().int(),
565
+ version: z7.string()
566
+ });
567
+ utf82 = new TextEncoder();
568
+ LocalClientFrame = z7.discriminatedUnion("t", [
569
+ z7.object({ t: z7.literal("hello"), nonce: Base64Url.length(43) }),
570
+ z7.object({ t: z7.literal("auth"), token: z7.string().min(1).max(8192) }),
571
+ z7.object({ t: z7.literal("cmd"), id: z7.uuid() }),
572
+ z7.object({ t: z7.literal("term"), terminalId: z7.uuid(), m: TerminalClientMessage }),
573
+ z7.object({ t: z7.literal("sub"), topic: LocalTopic }),
574
+ z7.object({ t: z7.literal("unsub"), topic: LocalTopic })
575
+ ]);
576
+ LocalRunnerFrame = z7.discriminatedUnion("t", [
577
+ z7.object({
578
+ t: z7.literal("hello"),
579
+ runnerId: z7.uuid(),
580
+ protocolVersion: z7.number().int(),
581
+ sig: Base64Url.length(86)
582
+ }),
583
+ z7.object({ t: z7.literal("ready"), userId: z7.uuid() }),
584
+ z7.object({ t: z7.literal("ack"), id: z7.uuid() }),
585
+ z7.object({
586
+ t: z7.literal("res"),
587
+ id: z7.uuid(),
588
+ status: CommandStatus.extract(["done", "failed", "cancelled"]),
589
+ result: z7.unknown().nullable(),
590
+ error: z7.string().nullable()
591
+ }),
592
+ z7.object({ t: z7.literal("cmd.err"), id: z7.uuid(), error: z7.string() }),
593
+ z7.object({ t: z7.literal("evt"), topic: LocalSessionTopic, e: SessionEvent }),
594
+ z7.object({ t: z7.literal("term"), terminalId: z7.uuid(), m: TerminalRunnerMessage }),
595
+ z7.object({ t: z7.literal("sub.err"), topic: LocalTopic, error: z7.string() })
596
+ ]);
597
+ }
598
+ });
599
+
600
+ // ../../packages/shared/src/protocol/runner.ts
601
+ import { z as z8 } from "zod";
492
602
  var ProviderModel, ProviderCapability, UNAVAILABLE, RunnerCapabilities, CODEX_TOOL_NAMES;
493
603
  var init_runner = __esm({
494
604
  "../../packages/shared/src/protocol/runner.ts"() {
495
605
  "use strict";
496
606
  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(),
607
+ ProviderModel = z8.object({
608
+ id: z8.string().min(1).max(200),
609
+ label: z8.string().min(1).max(200),
610
+ default: z8.boolean().optional(),
501
611
  /** From the app-server's `supportedReasoningEfforts`; empty means the control is hidden. */
502
- efforts: z7.array(Effort).default([]),
612
+ efforts: z8.array(Effort).default([]),
503
613
  /** The effort a new chat on this model opens at, sent back on `session.start`. */
504
614
  defaultEffort: Effort.optional()
505
615
  });
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([])
616
+ ProviderCapability = z8.object({
617
+ available: z8.boolean(),
618
+ reason: z8.enum(["not_installed", "not_logged_in", "error"]).optional(),
619
+ version: z8.string().max(50).optional(),
620
+ models: z8.array(ProviderModel).max(50).default([])
511
621
  });
512
622
  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({
623
+ RunnerCapabilities = z8.object({
624
+ kinds: z8.array(z8.string()),
625
+ local_port: z8.number().int(),
626
+ claude: z8.boolean(),
627
+ providers: z8.object({
518
628
  claude: ProviderCapability.default({ available: true, models: [] }),
519
629
  codex: ProviderCapability.default(UNAVAILABLE)
520
630
  }).default({ claude: { available: true, models: [] }, codex: UNAVAILABLE }),
@@ -523,7 +633,13 @@ var init_runner = __esm({
523
633
  * config `allowMemberTerminals` names (protocol §9). Absent reads as none; a workspace admin
524
634
  * cannot grant a terminal. Optional so an older row still parses.
525
635
  */
526
- memberTerminalIds: z7.array(z7.uuid()).max(64).optional()
636
+ memberTerminalIds: z8.array(z8.uuid()).max(64).optional(),
637
+ /**
638
+ * The raw Ed25519 public key of the running process (32 bytes, unpadded base64url), which signs
639
+ * the local server's `hello` (protocol §6.2). New on every start, republished by every capabilities refresh that changes the row;
640
+ * a client that finds it absent never opens the fast path. Optional so an older row still parses.
641
+ */
642
+ local_pubkey: z8.string().regex(/^[A-Za-z0-9_-]{43}$/).optional()
527
643
  });
528
644
  CODEX_TOOL_NAMES = {
529
645
  command: "codex.command_execution",
@@ -550,6 +666,7 @@ var init_protocol = __esm({
550
666
  init_events();
551
667
  init_git();
552
668
  init_github();
669
+ init_local();
553
670
  init_runner();
554
671
  init_terminal();
555
672
  init_transport();
@@ -652,31 +769,6 @@ var init_claude_dir = __esm({
652
769
  }
653
770
  });
654
771
 
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
772
  // src/profile.ts
681
773
  import { join as join2 } from "path";
682
774
  function assertProfileName(name) {
@@ -725,7 +817,7 @@ import {
725
817
  } from "fs";
726
818
  import { homedir as homedir2 } from "os";
727
819
  import { dirname, isAbsolute as isAbsolute2, join as join3 } from "path";
728
- import { z as z8 } from "zod";
820
+ import { z as z9 } from "zod";
729
821
  function setProfileOverride(name) {
730
822
  profileOverride = name;
731
823
  }
@@ -800,22 +892,24 @@ var init_config = __esm({
800
892
  init_cloud();
801
893
  init_profile();
802
894
  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),
895
+ RunnerConfig = z9.object({
896
+ supabaseUrl: z9.url(),
897
+ supabaseAnonKey: z9.string().min(1),
898
+ workspaceId: z9.uuid().optional(),
899
+ runnerId: z9.uuid().optional(),
900
+ name: z9.string().optional(),
901
+ roots: z9.array(z9.string()).default([]),
902
+ port: z9.number().int().default(LOCAL_PORT),
903
+ /** Extra page origins the local server admits, beside the built-in list (`buildOrigins`). */
904
+ localOrigins: z9.array(z9.string()).default([]),
905
+ allowBypassPermissions: z9.boolean().default(false),
812
906
  /** Absolute path to the `codex` binary, when it is not on the daemon's PATH. */
813
- codexPath: z8.string().optional(),
907
+ codexPath: z9.string().optional(),
814
908
  /**
815
909
  * The Claude Code config directory this profile's sessions run on (D33), handed to them as
816
910
  * `CLAUDE_CONFIG_DIR`. Unset means the CLI's own default, `~/.claude`.
817
911
  */
818
- claudeConfigDir: z8.string().optional(),
912
+ claudeConfigDir: z9.string().optional(),
819
913
  /**
820
914
  * Who besides the runner owner may hold a terminal here (D34, plan ruling 14): `false`, or the
821
915
  * user ids this machine's owner names. Machine-local like `allowBypassPermissions`: a workspace
@@ -823,15 +917,15 @@ var init_config = __esm({
823
917
  * `true` is refused — there is no "every member" switch. A listed member's shell runs as this
824
918
  * machine's OS user; the env allowlist is not a boundary.
825
919
  */
826
- allowMemberTerminals: z8.union(
920
+ allowMemberTerminals: z9.union(
827
921
  [
828
- z8.literal(false),
829
- z8.array(z8.uuid({ error: MEMBER_TERMINALS_ERROR }), { error: MEMBER_TERMINALS_ERROR }).max(64, { error: MEMBER_TERMINALS_ERROR })
922
+ z9.literal(false),
923
+ z9.array(z9.uuid({ error: MEMBER_TERMINALS_ERROR }), { error: MEMBER_TERMINALS_ERROR }).max(64, { error: MEMBER_TERMINALS_ERROR })
830
924
  ],
831
925
  { error: MEMBER_TERMINALS_ERROR }
832
926
  ).default(false),
833
927
  /** 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)
928
+ terminalIdleMinutes: z9.number().int().min(1).max(1440).default(10)
835
929
  });
836
930
  memberTerminalIdsOf = (cfg) => cfg.allowMemberTerminals === false ? [] : [...cfg.allowMemberTerminals];
837
931
  configPath = (dir) => join3(dir, "config.json");
@@ -911,24 +1005,12 @@ function grantedColumns(deps) {
911
1005
  protocol_version: PROTOCOL_VERSION,
912
1006
  status: "online",
913
1007
  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
1008
  // Parsed, not merely typed: this row is the writer and every client's `RunnerCapabilities`
920
1009
  // parse is the reader. A value the reader rejects fails that parse *whole*, so one bad model
921
1010
  // 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
- }),
1011
+ // loud, on one machine, at the moment the value is produced. The parse also returns a copy, so
1012
+ // the composer's arrays are never handed to a serialiser that could mutate them.
1013
+ capabilities: RunnerCapabilities.parse(deps.capabilities),
932
1014
  // The sandbox boundary from the config, resolved and never widened here.
933
1015
  allowed_roots: publishedRoots(cfg.roots)
934
1016
  };
@@ -1088,7 +1170,7 @@ import {
1088
1170
  } from "fs";
1089
1171
  import { uptime } from "os";
1090
1172
  import { join as join5 } from "path";
1091
- import { z as z9 } from "zod";
1173
+ import { z as z10 } from "zod";
1092
1174
  function bootedAt(now = Date.now()) {
1093
1175
  return new Date(Math.round((now - uptime() * 1e3) / 1e3) * 1e3).toISOString();
1094
1176
  }
@@ -1231,13 +1313,13 @@ var LockRecord, BOOT_DRIFT_MS, ATTEMPTS, BACKOFF_MS, CLAIM_TTL_MS, errno, claimO
1231
1313
  var init_lock = __esm({
1232
1314
  "src/lock.ts"() {
1233
1315
  "use strict";
1234
- LockRecord = z9.object({
1235
- pid: z9.number().int().positive(),
1236
- profile: z9.string(),
1237
- startedAt: z9.string(),
1316
+ LockRecord = z10.object({
1317
+ pid: z10.number().int().positive(),
1318
+ profile: z10.string(),
1319
+ startedAt: z10.string(),
1238
1320
  /** `bootedAt()` when the lock was taken; a lock from another boot is stale whatever its pid. */
1239
- bootedAt: z9.string(),
1240
- version: z9.string()
1321
+ bootedAt: z10.string(),
1322
+ version: z10.string()
1241
1323
  });
1242
1324
  BOOT_DRIFT_MS = 1e4;
1243
1325
  ATTEMPTS = 8;
@@ -1339,6 +1421,44 @@ var init_version = __esm({
1339
1421
  }
1340
1422
  });
1341
1423
 
1424
+ // src/exec-path.ts
1425
+ import { accessSync, constants, statSync as statSync3 } from "fs";
1426
+ import { delimiter, isAbsolute as isAbsolute4, join as join8 } from "path";
1427
+ function findOnPath(name, path) {
1428
+ for (const dir of (path ?? "").split(delimiter)) {
1429
+ if (!isAbsolute4(dir)) continue;
1430
+ const candidate = join8(dir, name);
1431
+ try {
1432
+ if (!statSync3(candidate).isFile()) continue;
1433
+ accessSync(candidate, constants.X_OK);
1434
+ return candidate;
1435
+ } catch {
1436
+ }
1437
+ }
1438
+ return null;
1439
+ }
1440
+ function makeLocator(name, locate = findOnPath) {
1441
+ const located = /* @__PURE__ */ new Map();
1442
+ return {
1443
+ find(path) {
1444
+ const key = path ?? "";
1445
+ const cached = located.get(key);
1446
+ if (cached !== void 0) return cached;
1447
+ const found = locate(name, path);
1448
+ if (found !== null) located.set(key, found);
1449
+ return found;
1450
+ },
1451
+ forget(path) {
1452
+ located.delete(path ?? "");
1453
+ }
1454
+ };
1455
+ }
1456
+ var init_exec_path = __esm({
1457
+ "src/exec-path.ts"() {
1458
+ "use strict";
1459
+ }
1460
+ });
1461
+
1342
1462
  // src/codex/rpc.ts
1343
1463
  var RpcError, JsonRpc;
1344
1464
  var init_rpc = __esm({
@@ -1446,20 +1566,9 @@ var init_rpc = __esm({
1446
1566
 
1447
1567
  // src/codex/detect.ts
1448
1568
  import { execFile as execFile2, spawn } from "child_process";
1449
- import { accessSync, constants } from "fs";
1450
- import { delimiter, join as join8 } from "path";
1451
1569
  import { promisify } from "util";
1452
1570
  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;
1571
+ return findOnPath("codex", env.PATH);
1463
1572
  }
1464
1573
  function resolveCodexPath(cfg, env, onPath) {
1465
1574
  return env.CAYENNE_CODEX_PATH ?? cfg.codexPath ?? onPath();
@@ -1536,6 +1645,7 @@ var init_detect = __esm({
1536
1645
  "src/codex/detect.ts"() {
1537
1646
  "use strict";
1538
1647
  init_src();
1648
+ init_exec_path();
1539
1649
  init_rpc();
1540
1650
  exec = promisify(execFile2);
1541
1651
  PROBE_TIMEOUT_MS = 5e3;
@@ -1564,11 +1674,12 @@ var init_dispatcher = __esm({
1564
1674
 
1565
1675
  // src/commands/source.ts
1566
1676
  import { LRUCache } from "lru-cache";
1567
- var byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
1677
+ var STOPPING, byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
1568
1678
  var init_source = __esm({
1569
1679
  "src/commands/source.ts"() {
1570
1680
  "use strict";
1571
1681
  init_src();
1682
+ STOPPING = "runner stopping";
1572
1683
  byCreatedAt = (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id.localeCompare(b.id);
1573
1684
  GIT_READ_KINDS = /* @__PURE__ */ new Set(["git.status", "git.diff", "git.branches"]);
1574
1685
  GIT_WORKTREE_KINDS = /* @__PURE__ */ new Set([
@@ -1598,6 +1709,10 @@ var init_source = __esm({
1598
1709
  dispatch;
1599
1710
  log;
1600
1711
  seen = new LRUCache({ max: 1e3 });
1712
+ /** Local-socket hooks per row id; dropped once fired, on `stop()`, or by the function `offer` returns. */
1713
+ hooks = /* @__PURE__ */ new Map();
1714
+ /** Rows between `enqueue` and their settlement: an `offer` for one of these registers hooks only. */
1715
+ inflight = /* @__PURE__ */ new Set();
1601
1716
  queues = /* @__PURE__ */ new Map();
1602
1717
  channel = null;
1603
1718
  staleMs;
@@ -1664,6 +1779,38 @@ var init_source = __esm({
1664
1779
  this.pending = [];
1665
1780
  await this.draining;
1666
1781
  while (this.queues.size > 0) await Promise.allSettled([...this.queues.values()]);
1782
+ for (const id of [...this.hooks.keys()]) this.dropHooks(id, STOPPING);
1783
+ }
1784
+ /**
1785
+ * A pointer that arrived over the local socket (§6.2; SEC-P12, SP16). The same admission a
1786
+ * broadcast gets — dedupe by id, conditional ack, the same lane — so a row the relay already
1787
+ * delivered runs once, and a `terminal.open` pointer serialises behind the relay's copy in the
1788
+ * `terminal:<id>` lane. Hooks are registered either way so the socket hears the outcome. A row
1789
+ * this source already settled (or never claimed) is answered from the row itself, never from a
1790
+ * guess (PR15). Returns the function that drops these hooks; the connection calls it on close.
1791
+ */
1792
+ offer(row, hooks) {
1793
+ if (this.stopped) {
1794
+ hooks.onDropped?.(STOPPING);
1795
+ return () => {
1796
+ };
1797
+ }
1798
+ const list = this.hooks.get(row.id) ?? [];
1799
+ list.push(hooks);
1800
+ this.hooks.set(row.id, list);
1801
+ if (!this.seen.has(row.id)) {
1802
+ if (this.draining) this.pending.push(row);
1803
+ else this.enqueue(row);
1804
+ } else if (!this.inflight.has(row.id)) {
1805
+ void this.settleFromRow(row.id);
1806
+ }
1807
+ return () => {
1808
+ const current = this.hooks.get(row.id);
1809
+ if (!current) return;
1810
+ const at = current.indexOf(hooks);
1811
+ if (at >= 0) current.splice(at, 1);
1812
+ if (current.length === 0) this.hooks.delete(row.id);
1813
+ };
1667
1814
  }
1668
1815
  /**
1669
1816
  * Claim the row now; queue only its execution.
@@ -1681,6 +1828,7 @@ var init_source = __esm({
1681
1828
  if (this.stopped) return;
1682
1829
  if (this.seen.has(row.id)) return;
1683
1830
  this.seen.set(row.id, true);
1831
+ this.inflight.add(row.id);
1684
1832
  const ageMs = this.now() - Date.parse(row.created_at);
1685
1833
  const admitted = this.admit(row, ageMs);
1686
1834
  const key = laneOf(row);
@@ -1707,6 +1855,7 @@ var init_source = __esm({
1707
1855
  if (row.flow_run_step_id === null && ageMs > this.staleMs) {
1708
1856
  const { error: error2 } = await this.finish(row.id, "cancelled", null, "STALE");
1709
1857
  if (error2) this.release(row.id, error2.message, "stale cancel failed");
1858
+ else this.settle(row.id, { status: "cancelled", result: null, error: "STALE" });
1710
1859
  return "skip";
1711
1860
  }
1712
1861
  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 +1865,10 @@ var init_source = __esm({
1716
1865
  }
1717
1866
  if (!data || data.length === 0) {
1718
1867
  this.log.info({ id: row.id }, "command no longer queued; not dispatching");
1868
+ await this.settleFromRow(row.id);
1719
1869
  return "skip";
1720
1870
  }
1871
+ for (const h of this.hooks.get(row.id) ?? []) h.onAck?.();
1721
1872
  return "dispatch";
1722
1873
  } catch (err) {
1723
1874
  this.release(row.id, err instanceof Error ? err.message : String(err), "ack failed");
@@ -1734,17 +1885,29 @@ var init_source = __esm({
1734
1885
  */
1735
1886
  release(id, err, msg) {
1736
1887
  this.seen.delete(id);
1888
+ this.inflight.delete(id);
1737
1889
  this.log.warn({ id, err }, msg);
1738
1890
  }
1739
1891
  async run(row, admitted) {
1740
1892
  if (await admitted !== "dispatch") return;
1893
+ let answered = false;
1894
+ try {
1895
+ await this.runAndFinish(row);
1896
+ answered = true;
1897
+ } finally {
1898
+ if (!answered) await this.settleFromRow(row.id);
1899
+ }
1900
+ }
1901
+ async runAndFinish(row) {
1741
1902
  let status = "done";
1742
1903
  let finished;
1904
+ let result = null;
1905
+ let message = null;
1743
1906
  try {
1744
- const result = await this.dispatch(row);
1907
+ result = await this.dispatch(row);
1745
1908
  finished = await this.finish(row.id, "done", result ?? null, null);
1746
1909
  } catch (err) {
1747
- const message = err instanceof Error ? err.message : String(err);
1910
+ message = err instanceof Error ? err.message : String(err);
1748
1911
  this.log.warn({ id: row.id, kind: row.kind, err: message }, "command failed");
1749
1912
  status = "failed";
1750
1913
  finished = await this.finish(row.id, status, null, message);
@@ -1755,6 +1918,52 @@ var init_source = __esm({
1755
1918
  "command finish write refused"
1756
1919
  );
1757
1920
  }
1921
+ if (finished.error) await this.settleFromRow(row.id);
1922
+ else {
1923
+ this.settle(row.id, {
1924
+ status,
1925
+ result: status === "done" ? result ?? null : null,
1926
+ error: status === "done" ? null : message
1927
+ });
1928
+ }
1929
+ }
1930
+ settle(id, r) {
1931
+ this.inflight.delete(id);
1932
+ const list = this.hooks.get(id);
1933
+ if (!list) return;
1934
+ this.hooks.delete(id);
1935
+ for (const h of list) h.onFinished?.(r);
1936
+ }
1937
+ /**
1938
+ * The outcome of a row this source did not finish itself — cancelled by its creator between the
1939
+ * socket's read and our ack, claimed by another process, or settled before the pointer arrived.
1940
+ * Read once; answered only when the row is terminal, otherwise the hooks are dropped and the
1941
+ * client's relay wait sees the row (PR15: never invent a status).
1942
+ */
1943
+ async settleFromRow(id) {
1944
+ this.inflight.delete(id);
1945
+ if (!this.hooks.has(id)) return;
1946
+ let reason = "row is not terminal";
1947
+ try {
1948
+ const { data } = await this.db.from("runner_commands").select("status, result, error").eq("id", id).maybeSingle();
1949
+ const status = data?.status;
1950
+ if (status === "done" || status === "failed" || status === "cancelled") {
1951
+ this.settle(id, { status, result: data?.result ?? null, error: data?.error ?? null });
1952
+ return;
1953
+ }
1954
+ if (!data) reason = "row not found";
1955
+ } catch (err) {
1956
+ this.log.warn({ id, err }, "could not re-read a command for its local pointer");
1957
+ reason = "row re-read failed";
1958
+ }
1959
+ this.dropHooks(id, reason);
1960
+ }
1961
+ /** Hooks this source will never fire `onFinished` for: told so, so a socket frees its pending slot. */
1962
+ dropHooks(id, reason) {
1963
+ const list = this.hooks.get(id);
1964
+ if (!list) return;
1965
+ this.hooks.delete(id);
1966
+ for (const h of list) h.onDropped?.(reason);
1758
1967
  }
1759
1968
  async finish(id, status, result, error) {
1760
1969
  const res = await this.db.from("runner_commands").update({ status, result, error, finished_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
@@ -1766,7 +1975,7 @@ var init_source = __esm({
1766
1975
 
1767
1976
  // src/paths.ts
1768
1977
  import { lstatSync, realpathSync as realpathSync3 } from "fs";
1769
- import { dirname as dirname3, isAbsolute as isAbsolute4, join as join9, relative, sep } from "path";
1978
+ import { dirname as dirname3, isAbsolute as isAbsolute5, join as join9, relative, sep } from "path";
1770
1979
  function assertInsideRoots(target, roots) {
1771
1980
  return resolveInsideRoots(target, roots).real;
1772
1981
  }
@@ -1784,7 +1993,7 @@ function resolveInsideRoots(target, roots) {
1784
1993
  }
1785
1994
  function assertInsideRootsOrAncestor(target, roots) {
1786
1995
  assertRoots(roots);
1787
- const absolute = isAbsolute4(target) ? target : `${process.cwd()}${sep}${target}`;
1996
+ const absolute = isAbsolute5(target) ? target : `${process.cwd()}${sep}${target}`;
1788
1997
  let walked = walk(target, absolute);
1789
1998
  if (walked.tail.includes("..")) walked = walk(target, join9(walked.real, ...walked.tail));
1790
1999
  const checked = walked.tail.length === 0 ? walked.real : join9(walked.real, ...walked.tail);
@@ -1833,7 +2042,7 @@ function matchRoot(candidate, roots) {
1833
2042
  continue;
1834
2043
  }
1835
2044
  const rel = relative(rootReal, candidate);
1836
- const inside2 = rel === "" || !rel.startsWith("..") && !isAbsolute4(rel);
2045
+ const inside2 = rel === "" || !rel.startsWith("..") && !isAbsolute5(rel);
1837
2046
  if (inside2 && (match === null || rootReal.length < match.length)) match = rootReal;
1838
2047
  }
1839
2048
  return match;
@@ -1847,7 +2056,7 @@ var init_paths = __esm({
1847
2056
  // src/git/introspect.ts
1848
2057
  import { execFile as execFile3 } from "child_process";
1849
2058
  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";
2059
+ import { delimiter as delimiter2, dirname as dirname4, isAbsolute as isAbsolute6, join as join10, resolve } from "path";
1851
2060
  import { promisify as promisify2 } from "util";
1852
2061
  function parseGitHubRemote(url) {
1853
2062
  const trimmed = url.trim();
@@ -1888,17 +2097,24 @@ function gitEnv(ceiling, env = process.env) {
1888
2097
  };
1889
2098
  }
1890
2099
  function makeGitRun(opts) {
2100
+ const git = makeLocator("git", opts.locate);
1891
2101
  return async (args, cwd, ceiling) => {
2102
+ const env = gitEnv(ceiling);
2103
+ const file = git.find(env.PATH);
2104
+ if (file === null) {
2105
+ return { code: 127, stdout: "", stderr: "git not found on an absolute PATH entry" };
2106
+ }
1892
2107
  try {
1893
- const { stdout, stderr } = await exec2("git", [...HARDENING, ...args], {
2108
+ const { stdout, stderr } = await exec2(file, [...HARDENING, ...args], {
1894
2109
  cwd,
1895
- env: gitEnv(ceiling),
2110
+ env,
1896
2111
  timeout: opts.timeoutMs,
1897
2112
  maxBuffer: GIT_MAX_BUFFER
1898
2113
  });
1899
2114
  return { code: 0, stdout, stderr };
1900
2115
  } catch (err) {
1901
2116
  const e = err;
2117
+ if (e.code === "ENOENT") git.forget(env.PATH);
1902
2118
  return {
1903
2119
  code: typeof e.code === "number" ? e.code : 1,
1904
2120
  stdout: e.stdout ?? "",
@@ -1932,7 +2148,7 @@ function isLinkedWorktree(gitDir, commonDir, repoRoot) {
1932
2148
  return false;
1933
2149
  }
1934
2150
  }
1935
- async function introspectRepo(cwd, run, root) {
2151
+ async function introspectRepo(cwd, run, root, { names = true } = {}) {
1936
2152
  try {
1937
2153
  const ceiling = dirname4(root);
1938
2154
  if (ceiling.includes(delimiter2)) return { ...NONE };
@@ -1950,16 +2166,16 @@ async function introspectRepo(cwd, run, root) {
1950
2166
  } catch {
1951
2167
  return { ...NONE };
1952
2168
  }
1953
- const head = await line(["rev-parse", "--abbrev-ref", "HEAD"]);
2169
+ const head = names ? await line(["rev-parse", "--abbrev-ref", "HEAD"]) : null;
1954
2170
  const branch = head === "HEAD" ? null : head;
1955
2171
  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];
2172
+ const [gitDir, commonDir] = dirs?.length === 2 && dirs.every((d) => isAbsolute6(d)) ? dirs.map((d) => canonical(d, cwd)) : [null, null];
1957
2173
  if (commonDir == null || gitDir == null) return { ...NONE };
1958
2174
  if (!insideRoot(commonDir, root) && !isLinkedWorktree(gitDir, commonDir, repoRoot)) {
1959
2175
  return { ...NONE };
1960
2176
  }
1961
2177
  const isWorktree = gitDir !== commonDir;
1962
- const remote = await line(["remote", "get-url", "origin"]);
2178
+ const remote = names ? await line(["remote", "get-url", "origin"]) : null;
1963
2179
  const gh = remote === null ? null : parseGitHubRemote(remote);
1964
2180
  return {
1965
2181
  repo_root: repoRoot,
@@ -1977,6 +2193,7 @@ var init_introspect = __esm({
1977
2193
  "src/git/introspect.ts"() {
1978
2194
  "use strict";
1979
2195
  init_src();
2196
+ init_exec_path();
1980
2197
  init_paths();
1981
2198
  exec2 = promisify2(execFile3);
1982
2199
  GIT_TIMEOUT_MS = 5e3;
@@ -2016,10 +2233,10 @@ var init_introspect = __esm({
2016
2233
  });
2017
2234
 
2018
2235
  // 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";
2236
+ import { lstatSync as lstatSync2, realpathSync as realpathSync5, statSync as statSync4 } from "fs";
2237
+ import { dirname as dirname5, isAbsolute as isAbsolute7, join as join11, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
2021
2238
  async function scopeRepo(cwd, run, root) {
2022
- const identity = await introspectRepo(cwd, run, root);
2239
+ const identity = await introspectRepo(cwd, run, root, { names: false });
2023
2240
  if (identity.repo_root === null) {
2024
2241
  throw new Error(`VALIDATION: ${cwd} is not inside a git repository under an allowed root`);
2025
2242
  }
@@ -2175,7 +2392,7 @@ async function gitDirs(scope, run) {
2175
2392
  );
2176
2393
  if (r.code !== 0) return null;
2177
2394
  const lines = r.stdout.split("\n").filter((l) => l !== "");
2178
- if (lines.length === 0 || !lines.every((l) => isAbsolute6(l))) return null;
2395
+ if (lines.length === 0 || !lines.every((l) => isAbsolute7(l))) return null;
2179
2396
  try {
2180
2397
  return lines.map((l) => realpathSync5.native(l));
2181
2398
  } catch {
@@ -2189,7 +2406,7 @@ function isGitDirShape(dir) {
2189
2406
  if (commondir?.isFile()) return true;
2190
2407
  const isDir = (name) => {
2191
2408
  try {
2192
- return statSync3(join11(dir, name), { throwIfNoEntry: false })?.isDirectory() === true;
2409
+ return statSync4(join11(dir, name), { throwIfNoEntry: false })?.isDirectory() === true;
2193
2410
  } catch {
2194
2411
  return false;
2195
2412
  }
@@ -2206,13 +2423,13 @@ function enclosesHome(repoRoot, home) {
2206
2423
  if (within(real, repoRoot)) return true;
2207
2424
  let root;
2208
2425
  try {
2209
- root = statSync3(repoRoot, { bigint: true });
2426
+ root = statSync4(repoRoot, { bigint: true });
2210
2427
  } catch {
2211
2428
  return false;
2212
2429
  }
2213
2430
  for (let cur = real; ; cur = dirname5(cur)) {
2214
2431
  try {
2215
- const st = statSync3(cur, { bigint: true, throwIfNoEntry: false });
2432
+ const st = statSync4(cur, { bigint: true, throwIfNoEntry: false });
2216
2433
  if (st && st.dev === root.dev && st.ino === root.ino) return true;
2217
2434
  } catch {
2218
2435
  }
@@ -2224,7 +2441,7 @@ function protectedTest(paths) {
2224
2441
  for (const path of paths) {
2225
2442
  try {
2226
2443
  const real = realpathSync5.native(path);
2227
- const st = statSync3(real, { bigint: true });
2444
+ const st = statSync4(real, { bigint: true });
2228
2445
  entries.push({ real, dev: st.dev, ino: st.ino });
2229
2446
  } catch {
2230
2447
  }
@@ -2236,7 +2453,7 @@ function protectedTest(paths) {
2236
2453
  if (cached !== void 0) return cached;
2237
2454
  let hit = false;
2238
2455
  try {
2239
- const st = statSync3(path, { bigint: true, throwIfNoEntry: false });
2456
+ const st = statSync4(path, { bigint: true, throwIfNoEntry: false });
2240
2457
  hit = st !== void 0 && entries.some((e) => e.dev === st.dev && e.ino === st.ino);
2241
2458
  } catch {
2242
2459
  }
@@ -2406,11 +2623,11 @@ import {
2406
2623
  openSync,
2407
2624
  readFileSync as readFileSync5,
2408
2625
  realpathSync as realpathSync6,
2409
- statSync as statSync4,
2626
+ statSync as statSync5,
2410
2627
  writeSync
2411
2628
  } from "fs";
2412
2629
  import { readdir } from "fs/promises";
2413
- import { dirname as dirname6, isAbsolute as isAbsolute7, join as join12, relative as relative3, sep as sep3 } from "path";
2630
+ import { dirname as dirname6, isAbsolute as isAbsolute8, join as join12, relative as relative3, sep as sep3 } from "path";
2414
2631
  import { setImmediate } from "timers/promises";
2415
2632
  function worktreePathFor(repoRoot, name) {
2416
2633
  return join12(repoRoot, WORKTREES_DIR, name);
@@ -2435,7 +2652,7 @@ async function excludeCayenneDir(scope, run) {
2435
2652
  scope.ceiling
2436
2653
  );
2437
2654
  const common = r.stdout.trim();
2438
- if (r.code !== 0 || !isAbsolute7(common)) return;
2655
+ if (r.code !== 0 || !isAbsolute8(common)) return;
2439
2656
  const dotGit = join12(scope.repoRoot, ".git");
2440
2657
  let dotGitIsRealDir;
2441
2658
  try {
@@ -2587,9 +2804,9 @@ async function worktreeIdentity(scope, run) {
2587
2804
  scope.ceiling
2588
2805
  );
2589
2806
  const gitDir = r.stdout.trim();
2590
- if (r.code !== 0 || !isAbsolute7(gitDir)) throw failed2("git rev-parse --git-dir", r.stderr);
2807
+ if (r.code !== 0 || !isAbsolute8(gitDir)) throw failed2("git rev-parse --git-dir", r.stderr);
2591
2808
  try {
2592
- const st = statSync4(realpathSync6.native(gitDir), { bigint: true });
2809
+ const st = statSync5(realpathSync6.native(gitDir), { bigint: true });
2593
2810
  return {
2594
2811
  gitDirDev: st.dev.toString(),
2595
2812
  gitDirIno: st.ino.toString(),
@@ -2624,7 +2841,7 @@ async function removeWorktree(scope, run, worktreePath, force) {
2624
2841
  throw new Error(`ROOT_NOT_ALLOWED: ${real}`);
2625
2842
  }
2626
2843
  const rel = relative3(base, real);
2627
- if (rel === "" || rel.startsWith("..") || isAbsolute7(rel) || rel.includes("/")) {
2844
+ if (rel === "" || rel.startsWith("..") || isAbsolute8(rel) || rel.includes("/")) {
2628
2845
  throw new Error(`VALIDATION: ${worktreePath} is not under ${base}`);
2629
2846
  }
2630
2847
  const inside2 = `${real}${sep3}`;
@@ -2748,7 +2965,7 @@ var init_worktree_owners = __esm({
2748
2965
  import { rmSync as rmSync4 } from "fs";
2749
2966
  import { join as join14 } from "path";
2750
2967
  function createSessionWorktrees(dir) {
2751
- const fileFor = (sessionId) => UUID.test(sessionId) ? join14(dir, `${sessionId}.json`) : null;
2968
+ const fileFor = (sessionId) => UUID2.test(sessionId) ? join14(dir, `${sessionId}.json`) : null;
2752
2969
  return {
2753
2970
  record(rec) {
2754
2971
  const file = fileFor(rec.sessionId);
@@ -2784,12 +3001,620 @@ function createSessionWorktrees(dir) {
2784
3001
  }
2785
3002
  };
2786
3003
  }
2787
- var UUID;
3004
+ var UUID2;
2788
3005
  var init_session_worktrees = __esm({
2789
3006
  "src/git/session-worktrees.ts"() {
2790
3007
  "use strict";
2791
3008
  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;
3009
+ UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3010
+ }
3011
+ });
3012
+
3013
+ // src/local/auth.ts
3014
+ import { createHash as createHash2 } from "crypto";
3015
+ import { LRUCache as LRUCache2 } from "lru-cache";
3016
+ function claimsOf(token) {
3017
+ const payload = token.split(".")[1];
3018
+ if (!payload) return null;
3019
+ try {
3020
+ const c = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
3021
+ return typeof c.sub === "string" && typeof c.exp === "number" ? { sub: c.sub, exp: c.exp } : null;
3022
+ } catch {
3023
+ return null;
3024
+ }
3025
+ }
3026
+ function retryable(error) {
3027
+ const { name, status } = error;
3028
+ if (name === "AuthRetryableFetchError") return true;
3029
+ if (typeof status !== "number" || status === 0) return true;
3030
+ return status >= 500 || status === 429;
3031
+ }
3032
+ function createTokenVerifier({
3033
+ db,
3034
+ ttlMs = LOCAL_TOKEN_CACHE_MS,
3035
+ negativeTtlMs = LOCAL_TOKEN_NEGATIVE_CACHE_MS,
3036
+ now = Date.now,
3037
+ admit = () => true,
3038
+ log
3039
+ }) {
3040
+ const cache = new LRUCache2({ max: 1024 });
3041
+ const key = (token) => createHash2("sha256").update(token).digest("hex");
3042
+ let windowStart = now();
3043
+ let calls = 0;
3044
+ let warned2 = false;
3045
+ const budgeted = () => {
3046
+ const t = now();
3047
+ if (t - windowStart >= 6e4) {
3048
+ windowStart = t;
3049
+ calls = 0;
3050
+ warned2 = false;
3051
+ }
3052
+ if (calls < LOCAL_GETUSER_PER_MINUTE) {
3053
+ calls += 1;
3054
+ return true;
3055
+ }
3056
+ if (!warned2) {
3057
+ warned2 = true;
3058
+ log?.warn(
3059
+ { perMinute: LOCAL_GETUSER_PER_MINUTE },
3060
+ "local token verification over budget; refusing until the minute turns"
3061
+ );
3062
+ }
3063
+ return false;
3064
+ };
3065
+ const refuse = (k) => {
3066
+ cache.set(k, { userId: null, exp: 0, until: now() + negativeTtlMs });
3067
+ return { status: "refused" };
3068
+ };
3069
+ return {
3070
+ cacheKeys: () => [...cache.keys()],
3071
+ async verify(token) {
3072
+ const k = key(token);
3073
+ const hit = cache.get(k);
3074
+ if (hit && hit.until > now()) {
3075
+ if (hit.userId === null) return { status: "refused" };
3076
+ if (hit.exp * 1e3 <= now()) return refuse(k);
3077
+ return { status: "valid", userId: hit.userId, exp: hit.exp };
3078
+ }
3079
+ cache.delete(k);
3080
+ const claims = claimsOf(token);
3081
+ if (!claims || claims.exp * 1e3 <= now() || !admit(claims.sub)) return refuse(k);
3082
+ if (!budgeted()) return { status: "unavailable" };
3083
+ const user = await db.auth.getUser(token).then(
3084
+ (r) => r.error ? retryable(r.error) ? void 0 : null : r.data.user,
3085
+ () => void 0
3086
+ );
3087
+ if (user === void 0) return { status: "unavailable" };
3088
+ if (!user || user.id !== claims.sub) return refuse(k);
3089
+ cache.set(k, { userId: user.id, exp: claims.exp, until: now() + ttlMs });
3090
+ return { status: "valid", userId: user.id, exp: claims.exp };
3091
+ }
3092
+ };
3093
+ }
3094
+ var init_auth = __esm({
3095
+ "src/local/auth.ts"() {
3096
+ "use strict";
3097
+ init_src();
3098
+ }
3099
+ });
3100
+
3101
+ // src/local/identity.ts
3102
+ import { generateKeyPairSync, sign } from "crypto";
3103
+ function createLocalIdentity() {
3104
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
3105
+ const x = publicKey.export({ format: "jwk" }).x;
3106
+ if (typeof x !== "string" || x.length !== 43) throw new Error("ed25519 public key export failed");
3107
+ return {
3108
+ publicKey: x,
3109
+ sign: (message) => sign(null, message, privateKey).toString("base64url")
3110
+ };
3111
+ }
3112
+ var init_identity = __esm({
3113
+ "src/local/identity.ts"() {
3114
+ "use strict";
3115
+ }
3116
+ });
3117
+
3118
+ // src/local/origins.ts
3119
+ function normalise(origin) {
3120
+ try {
3121
+ const u = new URL(origin);
3122
+ if (u.username || u.password) return null;
3123
+ return u.origin !== "null" ? u.origin.toLowerCase() : `${u.protocol}//${u.host}`.toLowerCase();
3124
+ } catch {
3125
+ return null;
3126
+ }
3127
+ }
3128
+ function buildOrigins(extra, cloudOrigin = CLOUD_APP_ORIGIN) {
3129
+ const builtIn = cloudOrigin ? [cloudOrigin, ...SHELLS] : [...DEV, ...SHELLS];
3130
+ const out = /* @__PURE__ */ new Set();
3131
+ for (const o of [...builtIn, ...extra]) {
3132
+ const n = normalise(o);
3133
+ if (n) out.add(n);
3134
+ }
3135
+ return [...out];
3136
+ }
3137
+ function originAllowed(origin, allowed) {
3138
+ if (!origin) return false;
3139
+ const n = normalise(origin);
3140
+ return n !== null && allowed.includes(n);
3141
+ }
3142
+ var SHELLS, DEV;
3143
+ var init_origins = __esm({
3144
+ "src/local/origins.ts"() {
3145
+ "use strict";
3146
+ init_cloud();
3147
+ SHELLS = ["capacitor://localhost", "app://cayenne"];
3148
+ DEV = ["http://localhost:5173", "http://127.0.0.1:5173"];
3149
+ }
3150
+ });
3151
+
3152
+ // src/local/connection.ts
3153
+ import { randomUUID as randomUUID2 } from "crypto";
3154
+ function closeSoon(ws, code, reason) {
3155
+ if (ws.readyState === ws.CLOSED) return;
3156
+ ws.close(code, reason);
3157
+ const timer = setTimeout(() => ws.terminate(), CLOSE_GRACE_MS);
3158
+ timer.unref();
3159
+ ws.once("close", () => clearTimeout(timer));
3160
+ }
3161
+ function parseFrame(data) {
3162
+ try {
3163
+ const parsed = LocalClientFrame.safeParse(JSON.parse(String(data)));
3164
+ return parsed.success ? parsed.data : null;
3165
+ } catch {
3166
+ return null;
3167
+ }
3168
+ }
3169
+ function handleConnection(ws, who, ctx, log, now) {
3170
+ const sessions = /* @__PURE__ */ new Set();
3171
+ const terminals = /* @__PURE__ */ new Set();
3172
+ const pending = /* @__PURE__ */ new Map();
3173
+ let closed = false;
3174
+ const pendingSubs = /* @__PURE__ */ new Map();
3175
+ const conn = {
3176
+ id: randomUUID2(),
3177
+ userId: who.userId,
3178
+ tokenExp: who.tokenExp,
3179
+ sessions,
3180
+ terminals,
3181
+ send(frame) {
3182
+ if (ws.readyState !== ws.OPEN) return;
3183
+ if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
3184
+ log.warn({ conn: conn.id, userId: conn.userId }, "local client is not reading; closing");
3185
+ closeSoon(ws, LOCAL_CLOSE_NOT_READY, "too far behind");
3186
+ return;
3187
+ }
3188
+ ws.send(JSON.stringify(frame));
3189
+ },
3190
+ close(code, reason) {
3191
+ closeSoon(ws, code, reason);
3192
+ },
3193
+ recheckSessions: () => recheckSessions()
3194
+ };
3195
+ const cmdErr = (id, error) => conn.send({ t: "cmd.err", id, error });
3196
+ const onCmd = async (id) => {
3197
+ if (pending.has(id)) return;
3198
+ if (pending.size >= LOCAL_MAX_PENDING_CMDS) {
3199
+ return cmdErr(id, "VALIDATION: too many pending commands on this socket");
3200
+ }
3201
+ pending.set(id, () => {
3202
+ });
3203
+ let row;
3204
+ try {
3205
+ row = await ctx.readRow(id);
3206
+ } catch (err) {
3207
+ pending.delete(id);
3208
+ log.warn({ conn: conn.id, id, err }, "local cmd: row read failed");
3209
+ return cmdErr(id, "PROVIDER_ERROR: could not read the command");
3210
+ }
3211
+ if (closed) return;
3212
+ if (!row || row.runner_id !== ctx.runnerId || row.created_by !== conn.userId) {
3213
+ const reason = !row ? "no row" : row.runner_id !== ctx.runnerId ? "another runner" : "another creator";
3214
+ pending.delete(id);
3215
+ log.info({ conn: conn.id, userId: conn.userId, id, reason }, "local cmd refused");
3216
+ return cmdErr(id, "VALIDATION: no such command on this runner");
3217
+ }
3218
+ if (row.status === "done" || row.status === "failed" || row.status === "cancelled") {
3219
+ pending.delete(id);
3220
+ return conn.send({ t: "res", id, status: row.status, result: row.result, error: row.error });
3221
+ }
3222
+ const drop = ctx.source.offer(row, {
3223
+ onAck: () => conn.send({ t: "ack", id }),
3224
+ onFinished: (r) => {
3225
+ pending.delete(id);
3226
+ conn.send({ t: "res", id, ...r });
3227
+ },
3228
+ // The source will never settle this pointer (the row is claimed elsewhere and still running,
3229
+ // or could not be re-read): free the slot and say so without settling (ruling B10, R-B6).
3230
+ onDropped: (reason) => {
3231
+ pending.delete(id);
3232
+ log.info({ conn: conn.id, userId: conn.userId, id, reason }, "local cmd pointer dropped");
3233
+ cmdErr(id, "VALIDATION: no such command on this runner");
3234
+ }
3235
+ });
3236
+ if (pending.has(id)) pending.set(id, drop);
3237
+ };
3238
+ const onAuth = async (token) => {
3239
+ const next = await ctx.verifier.verify(token);
3240
+ if (next.status === "unavailable") {
3241
+ log.info({ conn: conn.id, userId: conn.userId }, "local auth refresh unverified; kept");
3242
+ return;
3243
+ }
3244
+ if (next.status !== "valid" || next.userId !== conn.userId || expiresAt(next.exp) <= now() || !ctx.serves(next.userId)) {
3245
+ conn.close(LOCAL_CLOSE_UNAUTHORIZED, "unauthorized");
3246
+ return;
3247
+ }
3248
+ conn.tokenExp = expiresAt(next.exp);
3249
+ conn.send({ t: "ready", userId: conn.userId });
3250
+ await recheckSessions();
3251
+ };
3252
+ const SESSION_REFUSED = "SESSION_NOT_FOUND: not hosted on this runner";
3253
+ const sessionRefusal = (s) => !s ? "no row" : s.runner_id !== ctx.runnerId ? "another runner" : s.owner_id !== conn.userId && s.visibility !== "workspace" ? "private" : null;
3254
+ const recheckSessions = async () => {
3255
+ await Promise.all(
3256
+ [...sessions].map(async (id) => {
3257
+ let s;
3258
+ try {
3259
+ s = await ctx.readSession(id);
3260
+ } catch (err) {
3261
+ log.warn({ conn: conn.id, sessionId: id, err }, "local sub recheck: session read failed");
3262
+ return;
3263
+ }
3264
+ const reason = sessionRefusal(s);
3265
+ if (!reason || !sessions.delete(id)) return;
3266
+ log.info(
3267
+ { conn: conn.id, userId: conn.userId, sessionId: id, reason },
3268
+ "local sub revoked"
3269
+ );
3270
+ conn.send({ t: "sub.err", topic: `session:${id}`, error: SESSION_REFUSED });
3271
+ })
3272
+ );
3273
+ };
3274
+ const onSub = async (topic) => {
3275
+ const parsed = parseLocalTopic(topic);
3276
+ if (!parsed) return;
3277
+ if (parsed.kind === "session") {
3278
+ const mine = {};
3279
+ pendingSubs.set(parsed.id, mine);
3280
+ const s = await ctx.readSession(parsed.id).catch(() => null);
3281
+ if (closed || pendingSubs.get(parsed.id) !== mine) return;
3282
+ pendingSubs.delete(parsed.id);
3283
+ const reason = sessionRefusal(s);
3284
+ if (reason) {
3285
+ log.info(
3286
+ { conn: conn.id, userId: conn.userId, sessionId: parsed.id, reason },
3287
+ "local sub refused"
3288
+ );
3289
+ return conn.send({ t: "sub.err", topic, error: SESSION_REFUSED });
3290
+ }
3291
+ sessions.add(parsed.id);
3292
+ return;
3293
+ }
3294
+ const t = ctx.terminals;
3295
+ if (!t?.has(parsed.id) || t.ownerOf(parsed.id) !== conn.userId || !ctx.mayHoldTerminal(conn.userId)) {
3296
+ return conn.send({
3297
+ t: "sub.err",
3298
+ topic,
3299
+ error: "TERMINAL_NOT_FOUND: not your terminal here"
3300
+ });
3301
+ }
3302
+ terminals.add(parsed.id);
3303
+ t.attachLocal(parsed.id, conn.id);
3304
+ };
3305
+ const onUnsub = (topic) => {
3306
+ const parsed = parseLocalTopic(topic);
3307
+ if (!parsed) return;
3308
+ if (parsed.kind === "session") {
3309
+ sessions.delete(parsed.id);
3310
+ pendingSubs.delete(parsed.id);
3311
+ } else if (terminals.delete(parsed.id)) ctx.terminals?.detachLocal(parsed.id, conn.id);
3312
+ };
3313
+ const onTerm = (terminalId, m) => {
3314
+ const t = ctx.terminals;
3315
+ if (!t || !terminals.has(terminalId)) {
3316
+ log.warn(
3317
+ { conn: conn.id },
3318
+ "local term frame for a terminal this socket is not subscribed to; dropped"
3319
+ );
3320
+ return;
3321
+ }
3322
+ try {
3323
+ t.receive(terminalId, m.m, "local");
3324
+ } catch (err) {
3325
+ log.warn({ conn: conn.id, terminalId, err }, "local terminal message refused");
3326
+ }
3327
+ };
3328
+ ws.on("message", (data) => {
3329
+ const f = parseFrame(data);
3330
+ if (!f) {
3331
+ log.warn({ conn: conn.id }, "local frame did not parse");
3332
+ return;
3333
+ }
3334
+ if (f.t === "hello") conn.close(LOCAL_CLOSE_BAD_HANDSHAKE, "handshake already done");
3335
+ else if (f.t === "auth") void onAuth(f.token);
3336
+ else if (f.t === "cmd") void onCmd(f.id);
3337
+ else if (f.t === "sub") void onSub(f.topic);
3338
+ else if (f.t === "unsub") onUnsub(f.topic);
3339
+ else onTerm(f.terminalId, f);
3340
+ });
3341
+ ws.on("close", () => {
3342
+ closed = true;
3343
+ for (const id of terminals) ctx.terminals?.detachLocal(id, conn.id);
3344
+ for (const drop of pending.values()) drop();
3345
+ pending.clear();
3346
+ terminals.clear();
3347
+ sessions.clear();
3348
+ pendingSubs.clear();
3349
+ });
3350
+ return conn;
3351
+ }
3352
+ function sessionFrame(sessionId, e) {
3353
+ return { t: "evt", topic: `session:${sessionId}`, e };
3354
+ }
3355
+ var MAX_BUFFERED_BYTES, CLOSE_GRACE_MS, expiresAt;
3356
+ var init_connection = __esm({
3357
+ "src/local/connection.ts"() {
3358
+ "use strict";
3359
+ init_src();
3360
+ MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
3361
+ CLOSE_GRACE_MS = 250;
3362
+ expiresAt = (exp) => exp * 1e3;
3363
+ }
3364
+ });
3365
+
3366
+ // src/local/server.ts
3367
+ import { createServer } from "http";
3368
+ import { WebSocketServer } from "ws";
3369
+ function createLocalServer({
3370
+ port,
3371
+ origins,
3372
+ log,
3373
+ handshakeTimeoutMs = LOCAL_HANDSHAKE_TIMEOUT_MS,
3374
+ recheckMs = LOCAL_RECHECK_MS,
3375
+ now = Date.now,
3376
+ maxSockets = LOCAL_MAX_SOCKETS
3377
+ }) {
3378
+ let ctx = null;
3379
+ let bound = null;
3380
+ const conns = /* @__PURE__ */ new Set();
3381
+ let pendingUpgrades = 0;
3382
+ const wss = new WebSocketServer({ noServer: true, maxPayload: LOCAL_MAX_FRAME_BYTES });
3383
+ let untapTerminals = null;
3384
+ let unwatchRoster = null;
3385
+ let recheckTimer = null;
3386
+ const hostAllowed = (host) => bound !== null && (host === `${HOST}:${bound}` || host === `localhost:${bound}`);
3387
+ const socketsOf = (userId) => {
3388
+ let n = 0;
3389
+ for (const c of conns) if (c.userId === userId) n += 1;
3390
+ return n;
3391
+ };
3392
+ const recheck = () => {
3393
+ const current = ctx;
3394
+ if (!current) return;
3395
+ const t = now();
3396
+ for (const c of [...conns]) {
3397
+ if (!current.serves(c.userId) || t >= c.tokenExp) {
3398
+ c.close(LOCAL_CLOSE_UNAUTHORIZED, "no longer authorised");
3399
+ } else {
3400
+ void c.recheckSessions();
3401
+ }
3402
+ }
3403
+ };
3404
+ const cors = (req, res) => {
3405
+ const origin = req.headers.origin;
3406
+ if (!originAllowed(origin, origins)) return false;
3407
+ res.setHeader("Access-Control-Allow-Origin", origin);
3408
+ res.setHeader("Vary", "Origin");
3409
+ return true;
3410
+ };
3411
+ const server = createServer((req, res) => {
3412
+ if (!hostAllowed(req.headers.host)) {
3413
+ res.writeHead(403).end();
3414
+ return;
3415
+ }
3416
+ if (req.url !== LOCAL_HEALTH_PATH) {
3417
+ res.writeHead(404).end();
3418
+ return;
3419
+ }
3420
+ const allowed = cors(req, res);
3421
+ if (req.method === "OPTIONS") {
3422
+ if (!allowed) {
3423
+ res.writeHead(403).end();
3424
+ return;
3425
+ }
3426
+ res.setHeader("Access-Control-Allow-Methods", "GET");
3427
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
3428
+ res.setHeader("Access-Control-Max-Age", "600");
3429
+ res.writeHead(204).end();
3430
+ return;
3431
+ }
3432
+ if (req.method !== "GET") {
3433
+ res.writeHead(405).end();
3434
+ return;
3435
+ }
3436
+ res.setHeader("Content-Type", "application/json");
3437
+ res.setHeader("Cache-Control", "no-store");
3438
+ if (!ctx) {
3439
+ res.writeHead(503).end(JSON.stringify({ error: "starting" }));
3440
+ return;
3441
+ }
3442
+ res.writeHead(200).end(
3443
+ JSON.stringify({
3444
+ runnerId: ctx.runnerId,
3445
+ protocolVersion: PROTOCOL_VERSION,
3446
+ version: ctx.version
3447
+ })
3448
+ );
3449
+ });
3450
+ server.maxConnections = LOCAL_MAX_CONNECTIONS;
3451
+ server.headersTimeout = LOCAL_HANDSHAKE_TIMEOUT_MS;
3452
+ server.requestTimeout = LOCAL_HANDSHAKE_TIMEOUT_MS;
3453
+ server.keepAliveTimeout = 1e3;
3454
+ const refuse = (socket, status) => {
3455
+ const text = status === 403 ? "Forbidden" : "Service Unavailable";
3456
+ socket.write(`HTTP/1.1 ${status} ${text}\r
3457
+ Connection: close\r
3458
+ Content-Length: 0\r
3459
+ \r
3460
+ `);
3461
+ socket.destroy();
3462
+ };
3463
+ server.on("upgrade", (req, socket, head) => {
3464
+ const origin = req.headers.origin ?? "";
3465
+ if (req.url !== LOCAL_WS_PATH || !hostAllowed(req.headers.host) || !originAllowed(origin, origins)) {
3466
+ refuse(socket, 403);
3467
+ return;
3468
+ }
3469
+ if (pendingUpgrades >= LOCAL_MAX_PENDING_UPGRADES || conns.size >= maxSockets) {
3470
+ refuse(socket, 503);
3471
+ return;
3472
+ }
3473
+ pendingUpgrades += 1;
3474
+ let released = false;
3475
+ const release = () => {
3476
+ if (released) return;
3477
+ released = true;
3478
+ pendingUpgrades -= 1;
3479
+ };
3480
+ let ws = null;
3481
+ const timer = setTimeout(() => {
3482
+ if (ws) closeSoon(ws, LOCAL_CLOSE_NOT_READY, "handshake timeout");
3483
+ else socket.destroy();
3484
+ }, handshakeTimeoutMs);
3485
+ socket.once("close", release);
3486
+ wss.handleUpgrade(req, socket, head, (admitted) => {
3487
+ ws = admitted;
3488
+ admitted.on("close", release);
3489
+ admitted.on("error", (err) => {
3490
+ log.warn({ code: err.code }, "local socket error");
3491
+ });
3492
+ const current = ctx;
3493
+ if (!current) {
3494
+ clearTimeout(timer);
3495
+ closeSoon(admitted, LOCAL_CLOSE_NOT_READY, "starting");
3496
+ return;
3497
+ }
3498
+ let state = "awaiting-hello";
3499
+ const onHandshake = async (data) => {
3500
+ const f = parseFrame(data);
3501
+ if (state === "awaiting-hello") {
3502
+ if (f?.t !== "hello") {
3503
+ closeSoon(admitted, LOCAL_CLOSE_BAD_HANDSHAKE, "expected hello");
3504
+ return;
3505
+ }
3506
+ state = "awaiting-auth";
3507
+ const sig = current.identity.sign(localProofMessage(f.nonce, current.runnerId, origin));
3508
+ admitted.send(
3509
+ JSON.stringify({
3510
+ t: "hello",
3511
+ runnerId: current.runnerId,
3512
+ protocolVersion: PROTOCOL_VERSION,
3513
+ sig
3514
+ })
3515
+ );
3516
+ return;
3517
+ }
3518
+ if (state !== "awaiting-auth" || !f || f.t !== "auth") {
3519
+ closeSoon(admitted, LOCAL_CLOSE_BAD_HANDSHAKE, "expected auth");
3520
+ return;
3521
+ }
3522
+ state = "verifying";
3523
+ const who = await current.verifier.verify(f.token);
3524
+ if (admitted.readyState !== admitted.OPEN) return;
3525
+ if (who.status !== "valid" || expiresAt(who.exp) <= now() || !current.serves(who.userId)) {
3526
+ closeSoon(admitted, LOCAL_CLOSE_UNAUTHORIZED, "unauthorized");
3527
+ return;
3528
+ }
3529
+ const tokenExp = expiresAt(who.exp);
3530
+ if (socketsOf(who.userId) >= LOCAL_MAX_SOCKETS_PER_USER) {
3531
+ closeSoon(admitted, LOCAL_CLOSE_TOO_MANY, "too many sockets for this user");
3532
+ return;
3533
+ }
3534
+ if (conns.size >= maxSockets) {
3535
+ closeSoon(admitted, LOCAL_CLOSE_TOO_MANY, "too many sockets");
3536
+ return;
3537
+ }
3538
+ state = "ready";
3539
+ clearTimeout(timer);
3540
+ release();
3541
+ admitted.off("message", listener);
3542
+ const conn = handleConnection(
3543
+ admitted,
3544
+ { userId: who.userId, tokenExp },
3545
+ current,
3546
+ log,
3547
+ now
3548
+ );
3549
+ conns.add(conn);
3550
+ admitted.on("close", () => conns.delete(conn));
3551
+ conn.send({ t: "ready", userId: who.userId });
3552
+ log.info({ conn: conn.id, userId: who.userId }, "local client attached");
3553
+ };
3554
+ const listener = (data) => {
3555
+ void onHandshake(data);
3556
+ };
3557
+ admitted.on("message", listener);
3558
+ });
3559
+ });
3560
+ return {
3561
+ get port() {
3562
+ return bound;
3563
+ },
3564
+ listen() {
3565
+ return new Promise((resolve3, reject) => {
3566
+ server.once("error", reject);
3567
+ server.listen(port, HOST, () => {
3568
+ server.off("error", reject);
3569
+ const address = server.address();
3570
+ bound = typeof address === "object" && address ? address.port : port;
3571
+ log.info({ host: HOST, port: bound }, "local server listening");
3572
+ resolve3(bound);
3573
+ });
3574
+ });
3575
+ },
3576
+ attach(next) {
3577
+ ctx = next;
3578
+ untapTerminals?.();
3579
+ untapTerminals = next.terminals?.subscribe((terminalId, m, origin) => {
3580
+ if (m.type === "replay" && origin === "relay") return;
3581
+ for (const c of conns)
3582
+ if (c.terminals.has(terminalId)) c.send({ t: "term", terminalId, m });
3583
+ }) ?? null;
3584
+ unwatchRoster?.();
3585
+ unwatchRoster = next.onRosterRefresh(recheck);
3586
+ if (recheckTimer) clearInterval(recheckTimer);
3587
+ recheckTimer = setInterval(recheck, recheckMs);
3588
+ recheckTimer.unref();
3589
+ },
3590
+ emit(sessionId, e) {
3591
+ const frame = sessionFrame(sessionId, e);
3592
+ for (const c of conns) if (c.sessions.has(sessionId)) c.send(frame);
3593
+ },
3594
+ close() {
3595
+ if (recheckTimer) clearInterval(recheckTimer);
3596
+ recheckTimer = null;
3597
+ unwatchRoster?.();
3598
+ unwatchRoster = null;
3599
+ untapTerminals?.();
3600
+ untapTerminals = null;
3601
+ for (const client of wss.clients) client.close(1001, "runner stopping");
3602
+ return new Promise((resolve3) => {
3603
+ wss.close();
3604
+ server.close(() => resolve3());
3605
+ server.closeAllConnections();
3606
+ });
3607
+ }
3608
+ };
3609
+ }
3610
+ var HOST;
3611
+ var init_server = __esm({
3612
+ "src/local/server.ts"() {
3613
+ "use strict";
3614
+ init_src();
3615
+ init_connection();
3616
+ init_origins();
3617
+ HOST = "127.0.0.1";
2793
3618
  }
2794
3619
  });
2795
3620
 
@@ -3030,21 +3855,31 @@ function checkRunsArgs(req, sha) {
3030
3855
  }
3031
3856
  return ["api", `repos/${req.owner}/${req.name}/commits/${sha}/check-runs`];
3032
3857
  }
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
- }
3858
+ function makeGhRun(opts = {}) {
3859
+ const gh = makeLocator("gh", opts.locate);
3860
+ const currentPath = opts.path ?? (() => process.env.PATH);
3861
+ return async (args) => {
3862
+ const path = currentPath();
3863
+ const file = gh.find(path);
3864
+ if (file === null) {
3865
+ return { code: 127, stdout: "", stderr: "gh not found on an absolute PATH entry" };
3866
+ }
3867
+ try {
3868
+ const { stdout, stderr } = await exec3(file, args, {
3869
+ timeout: GH_TIMEOUT_MS,
3870
+ maxBuffer: MAX_OUTPUT * 2
3871
+ });
3872
+ return { code: 0, stdout, stderr };
3873
+ } catch (err) {
3874
+ const e = err;
3875
+ if (e.code === "ENOENT") gh.forget(path);
3876
+ return {
3877
+ code: typeof e.code === "number" ? e.code : 1,
3878
+ stdout: e.stdout ?? "",
3879
+ stderr: e.stderr ?? ""
3880
+ };
3881
+ }
3882
+ };
3048
3883
  }
3049
3884
  function statusFrom(stderr) {
3050
3885
  const m = /\(HTTP (\d{3})\)/.exec(stderr);
@@ -3082,16 +3917,18 @@ function createGhGateway(run) {
3082
3917
  }
3083
3918
  };
3084
3919
  }
3085
- var exec3, GH_TIMEOUT_MS, MAX_OUTPUT, ALL_DOTS3, COMMIT_SHA;
3920
+ var exec3, GH_TIMEOUT_MS, MAX_OUTPUT, ALL_DOTS3, COMMIT_SHA, runGh;
3086
3921
  var init_gh = __esm({
3087
3922
  "src/github/gh.ts"() {
3088
3923
  "use strict";
3089
3924
  init_src();
3925
+ init_exec_path();
3090
3926
  exec3 = promisify3(execFile4);
3091
3927
  GH_TIMEOUT_MS = 2e4;
3092
3928
  MAX_OUTPUT = 512e3;
3093
3929
  ALL_DOTS3 = /^\.+$/;
3094
3930
  COMMIT_SHA = /^[0-9a-f]{40}$/;
3931
+ runGh = makeGhRun();
3095
3932
  }
3096
3933
  });
3097
3934
 
@@ -3307,11 +4144,11 @@ var init_handlers = __esm({
3307
4144
 
3308
4145
  // src/git/watch.ts
3309
4146
  import { watch } from "fs";
3310
- import { isAbsolute as isAbsolute8, normalize, sep as sep4 } from "path";
4147
+ import { isAbsolute as isAbsolute9, normalize, sep as sep4 } from "path";
3311
4148
  function ignored(name) {
3312
4149
  if (name === null || name === "") return false;
3313
4150
  const rel = normalize(name);
3314
- if (isAbsolute8(rel) || rel === ".." || rel.startsWith(`..${sep4}`)) return true;
4151
+ if (isAbsolute9(rel) || rel === ".." || rel.startsWith(`..${sep4}`)) return true;
3315
4152
  return rel.split(/[\\/]/).some((segment) => IGNORED_NAMES.has(segment));
3316
4153
  }
3317
4154
  function watchChanges(cwd, onChange, debounceMs = CHANGES_DEBOUNCE_MS, onError = () => void 0, fsWatch = recursiveWatch) {
@@ -3513,7 +4350,7 @@ var init_pushable = __esm({
3513
4350
  });
3514
4351
 
3515
4352
  // src/claude/session.ts
3516
- import { randomUUID as randomUUID2 } from "crypto";
4353
+ import { randomUUID as randomUUID3 } from "crypto";
3517
4354
  import { EventEmitter } from "events";
3518
4355
  import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
3519
4356
  var ClaudeSession;
@@ -3621,7 +4458,7 @@ var init_session = __esm({
3621
4458
  }
3622
4459
  requestPermission(toolName2, input, suggestions, toolUseId, signal) {
3623
4460
  return new Promise((resolve3) => {
3624
- const id = randomUUID2();
4461
+ const id = randomUUID3();
3625
4462
  const settle = (d) => {
3626
4463
  const entry = this.pending.get(id);
3627
4464
  if (!entry) return;
@@ -4160,9 +4997,9 @@ var init_create = __esm({
4160
4997
  });
4161
4998
 
4162
4999
  // src/sessions/manager.ts
4163
- import { randomUUID as randomUUID3 } from "crypto";
5000
+ import { randomUUID as randomUUID4 } from "crypto";
4164
5001
  import { realpathSync as realpathSync7 } from "fs";
4165
- import { isAbsolute as isAbsolute9, sep as sep5 } from "path";
5002
+ import { isAbsolute as isAbsolute10, sep as sep5 } from "path";
4166
5003
  import { isDeepStrictEqual } from "util";
4167
5004
  function narrowToSession(suggestions) {
4168
5005
  if (!suggestions) return suggestions;
@@ -4320,7 +5157,7 @@ var init_manager = __esm({
4320
5157
  if (p.initialPrompt) {
4321
5158
  await this.prompt(
4322
5159
  sessionId,
4323
- { text: p.initialPrompt, clientUuid: randomUUID3(), attachments: [] },
5160
+ { text: p.initialPrompt, clientUuid: randomUUID4(), attachments: [] },
4324
5161
  createdBy
4325
5162
  );
4326
5163
  }
@@ -4797,7 +5634,7 @@ var init_manager = __esm({
4797
5634
  }
4798
5635
  /** A path as the SDK reads it: relative means relative to the session's cwd. */
4799
5636
  fromCwd(cwd, value) {
4800
- return isAbsolute9(value) ? value : `${cwd}${sep5}${value}`;
5637
+ return isAbsolute10(value) ? value : `${cwd}${sep5}${value}`;
4801
5638
  }
4802
5639
  /**
4803
5640
  * §2.9: a path in `updatedInput` passes the same boundary the session's `cwd` did, and the
@@ -5362,6 +6199,11 @@ var init_manager = __esm({
5362
6199
  }
5363
6200
  message = envelope(shrunk);
5364
6201
  }
6202
+ try {
6203
+ this.deps.tap?.(sessionId, message.payload);
6204
+ } catch (err) {
6205
+ this.deps.log.warn({ err, sessionId, type: event.type }, "session event tap threw");
6206
+ }
5365
6207
  const result = await live.channel.send(message);
5366
6208
  if (result !== "ok") {
5367
6209
  this.deps.log.warn(
@@ -5476,7 +6318,7 @@ var init_channels = __esm({
5476
6318
  // src/terminal/env.ts
5477
6319
  import { readFileSync as readFileSync7, realpathSync as realpathSync8 } from "fs";
5478
6320
  import { lstat } from "fs/promises";
5479
- import { dirname as dirname7, isAbsolute as isAbsolute10, relative as relative4 } from "path";
6321
+ import { dirname as dirname7, isAbsolute as isAbsolute11, relative as relative4 } from "path";
5480
6322
  function terminalEnv(base, { runnerOwner, claudeConfigDir }) {
5481
6323
  const env = {};
5482
6324
  for (const [key, value] of Object.entries(base)) {
@@ -5546,7 +6388,7 @@ function tryRealpath(fs, p) {
5546
6388
  }
5547
6389
  function inside(candidate, root) {
5548
6390
  const rel = relative4(root, candidate);
5549
- return rel === "" || !rel.startsWith("..") && !isAbsolute10(rel);
6391
+ return rel === "" || !rel.startsWith("..") && !isAbsolute11(rel);
5550
6392
  }
5551
6393
  var SECRET_ENV, MEMBER_ENV_ALLOW, MEMBER_ENV_KEYS, LOCALE_ENV, nodeShellFs;
5552
6394
  var init_env = __esm({
@@ -5753,6 +6595,7 @@ var init_manager2 = __esm({
5753
6595
  constructor(deps) {
5754
6596
  this.deps = deps;
5755
6597
  this.flushMs = deps.flushMs ?? TERMINAL_FLUSH_MS;
6598
+ this.localFlushMs = deps.localFlushMs ?? TERMINAL_LOCAL_FLUSH_MS;
5756
6599
  this.flushBytes = deps.flushBytes ?? TERMINAL_FLUSH_BYTES;
5757
6600
  this.ringBytes = deps.ringBytes ?? TERMINAL_RING_BYTES;
5758
6601
  this.reaper = setInterval(() => this.reap(), deps.reapIntervalMs ?? TERMINAL_REAP_INTERVAL_MS);
@@ -5769,6 +6612,7 @@ var init_manager2 = __esm({
5769
6612
  opens = /* @__PURE__ */ new Set();
5770
6613
  reaper;
5771
6614
  flushMs;
6615
+ localFlushMs;
5772
6616
  flushBytes;
5773
6617
  ringBytes;
5774
6618
  /** Set once by `shutdown()` (fix round 1 L7): `open()` checks it after every await. */
@@ -6092,9 +6936,14 @@ var init_manager2 = __esm({
6092
6936
  return;
6093
6937
  }
6094
6938
  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;
6939
+ let delay2 = this.localFlushMs;
6940
+ if (!this.relaySuppressed(terminalId)) {
6941
+ let others = 0;
6942
+ for (const [id, t] of this.terms) {
6943
+ if (t.flushTimer !== null && !this.relaySuppressed(id)) others += 1;
6944
+ }
6945
+ delay2 = others > 0 ? this.flushMs * 2 : this.flushMs;
6946
+ }
6098
6947
  term.flushTimer = setTimeout(() => {
6099
6948
  term.flushTimer = null;
6100
6949
  this.flush(terminalId, term);
@@ -6635,9 +7484,36 @@ async function compose(cfg, lock, host, preflight) {
6635
7484
  const pty = await loadPty();
6636
7485
  const memberTerminalIds = memberTerminalIdsOf(cfg);
6637
7486
  const kinds2 = handledKinds({ terminal: pty !== null });
7487
+ const localIdentity = createLocalIdentity();
7488
+ const origins = buildOrigins(cfg.localOrigins);
7489
+ if (CLOUD_SUPABASE_URL !== "" && CLOUD_APP_ORIGIN === "") {
7490
+ logger.warn(
7491
+ { origins },
7492
+ "local origins: dev origins enabled because CAYENNE_CLOUD_APP_ORIGIN is empty"
7493
+ );
7494
+ }
7495
+ logger.info({ origins }, "local origins");
7496
+ const local = createLocalServer({ port: cfg.port, origins, log: logger });
7497
+ let localPort = 0;
7498
+ if (cfg.port < LOCAL_PORT_MIN || cfg.port > LOCAL_PORT_MAX) {
7499
+ logger.warn(
7500
+ { port: cfg.port },
7501
+ `local port ${cfg.port} is outside ${LOCAL_PORT_MIN}\u2013${LOCAL_PORT_MAX}; fast path disabled for this run`
7502
+ );
7503
+ } else {
7504
+ try {
7505
+ localPort = await local.listen();
7506
+ } catch (err) {
7507
+ logger.warn(
7508
+ { port: cfg.port, err: err instanceof Error ? err.message : String(err) },
7509
+ "local server could not bind; fast path disabled for this run"
7510
+ );
7511
+ }
7512
+ }
6638
7513
  const capabilities = () => ({
6639
7514
  kinds: [...kinds2],
6640
- local_port: cfg.port,
7515
+ local_port: localPort,
7516
+ local_pubkey: localIdentity.publicKey,
6641
7517
  claude: true,
6642
7518
  providers,
6643
7519
  // Who the runner's own config names (D34); every heartbeat refresh republishes it.
@@ -6654,17 +7530,27 @@ async function compose(cfg, lock, host, preflight) {
6654
7530
  }
6655
7531
  return capabilities();
6656
7532
  };
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);
7533
+ let identity;
7534
+ try {
7535
+ identity = await registerRunner({
7536
+ db,
7537
+ cfg: registerAs === cfg.runnerId ? cfg : { ...cfg, runnerId: void 0 },
7538
+ hostname: hostname(),
7539
+ profile,
7540
+ platform: `${process.platform}-${process.arch}`,
7541
+ version: VERSION,
7542
+ capabilities: registered
7543
+ });
7544
+ } catch (err) {
7545
+ await local.close();
7546
+ throw err;
7547
+ }
7548
+ try {
7549
+ lock.take(identity.runnerId);
7550
+ } catch (err) {
7551
+ await local.close();
7552
+ throw err;
7553
+ }
6668
7554
  let stopHeartbeat = null;
6669
7555
  let roster = null;
6670
7556
  try {
@@ -6699,11 +7585,14 @@ async function compose(cfg, lock, host, preflight) {
6699
7585
  codexAvailable: () => providers.codex.available,
6700
7586
  claudeEnv: claudeEnv(claudeConfigDir),
6701
7587
  git,
6702
- log: logger
7588
+ log: logger,
7589
+ // Every session event also reaches the loopback sockets subscribed to it (Task 10a).
7590
+ tap: (sessionId, event) => local.emit(sessionId, event)
6703
7591
  });
6704
7592
  let terminals;
7593
+ let terminalManager = null;
6705
7594
  if (pty) {
6706
- const terminalManager = new TerminalManager({
7595
+ const tm = new TerminalManager({
6707
7596
  pty,
6708
7597
  roots,
6709
7598
  env: process.env,
@@ -6726,16 +7615,17 @@ async function compose(cfg, lock, host, preflight) {
6726
7615
  memberTerminalIds,
6727
7616
  serves: (userId) => liveRoster.serves(userId),
6728
7617
  rosterHealthy: () => liveRoster.healthy(),
6729
- manager: terminalManager,
6730
- channels: createTerminalChannels({ db, manager: terminalManager, log: logger }),
7618
+ manager: tm,
7619
+ channels: createTerminalChannels({ db, manager: tm, log: logger }),
6731
7620
  log: logger
6732
7621
  });
6733
7622
  liveRoster.onRefresh(() => {
6734
- terminalManager.revalidate();
7623
+ tm.revalidate();
6735
7624
  void service2.reconcileRows();
6736
7625
  });
6737
7626
  await service2.closeOpenRows();
6738
7627
  terminals = service2;
7628
+ terminalManager = tm;
6739
7629
  }
6740
7630
  const dispatch = createDispatcher(
6741
7631
  buildHandlers(manager, {
@@ -6758,6 +7648,43 @@ async function compose(cfg, lock, host, preflight) {
6758
7648
  );
6759
7649
  stopHeartbeat = heartbeat;
6760
7650
  await source.start();
7651
+ if (localPort !== 0) {
7652
+ local.attach({
7653
+ runnerId: identity.runnerId,
7654
+ version: VERSION,
7655
+ identity: localIdentity,
7656
+ // R-B4: a token whose subject the roster does not serve (or whose payload is malformed or
7657
+ // expired) is refused inside the verifier, before any GoTrue call — junk on the loopback
7658
+ // port cannot rate-limit the real member's `/user` check.
7659
+ verifier: createTokenVerifier({
7660
+ db,
7661
+ admit: (sub) => liveRoster.serves(sub),
7662
+ log: logger
7663
+ }),
7664
+ // The roster's own predicate, read live: the owner while a member, listed members while
7665
+ // still members (PR16); every refresh re-checks every socket (SEC-P2).
7666
+ serves: (userId) => liveRoster.serves(userId),
7667
+ onRosterRefresh: (cb) => liveRoster.onRefresh(cb),
7668
+ // Exactly the manager's rule (ruling 14, R2-H1), built from the same deps it was given.
7669
+ mayHoldTerminal: (userId) => mayHoldTerminal(userId, {
7670
+ runnerUserId: identity.userId,
7671
+ memberTerminalIds,
7672
+ serves: (u) => liveRoster.serves(u),
7673
+ rosterHealthy: () => liveRoster.healthy()
7674
+ }),
7675
+ source,
7676
+ readRow: async (id) => {
7677
+ const { data } = await db.from("runner_commands").select("*").eq("id", id).maybeSingle();
7678
+ const parsed = CommandRow.safeParse(data);
7679
+ return parsed.success ? parsed.data : null;
7680
+ },
7681
+ readSession: async (id) => {
7682
+ const { data } = await db.from("chat_sessions").select("runner_id, owner_id, visibility").eq("id", id).maybeSingle();
7683
+ return data;
7684
+ },
7685
+ terminals: terminalManager
7686
+ });
7687
+ }
6761
7688
  logger.info(
6762
7689
  {
6763
7690
  runnerId: identity.runnerId,
@@ -6792,6 +7719,7 @@ async function compose(cfg, lock, host, preflight) {
6792
7719
  await stage("terminals.shutdown", async () => {
6793
7720
  await terminals?.shutdown();
6794
7721
  });
7722
+ await stage("local.close", () => local.close());
6795
7723
  await stage("roster.stop", () => liveRoster.stop());
6796
7724
  if (failures.length > 0) throw failures[0];
6797
7725
  } finally {
@@ -6858,6 +7786,7 @@ async function compose(cfg, lock, host, preflight) {
6858
7786
  "runner roster stop failed during a failed start"
6859
7787
  );
6860
7788
  }
7789
+ await local.close().catch(() => void 0);
6861
7790
  await markOffline(db, identity.runnerId);
6862
7791
  throw err;
6863
7792
  }
@@ -6892,6 +7821,7 @@ var init_start = __esm({
6892
7821
  "use strict";
6893
7822
  init_src();
6894
7823
  init_claude_dir();
7824
+ init_cloud();
6895
7825
  init_detect();
6896
7826
  init_dispatcher();
6897
7827
  init_source();
@@ -6899,6 +7829,10 @@ var init_start = __esm({
6899
7829
  init_ops();
6900
7830
  init_session_worktrees();
6901
7831
  init_worktree_owners();
7832
+ init_auth();
7833
+ init_identity();
7834
+ init_origins();
7835
+ init_server();
6902
7836
  init_lock();
6903
7837
  init_log();
6904
7838
  init_profile();
@@ -6944,6 +7878,9 @@ async function logout(db) {
6944
7878
  await db.auth.signOut();
6945
7879
  }
6946
7880
 
7881
+ // src/cli.ts
7882
+ init_cloud();
7883
+
6947
7884
  // src/commands/doctor.ts
6948
7885
  async function probeBackend(url, anonKey, fetchFn = fetch) {
6949
7886
  try {
@@ -7022,6 +7959,19 @@ async function doctor(deps) {
7022
7959
  fix: TERMINAL_FIX
7023
7960
  }
7024
7961
  );
7962
+ const devOriginsInPublishedBuild = deps.cloud.supabaseUrl !== "" && deps.cloud.appOrigin === "";
7963
+ checks.push(
7964
+ devOriginsInPublishedBuild ? {
7965
+ name: "local origins",
7966
+ ok: false,
7967
+ detail: "local origins: dev origins enabled because CAYENNE_CLOUD_APP_ORIGIN is empty",
7968
+ fix: "Rebuild the runner with CAYENNE_CLOUD_APP_ORIGIN set to the app origin (tsup.config.ts `define`)."
7969
+ } : {
7970
+ name: "local origins",
7971
+ ok: true,
7972
+ detail: deps.cloud.appOrigin || "source checkout: dev origins"
7973
+ }
7974
+ );
7025
7975
  return checks;
7026
7976
  }
7027
7977
 
@@ -7495,7 +8445,8 @@ program.command("doctor").description("Check the install and say how to fix what
7495
8445
  probe: (url) => probeBackend(url, backend?.anonKey ?? ""),
7496
8446
  runnerId,
7497
8447
  holder: runnerId ? holderOf({ root: cayenneHome(), runnerId }) : null,
7498
- pty: await loadPty() !== null
8448
+ pty: await loadPty() !== null,
8449
+ cloud: { appOrigin: CLOUD_APP_ORIGIN, supabaseUrl: CLOUD_SUPABASE_URL }
7499
8450
  });
7500
8451
  for (const c of checks) {
7501
8452
  console.log(`${c.ok ? "ok " : "FAIL"} ${c.name}: ${c.detail}`);