@jpcasabiancai/cayenne 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +126 -33
  2. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -324,8 +324,35 @@ var init_command = __esm({
324
324
  effort: Effort.optional(),
325
325
  model: z4.string().optional(),
326
326
  resumeProviderSessionId: z4.string().optional(),
327
+ // Duplicate a chat (D36): start from a fork of `resumeProviderSessionId` instead of appending
328
+ // to its transcript. Claude-only, no `worktree`. The runner additionally refuses a source that
329
+ // is not the caller's own Claude session on this runner, or a cwd other than the source's own directory.
330
+ forkSession: z4.boolean().optional(),
327
331
  initialPrompt: z4.string().optional(),
328
332
  title: z4.string().max(200).optional()
333
+ }).superRefine((p, ctx) => {
334
+ if (p.forkSession !== true) return;
335
+ if (p.resumeProviderSessionId === void 0) {
336
+ ctx.addIssue({
337
+ code: "custom",
338
+ message: "forkSession needs resumeProviderSessionId",
339
+ path: ["forkSession"]
340
+ });
341
+ }
342
+ if (p.worktree !== void 0) {
343
+ ctx.addIssue({
344
+ code: "custom",
345
+ message: "a forked session keeps its working directory",
346
+ path: ["worktree"]
347
+ });
348
+ }
349
+ if (p.provider === "codex") {
350
+ ctx.addIssue({
351
+ code: "custom",
352
+ message: "codex sessions cannot be forked",
353
+ path: ["provider"]
354
+ });
355
+ }
329
356
  }),
330
357
  "session.prompt": z4.object({
331
358
  text: z4.string().min(1).max(2e5),
@@ -634,6 +661,12 @@ var init_runner = __esm({
634
661
  * cannot grant a terminal. Optional so an older row still parses.
635
662
  */
636
663
  memberTerminalIds: z8.array(z8.uuid()).max(64).optional(),
664
+ /**
665
+ * Whether this runner honours `session.start { forkSession: true }` (D36, protocol §9). An older
666
+ * runner strips the field and then refuses the resume under R30, so a client sends
667
+ * `forkSession` only when this is true. Absent reads as false; optional so an older row parses.
668
+ */
669
+ sessionFork: z8.boolean().optional(),
637
670
  /**
638
671
  * The raw Ed25519 public key of the running process (32 bytes, unpadded base64url), which signs
639
672
  * the local server's `hello` (protocol §6.2). New on every start, republished by every capabilities refresh that changes the row;
@@ -650,6 +683,21 @@ var init_runner = __esm({
650
683
  }
651
684
  });
652
685
 
686
+ // ../../packages/shared/src/protocol/session.ts
687
+ import { z as z9 } from "zod";
688
+ var SlashCommand, SessionCommandsListed;
689
+ var init_session = __esm({
690
+ "../../packages/shared/src/protocol/session.ts"() {
691
+ "use strict";
692
+ SlashCommand = z9.object({
693
+ name: z9.string(),
694
+ description: z9.string(),
695
+ argumentHint: z9.string()
696
+ });
697
+ SessionCommandsListed = z9.object({ slashCommands: z9.array(SlashCommand) });
698
+ }
699
+ });
700
+
653
701
  // ../../packages/shared/src/protocol/transport.ts
654
702
  var init_transport = __esm({
655
703
  "../../packages/shared/src/protocol/transport.ts"() {
@@ -668,6 +716,7 @@ var init_protocol = __esm({
668
716
  init_github();
669
717
  init_local();
670
718
  init_runner();
719
+ init_session();
671
720
  init_terminal();
672
721
  init_transport();
673
722
  }
@@ -817,7 +866,7 @@ import {
817
866
  } from "fs";
818
867
  import { homedir as homedir2 } from "os";
819
868
  import { dirname, isAbsolute as isAbsolute2, join as join3 } from "path";
820
- import { z as z9 } from "zod";
869
+ import { z as z10 } from "zod";
821
870
  function setProfileOverride(name) {
822
871
  profileOverride = name;
823
872
  }
@@ -892,24 +941,24 @@ var init_config = __esm({
892
941
  init_cloud();
893
942
  init_profile();
894
943
  MEMBER_TERMINALS_ERROR = "allowMemberTerminals must be false or a list of user ids";
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),
944
+ RunnerConfig = z10.object({
945
+ supabaseUrl: z10.url(),
946
+ supabaseAnonKey: z10.string().min(1),
947
+ workspaceId: z10.uuid().optional(),
948
+ runnerId: z10.uuid().optional(),
949
+ name: z10.string().optional(),
950
+ roots: z10.array(z10.string()).default([]),
951
+ port: z10.number().int().default(LOCAL_PORT),
903
952
  /** 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),
953
+ localOrigins: z10.array(z10.string()).default([]),
954
+ allowBypassPermissions: z10.boolean().default(false),
906
955
  /** Absolute path to the `codex` binary, when it is not on the daemon's PATH. */
907
- codexPath: z9.string().optional(),
956
+ codexPath: z10.string().optional(),
908
957
  /**
909
958
  * The Claude Code config directory this profile's sessions run on (D33), handed to them as
910
959
  * `CLAUDE_CONFIG_DIR`. Unset means the CLI's own default, `~/.claude`.
911
960
  */
912
- claudeConfigDir: z9.string().optional(),
961
+ claudeConfigDir: z10.string().optional(),
913
962
  /**
914
963
  * Who besides the runner owner may hold a terminal here (D34, plan ruling 14): `false`, or the
915
964
  * user ids this machine's owner names. Machine-local like `allowBypassPermissions`: a workspace
@@ -917,15 +966,15 @@ var init_config = __esm({
917
966
  * `true` is refused — there is no "every member" switch. A listed member's shell runs as this
918
967
  * machine's OS user; the env allowlist is not a boundary.
919
968
  */
920
- allowMemberTerminals: z9.union(
969
+ allowMemberTerminals: z10.union(
921
970
  [
922
- z9.literal(false),
923
- z9.array(z9.uuid({ error: MEMBER_TERMINALS_ERROR }), { error: MEMBER_TERMINALS_ERROR }).max(64, { error: MEMBER_TERMINALS_ERROR })
971
+ z10.literal(false),
972
+ z10.array(z10.uuid({ error: MEMBER_TERMINALS_ERROR }), { error: MEMBER_TERMINALS_ERROR }).max(64, { error: MEMBER_TERMINALS_ERROR })
924
973
  ],
925
974
  { error: MEMBER_TERMINALS_ERROR }
926
975
  ).default(false),
927
976
  /** Minutes a terminal nobody attends stays alive before the runner kills it (features §2). */
928
- terminalIdleMinutes: z9.number().int().min(1).max(1440).default(10)
977
+ terminalIdleMinutes: z10.number().int().min(1).max(1440).default(10)
929
978
  });
930
979
  memberTerminalIdsOf = (cfg) => cfg.allowMemberTerminals === false ? [] : [...cfg.allowMemberTerminals];
931
980
  configPath = (dir) => join3(dir, "config.json");
@@ -1170,7 +1219,7 @@ import {
1170
1219
  } from "fs";
1171
1220
  import { uptime } from "os";
1172
1221
  import { join as join5 } from "path";
1173
- import { z as z10 } from "zod";
1222
+ import { z as z11 } from "zod";
1174
1223
  function bootedAt(now = Date.now()) {
1175
1224
  return new Date(Math.round((now - uptime() * 1e3) / 1e3) * 1e3).toISOString();
1176
1225
  }
@@ -1313,13 +1362,13 @@ var LockRecord, BOOT_DRIFT_MS, ATTEMPTS, BACKOFF_MS, CLAIM_TTL_MS, errno, claimO
1313
1362
  var init_lock = __esm({
1314
1363
  "src/lock.ts"() {
1315
1364
  "use strict";
1316
- LockRecord = z10.object({
1317
- pid: z10.number().int().positive(),
1318
- profile: z10.string(),
1319
- startedAt: z10.string(),
1365
+ LockRecord = z11.object({
1366
+ pid: z11.number().int().positive(),
1367
+ profile: z11.string(),
1368
+ startedAt: z11.string(),
1320
1369
  /** `bootedAt()` when the lock was taken; a lock from another boot is stale whatever its pid. */
1321
- bootedAt: z10.string(),
1322
- version: z10.string()
1370
+ bootedAt: z11.string(),
1371
+ version: z11.string()
1323
1372
  });
1324
1373
  BOOT_DRIFT_MS = 1e4;
1325
1374
  ATTEMPTS = 8;
@@ -3962,11 +4011,12 @@ var init_refusals = __esm({
3962
4011
  });
3963
4012
 
3964
4013
  // src/sessions/handlers.ts
4014
+ import { ZodError } from "zod";
3965
4015
  function parsePayload(row, kind) {
3966
4016
  try {
3967
4017
  return parseCommand(row, kind);
3968
4018
  } catch (err) {
3969
- const message = err instanceof Error ? err.message : String(err);
4019
+ const message = err instanceof ZodError ? err.issues.map((i) => i.message).join("; ") : err instanceof Error ? err.message : String(err);
3970
4020
  throw new Error(message.startsWith("VALIDATION:") ? message : `VALIDATION: ${message}`);
3971
4021
  }
3972
4022
  }
@@ -4354,7 +4404,7 @@ import { randomUUID as randomUUID3 } from "crypto";
4354
4404
  import { EventEmitter } from "events";
4355
4405
  import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
4356
4406
  var ClaudeSession;
4357
- var init_session = __esm({
4407
+ var init_session2 = __esm({
4358
4408
  "src/claude/session.ts"() {
4359
4409
  "use strict";
4360
4410
  init_src();
@@ -4364,7 +4414,7 @@ var init_session = __esm({
4364
4414
  constructor(opts) {
4365
4415
  super();
4366
4416
  this.opts = opts;
4367
- this.providerSessionId = opts.resume ?? null;
4417
+ this.providerSessionId = opts.forkSession ? null : opts.resume ?? null;
4368
4418
  }
4369
4419
  opts;
4370
4420
  provider = "claude";
@@ -4397,6 +4447,7 @@ var init_session = __esm({
4397
4447
  // Present only when asked for, so the default call stays the CLI's own behaviour.
4398
4448
  ...this.opts.strictMcpConfig === void 0 ? {} : { strictMcpConfig: this.opts.strictMcpConfig },
4399
4449
  ...this.opts.mcpServers === void 0 ? {} : { mcpServers: this.opts.mcpServers },
4450
+ ...this.opts.forkSession ? { forkSession: true } : {},
4400
4451
  canUseTool,
4401
4452
  abortController: this.abort,
4402
4453
  stderr: (line) => {
@@ -4645,7 +4696,7 @@ function scrubSecrets(text) {
4645
4696
  return SECRET_PATTERNS.reduce((acc, [pattern, to]) => acc.replace(pattern, to), text);
4646
4697
  }
4647
4698
  var STDERR_TAIL_MAX, SIGKILL_AFTER_MS, SECRET_PATTERNS, APPROVAL_METHODS, CodexSession;
4648
- var init_session2 = __esm({
4699
+ var init_session3 = __esm({
4649
4700
  "src/codex/session.ts"() {
4650
4701
  "use strict";
4651
4702
  init_src();
@@ -4991,8 +5042,8 @@ function createProviderSession(provider, opts) {
4991
5042
  var init_create = __esm({
4992
5043
  "src/providers/create.ts"() {
4993
5044
  "use strict";
4994
- init_session();
4995
5045
  init_session2();
5046
+ init_session3();
4996
5047
  }
4997
5048
  });
4998
5049
 
@@ -5095,10 +5146,16 @@ var init_manager = __esm({
5095
5146
  }
5096
5147
  this.assertModeAllowed(p.permissionMode);
5097
5148
  const provider = p.provider ?? "claude";
5149
+ if (p.forkSession && provider !== "claude") {
5150
+ throw new Error("VALIDATION: codex sessions cannot be forked");
5151
+ }
5098
5152
  this.assertProviderAllowed(provider, createdBy);
5099
5153
  const typed = resolveInsideRoots(p.cwd, this.deps.roots);
5100
5154
  this.assertNotRemoving(typed.real);
5101
- if (p.resumeProviderSessionId !== void 0) {
5155
+ const forking = p.forkSession === true && p.resumeProviderSessionId !== void 0;
5156
+ if (forking) {
5157
+ await this.assertForkable(p.resumeProviderSessionId, createdBy, typed.real);
5158
+ } else if (p.resumeProviderSessionId !== void 0) {
5102
5159
  await this.assertResumable(sessionId, p.resumeProviderSessionId);
5103
5160
  }
5104
5161
  let { real: cwd, root } = typed;
@@ -5106,7 +5163,7 @@ var init_manager = __esm({
5106
5163
  if (p.worktree) {
5107
5164
  cwd = await this.startWorktree(typed.real, typed.root, p.worktree, createdBy);
5108
5165
  inWorktree = true;
5109
- } else if (p.resumeProviderSessionId !== void 0) {
5166
+ } else if (p.resumeProviderSessionId !== void 0 && !forking) {
5110
5167
  const recorded = await this.recordedWorktree(sessionId);
5111
5168
  if (recorded) ({ real: cwd, root } = recorded);
5112
5169
  }
@@ -5127,6 +5184,7 @@ var init_manager = __esm({
5127
5184
  permissionMode: p.permissionMode,
5128
5185
  model: p.model,
5129
5186
  resume: p.resumeProviderSessionId,
5187
+ ...forking ? { forkSession: true } : {},
5130
5188
  ...provider === "codex" ? {
5131
5189
  codexPath: this.deps.codexPath,
5132
5190
  checkInput: this.codexGate(sessionId, cwd),
@@ -5137,7 +5195,9 @@ var init_manager = __esm({
5137
5195
  started();
5138
5196
  }
5139
5197
  if (inWorktree) await this.recordSessionWorktree(sessionId, cwd, root);
5140
- else if (p.resumeProviderSessionId === void 0) this.forgetSessionWorktree(sessionId);
5198
+ else if (p.resumeProviderSessionId === void 0 || forking) {
5199
+ this.forgetSessionWorktree(sessionId);
5200
+ }
5141
5201
  live.status = "idle";
5142
5202
  await this.updateSession(sessionId, { status: "idle", model: p.model ?? null });
5143
5203
  await this.broadcast(sessionId, {
@@ -5584,6 +5644,37 @@ var init_manager = __esm({
5584
5644
  throw new Error("VALIDATION: resumeProviderSessionId does not belong to this session");
5585
5645
  }
5586
5646
  }
5647
+ /**
5648
+ * D36, amending R30 for `forkSession: true`: the resume id may be held by another session, but
5649
+ * only one on this runner that `createdBy` owns — a member must not fork the runner owner's
5650
+ * conversation, or anyone else's. A command with no creator owns nothing. Only a Claude row with
5651
+ * a non-empty id can be a source. The new session must run where the source did, decided the way
5652
+ * a resume of the source would be: the worktree this runner recorded for it, else its canonical
5653
+ * `cwd`. Never `worktree_path`: the runner owner can rewrite that column on a member's chat, and
5654
+ * it must not steer where the member's fork runs. A source path that no longer resolves inside
5655
+ * the roots cannot be proved equal, so it is refused, not thrown through.
5656
+ */
5657
+ async assertForkable(providerSessionId, createdBy, cwd) {
5658
+ if (providerSessionId === "") {
5659
+ throw new Error("VALIDATION: forkSession source is not a session you own on this runner");
5660
+ }
5661
+ const { data, error } = await this.deps.db.from("chat_sessions").select("id, owner_id, cwd").eq("provider_session_id", providerSessionId).eq("runner_id", this.deps.runnerId).eq("provider", "claude").maybeSingle();
5662
+ if (error) throw new Error(`PROVIDER_ERROR: could not read the session: ${error.message}`);
5663
+ if (!data || createdBy === null || data.owner_id !== createdBy) {
5664
+ throw new Error("VALIDATION: forkSession source is not a session you own on this runner");
5665
+ }
5666
+ let sourceCwd = (await this.recordedWorktree(data.id))?.real ?? null;
5667
+ if (sourceCwd === null) {
5668
+ try {
5669
+ sourceCwd = resolveInsideRoots(data.cwd, this.deps.roots).real;
5670
+ } catch {
5671
+ sourceCwd = null;
5672
+ }
5673
+ }
5674
+ if (sourceCwd !== cwd) {
5675
+ throw new Error("VALIDATION: a forked session keeps its working directory");
5676
+ }
5677
+ }
5587
5678
  /**
5588
5679
  * Whether this runner may open a session on `provider` for `createdBy`.
5589
5680
  *
@@ -7517,7 +7608,9 @@ async function compose(cfg, lock, host, preflight) {
7517
7608
  claude: true,
7518
7609
  providers,
7519
7610
  // Who the runner's own config names (D34); every heartbeat refresh republishes it.
7520
- memberTerminalIds: [...memberTerminalIds]
7611
+ memberTerminalIds: [...memberTerminalIds],
7612
+ // D36: this build forks a Claude session on `session.start { forkSession: true }`.
7613
+ sessionFork: true
7521
7614
  });
7522
7615
  const registered = capabilities();
7523
7616
  let loggedCodex = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpcasabiancai/cayenne",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "files": [
5
5
  "dist/cli.js"
6
6
  ],
@@ -42,8 +42,8 @@
42
42
  "tsx": "4.23.13",
43
43
  "typescript": "~5.9.3",
44
44
  "vitest": "5.0.0",
45
- "@cayenne/shared": "0.0.1",
46
- "@cayenne/db": "0.0.1"
45
+ "@cayenne/db": "0.0.1",
46
+ "@cayenne/shared": "0.0.1"
47
47
  },
48
48
  "scripts": {
49
49
  "cli": "tsx src/cli.ts",