@jpcasabiancai/cayenne 0.3.3 → 0.3.5

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 +987 -159
  2. package/package.json +7 -3
package/dist/cli.js CHANGED
@@ -27,11 +27,11 @@ var CLOUD_SUPABASE_URL, CLOUD_SUPABASE_ANON_KEY, CLOUD_APP_ORIGIN;
27
27
  var init_cloud = __esm({
28
28
  "src/cloud.ts"() {
29
29
  "use strict";
30
- CLOUD_SUPABASE_URL = "";
30
+ CLOUD_SUPABASE_URL = "https://ndqrkqxwkzxgnhkvcunr.supabase.co";
31
31
  CLOUD_SUPABASE_ANON_KEY = assertPublishable(
32
- ""
32
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5kcXJrcXh3a3p4Z25oa3ZjdW5yIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODg3MTMyNzYsImV4cCI6MjEwNDI4OTI3Nn0.Dlsxw91E1QYZ4eoRDjP80Rf4feY77Z-T-fiIwUDY2k0"
33
33
  );
34
- CLOUD_APP_ORIGIN = "";
34
+ CLOUD_APP_ORIGIN = "https://cayenne-pink.vercel.app";
35
35
  }
36
36
  });
37
37
 
@@ -632,6 +632,48 @@ var init_content = __esm({
632
632
  }
633
633
  });
634
634
 
635
+ // ../../packages/shared/src/protocol/documents.ts
636
+ function documentFilename(relPath) {
637
+ const leaf = relPath.split(/[/\\]/).at(-1) ?? "";
638
+ const folded = leaf.replace(OUTSIDE_CLASS, "-");
639
+ const prefixed = /^[A-Za-z0-9]/.test(folded) ? folded : `d-${folded}`;
640
+ return prefixed.replace(DASH_RUN, "-").slice(0, MAX_FILENAME);
641
+ }
642
+ function documentKindFor(filename) {
643
+ const dot = filename.lastIndexOf(".");
644
+ const ext = dot === -1 ? "" : filename.slice(dot + 1).toLowerCase();
645
+ return KIND_BY_EXTENSION.get(ext) ?? "file";
646
+ }
647
+ function documentPath(workspaceId, documentId, filename) {
648
+ const path = `${workspaceId.toLowerCase()}/${documentId.toLowerCase()}/${filename}`;
649
+ if (!DOCUMENT_NAME.test(path)) {
650
+ throw new Error(`VALIDATION: not a valid document name: ${path}`);
651
+ }
652
+ return path;
653
+ }
654
+ var DOCUMENTS_BUCKET, DOCUMENT_MAX_BYTES, DOCUMENT_TITLE_MAX, DOCUMENTS_DIR, DOCUMENTS_MAX_FILES, DOCUMENT_REL_PATH_MAX, DOCUMENT_NAME, KIND_BY_EXTENSION, MAX_FILENAME, OUTSIDE_CLASS, DASH_RUN;
655
+ var init_documents = __esm({
656
+ "../../packages/shared/src/protocol/documents.ts"() {
657
+ "use strict";
658
+ DOCUMENTS_BUCKET = "documents";
659
+ DOCUMENT_MAX_BYTES = 2 * 1024 * 1024;
660
+ DOCUMENT_TITLE_MAX = 200;
661
+ DOCUMENTS_DIR = ".cayenne/docs";
662
+ DOCUMENTS_MAX_FILES = 200;
663
+ DOCUMENT_REL_PATH_MAX = 1024;
664
+ DOCUMENT_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
665
+ KIND_BY_EXTENSION = /* @__PURE__ */ new Map([
666
+ ["md", "markdown"],
667
+ ["markdown", "markdown"],
668
+ ["html", "html"],
669
+ ["htm", "html"]
670
+ ]);
671
+ MAX_FILENAME = 200;
672
+ OUTSIDE_CLASS = /[^A-Za-z0-9._-]/g;
673
+ DASH_RUN = /-{2,}/g;
674
+ }
675
+ });
676
+
635
677
  // ../../packages/shared/src/protocol/events.ts
636
678
  import { z as z7 } from "zod";
637
679
  var SessionStatus, SessionEvent, SESSION_EVENT_NAME, sessionTopic, runnerTopic, terminalTopic;
@@ -816,6 +858,13 @@ var init_runner = __esm({
816
858
  * older runner will never handle. Absent reads as false; optional so an older row parses.
817
859
  */
818
860
  skills: z9.boolean().optional(),
861
+ /**
862
+ * Whether this runner mirrors `.cayenne/docs` into `public.documents` (D40, protocol §9). No kind
863
+ * carries documents, so the flag is the only signal: the tab says "this runner does not publish
864
+ * documents" instead of "nothing written yet". Absent reads as false; optional so an older row
865
+ * parses.
866
+ */
867
+ documents: z9.boolean().optional(),
819
868
  /**
820
869
  * The raw Ed25519 public key of the running process (32 bytes, unpadded base64url), which signs
821
870
  * the local server's `hello` (protocol §6.2). New on every start, republished by every capabilities refresh that changes the row;
@@ -861,6 +910,7 @@ var init_protocol = __esm({
861
910
  init_attachments();
862
911
  init_command();
863
912
  init_content();
913
+ init_documents();
864
914
  init_events();
865
915
  init_git();
866
916
  init_github();
@@ -1596,11 +1646,13 @@ var init_lock = __esm({
1596
1646
  });
1597
1647
 
1598
1648
  // ../../packages/db/src/client.ts
1599
- import { createClient } from "@supabase/supabase-js";
1600
- function createRunnerClient(url, anonKey, storage) {
1649
+ import {
1650
+ createClient
1651
+ } from "@supabase/supabase-js";
1652
+ function createRunnerClient(url, anonKey, storage, transport) {
1601
1653
  return createClient(url, anonKey, {
1602
1654
  auth: { storage, persistSession: true, autoRefreshToken: true, detectSessionInUrl: false },
1603
- realtime: { params: { eventsPerSecond: 50 } }
1655
+ realtime: { params: { eventsPerSecond: 50 }, transport }
1604
1656
  });
1605
1657
  }
1606
1658
  var init_client = __esm({
@@ -1617,17 +1669,38 @@ var init_src2 = __esm({
1617
1669
  }
1618
1670
  });
1619
1671
 
1672
+ // src/realtime-transport.ts
1673
+ import { WebSocket } from "ws";
1674
+ function realtimeTransport(handshakeTimeoutMs = REALTIME_HANDSHAKE_TIMEOUT_MS) {
1675
+ return class RealtimeTransport2 extends WebSocket {
1676
+ static handshakeTimeoutMs = handshakeTimeoutMs;
1677
+ constructor(address, protocols) {
1678
+ super(address, protocols, { handshakeTimeout: RealtimeTransport2.handshakeTimeoutMs });
1679
+ }
1680
+ };
1681
+ }
1682
+ var REALTIME_HANDSHAKE_TIMEOUT_MS, RealtimeTransport;
1683
+ var init_realtime_transport = __esm({
1684
+ "src/realtime-transport.ts"() {
1685
+ "use strict";
1686
+ REALTIME_HANDSHAKE_TIMEOUT_MS = 1e4;
1687
+ RealtimeTransport = realtimeTransport();
1688
+ }
1689
+ });
1690
+
1620
1691
  // src/supabase.ts
1621
1692
  import { join as join7 } from "path";
1622
1693
  function makeClient(cfg, dir = cayenneDir()) {
1623
1694
  const storage = new FileSessionStorage(join7(dir, "credentials.json"));
1624
- return createRunnerClient(cfg.supabaseUrl, cfg.supabaseAnonKey, storage);
1695
+ const transport = RealtimeTransport;
1696
+ return createRunnerClient(cfg.supabaseUrl, cfg.supabaseAnonKey, storage, transport);
1625
1697
  }
1626
1698
  var init_supabase = __esm({
1627
1699
  "src/supabase.ts"() {
1628
1700
  "use strict";
1629
1701
  init_src2();
1630
1702
  init_config();
1703
+ init_realtime_transport();
1631
1704
  }
1632
1705
  });
1633
1706
 
@@ -1928,12 +2001,17 @@ var init_dispatcher = __esm({
1928
2001
 
1929
2002
  // src/commands/source.ts
1930
2003
  import { LRUCache } from "lru-cache";
1931
- var STOPPING, byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
2004
+ var STOPPING, REJOIN_MIN_MS, REJOIN_MAX_MS, SOCKET_RESET_MS, LOST, messageOf, byCreatedAt, GIT_READ_KINDS, GIT_WORKTREE_KINDS, laneOf, CommandSource;
1932
2005
  var init_source = __esm({
1933
2006
  "src/commands/source.ts"() {
1934
2007
  "use strict";
1935
2008
  init_src();
1936
2009
  STOPPING = "runner stopping";
2010
+ REJOIN_MIN_MS = 1e3;
2011
+ REJOIN_MAX_MS = 3e4;
2012
+ SOCKET_RESET_MS = 6e4;
2013
+ LOST = /* @__PURE__ */ new Set(["CHANNEL_ERROR", "TIMED_OUT", "CLOSED"]);
2014
+ messageOf = (err) => err instanceof Error ? err.message : String(err);
1937
2015
  byCreatedAt = (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id.localeCompare(b.id);
1938
2016
  GIT_READ_KINDS = /* @__PURE__ */ new Set(["git.status", "git.diff", "git.branches"]);
1939
2017
  GIT_WORKTREE_KINDS = /* @__PURE__ */ new Set([
@@ -1968,32 +2046,175 @@ var init_source = __esm({
1968
2046
  /** Rows between `enqueue` and their settlement: an `offer` for one of these registers hooks only. */
1969
2047
  inflight = /* @__PURE__ */ new Set();
1970
2048
  queues = /* @__PURE__ */ new Map();
2049
+ /** The live `runner:{id}` instance; only its statuses count (see `onStatus`). */
1971
2050
  channel = null;
2051
+ /** Every instance this source gave up on: the client can hand one back if its removal did not take. */
2052
+ abandoned = /* @__PURE__ */ new WeakSet();
2053
+ /** Removals of abandoned instances; never rejects. A rejoin waits for it before reusing the topic. */
2054
+ removal = Promise.resolve();
2055
+ rejoinTimer = null;
2056
+ /** Rejoin attempts scheduled since the last `SUBSCRIBED`: the backoff step. */
2057
+ attempts = 0;
2058
+ /** `now()` at the first attempt that found the socket down, or at its last reset; null while up. */
2059
+ socketDownSince = null;
1972
2060
  staleMs;
1973
2061
  now;
1974
2062
  stopped = false;
1975
2063
  /** The in-flight `drain()`, so `stop()` can wait for work it never started itself. */
1976
2064
  draining = null;
2065
+ /** A `drain()` arrived while one was in flight: that one reads once more before it ends. */
2066
+ again = false;
1977
2067
  /** Broadcasts held back while a drain is in flight; see `drain()` for why. */
1978
2068
  pending = [];
2069
+ /** Join `runner:{id}` at once; no connectedness check, since `subscribe` connects the socket. */
1979
2070
  async start() {
1980
- this.channel = this.db.channel(runnerTopic(this.runnerId), { config: { private: true } }).on("broadcast", { event: "command" }, ({ payload }) => {
1981
- const record = payload?.record;
1982
- const parsed = CommandRow.safeParse(record);
1983
- if (!parsed.success) {
1984
- this.log.warn({ issues: parsed.error.issues }, "ignoring malformed command broadcast");
2071
+ this.join(this.runnerChannel());
2072
+ }
2073
+ /**
2074
+ * The one place a `runner:{id}` instance is asked for, first join and rejoins alike. Private:
2075
+ * Realtime authorises the join against RLS, which is what keeps this topic this runner's.
2076
+ */
2077
+ runnerChannel() {
2078
+ return this.db.channel(runnerTopic(this.runnerId), { config: { private: true } });
2079
+ }
2080
+ /** Make `ch` the live instance, then give it the one broadcast and status handler every instance gets. */
2081
+ join(ch) {
2082
+ this.channel = ch;
2083
+ ch.on("broadcast", { event: "command" }, ({ payload }) => this.onBroadcast(payload)).subscribe(
2084
+ (status, err) => this.onStatus(ch, status, err)
2085
+ );
2086
+ }
2087
+ onBroadcast(payload) {
2088
+ const record = payload?.record;
2089
+ const parsed = CommandRow.safeParse(record);
2090
+ if (!parsed.success) {
2091
+ this.log.warn({ issues: parsed.error.issues }, "ignoring malformed command broadcast");
2092
+ return;
2093
+ }
2094
+ if (parsed.data.runner_id !== this.runnerId) {
2095
+ this.log.warn({ id: parsed.data.id }, "ignoring command for another runner");
2096
+ return;
2097
+ }
2098
+ if (this.draining) this.pending.push(parsed.data);
2099
+ else this.enqueue(parsed.data);
2100
+ }
2101
+ /**
2102
+ * The subscribe callback of every instance this source joined (BACKLOG P-29, protocol §8 step
2103
+ * 5). Nothing else rejoins a lost channel: after a three-hour outage the client's own rejoin never
2104
+ * ran, because its socket never reopened. So a lost instance is abandoned and a fresh one joined
2105
+ * after a backoff; every `SUBSCRIBED` drains, since broadcasts sent while the channel was down
2106
+ * are gone and their rows sit `queued`.
2107
+ *
2108
+ * Only the live instance counts. An abandoned one still reports: its own removal fires `CLOSED`
2109
+ * in-line, and handling that as a loss would remove the instance again, whose leave fires
2110
+ * `CLOSED` again, and so on: removals nested inside removals.
2111
+ */
2112
+ onStatus(ch, status, err) {
2113
+ if (ch !== this.channel) {
2114
+ this.log.debug({ status }, "ignoring the status of a runner channel no longer in use");
2115
+ return;
2116
+ }
2117
+ this.log.info({ status, err: err?.message }, "runner channel status");
2118
+ if (status === "SUBSCRIBED") {
2119
+ this.attempts = 0;
2120
+ this.socketDownSince = null;
2121
+ void this.drain();
2122
+ } else if (LOST.has(status)) {
2123
+ this.abandon(ch);
2124
+ this.scheduleRejoin(status);
2125
+ }
2126
+ }
2127
+ /** Stop listening to `ch` (null first, so its removal's `CLOSED` is ignored), then remove it. */
2128
+ abandon(ch) {
2129
+ if (this.channel === ch) this.channel = null;
2130
+ this.abandoned.add(ch);
2131
+ const left = (async () => this.db.removeChannel(ch))().then(
2132
+ (result) => {
2133
+ if (result === "ok" || result === "timed out") return true;
2134
+ this.log.warn({ result }, "leaving the runner channel failed");
2135
+ return false;
2136
+ },
2137
+ (err) => {
2138
+ this.log.warn({ err: messageOf(err) }, "leaving the runner channel failed");
2139
+ return false;
2140
+ }
2141
+ );
2142
+ this.removal = Promise.all([this.removal, left]).then(() => {
2143
+ });
2144
+ return left;
2145
+ }
2146
+ /**
2147
+ * At most one pending attempt, `min(1 s * 2^n, 30 s)` away, n = attempts since the last
2148
+ * `SUBSCRIBED`. `reason` says why: the lost channel's status, or `SOCKET_DOWN`, `NOT_REMOVED`,
2149
+ * `JOIN_FAILED` for an attempt that could not join. It is logged as `reason`, not `status`, so
2150
+ * a search for the client's statuses never meets one of these.
2151
+ */
2152
+ scheduleRejoin(reason) {
2153
+ if (this.stopped || this.rejoinTimer) return;
2154
+ const delayMs = Math.min(REJOIN_MIN_MS * 2 ** this.attempts, REJOIN_MAX_MS);
2155
+ this.attempts += 1;
2156
+ this.log.warn({ reason, attempt: this.attempts, delayMs }, "runner channel lost; rejoining");
2157
+ this.rejoinTimer = setTimeout(() => {
2158
+ this.rejoinTimer = null;
2159
+ void this.rejoin();
2160
+ }, delayMs);
2161
+ }
2162
+ /**
2163
+ * One attempt. The client hands back the instance it holds for a topic and drops a topic's
2164
+ * entry when any instance of it closes, so the old removal must have finished before a fresh
2165
+ * instance is created. A socket that is down gets a `connect()` and no join: it buffers every
2166
+ * push while down and flushes them all on reopen, so a join per attempt through a long outage
2167
+ * would arrive as a burst of joins and leaves.
2168
+ */
2169
+ async rejoin() {
2170
+ await this.removal;
2171
+ if (this.stopped) return;
2172
+ try {
2173
+ if (!this.db.realtime.isConnected()) {
2174
+ await this.reconnectSocket();
2175
+ this.scheduleRejoin("SOCKET_DOWN");
1985
2176
  return;
1986
2177
  }
1987
- if (parsed.data.runner_id !== this.runnerId) {
1988
- this.log.warn({ id: parsed.data.id }, "ignoring command for another runner");
2178
+ this.socketDownSince = null;
2179
+ const ch = this.runnerChannel();
2180
+ if (this.abandoned.has(ch)) {
2181
+ this.log.warn({ attempt: this.attempts }, "runner channel still held after leaving it");
2182
+ this.abandon(ch);
2183
+ this.scheduleRejoin("NOT_REMOVED");
1989
2184
  return;
1990
2185
  }
1991
- if (this.draining) this.pending.push(parsed.data);
1992
- else this.enqueue(parsed.data);
1993
- }).subscribe((status, err) => {
1994
- this.log.info({ status, err: err?.message }, "runner channel status");
1995
- if (status === "SUBSCRIBED") void this.drain();
1996
- });
2186
+ this.join(ch);
2187
+ } catch (err) {
2188
+ this.log.warn(
2189
+ { attempt: this.attempts, err: messageOf(err) },
2190
+ "runner channel rejoin failed"
2191
+ );
2192
+ if (this.channel) this.abandon(this.channel);
2193
+ this.scheduleRejoin("JOIN_FAILED");
2194
+ }
2195
+ }
2196
+ /**
2197
+ * Ask a down socket to connect. Once attempts have found it down for `SOCKET_RESET_MS`, tear it
2198
+ * down first: `connect()` returns early while the client still holds a conn, and a conn whose
2199
+ * handshake failed without a close (Node's own WebSocket, BACKLOG P-29) is held forever. The
2200
+ * `ws` transport should never leave one; this clears it whatever the transport. In an ordinary
2201
+ * outage it costs nothing, since phoenix's reconnect loop carries on from this `connect()`.
2202
+ */
2203
+ async reconnectSocket() {
2204
+ const now = this.now();
2205
+ this.socketDownSince ??= now;
2206
+ const downMs = now - this.socketDownSince;
2207
+ if (downMs >= SOCKET_RESET_MS) {
2208
+ this.log.warn({ downMs }, "realtime socket still down; resetting it");
2209
+ this.socketDownSince = now;
2210
+ try {
2211
+ await this.db.realtime.disconnect();
2212
+ } catch (err) {
2213
+ this.log.warn({ err: messageOf(err) }, "realtime socket reset failed");
2214
+ }
2215
+ if (this.stopped) return;
2216
+ }
2217
+ this.db.realtime.connect();
1997
2218
  }
1998
2219
  /**
1999
2220
  * Pick up rows queued while the runner was offline, oldest first.
@@ -2001,15 +2222,37 @@ var init_source = __esm({
2001
2222
  * Broadcasts that land while the select is in flight are buffered and merged in here rather
2002
2223
  * than enqueued as they arrive: every drained row predates them, and protocol §2.1 requires
2003
2224
  * one session's commands to execute in `created_at` order.
2225
+ *
2226
+ * One drain at a time: a call while one is in flight (a rejoin's `SUBSCRIBED`) asks it for one
2227
+ * more read and waits for it. A second, overlapping select would release the buffer while the
2228
+ * first was still reading older rows. Rows and buffered broadcasts of every read are merged once,
2229
+ * at the end, because a row only the later read finds can predate a broadcast held earlier.
2004
2230
  */
2005
- async drain() {
2006
- if (this.stopped) return;
2007
- let release = () => {
2008
- };
2009
- this.draining = new Promise((res) => {
2010
- release = res;
2011
- });
2231
+ drain() {
2232
+ if (this.stopped) return Promise.resolve();
2233
+ if (this.draining) {
2234
+ this.again = true;
2235
+ return this.draining;
2236
+ }
2237
+ this.draining = this.drainAll();
2238
+ return this.draining;
2239
+ }
2240
+ async drainAll() {
2012
2241
  const rows = [];
2242
+ try {
2243
+ do {
2244
+ this.again = false;
2245
+ await this.readQueued(rows);
2246
+ } while (this.again && !this.stopped);
2247
+ } finally {
2248
+ const merged = [...rows, ...this.pending].sort(byCreatedAt);
2249
+ this.pending = [];
2250
+ this.draining = null;
2251
+ for (const row of merged) this.enqueue(row);
2252
+ }
2253
+ }
2254
+ /** One read of this runner's `queued` rows, appended to `rows`. Never rejects. */
2255
+ async readQueued(rows) {
2013
2256
  try {
2014
2257
  const { data, error } = await this.db.from("runner_commands").select("*").eq("runner_id", this.runnerId).eq("status", "queued").order("created_at", { ascending: true });
2015
2258
  if (error) this.log.error({ err: error.message }, "drain failed");
@@ -2019,17 +2262,16 @@ var init_source = __esm({
2019
2262
  }
2020
2263
  } catch (err) {
2021
2264
  this.log.error({ err }, "drain failed");
2022
- } finally {
2023
- const merged = [...rows, ...this.pending].sort(byCreatedAt);
2024
- this.pending = [];
2025
- this.draining = null;
2026
- for (const row of merged) this.enqueue(row);
2027
- release();
2028
2265
  }
2029
2266
  }
2030
2267
  async stop() {
2031
2268
  this.stopped = true;
2032
- if (this.channel) await this.db.removeChannel(this.channel);
2269
+ if (this.rejoinTimer) clearTimeout(this.rejoinTimer);
2270
+ this.rejoinTimer = null;
2271
+ const live = this.channel;
2272
+ const left = live ? this.abandon(live) : null;
2273
+ await this.removal;
2274
+ if (left && await left) this.log.info({ status: "CLOSED" }, "runner channel status");
2033
2275
  this.pending = [];
2034
2276
  await this.draining;
2035
2277
  while (this.queues.size > 0) await Promise.allSettled([...this.queues.values()]);
@@ -3445,7 +3687,7 @@ function createTokenVerifier({
3445
3687
  ttlMs = LOCAL_TOKEN_CACHE_MS,
3446
3688
  negativeTtlMs = LOCAL_TOKEN_NEGATIVE_CACHE_MS,
3447
3689
  now = Date.now,
3448
- admit = () => true,
3690
+ admit: admit2 = () => true,
3449
3691
  log
3450
3692
  }) {
3451
3693
  const cache = new LRUCache2({ max: 1024 });
@@ -3489,7 +3731,7 @@ function createTokenVerifier({
3489
3731
  }
3490
3732
  cache.delete(k);
3491
3733
  const claims = claimsOf(token);
3492
- if (!claims || claims.exp * 1e3 <= now() || !admit(claims.sub)) return refuse3(k);
3734
+ if (!claims || claims.exp * 1e3 <= now() || !admit2(claims.sub)) return refuse3(k);
3493
3735
  if (!budgeted()) return { status: "unavailable" };
3494
3736
  const user = await db.auth.getUser(token).then(
3495
3737
  (r) => r.error ? retryable(r.error) ? void 0 : null : r.data.user,
@@ -4651,6 +4893,522 @@ var init_handlers = __esm({
4651
4893
  }
4652
4894
  });
4653
4895
 
4896
+ // src/documents/paths.ts
4897
+ import { realpathSync as realpathSync8 } from "fs";
4898
+ import { isAbsolute as isAbsolute10, join as join16, relative as relative4, sep as sep5 } from "path";
4899
+ function contained(dir, candidate) {
4900
+ const rel = relative4(dir, candidate);
4901
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep5}`) && !isAbsolute10(rel);
4902
+ }
4903
+ function resolveDocsDir(cwd, log) {
4904
+ const dir = docsDir(cwd);
4905
+ let real;
4906
+ try {
4907
+ real = realpathSync8.native(dir);
4908
+ } catch {
4909
+ return null;
4910
+ }
4911
+ if (!contained(cwd, real)) {
4912
+ log.warn(
4913
+ { dir, resolved: real, cwd },
4914
+ "documents directory skipped: it resolves outside the session working directory"
4915
+ );
4916
+ return null;
4917
+ }
4918
+ return real;
4919
+ }
4920
+ function assertInsideDocsDir(dir, candidate) {
4921
+ let real;
4922
+ try {
4923
+ real = realpathSync8.native(candidate);
4924
+ } catch {
4925
+ throw new Error(`ROOT_NOT_ALLOWED: ${candidate} cannot be resolved`);
4926
+ }
4927
+ if (!contained(dir, real)) throw new Error(`ROOT_NOT_ALLOWED: ${candidate}`);
4928
+ return real;
4929
+ }
4930
+ var docsDir;
4931
+ var init_paths2 = __esm({
4932
+ "src/documents/paths.ts"() {
4933
+ "use strict";
4934
+ init_src();
4935
+ docsDir = (cwd) => join16(cwd, ...DOCUMENTS_DIR.split("/"));
4936
+ }
4937
+ });
4938
+
4939
+ // src/documents/mirror.ts
4940
+ async function syncDocuments(deps, pass) {
4941
+ const log = deps.log ?? logger;
4942
+ const { db } = deps;
4943
+ const result = { inserted: 0, updated: 0, missing: 0 };
4944
+ let known = null;
4945
+ try {
4946
+ const { data, error } = await db.from("documents").select(SELECT).eq("session_id", deps.sessionId).eq("owner_id", deps.ownerId).limit(DOCUMENTS_MIRROR_MAX_ROWS);
4947
+ if (error) throw new Error(error.message);
4948
+ known = data ?? [];
4949
+ } catch (err) {
4950
+ log.warn(
4951
+ { err: said(err), sessionId: deps.sessionId },
4952
+ "could not read this session\u2019s document rows; writing nothing and reconciling nothing"
4953
+ );
4954
+ return result;
4955
+ }
4956
+ const byPath = /* @__PURE__ */ new Map();
4957
+ for (const row of known) if (row.rel_path !== null) byPath.set(row.rel_path, row);
4958
+ const upload = async (path, doc) => {
4959
+ try {
4960
+ const { error } = await db.storage.from(DOCUMENTS_BUCKET).upload(path, doc.bytes, { contentType: contentTypeFor(doc), upsert: true });
4961
+ return error ? error.message : null;
4962
+ } catch (err) {
4963
+ return said(err);
4964
+ }
4965
+ };
4966
+ const publish2 = async (row, doc) => {
4967
+ let expected;
4968
+ try {
4969
+ expected = documentPath(deps.workspaceId, row.id, doc.filename);
4970
+ } catch (err) {
4971
+ return said(err);
4972
+ }
4973
+ if (row.storage_path !== expected) {
4974
+ return `storage_path ${String(row.storage_path)} is not ${expected}`;
4975
+ }
4976
+ return upload(expected, doc);
4977
+ };
4978
+ const takeBack = async (id, relPath) => {
4979
+ const { error } = await db.from("documents").delete().eq("id", id);
4980
+ if (error) {
4981
+ log.warn(
4982
+ { err: error.message, id, relPath, sessionId: deps.sessionId },
4983
+ "could not remove a document row that has no object"
4984
+ );
4985
+ }
4986
+ };
4987
+ for (const doc of pass.write) {
4988
+ const before = byPath.get(doc.relPath);
4989
+ try {
4990
+ if (before === void 0) {
4991
+ const { data, error: error2 } = await db.from("documents").insert(rowOf(deps, doc)).select("id, storage_path").single();
4992
+ if (error2) throw new Error(error2.message);
4993
+ const row = data;
4994
+ if (!row?.id) throw new Error("the insert returned no row");
4995
+ const failed3 = await publish2(row, doc);
4996
+ if (failed3 !== null) {
4997
+ log.warn(
4998
+ { err: failed3, relPath: doc.relPath, sessionId: deps.sessionId },
4999
+ "could not upload a document; removing the row so the next pass retries it"
5000
+ );
5001
+ await takeBack(row.id, doc.relPath);
5002
+ continue;
5003
+ }
5004
+ result.inserted += 1;
5005
+ continue;
5006
+ }
5007
+ const rewritten = before.content_hash !== doc.contentHash;
5008
+ const restated = before.status !== "present" || before.title !== doc.title || before.kind !== doc.kind || before.size_bytes !== doc.size;
5009
+ if (!rewritten && !restated) continue;
5010
+ if (rewritten) {
5011
+ const failed3 = await upload(before.storage_path, doc);
5012
+ if (failed3 !== null) {
5013
+ log.warn(
5014
+ { err: failed3, relPath: doc.relPath, sessionId: deps.sessionId },
5015
+ "could not overwrite a document; its row still describes the bytes in the bucket"
5016
+ );
5017
+ continue;
5018
+ }
5019
+ }
5020
+ const { error } = await db.from("documents").update(patchOf(doc)).eq("id", before.id);
5021
+ if (error) throw new Error(error.message);
5022
+ result.updated += 1;
5023
+ } catch (err) {
5024
+ log.warn(
5025
+ { err: said(err), relPath: doc.relPath, sessionId: deps.sessionId },
5026
+ "could not mirror a document"
5027
+ );
5028
+ }
5029
+ }
5030
+ if (!pass.reconcile) return result;
5031
+ const gone2 = known.filter((r) => r.rel_path !== null && r.status === "present" && !pass.present.has(r.rel_path)).map((r) => r.id);
5032
+ if (gone2.length > 0) {
5033
+ try {
5034
+ const { error } = await db.from("documents").update({ status: "missing" }).in("id", gone2);
5035
+ if (error) throw new Error(error.message);
5036
+ result.missing = gone2.length;
5037
+ } catch (err) {
5038
+ log.warn(
5039
+ { err: said(err), count: gone2.length, sessionId: deps.sessionId },
5040
+ "could not mark vanished documents missing"
5041
+ );
5042
+ }
5043
+ }
5044
+ return result;
5045
+ }
5046
+ var DOCUMENTS_MIRROR_MAX_ROWS, SELECT, contentTypeFor, rowOf, patchOf, said;
5047
+ var init_mirror = __esm({
5048
+ "src/documents/mirror.ts"() {
5049
+ "use strict";
5050
+ init_src();
5051
+ init_log();
5052
+ DOCUMENTS_MIRROR_MAX_ROWS = 1e3;
5053
+ SELECT = "id, rel_path, storage_path, filename, kind, title, content_hash, size_bytes, status";
5054
+ contentTypeFor = (doc) => doc.kind === "file" ? "application/octet-stream" : "text/plain";
5055
+ rowOf = (deps, doc) => ({
5056
+ // Stamped from the session by `stamp_document_insert`; `documents_insert` still checks
5057
+ // `is_member(workspace_id)` against a non-null value, so it is sent.
5058
+ workspace_id: deps.workspaceId,
5059
+ owner_id: deps.ownerId,
5060
+ session_id: deps.sessionId,
5061
+ rel_path: doc.relPath,
5062
+ filename: doc.filename,
5063
+ kind: doc.kind,
5064
+ title: doc.title,
5065
+ content_hash: doc.contentHash,
5066
+ size_bytes: doc.size,
5067
+ source: "watcher",
5068
+ status: "present"
5069
+ });
5070
+ patchOf = (doc) => ({
5071
+ title: doc.title,
5072
+ kind: doc.kind,
5073
+ content_hash: doc.contentHash,
5074
+ size_bytes: doc.size,
5075
+ status: "present"
5076
+ });
5077
+ said = (err) => err instanceof Error ? err.message : String(err);
5078
+ }
5079
+ });
5080
+
5081
+ // src/documents/scan.ts
5082
+ import { createHash as createHash3 } from "crypto";
5083
+ import {
5084
+ closeSync as closeSync2,
5085
+ constants as fsConstants,
5086
+ lstatSync as lstatSync4,
5087
+ opendirSync,
5088
+ openSync as openSync2,
5089
+ readFileSync as readFileSync7
5090
+ } from "fs";
5091
+ import { open } from "fs/promises";
5092
+ import { join as join17 } from "path";
5093
+ function isNew(baseline, relPath, contentHash) {
5094
+ const before = baseline.hashes.get(relPath);
5095
+ if (before === void 0) return baseline.complete;
5096
+ if (before === UNREADABLE) return false;
5097
+ return before !== contentHash;
5098
+ }
5099
+ function readCapped(dir, st) {
5100
+ const handle = opendirSync(dir);
5101
+ const entries = [];
5102
+ try {
5103
+ for (; ; ) {
5104
+ const entry = handle.readSync();
5105
+ if (entry === null) break;
5106
+ if (entries.length === DOCS_SCAN_MAX_ENTRIES) {
5107
+ st.log.warn(
5108
+ { dir, cap: DOCS_SCAN_MAX_ENTRIES },
5109
+ `documents skipped: more than ${DOCS_SCAN_MAX_ENTRIES} entries in one directory`
5110
+ );
5111
+ st.complete = false;
5112
+ break;
5113
+ }
5114
+ entries.push(entry);
5115
+ }
5116
+ } finally {
5117
+ try {
5118
+ handle.closeSync();
5119
+ } catch {
5120
+ }
5121
+ }
5122
+ return entries.sort((a, b) => a.name < b.name ? -1 : 1);
5123
+ }
5124
+ function admit(st, path, relPath) {
5125
+ const skip = (reason) => {
5126
+ st.log.warn({ path, relPath }, `document skipped: ${reason}`);
5127
+ };
5128
+ if (relPath.length > DOCUMENT_REL_PATH_MAX) {
5129
+ skip(`its path is over ${DOCUMENT_REL_PATH_MAX} characters`);
5130
+ return;
5131
+ }
5132
+ if (st.files.length >= DOCUMENTS_MAX_FILES) {
5133
+ st.log.warn(
5134
+ { dir: st.root, cap: DOCUMENTS_MAX_FILES },
5135
+ `documents skipped: this session's directory holds more than ${DOCUMENTS_MAX_FILES} files`
5136
+ );
5137
+ st.complete = false;
5138
+ st.stop = true;
5139
+ return;
5140
+ }
5141
+ let real;
5142
+ try {
5143
+ real = assertInsideDocsDir(st.root, path);
5144
+ } catch (err) {
5145
+ skip(err instanceof Error ? err.message : String(err));
5146
+ return;
5147
+ }
5148
+ let stat;
5149
+ try {
5150
+ stat = lstatSync4(real);
5151
+ } catch {
5152
+ skip("it cannot be read");
5153
+ return;
5154
+ }
5155
+ if (!stat.isFile()) return;
5156
+ if (stat.size > DOCUMENT_MAX_BYTES) {
5157
+ skip(`it is over ${DOCUMENT_MAX_BYTES} bytes`);
5158
+ return;
5159
+ }
5160
+ st.files.push({ relPath, path: real, size: stat.size });
5161
+ }
5162
+ function docFiles(root, log) {
5163
+ const st = { root, files: [], complete: true, stop: false, log };
5164
+ walk2(st, root, "", 0);
5165
+ return st;
5166
+ }
5167
+ function walk2(st, dir, rel, depth) {
5168
+ if (st.stop) return;
5169
+ let entries;
5170
+ try {
5171
+ entries = readCapped(dir, st);
5172
+ } catch {
5173
+ st.complete = false;
5174
+ return;
5175
+ }
5176
+ for (const entry of entries) {
5177
+ if (st.stop) return;
5178
+ if (entry.name.startsWith(".")) continue;
5179
+ const path = join17(dir, entry.name);
5180
+ const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
5181
+ if (entry.isSymbolicLink()) {
5182
+ st.log.warn({ path, relPath }, `document skipped: ${SYMLINK_SKIPPED}`);
5183
+ continue;
5184
+ }
5185
+ if (entry.isDirectory()) {
5186
+ if (depth + 1 < DOCS_MAX_DEPTH) walk2(st, path, relPath, depth + 1);
5187
+ continue;
5188
+ }
5189
+ if (entry.isFile()) admit(st, path, relPath);
5190
+ }
5191
+ }
5192
+ async function readAtMost(fh, limit) {
5193
+ const buffer = Buffer.allocUnsafe(limit);
5194
+ let filled = 0;
5195
+ while (filled < limit) {
5196
+ const { bytesRead } = await fh.read(buffer, filled, limit - filled, filled);
5197
+ if (bytesRead === 0) break;
5198
+ filled += bytesRead;
5199
+ }
5200
+ return buffer.subarray(0, filled);
5201
+ }
5202
+ function documentTitle(text, kind, relPath) {
5203
+ const head = text.slice(0, TITLE_SEARCH_MAX);
5204
+ let raw = "";
5205
+ if (kind === "markdown") raw = ATX.exec(head)?.[2] ?? "";
5206
+ else if (kind === "html") raw = HTML_TITLE.exec(head)?.[1] ?? "";
5207
+ const leaf = relPath.split("/").at(-1) ?? "";
5208
+ return clean(raw) || clean(leaf) || "document";
5209
+ }
5210
+ async function openProved(real) {
5211
+ return open(real, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
5212
+ }
5213
+ async function readDocument(file, root, log) {
5214
+ const skip = (reason) => {
5215
+ log.warn({ path: file.path, relPath: file.relPath }, `document skipped: ${reason}`);
5216
+ return null;
5217
+ };
5218
+ let real;
5219
+ try {
5220
+ real = assertInsideDocsDir(root, file.path);
5221
+ } catch (err) {
5222
+ return skip(err instanceof Error ? err.message : String(err));
5223
+ }
5224
+ if (real !== file.path) return skip(SYMLINK_SKIPPED);
5225
+ let fh;
5226
+ try {
5227
+ fh = await openProved(real);
5228
+ } catch {
5229
+ return skip("it is no longer a readable file");
5230
+ }
5231
+ let bytes;
5232
+ try {
5233
+ const stat = await fh.stat();
5234
+ if (!stat.isFile()) return skip("it is no longer a file");
5235
+ if (stat.size > DOCUMENT_MAX_BYTES) return skip(`it is over ${DOCUMENT_MAX_BYTES} bytes`);
5236
+ bytes = await readAtMost(fh, DOCUMENT_MAX_BYTES + 1);
5237
+ } finally {
5238
+ await fh.close().catch(() => void 0);
5239
+ }
5240
+ if (bytes.byteLength > DOCUMENT_MAX_BYTES) return skip(`it is over ${DOCUMENT_MAX_BYTES} bytes`);
5241
+ const filename = documentFilename(file.relPath);
5242
+ const kind = documentKindFor(filename);
5243
+ return {
5244
+ relPath: file.relPath,
5245
+ path: real,
5246
+ filename,
5247
+ kind,
5248
+ title: documentTitle(bytes.subarray(0, TITLE_SEARCH_MAX).toString("utf8"), kind, file.relPath),
5249
+ contentHash: sha256(bytes),
5250
+ size: bytes.byteLength,
5251
+ bytes
5252
+ };
5253
+ }
5254
+ async function scanDocuments(root, log) {
5255
+ const walked = docFiles(root, log);
5256
+ const documents = [];
5257
+ const present = /* @__PURE__ */ new Set();
5258
+ let complete = walked.complete;
5259
+ let budget = DOCS_SCAN_MAX_BYTES;
5260
+ for (const file of walked.files) {
5261
+ if (budget <= 0) {
5262
+ log.warn(
5263
+ { dir: root, cap: DOCS_SCAN_MAX_BYTES },
5264
+ "documents skipped: this pass reached its read budget"
5265
+ );
5266
+ complete = false;
5267
+ break;
5268
+ }
5269
+ budget -= file.size;
5270
+ const doc = await readDocument(file, root, log);
5271
+ if (doc === null) continue;
5272
+ documents.push(doc);
5273
+ present.add(doc.relPath);
5274
+ }
5275
+ return { documents, present, complete };
5276
+ }
5277
+ function baselineOf(root, log) {
5278
+ const hashes = /* @__PURE__ */ new Map();
5279
+ if (root === null) return { hashes, complete: true };
5280
+ const walked = docFiles(root, log);
5281
+ for (const file of walked.files) {
5282
+ let fd;
5283
+ try {
5284
+ fd = openSync2(file.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
5285
+ } catch {
5286
+ hashes.set(file.relPath, UNREADABLE);
5287
+ continue;
5288
+ }
5289
+ try {
5290
+ hashes.set(file.relPath, sha256(readFileSync7(fd)));
5291
+ } catch {
5292
+ hashes.set(file.relPath, UNREADABLE);
5293
+ } finally {
5294
+ try {
5295
+ closeSync2(fd);
5296
+ } catch {
5297
+ }
5298
+ }
5299
+ }
5300
+ return { hashes, complete: walked.complete };
5301
+ }
5302
+ var DOCS_MAX_DEPTH, DOCS_SCAN_MAX_ENTRIES, DOCS_SCAN_MAX_BYTES, TITLE_SEARCH_MAX, UNREADABLE, sha256, SYMLINK_SKIPPED, WHITESPACE, ATX, HTML_TITLE, stripControl, firstCodePoints, clean;
5303
+ var init_scan = __esm({
5304
+ "src/documents/scan.ts"() {
5305
+ "use strict";
5306
+ init_src();
5307
+ init_paths2();
5308
+ DOCS_MAX_DEPTH = 3;
5309
+ DOCS_SCAN_MAX_ENTRIES = 500;
5310
+ DOCS_SCAN_MAX_BYTES = 32 * 1024 * 1024;
5311
+ TITLE_SEARCH_MAX = 64 * 1024;
5312
+ UNREADABLE = "unreadable";
5313
+ sha256 = (bytes) => createHash3("sha256").update(bytes).digest("hex");
5314
+ SYMLINK_SKIPPED = "a symlink is never followed";
5315
+ WHITESPACE = /\s+/g;
5316
+ ATX = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/m;
5317
+ HTML_TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i;
5318
+ stripControl = (raw) => [...raw].filter((ch) => {
5319
+ const code = ch.codePointAt(0) ?? 0;
5320
+ return code > 31 && (code < 127 || code > 159);
5321
+ }).join("");
5322
+ firstCodePoints = (s, max) => [...s].slice(0, max).join("");
5323
+ clean = (raw) => firstCodePoints(stripControl(raw).replace(WHITESPACE, " ").trim(), DOCUMENT_TITLE_MAX).trim();
5324
+ }
5325
+ });
5326
+
5327
+ // src/documents/service.ts
5328
+ function createDocumentsService(deps) {
5329
+ const log = deps.log ?? logger;
5330
+ const scan = deps.scan ?? scanDocuments;
5331
+ const sync = deps.sync ?? syncDocuments;
5332
+ const mirror = {
5333
+ db: deps.db,
5334
+ workspaceId: deps.workspaceId,
5335
+ ownerId: deps.ownerId,
5336
+ sessionId: deps.sessionId,
5337
+ log
5338
+ };
5339
+ const baseline = baselineOf(resolveDocsDir(deps.cwd, log), log);
5340
+ const owned = /* @__PURE__ */ new Set();
5341
+ let seen = false;
5342
+ let closed = false;
5343
+ let inFlight = null;
5344
+ let dirty = false;
5345
+ const pass = async () => {
5346
+ const dir = resolveDocsDir(deps.cwd, log);
5347
+ if (dir === null) {
5348
+ if (!seen) return;
5349
+ await sync(mirror, { write: [], present: /* @__PURE__ */ new Set(), reconcile: true });
5350
+ return;
5351
+ }
5352
+ seen = true;
5353
+ const { documents, present, complete } = await scan(dir, log);
5354
+ const write = documents.filter(
5355
+ (doc) => owned.has(doc.relPath) || isNew(baseline, doc.relPath, doc.contentHash)
5356
+ );
5357
+ const job = {
5358
+ write,
5359
+ present,
5360
+ // A pass that saw only part of the directory reconciles nothing: the rows it did not see
5361
+ // are not evidence that their files went away.
5362
+ reconcile: complete
5363
+ };
5364
+ const result = await sync(mirror, job);
5365
+ for (const doc of write) owned.add(doc.relPath);
5366
+ if (result.inserted || result.updated || result.missing) {
5367
+ log.info?.({ sessionId: deps.sessionId, ...result }, "documents mirrored for this session");
5368
+ }
5369
+ };
5370
+ const start2 = () => {
5371
+ dirty = false;
5372
+ const started = pass().catch(
5373
+ (err) => log.warn({ err: said2(err), sessionId: deps.sessionId }, "a documents pass failed")
5374
+ ).finally(() => {
5375
+ if (inFlight === started) inFlight = null;
5376
+ if (dirty && !closed) void start2();
5377
+ });
5378
+ inFlight = started;
5379
+ return started;
5380
+ };
5381
+ return {
5382
+ refresh: () => {
5383
+ if (closed) return Promise.resolve();
5384
+ const running = inFlight;
5385
+ if (running) {
5386
+ dirty = true;
5387
+ return running;
5388
+ }
5389
+ return start2();
5390
+ },
5391
+ close: async () => {
5392
+ closed = true;
5393
+ dirty = false;
5394
+ await inFlight?.catch(() => void 0);
5395
+ }
5396
+ };
5397
+ }
5398
+ var DOCS_DEBOUNCE_MS, DOCS_RETRY_MS, said2;
5399
+ var init_service = __esm({
5400
+ "src/documents/service.ts"() {
5401
+ "use strict";
5402
+ init_log();
5403
+ init_mirror();
5404
+ init_paths2();
5405
+ init_scan();
5406
+ DOCS_DEBOUNCE_MS = 1e3;
5407
+ DOCS_RETRY_MS = 1e3;
5408
+ said2 = (err) => err instanceof Error ? err.message : String(err);
5409
+ }
5410
+ });
5411
+
4654
5412
  // src/claude/auth-mode.ts
4655
5413
  function authModeFrom(info) {
4656
5414
  if (info.apiProvider && info.apiProvider !== "firstParty") return "third_party";
@@ -5149,8 +5907,8 @@ var init_policy = __esm({
5149
5907
  import { spawn as spawn2 } from "child_process";
5150
5908
  import { randomUUID as randomUUID4 } from "crypto";
5151
5909
  import { EventEmitter as EventEmitter2 } from "events";
5152
- import { chmodSync as chmodSync4, lstatSync as lstatSync4, mkdirSync as mkdirSync6, rmdirSync, rmSync as rmSync6, writeFileSync as writeFileSync5 } from "fs";
5153
- import { dirname as dirname7, join as join16 } from "path";
5910
+ import { chmodSync as chmodSync4, lstatSync as lstatSync5, mkdirSync as mkdirSync6, rmdirSync, rmSync as rmSync6, writeFileSync as writeFileSync5 } from "fs";
5911
+ import { dirname as dirname7, join as join18 } from "path";
5154
5912
  function removeFiles(files) {
5155
5913
  for (const file of files) rmSync6(file, { force: true });
5156
5914
  }
@@ -5303,14 +6061,14 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
5303
6061
  mkdirSync6(dir, { recursive: true, mode: 448 });
5304
6062
  assertStagingInsideHome(home, dir);
5305
6063
  for (const path of [dirname7(dir), dir]) {
5306
- const st = lstatSync4(path);
6064
+ const st = lstatSync5(path);
5307
6065
  if (st.isSymbolicLink() || !st.isDirectory()) {
5308
6066
  throw new Error(`${path} is not a plain directory`);
5309
6067
  }
5310
6068
  }
5311
6069
  chmodSync4(dir, 448);
5312
6070
  for (const image of images) {
5313
- const path = join16(dir, `${randomUUID4()}.${ATTACHMENT_MEDIA_TYPES[image.mediaType]}`);
6071
+ const path = join18(dir, `${randomUUID4()}.${ATTACHMENT_MEDIA_TYPES[image.mediaType]}`);
5314
6072
  writeFileSync5(path, image.data, { mode: 384, flag: "wx" });
5315
6073
  written.push(path);
5316
6074
  }
@@ -5600,15 +6358,15 @@ var init_types = __esm({
5600
6358
  });
5601
6359
 
5602
6360
  // src/skills/paths.ts
5603
- import { realpathSync as realpathSync8 } from "fs";
5604
- import { isAbsolute as isAbsolute10, join as join17, relative as relative4, sep as sep5 } from "path";
6361
+ import { realpathSync as realpathSync9 } from "fs";
6362
+ import { isAbsolute as isAbsolute11, join as join19, relative as relative5, sep as sep6 } from "path";
5605
6363
  function clearSessionSkills(home, runnerId, heldBy) {
5606
- clearRunnerStaging(home, join17(home, "session-skills"), runnerId, heldBy);
6364
+ clearRunnerStaging(home, join19(home, "session-skills"), runnerId, heldBy);
5607
6365
  }
5608
6366
  function assertInsideScopeDir(path, scopeDir) {
5609
6367
  let dirNow;
5610
6368
  try {
5611
- dirNow = realpathSync8.native(scopeDir);
6369
+ dirNow = realpathSync9.native(scopeDir);
5612
6370
  } catch {
5613
6371
  throw new Error(`ROOT_NOT_ALLOWED: ${scopeDir} does not exist`);
5614
6372
  }
@@ -5616,20 +6374,20 @@ function assertInsideScopeDir(path, scopeDir) {
5616
6374
  throw new Error(`ROOT_NOT_ALLOWED: ${scopeDir} no longer resolves to itself (${dirNow})`);
5617
6375
  }
5618
6376
  const real = assertInsideRoots(path, [scopeDir]);
5619
- const rel = relative4(scopeDir, real);
5620
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep5}`) || isAbsolute10(rel)) {
6377
+ const rel = relative5(scopeDir, real);
6378
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep6}`) || isAbsolute11(rel)) {
5621
6379
  throw new Error(`ROOT_NOT_ALLOWED: ${path}`);
5622
6380
  }
5623
6381
  return real;
5624
6382
  }
5625
6383
  var sessionSkillsRoot, sessionSkillsDir;
5626
- var init_paths2 = __esm({
6384
+ var init_paths3 = __esm({
5627
6385
  "src/skills/paths.ts"() {
5628
6386
  "use strict";
5629
6387
  init_paths();
5630
6388
  init_attachments2();
5631
- sessionSkillsRoot = (home, runnerId) => join17(home, "session-skills", runnerId);
5632
- sessionSkillsDir = (home, runnerId, sessionId) => join17(sessionSkillsRoot(home, runnerId), sessionId);
6389
+ sessionSkillsRoot = (home, runnerId) => join19(home, "session-skills", runnerId);
6390
+ sessionSkillsDir = (home, runnerId, sessionId) => join19(sessionSkillsRoot(home, runnerId), sessionId);
5633
6391
  }
5634
6392
  });
5635
6393
 
@@ -5760,22 +6518,22 @@ var init_refusal = __esm({
5760
6518
  });
5761
6519
 
5762
6520
  // src/skills/scan.ts
5763
- import { createHash as createHash3 } from "crypto";
6521
+ import { createHash as createHash4 } from "crypto";
5764
6522
  import {
5765
6523
  existsSync as existsSync5,
5766
- constants as fsConstants,
5767
- lstatSync as lstatSync5,
5768
- realpathSync as realpathSync9
6524
+ constants as fsConstants2,
6525
+ lstatSync as lstatSync6,
6526
+ realpathSync as realpathSync10
5769
6527
  } from "fs";
5770
- import { open, opendir } from "fs/promises";
5771
- import { isAbsolute as isAbsolute11, join as join18, relative as relative5, sep as sep6 } from "path";
6528
+ import { open as open2, opendir } from "fs/promises";
6529
+ import { isAbsolute as isAbsolute12, join as join20, relative as relative6, sep as sep7 } from "path";
5772
6530
  function inside(root, candidate) {
5773
- const rel = relative5(root, candidate);
5774
- return rel === "" || !rel.startsWith(`..${sep6}`) && rel !== ".." && !isAbsolute11(rel);
6531
+ const rel = relative6(root, candidate);
6532
+ return rel === "" || !rel.startsWith(`..${sep7}`) && rel !== ".." && !isAbsolute12(rel);
5775
6533
  }
5776
6534
  function canonical2(dir) {
5777
6535
  try {
5778
- return realpathSync9.native(dir);
6536
+ return realpathSync10.native(dir);
5779
6537
  } catch {
5780
6538
  return dir;
5781
6539
  }
@@ -5797,11 +6555,11 @@ function scopeRoots(opts) {
5797
6555
  const isGitRepo = opts.isGitRepo ?? defaultIsGitRepo;
5798
6556
  const home = canonical2(opts.home);
5799
6557
  const homes = protectedHomes(opts);
5800
- const found = [{ scope: "personal", dir: join18(home, "skills"), base: home }];
6558
+ const found = [{ scope: "personal", dir: join20(home, "skills"), base: home }];
5801
6559
  let skillsRepo;
5802
6560
  if (opts.skillsRepo !== void 0) {
5803
6561
  try {
5804
- if (!isAbsolute11(opts.skillsRepo)) throw new Error("skillsRepo must be an absolute path");
6562
+ if (!isAbsolute12(opts.skillsRepo)) throw new Error("skillsRepo must be an absolute path");
5805
6563
  skillsRepo = resolveInsideRoots(opts.skillsRepo, opts.roots).real;
5806
6564
  } catch (err) {
5807
6565
  log.warn(
@@ -5813,12 +6571,12 @@ function scopeRoots(opts) {
5813
6571
  for (const root of opts.roots) {
5814
6572
  let real;
5815
6573
  try {
5816
- real = realpathSync9.native(root);
6574
+ real = realpathSync10.native(root);
5817
6575
  } catch {
5818
6576
  continue;
5819
6577
  }
5820
6578
  if (!isGitRepo(real)) continue;
5821
- const dir = join18(real, ".cayenne", "skills");
6579
+ const dir = join20(real, ".cayenne", "skills");
5822
6580
  const shared = skillsRepo !== void 0 && real === skillsRepo ? "workspace" : "project";
5823
6581
  if (overlapsHome(canonical2(dir), homes, shared, log)) continue;
5824
6582
  if (skillsRepo !== void 0 && real === skillsRepo) {
@@ -5836,7 +6594,7 @@ function resolveScopeRoots(opts) {
5836
6594
  for (const root of scopeRoots(opts)) {
5837
6595
  let dirReal;
5838
6596
  try {
5839
- dirReal = realpathSync9.native(root.dir);
6597
+ dirReal = realpathSync10.native(root.dir);
5840
6598
  } catch {
5841
6599
  continue;
5842
6600
  }
@@ -5861,7 +6619,7 @@ async function scanSkills(opts) {
5861
6619
  if (exhausted) break;
5862
6620
  let entries;
5863
6621
  try {
5864
- entries = await readCapped(root.dirReal, log, root.scope);
6622
+ entries = await readCapped2(root.dirReal, log, root.scope);
5865
6623
  } catch {
5866
6624
  continue;
5867
6625
  }
@@ -5880,7 +6638,7 @@ async function scanSkills(opts) {
5880
6638
  } catch (err) {
5881
6639
  log.warn(
5882
6640
  {
5883
- path: join18(root.dirReal, entry.name),
6641
+ path: join20(root.dirReal, entry.name),
5884
6642
  scope: root.scope,
5885
6643
  err: err instanceof Error ? err.message : String(err)
5886
6644
  },
@@ -5895,7 +6653,7 @@ async function scanSkills(opts) {
5895
6653
  }
5896
6654
  return found;
5897
6655
  }
5898
- async function readCapped(dir, log, scope) {
6656
+ async function readCapped2(dir, log, scope) {
5899
6657
  const handle = await opendir(dir);
5900
6658
  const entries = [];
5901
6659
  try {
@@ -5914,7 +6672,7 @@ async function readCapped(dir, log, scope) {
5914
6672
  }
5915
6673
  return entries.sort((a, b) => a.name < b.name ? -1 : 1);
5916
6674
  }
5917
- async function readAtMost(fh, limit) {
6675
+ async function readAtMost2(fh, limit) {
5918
6676
  const buffer = Buffer.allocUnsafe(limit);
5919
6677
  let filled = 0;
5920
6678
  while (filled < limit) {
@@ -5925,13 +6683,13 @@ async function readAtMost(fh, limit) {
5925
6683
  return buffer.subarray(0, filled);
5926
6684
  }
5927
6685
  async function readEntry(root, entry, log, budget) {
5928
- const path = join18(root.dirReal, entry.name);
6686
+ const path = join20(root.dirReal, entry.name);
5929
6687
  const skip = (reason) => {
5930
6688
  log.warn({ path, scope: root.scope }, `skill skipped: ${reason}`);
5931
6689
  return null;
5932
6690
  };
5933
6691
  if (entry.isDirectory()) {
5934
- const nested = existsSync5(join18(path, "SKILL.md"));
6692
+ const nested = existsSync5(join20(path, "SKILL.md"));
5935
6693
  return skip(
5936
6694
  nested ? `a directory holding SKILL.md: Cayenne's store is flat, so move it to ${entry.name}.md beside this directory` : "a directory, and the scan does not recurse"
5937
6695
  );
@@ -5941,20 +6699,20 @@ async function readEntry(root, entry, log, budget) {
5941
6699
  if (!SKILL_SLUG.test(slug)) return skip("the filename is not a legal slug");
5942
6700
  let real;
5943
6701
  try {
5944
- real = realpathSync9.native(path);
6702
+ real = realpathSync10.native(path);
5945
6703
  } catch {
5946
6704
  return skip("cannot be resolved");
5947
6705
  }
5948
6706
  if (!inside(root.dirReal, real)) return skip("a symlink whose target is outside the scope root");
5949
6707
  let proved;
5950
6708
  try {
5951
- proved = lstatSync5(real);
6709
+ proved = lstatSync6(real);
5952
6710
  } catch {
5953
6711
  return skip("cannot be read");
5954
6712
  }
5955
6713
  let fh;
5956
6714
  try {
5957
- fh = await open(real, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
6715
+ fh = await open2(real, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
5958
6716
  } catch {
5959
6717
  return skip("cannot be read");
5960
6718
  }
@@ -5965,7 +6723,7 @@ async function readEntry(root, entry, log, budget) {
5965
6723
  if (!stat.isFile()) return skip("not a regular file");
5966
6724
  if (stat.dev !== proved.dev || stat.ino !== proved.ino) return skip("changed while being read");
5967
6725
  if (stat.size > SKILL_BODY_MAX) return skip(`larger than ${SKILL_BODY_MAX} bytes`);
5968
- bytes = await readAtMost(fh, SKILL_BODY_MAX + 1);
6726
+ bytes = await readAtMost2(fh, SKILL_BODY_MAX + 1);
5969
6727
  } catch {
5970
6728
  return skip("cannot be read");
5971
6729
  } finally {
@@ -5994,9 +6752,9 @@ async function readEntry(root, entry, log, budget) {
5994
6752
  ...parsed.ok && parsed.data.description !== void 0 ? { description: parsed.data.description } : {},
5995
6753
  declaredTools: parsed.ok ? parsed.data.tools : [],
5996
6754
  permission: parsed.ok ? parsed.data.permission : "inherit",
5997
- sourcePath: relative5(root.base, path),
6755
+ sourcePath: relative6(root.base, path),
5998
6756
  ...root.projectPath !== void 0 ? { projectPath: root.projectPath } : {},
5999
- contentHash: createHash3("sha256").update(bytes).digest("hex"),
6757
+ contentHash: createHash4("sha256").update(bytes).digest("hex"),
6000
6758
  ...fileMtime !== void 0 ? { fileMtime } : {},
6001
6759
  // The reason only: `refusal.detail` can quote the file's own frontmatter (protocol §2.3).
6002
6760
  refusal: refusal?.reason ?? null
@@ -6080,7 +6838,7 @@ function scalar(raw, prose = false) {
6080
6838
  return !prose && /:\s/.test(value) || /\s#/.test(value) ? null : value;
6081
6839
  }
6082
6840
  var SKILL_SCAN_MAX_PER_DIR, SKILL_SCAN_MAX_SKILLS, SKILL_SCAN_MAX_BYTES, defaultIsGitRepo, summariesOf, FRONTMATTER, KEY, ITEM;
6083
- var init_scan = __esm({
6841
+ var init_scan2 = __esm({
6084
6842
  "src/skills/scan.ts"() {
6085
6843
  "use strict";
6086
6844
  init_src();
@@ -6091,7 +6849,7 @@ var init_scan = __esm({
6091
6849
  SKILL_SCAN_MAX_PER_DIR = 500;
6092
6850
  SKILL_SCAN_MAX_SKILLS = 500;
6093
6851
  SKILL_SCAN_MAX_BYTES = 32 * 1024 * 1024;
6094
- defaultIsGitRepo = (dir) => lstatSync5(join18(dir, ".git"), { throwIfNoEntry: false }) !== void 0;
6852
+ defaultIsGitRepo = (dir) => lstatSync6(join20(dir, ".git"), { throwIfNoEntry: false }) !== void 0;
6095
6853
  summariesOf = (scan) => scan.map((s) => s.summary);
6096
6854
  FRONTMATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
6097
6855
  KEY = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]*(.*)$/;
@@ -6100,17 +6858,17 @@ var init_scan = __esm({
6100
6858
  });
6101
6859
 
6102
6860
  // src/skills/materialise.ts
6103
- import { createHash as createHash4 } from "crypto";
6861
+ import { createHash as createHash5 } from "crypto";
6104
6862
  import {
6105
6863
  chmodSync as chmodSync5,
6106
- constants as fsConstants2,
6107
- lstatSync as lstatSync6,
6864
+ constants as fsConstants3,
6865
+ lstatSync as lstatSync7,
6108
6866
  mkdirSync as mkdirSync7,
6109
6867
  readdirSync as readdirSync2,
6110
6868
  rmSync as rmSync7
6111
6869
  } from "fs";
6112
- import { open as open2, writeFile } from "fs/promises";
6113
- import { dirname as dirname8, isAbsolute as isAbsolute12, join as join19, relative as relative6, sep as sep7 } from "path";
6870
+ import { open as open3, writeFile } from "fs/promises";
6871
+ import { dirname as dirname8, isAbsolute as isAbsolute13, join as join21, relative as relative7, sep as sep8 } from "path";
6114
6872
  function isSkillRefusal(err) {
6115
6873
  const msg = err instanceof Error ? err.message : String(err);
6116
6874
  return msg.startsWith("ROOT_NOT_ALLOWED") || msg.startsWith(BUDGET) || msg.includes("is not a plain directory") || msg.includes("is not part of the session skills plugin");
@@ -6157,7 +6915,7 @@ async function materialiseSkills(scan, t) {
6157
6915
  }
6158
6916
  const path = plainDirectory(t.home, sessionSkillsDir(t.home, t.runnerId, t.sessionId));
6159
6917
  await writeFile(
6160
- join19(plainDirectory(t.home, join19(path, ".claude-plugin")), "plugin.json"),
6918
+ join21(plainDirectory(t.home, join21(path, ".claude-plugin")), "plugin.json"),
6161
6919
  `${JSON.stringify(
6162
6920
  {
6163
6921
  name: SKILL_PLUGIN_NAME,
@@ -6187,13 +6945,13 @@ async function materialiseSkills(scan, t) {
6187
6945
  );
6188
6946
  }
6189
6947
  try {
6190
- const dir = plainDirectory(t.home, join19(path, "skills", slug));
6191
- await writeFile(join19(dir, "SKILL.md"), bytes, { mode: 384, flag: "wx" });
6948
+ const dir = plainDirectory(t.home, join21(path, "skills", slug));
6949
+ await writeFile(join21(dir, "SKILL.md"), bytes, { mode: 384, flag: "wx" });
6192
6950
  slugs.push(slug);
6193
6951
  } catch (err) {
6194
6952
  note(log, err, { slug, scope, path: skill.path }, "skill not materialised for this session");
6195
6953
  try {
6196
- rmSync7(assertStagingInsideHome(t.home, join19(path, "skills", slug)), {
6954
+ rmSync7(assertStagingInsideHome(t.home, join21(path, "skills", slug)), {
6197
6955
  recursive: true,
6198
6956
  force: true
6199
6957
  });
@@ -6220,7 +6978,7 @@ async function firstAcceptable(candidates, log, markStale) {
6220
6978
  note(log, err, fields, "skill not materialised for this session");
6221
6979
  continue;
6222
6980
  }
6223
- const hash = createHash4("sha256").update(bytes).digest("hex");
6981
+ const hash = createHash5("sha256").update(bytes).digest("hex");
6224
6982
  if (hash !== skill.summary.contentHash) {
6225
6983
  markStale();
6226
6984
  say(
@@ -6257,21 +7015,21 @@ function assertSealed(path, slugs) {
6257
7015
  const want = new Set(expected);
6258
7016
  for (const entry of entries) {
6259
7017
  const ok = kind === "dir" ? entry.isDirectory() : entry.isFile();
6260
- if (!ok || !want.has(entry.name)) throw stray(join19(dir, entry.name));
7018
+ if (!ok || !want.has(entry.name)) throw stray(join21(dir, entry.name));
6261
7019
  }
6262
- if (entries.length !== want.size) throw stray(join19(dir, [...want].join("|")));
7020
+ if (entries.length !== want.size) throw stray(join21(dir, [...want].join("|")));
6263
7021
  };
6264
7022
  named(path, [".claude-plugin", "skills"], "dir");
6265
- named(join19(path, ".claude-plugin"), ["plugin.json"], "file");
6266
- named(join19(path, "skills"), slugs, "dir");
6267
- for (const slug of slugs) named(join19(path, "skills", slug), ["SKILL.md"], "file");
7023
+ named(join21(path, ".claude-plugin"), ["plugin.json"], "file");
7024
+ named(join21(path, "skills"), slugs, "dir");
7025
+ for (const slug of slugs) named(join21(path, "skills", slug), ["SKILL.md"], "file");
6268
7026
  }
6269
7027
  function plainDirectory(home, dir) {
6270
7028
  const intended = assertStagingInsideHome(home, dir);
6271
7029
  mkdirSync7(intended, { recursive: true, mode: 448 });
6272
7030
  const proved = assertStagingInsideHome(home, intended);
6273
7031
  for (const p of [dirname8(proved), proved]) {
6274
- const st = lstatSync6(p);
7032
+ const st = lstatSync7(p);
6275
7033
  if (st.isSymbolicLink() || !st.isDirectory()) throw new Error(`${p} is not a plain directory`);
6276
7034
  }
6277
7035
  chmodSync5(proved, 448);
@@ -6289,7 +7047,7 @@ async function readBody(skill) {
6289
7047
  }
6290
7048
  let fh;
6291
7049
  try {
6292
- fh = await open2(real, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
7050
+ fh = await open3(real, fsConstants3.O_RDONLY | fsConstants3.O_NOFOLLOW);
6293
7051
  } catch {
6294
7052
  throw new Error("no longer on disk, or no longer a file the runner may follow");
6295
7053
  }
@@ -6299,7 +7057,7 @@ async function readBody(skill) {
6299
7057
  stat = await fh.stat();
6300
7058
  if (!stat.isFile()) throw new Error("no longer a regular file");
6301
7059
  if (stat.size > SKILL_BODY_MAX) throw new Error(`larger than ${SKILL_BODY_MAX} bytes`);
6302
- bytes = await readAtMost(fh, SKILL_BODY_MAX + 1);
7060
+ bytes = await readAtMost2(fh, SKILL_BODY_MAX + 1);
6303
7061
  } finally {
6304
7062
  await fh.close().catch(() => void 0);
6305
7063
  }
@@ -6313,22 +7071,22 @@ var init_materialise = __esm({
6313
7071
  init_src();
6314
7072
  init_log();
6315
7073
  init_attachments2();
6316
- init_paths2();
6317
- init_scan();
7074
+ init_paths3();
7075
+ init_scan2();
6318
7076
  SKILL_PLUGIN_NAME = "cayenne";
6319
7077
  SESSION_SKILLS_MAX = 64;
6320
7078
  SESSION_SKILLS_MAX_BYTES = 8 * 1024 * 1024;
6321
7079
  BUDGET = "SKILL_BUDGET:";
6322
7080
  PRECEDENCE = { personal: 0, project: 1, workspace: 2 };
6323
7081
  isAtOrBelow = (p, dir) => {
6324
- const rel = relative6(dir, p);
6325
- return rel === "" || !rel.startsWith(`..${sep7}`) && rel !== ".." && !isAbsolute12(rel);
7082
+ const rel = relative7(dir, p);
7083
+ return rel === "" || !rel.startsWith(`..${sep8}`) && rel !== ".." && !isAbsolute13(rel);
6326
7084
  };
6327
7085
  }
6328
7086
  });
6329
7087
 
6330
7088
  // src/sessions/repo-settings.ts
6331
- import { isAbsolute as isAbsolute13, relative as relative7, sep as sep8 } from "path";
7089
+ import { isAbsolute as isAbsolute14, relative as relative8, sep as sep9 } from "path";
6332
7090
  function repoSettingsPolicy(input) {
6333
7091
  const bypassLock = input.permissionMode === "bypassPermissions" ? {} : { permissions: { disableBypassPermissionsMode: "disable" } };
6334
7092
  const claudeMd = () => ({
@@ -6350,8 +7108,8 @@ function repoSettingsPolicy(input) {
6350
7108
  return { settingSources: ["user"], ...claudeMd(), settings: { ...bypassLock } };
6351
7109
  }
6352
7110
  function insideRoot2(candidate, root) {
6353
- const rel = relative7(root, candidate);
6354
- return rel === "" || rel !== ".." && !rel.startsWith(`..${sep8}`) && !isAbsolute13(rel);
7111
+ const rel = relative8(root, candidate);
7112
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep9}`) && !isAbsolute14(rel);
6355
7113
  }
6356
7114
  var init_repo_settings = __esm({
6357
7115
  "src/sessions/repo-settings.ts"() {
@@ -6361,8 +7119,8 @@ var init_repo_settings = __esm({
6361
7119
 
6362
7120
  // src/sessions/manager.ts
6363
7121
  import { randomUUID as randomUUID5 } from "crypto";
6364
- import { realpathSync as realpathSync10 } from "fs";
6365
- import { isAbsolute as isAbsolute14, sep as sep9 } from "path";
7122
+ import { realpathSync as realpathSync11 } from "fs";
7123
+ import { isAbsolute as isAbsolute15, sep as sep10 } from "path";
6366
7124
  import { isDeepStrictEqual } from "util";
6367
7125
  function narrowToSession(suggestions) {
6368
7126
  if (!suggestions) return suggestions;
@@ -6390,6 +7148,8 @@ var init_manager = __esm({
6390
7148
  "use strict";
6391
7149
  init_src();
6392
7150
  init_config();
7151
+ init_paths2();
7152
+ init_service();
6393
7153
  init_introspect();
6394
7154
  init_watch();
6395
7155
  init_worktree();
@@ -6401,7 +7161,7 @@ var init_manager = __esm({
6401
7161
  init_attachments2();
6402
7162
  init_repo_settings();
6403
7163
  roundUsd = (usd) => Math.round(usd * 1e6) / 1e6;
6404
- isAtOrBelow2 = (p, dir) => p === dir || p.startsWith(dir.endsWith(sep9) ? dir : `${dir}${sep9}`);
7164
+ isAtOrBelow2 = (p, dir) => p === dir || p.startsWith(dir.endsWith(sep10) ? dir : `${dir}${sep10}`);
6405
7165
  DENY_MESSAGE_MAX = 2e3;
6406
7166
  EXIT_ERROR_MAX = 8 * 1024;
6407
7167
  INITIAL_PROMPT_MAX = 2e5;
@@ -6897,7 +7657,7 @@ var init_manager = __esm({
6897
7657
  if (stored === null) return false;
6898
7658
  if (isAtOrBelow2(stored, target)) return true;
6899
7659
  try {
6900
- return isAtOrBelow2(realpathSync10.native(stored), target);
7660
+ return isAtOrBelow2(realpathSync11.native(stored), target);
6901
7661
  } catch {
6902
7662
  return false;
6903
7663
  }
@@ -7155,7 +7915,7 @@ var init_manager = __esm({
7155
7915
  }
7156
7916
  /** A path as the SDK reads it: relative means relative to the session's cwd. */
7157
7917
  fromCwd(cwd, value) {
7158
- return isAbsolute14(value) ? value : `${cwd}${sep9}${value}`;
7918
+ return isAbsolute15(value) ? value : `${cwd}${sep10}${value}`;
7159
7919
  }
7160
7920
  /**
7161
7921
  * §2.9: a path in `updatedInput` passes the same boundary the session's `cwd` did, and the
@@ -7386,6 +8146,7 @@ var init_manager = __esm({
7386
8146
  inflight: /* @__PURE__ */ new Set(),
7387
8147
  changesVersion: this.nextChangesSeed(),
7388
8148
  watcher: null,
8149
+ docs: null,
7389
8150
  costLast: 0,
7390
8151
  costTotal: null,
7391
8152
  costUnsaved: 0,
@@ -7393,10 +8154,64 @@ var init_manager = __esm({
7393
8154
  };
7394
8155
  this.live.set(sessionId, live);
7395
8156
  this.wire(sessionId, live);
8157
+ live.docs = this.watchDocuments(sessionId, live);
7396
8158
  live.session.start();
7397
8159
  live.watcher = this.watchWorkingCopy(sessionId, live);
7398
8160
  return live;
7399
8161
  }
8162
+ /**
8163
+ * Arm this session's documents mirror: the service, one pass now, and the watch on
8164
+ * `<cwd>/.cayenne/docs` (lane 5 design §3).
8165
+ *
8166
+ * The directory is never created. Most sessions never write a document, and making it would put
8167
+ * a directory inside the person's repository that they did not ask for; `watchDirs` retries a
8168
+ * directory that is not there and announces on attach, so a file written before it existed is
8169
+ * still picked up — one retry tick plus one debounce, inside the feature's five-second budget.
8170
+ *
8171
+ * Nothing here can fail the session. A service that cannot be built and a watch that cannot be
8172
+ * armed are both a Documents tab that does not update, logged once at warn.
8173
+ */
8174
+ watchDocuments(sessionId, live) {
8175
+ const make = this.deps.documents ?? createDocumentsService;
8176
+ let service2;
8177
+ try {
8178
+ service2 = make({
8179
+ db: this.deps.db,
8180
+ workspaceId: this.deps.workspaceId,
8181
+ // The runner owner, always: it writes as the member it logged in as, and 0022's session
8182
+ // clause is what hands the row to the member whose chat it is. There is no
8183
+ // insert-on-behalf-of path and this must not grow one.
8184
+ ownerId: this.deps.runnerUserId,
8185
+ sessionId,
8186
+ // The canonical directory the provider was spawned in — the worktree when one applies —
8187
+ // never a path a payload carried.
8188
+ cwd: live.cwd,
8189
+ log: this.deps.log
8190
+ });
8191
+ } catch (err) {
8192
+ this.deps.log.warn({ err, sessionId }, "the documents service could not be built");
8193
+ return null;
8194
+ }
8195
+ const refresh = () => {
8196
+ if (this.live.get(sessionId) !== live) return;
8197
+ void service2.refresh().catch(
8198
+ (err) => this.deps.log.warn({ err, sessionId }, "documents refresh failed")
8199
+ );
8200
+ };
8201
+ const attach = this.deps.watchDocs ?? watchDirs;
8202
+ let watcher = null;
8203
+ try {
8204
+ watcher = attach([docsDir(live.cwd)], refresh, {
8205
+ debounceMs: DOCS_DEBOUNCE_MS,
8206
+ retryMs: DOCS_RETRY_MS,
8207
+ onError: (err) => this.deps.log.warn({ err, sessionId }, "documents watcher error; live refresh stopped")
8208
+ });
8209
+ } catch (err) {
8210
+ this.deps.log.warn({ err, sessionId }, "the documents watcher failed to start");
8211
+ }
8212
+ refresh();
8213
+ return { service: service2, watcher };
8214
+ }
7400
8215
  /**
7401
8216
  * Watch the directory the provider was spawned in — `live.cwd`, the canonical path `start()` or
7402
8217
  * `ensureLive()` proved inside the roots (the worktree, when one applies) — and re-broadcast
@@ -7660,6 +8475,14 @@ var init_manager = __esm({
7660
8475
  await Promise.resolve().then(() => watcher?.close()).catch(
7661
8476
  (err) => this.deps.log.warn({ err, sessionId }, "closing the working-copy watcher failed")
7662
8477
  );
8478
+ const docs = live.docs;
8479
+ live.docs = null;
8480
+ await Promise.resolve().then(async () => {
8481
+ await docs?.watcher?.close();
8482
+ await docs?.service.close();
8483
+ }).catch(
8484
+ (err) => this.deps.log.warn({ err, sessionId }, "closing the documents watch failed")
8485
+ );
7663
8486
  if (this.live.get(sessionId) !== live) return;
7664
8487
  this.live.delete(sessionId);
7665
8488
  this.discardSkills(sessionId);
@@ -7804,7 +8627,7 @@ var init_manager = __esm({
7804
8627
  });
7805
8628
 
7806
8629
  // src/skills/mirror.ts
7807
- function rowOf(deps, skill) {
8630
+ function rowOf2(deps, skill) {
7808
8631
  return {
7809
8632
  workspace_id: deps.workspaceId,
7810
8633
  scope: skill.scope,
@@ -7872,7 +8695,7 @@ async function syncSkills(deps, found) {
7872
8695
  const seen = /* @__PURE__ */ new Set();
7873
8696
  const rows = [];
7874
8697
  for (const skill of found) {
7875
- const row = rowOf(deps, skill);
8698
+ const row = rowOf2(deps, skill);
7876
8699
  const key = keyOf2({
7877
8700
  scope: skill.scope,
7878
8701
  slug: skill.slug,
@@ -7933,7 +8756,7 @@ async function syncSkills(deps, found) {
7933
8756
  return { upserted, missing };
7934
8757
  }
7935
8758
  var SKILLS_IDENTITY, SKILLS_MIRROR_MAX_ROWS, keyOf2, instant;
7936
- var init_mirror = __esm({
8759
+ var init_mirror2 = __esm({
7937
8760
  "src/skills/mirror.ts"() {
7938
8761
  "use strict";
7939
8762
  init_log();
@@ -7945,9 +8768,9 @@ var init_mirror = __esm({
7945
8768
  });
7946
8769
 
7947
8770
  // src/skills/service.ts
7948
- import { createHash as createHash5 } from "crypto";
7949
- import { constants as fsConstants3, realpathSync as realpathSync11 } from "fs";
7950
- import { open as open3 } from "fs/promises";
8771
+ import { createHash as createHash6 } from "crypto";
8772
+ import { constants as fsConstants4, realpathSync as realpathSync12 } from "fs";
8773
+ import { open as open4 } from "fs/promises";
7951
8774
  function createSkillsService(deps) {
7952
8775
  const log = deps.log ?? logger;
7953
8776
  const scanner = deps.scanner ?? scanSkills;
@@ -8008,7 +8831,7 @@ function createSkillsService(deps) {
8008
8831
  let wanted;
8009
8832
  if (payload.scope === "project" && payload.projectPath !== void 0) {
8010
8833
  try {
8011
- wanted = realpathSync11.native(payload.projectPath);
8834
+ wanted = realpathSync12.native(payload.projectPath);
8012
8835
  } catch {
8013
8836
  throw missing();
8014
8837
  }
@@ -8046,7 +8869,7 @@ function createSkillsService(deps) {
8046
8869
  const real = assertInsideScopeDir(skill.path, skill.scopeDir);
8047
8870
  let fh;
8048
8871
  try {
8049
- fh = await open3(real, fsConstants3.O_RDONLY | fsConstants3.O_NOFOLLOW);
8872
+ fh = await open4(real, fsConstants4.O_RDONLY | fsConstants4.O_NOFOLLOW);
8050
8873
  } catch {
8051
8874
  throw refuse2(`skill ${payload.slug} is no longer on disk`);
8052
8875
  }
@@ -8056,28 +8879,28 @@ function createSkillsService(deps) {
8056
8879
  stat = await fh.stat();
8057
8880
  if (!stat.isFile()) throw refuse2(`skill ${payload.slug} is no longer a file`);
8058
8881
  if (stat.size > SKILL_BODY_MAX) throw refuse2("skill body too large");
8059
- bytes = await readAtMost(fh, SKILL_BODY_MAX + 1);
8882
+ bytes = await readAtMost2(fh, SKILL_BODY_MAX + 1);
8060
8883
  } finally {
8061
8884
  await fh.close().catch(() => void 0);
8062
8885
  }
8063
8886
  if (bytes.byteLength > SKILL_BODY_MAX) throw refuse2("skill body too large");
8064
8887
  return {
8065
8888
  body: bytes.toString("utf8"),
8066
- contentHash: createHash5("sha256").update(bytes).digest("hex"),
8889
+ contentHash: createHash6("sha256").update(bytes).digest("hex"),
8067
8890
  sourcePath: skill.summary.sourcePath
8068
8891
  };
8069
8892
  }
8070
8893
  };
8071
8894
  }
8072
8895
  var refuse2, SKILL_LIST_RESULT_MAX;
8073
- var init_service = __esm({
8896
+ var init_service2 = __esm({
8074
8897
  "src/skills/service.ts"() {
8075
8898
  "use strict";
8076
8899
  init_src();
8077
8900
  init_log();
8078
- init_mirror();
8079
- init_paths2();
8080
- init_scan();
8901
+ init_mirror2();
8902
+ init_paths3();
8903
+ init_scan2();
8081
8904
  refuse2 = (message) => new Error(`VALIDATION: ${message}`);
8082
8905
  SKILL_LIST_RESULT_MAX = 1024 * 1024;
8083
8906
  }
@@ -8102,7 +8925,7 @@ function createTerminalChannels({
8102
8925
  log.warn({ terminalId, type: m.type, result }, "terminal frame not sent");
8103
8926
  },
8104
8927
  (err) => {
8105
- log.warn({ terminalId, type: m.type, err: messageOf(err) }, "terminal frame not sent");
8928
+ log.warn({ terminalId, type: m.type, err: messageOf2(err) }, "terminal frame not sent");
8106
8929
  }
8107
8930
  ).finally(() => {
8108
8931
  entry.pending.delete(sent);
@@ -8126,9 +8949,9 @@ function createTerminalChannels({
8126
8949
  const inflight = joining.get(terminalId);
8127
8950
  if (inflight) return inflight;
8128
8951
  if (joined.has(terminalId)) return;
8129
- const join22 = attachOne(terminalId).finally(() => joining.delete(terminalId));
8130
- joining.set(terminalId, join22);
8131
- return join22;
8952
+ const join24 = attachOne(terminalId).finally(() => joining.delete(terminalId));
8953
+ joining.set(terminalId, join24);
8954
+ return join24;
8132
8955
  },
8133
8956
  flush,
8134
8957
  detach,
@@ -8142,7 +8965,7 @@ function createTerminalChannels({
8142
8965
  try {
8143
8966
  manager.receive(terminalId, payload, "relay");
8144
8967
  } catch (err) {
8145
- log.warn({ terminalId, err: messageOf(err) }, "terminal message refused");
8968
+ log.warn({ terminalId, err: messageOf2(err) }, "terminal message refused");
8146
8969
  }
8147
8970
  });
8148
8971
  joined.set(terminalId, { channel, pending: /* @__PURE__ */ new Set() });
@@ -8173,19 +8996,19 @@ function createTerminalChannels({
8173
8996
  }
8174
8997
  }
8175
8998
  }
8176
- var messageOf;
8999
+ var messageOf2;
8177
9000
  var init_channels = __esm({
8178
9001
  "src/terminal/channels.ts"() {
8179
9002
  "use strict";
8180
9003
  init_src();
8181
- messageOf = (err) => err instanceof Error ? err.message : String(err);
9004
+ messageOf2 = (err) => err instanceof Error ? err.message : String(err);
8182
9005
  }
8183
9006
  });
8184
9007
 
8185
9008
  // src/terminal/env.ts
8186
- import { readFileSync as readFileSync7, realpathSync as realpathSync12 } from "fs";
9009
+ import { readFileSync as readFileSync8, realpathSync as realpathSync13 } from "fs";
8187
9010
  import { lstat } from "fs/promises";
8188
- import { dirname as dirname9, isAbsolute as isAbsolute15, relative as relative8 } from "path";
9011
+ import { dirname as dirname9, isAbsolute as isAbsolute16, relative as relative9 } from "path";
8189
9012
  function terminalEnv(base, { runnerOwner, claudeConfigDir }) {
8190
9013
  const env = {};
8191
9014
  for (const [key, value] of Object.entries(base)) {
@@ -8254,8 +9077,8 @@ function tryRealpath(fs, p) {
8254
9077
  }
8255
9078
  }
8256
9079
  function inside2(candidate, root) {
8257
- const rel = relative8(root, candidate);
8258
- return rel === "" || !rel.startsWith("..") && !isAbsolute15(rel);
9080
+ const rel = relative9(root, candidate);
9081
+ return rel === "" || !rel.startsWith("..") && !isAbsolute16(rel);
8259
9082
  }
8260
9083
  var SECRET_ENV, MEMBER_ENV_ALLOW, MEMBER_ENV_KEYS, LOCALE_ENV, nodeShellFs;
8261
9084
  var init_env = __esm({
@@ -8280,12 +9103,12 @@ var init_env = __esm({
8280
9103
  nodeShellFs = {
8281
9104
  readEtcShells: () => {
8282
9105
  try {
8283
- return readFileSync7("/etc/shells", "utf8");
9106
+ return readFileSync8("/etc/shells", "utf8");
8284
9107
  } catch {
8285
9108
  return "";
8286
9109
  }
8287
9110
  },
8288
- realpath: (p) => realpathSync12.native(p),
9111
+ realpath: (p) => realpathSync13.native(p),
8289
9112
  lstat: (p) => lstat(p)
8290
9113
  };
8291
9114
  }
@@ -9115,7 +9938,7 @@ function createTerminalService({
9115
9938
  await closeRow(terminalId);
9116
9939
  await channels.detach(terminalId);
9117
9940
  })().catch((err) => {
9118
- log.warn({ terminalId, err: messageOf2(err) }, "terminal exit cleanup failed");
9941
+ log.warn({ terminalId, err: messageOf3(err) }, "terminal exit cleanup failed");
9119
9942
  }).finally(() => {
9120
9943
  exiting.delete(done);
9121
9944
  });
@@ -9146,7 +9969,7 @@ function createTerminalService({
9146
9969
  if (!manager.has(terminalId) && !cleanedWhileOpening.has(terminalId)) {
9147
9970
  live.delete(terminalId);
9148
9971
  await channels.detach(terminalId).catch((detachErr) => {
9149
- log.warn({ terminalId, err: messageOf2(detachErr) }, "terminal channel detach failed");
9972
+ log.warn({ terminalId, err: messageOf3(detachErr) }, "terminal channel detach failed");
9150
9973
  });
9151
9974
  await closeRow(terminalId);
9152
9975
  }
@@ -9207,13 +10030,13 @@ function createTerminalService({
9207
10030
  }
9208
10031
  };
9209
10032
  }
9210
- var messageOf2;
9211
- var init_service2 = __esm({
10033
+ var messageOf3;
10034
+ var init_service3 = __esm({
9212
10035
  "src/terminal/service.ts"() {
9213
10036
  "use strict";
9214
10037
  init_manager2();
9215
10038
  init_refusals();
9216
- messageOf2 = (err) => err instanceof Error ? err.message : String(err);
10039
+ messageOf3 = (err) => err instanceof Error ? err.message : String(err);
9217
10040
  }
9218
10041
  });
9219
10042
 
@@ -9225,7 +10048,7 @@ __export(start_exports, {
9225
10048
  start: () => start
9226
10049
  });
9227
10050
  import { homedir as homedir4, hostname } from "os";
9228
- import { join as join20 } from "path";
10051
+ import { join as join22 } from "path";
9229
10052
  function protectedPaths(claudeConfigDir, home = homedir4()) {
9230
10053
  const inHome = [
9231
10054
  ".claude",
@@ -9233,12 +10056,12 @@ function protectedPaths(claudeConfigDir, home = homedir4()) {
9233
10056
  ".codex",
9234
10057
  ".ssh",
9235
10058
  ".aws",
9236
- join20(".config", "gh"),
10059
+ join22(".config", "gh"),
9237
10060
  ".gnupg",
9238
10061
  ".git-credentials",
9239
10062
  ".npmrc",
9240
10063
  ".netrc"
9241
- ].map((p) => join20(home, p));
10064
+ ].map((p) => join22(home, p));
9242
10065
  return [cayenneHome(), ...claudeConfigDir ? [claudeConfigDir] : [], ...inHome];
9243
10066
  }
9244
10067
  function logCodex(codex) {
@@ -9391,7 +10214,12 @@ async function compose(cfg, lock, host, preflight) {
9391
10214
  promptImages: true,
9392
10215
  // D38: this build scans `.cayenne/skills` and answers both `runner.skills.*` kinds, so the
9393
10216
  // Skills page can say "this runner cannot list skills" about an older one instead of waiting.
9394
- skills: true
10217
+ skills: true,
10218
+ // D40: this build mirrors what a session writes under `.cayenne/docs`, so the Documents tab
10219
+ // can say "this runner does not publish documents" about an older one instead of "none yet".
10220
+ // Nothing else is composed here: the mirror is one service per live session, built by the
10221
+ // manager from the directory the provider actually runs in (lane 5 design §3).
10222
+ documents: true
9395
10223
  });
9396
10224
  const registered = capabilities();
9397
10225
  let loggedCodex = null;
@@ -9478,9 +10306,9 @@ async function compose(cfg, lock, host, preflight) {
9478
10306
  // The roster's own array, mutated in place: `serves` reads the live list with no restart.
9479
10307
  allowedMemberIds: liveRoster.allowedMemberIds,
9480
10308
  // Outside every profile and repository: the creator of a worktree is the machine's record.
9481
- worktreeOwners: createWorktreeOwners(join20(cayenneHome(), "worktrees")),
10309
+ worktreeOwners: createWorktreeOwners(join22(cayenneHome(), "worktrees")),
9482
10310
  // Beside it: where each session was spawned, the only source of a resumed directory.
9483
- sessionWorktrees: createSessionWorktrees(join20(cayenneHome(), "session-worktrees")),
10311
+ sessionWorktrees: createSessionWorktrees(join22(cayenneHome(), "session-worktrees")),
9484
10312
  roots,
9485
10313
  allowBypass: cfg.allowBypassPermissions,
9486
10314
  trustedRoots,
@@ -9775,22 +10603,22 @@ var init_start = __esm({
9775
10603
  init_attachments2();
9776
10604
  init_handlers();
9777
10605
  init_manager();
9778
- init_paths2();
9779
- init_service();
10606
+ init_paths3();
10607
+ init_service2();
9780
10608
  init_supabase();
9781
10609
  init_channels();
9782
10610
  init_manager2();
9783
10611
  init_process_group();
9784
10612
  init_pty();
9785
- init_service2();
10613
+ init_service3();
9786
10614
  init_version();
9787
10615
  }
9788
10616
  });
9789
10617
 
9790
10618
  // src/cli.ts
9791
- import { existsSync as existsSync6, mkdirSync as mkdirSync8, readFileSync as readFileSync8, realpathSync as realpathSync13, rmSync as rmSync8, writeFileSync as writeFileSync6 } from "fs";
10619
+ import { existsSync as existsSync6, mkdirSync as mkdirSync8, readFileSync as readFileSync9, realpathSync as realpathSync14, rmSync as rmSync8, writeFileSync as writeFileSync6 } from "fs";
9792
10620
  import { homedir as homedir5, hostname as hostname2, userInfo } from "os";
9793
- import { dirname as dirname10, join as join21 } from "path";
10621
+ import { dirname as dirname10, join as join23 } from "path";
9794
10622
  import { Command } from "commander";
9795
10623
  import prompts from "prompts";
9796
10624
 
@@ -10392,7 +11220,7 @@ program.command("doctor").description("Check the install and say how to fix what
10392
11220
  if (checks.some((c) => !c.ok)) process.exitCode = 1;
10393
11221
  });
10394
11222
  program.command("logs").description("Show the service log").option("-f, --follow", "keep streaming").action(async (opts) => {
10395
- const file = join21(cayenneDir(), "logs", "runner.log");
11223
+ const file = join23(cayenneDir(), "logs", "runner.log");
10396
11224
  if (!existsSync6(file)) throw new Error(`no log yet at ${file}. Run \`cayenne up\` first.`);
10397
11225
  const print = (chunk) => {
10398
11226
  for (const line of chunk.split("\n")) {
@@ -10405,12 +11233,12 @@ program.command("logs").description("Show the service log").option("-f, --follow
10405
11233
  }
10406
11234
  }
10407
11235
  };
10408
- print(readFileSync8(file, "utf8"));
11236
+ print(readFileSync9(file, "utf8"));
10409
11237
  if (opts.follow) {
10410
11238
  const { watch: watch2 } = await import("fs");
10411
- let size = readFileSync8(file).length;
11239
+ let size = readFileSync9(file).length;
10412
11240
  watch2(file, () => {
10413
- const buf = readFileSync8(file);
11241
+ const buf = readFileSync9(file);
10414
11242
  if (buf.length > size) print(buf.subarray(size).toString("utf8"));
10415
11243
  size = buf.length;
10416
11244
  });
@@ -10438,7 +11266,7 @@ program.command("update").description("Re-pin the installed app and restart the
10438
11266
  cliPath: record.cliPath,
10439
11267
  profile: currentProfile(),
10440
11268
  pathEnv: record.pathEnv,
10441
- logDir: join21(dir, "logs")
11269
+ logDir: join23(dir, "logs")
10442
11270
  })
10443
11271
  );
10444
11272
  await launchd.bootout(label);
@@ -10454,12 +11282,12 @@ program.command("uninstall").description("Stop the service and remove the launch
10454
11282
  console.log(`removed ${label}`);
10455
11283
  return;
10456
11284
  }
10457
- const dir = realpathSync13(cayenneDir());
10458
- const home = realpathSync13(homedir5());
11285
+ const dir = realpathSync14(cayenneDir());
11286
+ const home = realpathSync14(homedir5());
10459
11287
  if (dir === "/" || dir === home || home.startsWith(`${dir}/`)) {
10460
11288
  throw new Error(`refusing to purge ${dir}: it is the home directory or an ancestor of it`);
10461
11289
  }
10462
- if (!existsSync6(join21(dir, "config.json")) && !existsSync6(join21(dir, "app", "install.json"))) {
11290
+ if (!existsSync6(join23(dir, "config.json")) && !existsSync6(join23(dir, "app", "install.json"))) {
10463
11291
  throw new Error(`refusing to purge ${dir}: it does not look like a Cayenne profile`);
10464
11292
  }
10465
11293
  if (!opts.yes) {