nawabari 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PAT
6
6
  import { createLocalSessionBackend } from "./domain/session-backend.js";
7
7
  import { defaultCliIO, renderFailure, renderSuccess } from "./presentation.js";
8
8
  import { MACHINE_CONTRACT_ID, MACHINE_CONTRACT_SCHEMA_VERSION, machineContract } from "./contract.js";
9
+ import { resolveSandboxExecutionRequest, runSandboxedCommand, } from "./domain/sandbox.js";
9
10
  const CLI_NAME = "nawabari";
10
11
  const packageMetadata = createRequire(import.meta.url)("../package.json");
11
12
  const VERSION = packageMetadata.version;
@@ -25,14 +26,18 @@ const HELP_COMMANDS = [
25
26
  value: "<name>",
26
27
  default: "nawabari/session/<session_id>",
27
28
  }),
28
- option("--worktree", "Managed worktree path; omitted uses the resolved repository-local root", {
29
+ option("--worktree", "Exact managed worktree path override; mutually exclusive with --worktree-root", {
29
30
  value: "<path>",
30
31
  default: "<managed_worktree_root>/<repository>-<session_id>",
31
32
  }),
33
+ option("--worktree-root", "Managed root to place the worktree under; Nawabari derives the final path. Mutually exclusive with --worktree", { value: "<path>", default: "resolved repository-local root" }),
32
34
  option("--base", "Commit-resolving base ref for the new worktree", { value: "<ref>", default: "HEAD" }),
33
35
  option("--label", "Optional display label; never used as an identity", { value: "<text>", default: "omitted" }),
34
36
  ],
35
- notes: ["All create options are optional. Use status --json to discover managed_worktree_root."],
37
+ notes: [
38
+ "All create options are optional. Use status --json to discover managed_worktree_root.",
39
+ "--worktree and --worktree-root cannot be combined.",
40
+ ],
36
41
  },
37
42
  {
38
43
  name: "session id",
@@ -46,6 +51,30 @@ const HELP_COMMANDS = [
46
51
  usage: `${CLI_NAME} session show [--session <id>]`,
47
52
  options: [option("--session", "Select a session instead of the current worktree owner", { value: "<id>" })],
48
53
  },
54
+ {
55
+ name: "session inspect",
56
+ summary: "Report side-effect-free close/cleanup readiness for a session",
57
+ usage: `${CLI_NAME} session inspect [--session <id>] [--integrated-revision <rev>]`,
58
+ options: [
59
+ option("--session", "Select a session instead of the current worktree owner", { value: "<id>" }),
60
+ option("--integrated-revision", "Externally evidenced revision to test for non-ancestry (squash/rebase) integration; independently re-verified via exact Git tree-object equivalence, never trusted blindly", { value: "<rev>" }),
61
+ ],
62
+ notes: [
63
+ "Read-only: never mutates session, claim, Git, worktree, branch, or registry state. Repeated calls are idempotent.",
64
+ "Derived from the same authoritative close/cleanup Git evidence as session close; does not duplicate or diverge from that logic.",
65
+ "Nawabari never queries GitHub or any remote provider; --integrated-revision only names a local revision for Nawabari to independently verify.",
66
+ ],
67
+ },
68
+ {
69
+ name: "session run",
70
+ summary: "Run one command inside the protected session sandbox",
71
+ usage: `${CLI_NAME} session run [--session <id>] -- <command> [args...]`,
72
+ options: [option("--session", "Select the active owned session", { value: "<id>" })],
73
+ notes: [
74
+ "The -- terminator is mandatory. The command is passed as argv without a shell, and protected execution is fail-closed.",
75
+ "session exec is an alias.",
76
+ ],
77
+ },
49
78
  {
50
79
  name: "session list",
51
80
  summary: "List bounded repository session records",
@@ -69,14 +98,25 @@ const HELP_COMMANDS = [
69
98
  },
70
99
  {
71
100
  name: "session update",
72
- summary: "Replace a session's resource claims",
73
- usage: `${CLI_NAME} session update --resource <path-or-glob> --mode <read|write|exclusive-write> [--session <id>]`,
101
+ summary: "Atomically replace a session's complete resource claim set",
102
+ usage: `${CLI_NAME} session update --resource <path-or-glob> --mode <read|write|exclusive-write> [--resource <path-or-glob> --mode <read|write|exclusive-write> ...] [--session <id>]`,
74
103
  options: [
75
- option("--resource", "Repository-relative resource", { value: "<path-or-glob>", required: true }),
76
- option("--mode", "Granted claim mode", { value: "<read|write|exclusive-write>", required: true }),
104
+ option("--resource", "Repository-relative resource; repeatable, each paired with the --mode immediately after it", {
105
+ value: "<path-or-glob>",
106
+ required: true,
107
+ }),
108
+ option("--mode", "Mode for the --resource immediately before it; repeatable", {
109
+ value: "<read|write|exclusive-write>",
110
+ required: true,
111
+ }),
77
112
  option("--session", "Target active session; omitted resolves the current owner", { value: "<id>" }),
78
113
  option("--repository", "Expected repository identity", { value: "<id>" }),
79
114
  ],
115
+ notes: [
116
+ "The desired claim set fully replaces the session's current claims in one updateClaims() transaction; " +
117
+ "on any invalid or conflicting claim the prior set is left unchanged.",
118
+ "Each --resource must be immediately followed by its own --mode; pairing is positional adjacency, not flag order.",
119
+ ],
80
120
  },
81
121
  {
82
122
  name: "session claims",
@@ -101,9 +141,25 @@ const HELP_COMMANDS = [
101
141
  },
102
142
  {
103
143
  name: "resource update",
104
- summary: "Replace a session's resource claims (alias)",
105
- usage: `${CLI_NAME} resource update --resource <path-or-glob> --mode <read|write|exclusive-write>`,
106
- options: [],
144
+ summary: "Atomically replace a session's complete resource claim set (alias)",
145
+ usage: `${CLI_NAME} resource update --resource <path-or-glob> --mode <read|write|exclusive-write> [--resource <path-or-glob> --mode <read|write|exclusive-write> ...] [--session <id>]`,
146
+ options: [
147
+ option("--resource", "Repository-relative resource; repeatable, each paired with the --mode immediately after it", {
148
+ value: "<path-or-glob>",
149
+ required: true,
150
+ }),
151
+ option("--mode", "Mode for the --resource immediately before it; repeatable", {
152
+ value: "<read|write|exclusive-write>",
153
+ required: true,
154
+ }),
155
+ option("--session", "Target active session; omitted resolves the current owner", { value: "<id>" }),
156
+ option("--repository", "Expected repository identity", { value: "<id>" }),
157
+ ],
158
+ notes: [
159
+ "The desired claim set fully replaces the session's current claims in one updateClaims() transaction; " +
160
+ "on any invalid or conflicting claim the prior set is left unchanged.",
161
+ "Each --resource must be immediately followed by its own --mode; pairing is positional adjacency, not flag order.",
162
+ ],
107
163
  },
108
164
  {
109
165
  name: "resource list",
@@ -123,8 +179,15 @@ const HELP_COMMANDS = [
123
179
  {
124
180
  name: "session close",
125
181
  summary: "Close the current or selected session",
126
- usage: `${CLI_NAME} session close [--session <id>]`,
127
- options: [option("--session", "Select a session instead of the current worktree owner", { value: "<id>" })],
182
+ usage: `${CLI_NAME} session close [--session <id>] [--integrated-revision <rev>]`,
183
+ options: [
184
+ option("--session", "Select a session instead of the current worktree owner", { value: "<id>" }),
185
+ option("--integrated-revision", "Externally evidenced revision proving non-ancestry (squash/rebase) integration; independently re-verified via exact Git tree-object equivalence, never trusted blindly", { value: "<rev>" }),
186
+ ],
187
+ notes: [
188
+ "Ordinary ancestry-based close remains the cheap/default path and requires no flags.",
189
+ "Nawabari never queries GitHub or any remote provider; --integrated-revision only names a local revision for Nawabari to independently verify.",
190
+ ],
128
191
  },
129
192
  {
130
193
  name: "authorize",
@@ -246,7 +309,9 @@ function helpSpecFor(commandArguments) {
246
309
  return ROOT_HELP_SPEC;
247
310
  let key = commandArguments[0] === "resource"
248
311
  ? `resource ${commandArguments[1] ?? "list"}`
249
- : commandArguments.slice(0, 2).join(" ");
312
+ : commandArguments[0] === "session" && commandArguments[1] === "exec"
313
+ ? "session run"
314
+ : commandArguments.slice(0, 2).join(" ");
250
315
  if (key === "resource claims")
251
316
  key = "resource list";
252
317
  const direct = HELP_COMMANDS.find((spec) => spec.name === key);
@@ -348,7 +413,17 @@ function parseGlobalArguments(argv) {
348
413
  let help = false;
349
414
  let version = false;
350
415
  const commandArguments = [];
416
+ let passthrough = false;
351
417
  for (const argument of argv) {
418
+ if (passthrough) {
419
+ commandArguments.push(argument);
420
+ continue;
421
+ }
422
+ if (argument === "--") {
423
+ passthrough = true;
424
+ commandArguments.push(argument);
425
+ continue;
426
+ }
352
427
  if (argument === "--json") {
353
428
  json = true;
354
429
  }
@@ -381,6 +456,7 @@ function parseOptions(arguments_, allowed) {
381
456
  session_id: null,
382
457
  branch: null,
383
458
  worktree: null,
459
+ worktree_root: null,
384
460
  base: null,
385
461
  label: null,
386
462
  resource: null,
@@ -406,6 +482,7 @@ function parseOptions(arguments_, allowed) {
406
482
  patch: false,
407
483
  max_bytes: null,
408
484
  max_hunks: null,
485
+ integrated_revision: null,
409
486
  };
410
487
  let dryRun = false;
411
488
  for (let index = 0; index < arguments_.length; index += 1) {
@@ -451,6 +528,8 @@ function parseOptions(arguments_, allowed) {
451
528
  options.branch = value;
452
529
  else if (name === "--worktree")
453
530
  options.worktree = value;
531
+ else if (name === "--worktree-root")
532
+ options.worktree_root = value;
454
533
  else if (name === "--base")
455
534
  options.base = value;
456
535
  else if (name === "--label")
@@ -489,6 +568,8 @@ function parseOptions(arguments_, allowed) {
489
568
  options.max_bytes = value;
490
569
  else if (name === "--max-hunks")
491
570
  options.max_hunks = value;
571
+ else if (name === "--integrated-revision")
572
+ options.integrated_revision = value;
492
573
  }
493
574
  if (options.apply && dryRun) {
494
575
  return failure(usageError("INVALID_ARGUMENT", "--apply and --dry-run cannot be used together."));
@@ -498,6 +579,83 @@ function parseOptions(arguments_, allowed) {
498
579
  function noOptions(arguments_) {
499
580
  return parseOptions(arguments_, new Set());
500
581
  }
582
+ /**
583
+ * `update` accepts a complete desired claim set as repeated
584
+ * `--resource <path> --mode <mode>` pairs. Pairing is by strict local
585
+ * adjacency (each `--resource` must be immediately followed by its own
586
+ * `--mode`, with no other option able to intervene) rather than by
587
+ * parallel-array position, so argv order can never associate a resource
588
+ * with the wrong mode.
589
+ */
590
+ function parseClaimReplacementPairs(arguments_) {
591
+ let sessionId = null;
592
+ let repository = null;
593
+ const pairs = [];
594
+ let pendingResource = null;
595
+ for (let index = 0; index < arguments_.length; index += 1) {
596
+ const { name, inlineValue } = optionParts(arguments_[index]);
597
+ if (name !== "--session" && name !== "--repository" && name !== "--resource" && name !== "--mode") {
598
+ return failure(usageError("INVALID_ARGUMENT", `Unknown option: ${name}.`, { option: name }));
599
+ }
600
+ if (pendingResource !== null && name !== "--mode") {
601
+ return failure(usageError("INVALID_ARGUMENT", `${name} cannot appear between --resource and its --mode; only --mode is permitted immediately after --resource.`, { option: name }));
602
+ }
603
+ const value = inlineValue ?? arguments_[index + 1];
604
+ if (value === undefined || value === "" || (inlineValue === null && value.startsWith("-"))) {
605
+ return failure(usageError("MISSING_ARGUMENT", `${name} requires a value.`, { option: name }));
606
+ }
607
+ if (inlineValue === null)
608
+ index += 1;
609
+ if (name === "--session")
610
+ sessionId = value;
611
+ else if (name === "--repository")
612
+ repository = value;
613
+ else if (name === "--resource")
614
+ pendingResource = value;
615
+ else {
616
+ if (pendingResource === null) {
617
+ return failure(usageError("INVALID_ARGUMENT", "--mode must be immediately preceded by its own --resource.", {
618
+ option: "--mode",
619
+ }));
620
+ }
621
+ pairs.push({ resource: pendingResource, mode: value });
622
+ pendingResource = null;
623
+ }
624
+ }
625
+ if (pendingResource !== null) {
626
+ return failure(usageError("MISSING_ARGUMENT", "--resource must be immediately followed by --mode.", { option: "--mode" }));
627
+ }
628
+ if (pairs.length === 0) {
629
+ return failure(usageError("MISSING_ARGUMENT", "--resource requires a value.", { option: "--resource" }));
630
+ }
631
+ return { ok: true, value: { session_id: sessionId, repository, pairs } };
632
+ }
633
+ /**
634
+ * `claim` accepts exactly one `--resource`/`--mode` pair. Reuses the same
635
+ * strict-adjacency scan as `update` so a second `--resource`/`--mode` (in
636
+ * any order) is rejected outright instead of silently overwriting the
637
+ * first pair (last-wins).
638
+ */
639
+ function parseSingleClaimPair(arguments_) {
640
+ const parsed = parseClaimReplacementPairs(arguments_);
641
+ if (!parsed.ok)
642
+ return parsed;
643
+ if (parsed.value.pairs.length > 1) {
644
+ return failure(usageError("INVALID_ARGUMENT", "claim accepts exactly one --resource/--mode pair.", {
645
+ pair_count: parsed.value.pairs.length,
646
+ }));
647
+ }
648
+ const [pair] = parsed.value.pairs;
649
+ return {
650
+ ok: true,
651
+ value: {
652
+ session_id: parsed.value.session_id,
653
+ repository: parsed.value.repository,
654
+ resource: pair.resource,
655
+ mode: pair.mode,
656
+ },
657
+ };
658
+ }
501
659
  function sessionListingOptions(parsed) {
502
660
  const parseInteger = (option, value) => {
503
661
  if (value === null)
@@ -557,25 +715,33 @@ async function executeCommand(commandArguments, dependencies) {
557
715
  if (subcommand === undefined) {
558
716
  return failure(usageError("MISSING_ARGUMENT", "session requires a subcommand."));
559
717
  }
560
- if (subcommand === "claim" || subcommand === "update") {
561
- const parsed = parseOptions(rest, new Set(["--session", "--resource", "--mode", "--repository"]));
718
+ if (subcommand === "claim") {
719
+ const parsed = parseSingleClaimPair(rest);
562
720
  if (!parsed.ok)
563
721
  return parsed;
564
- if (parsed.value.resource === null) {
565
- return failure(usageError("MISSING_ARGUMENT", "--resource requires a value.", { option: "--resource" }));
566
- }
567
- if (parsed.value.mode === null) {
568
- return failure(usageError("MISSING_ARGUMENT", "--mode requires a value.", { option: "--mode" }));
569
- }
570
- const options = {
722
+ if (dependencies.backend.claimResources === undefined)
723
+ return claimCapabilityUnavailable(subcommand);
724
+ const result = await dependencies.backend.claimResources(context, {
571
725
  session_id: parsed.value.session_id,
572
726
  repository: parsed.value.repository,
573
727
  claims: [{ resource: parsed.value.resource, mode: parsed.value.mode }],
574
- };
575
- const operation = subcommand === "claim" ? dependencies.backend.claimResources : dependencies.backend.updateClaims;
576
- if (operation === undefined)
728
+ });
729
+ return result.ok ? { ok: true, value: result.value } : result;
730
+ }
731
+ if (subcommand === "update") {
732
+ const parsed = parseClaimReplacementPairs(rest);
733
+ if (!parsed.ok)
734
+ return parsed;
735
+ if (dependencies.backend.updateClaims === undefined)
577
736
  return claimCapabilityUnavailable(subcommand);
578
- const result = await operation.call(dependencies.backend, context, options);
737
+ const result = await dependencies.backend.updateClaims(context, {
738
+ session_id: parsed.value.session_id,
739
+ repository: parsed.value.repository,
740
+ claims: parsed.value.pairs.map((pair) => ({
741
+ resource: pair.resource,
742
+ mode: pair.mode,
743
+ })),
744
+ });
579
745
  return result.ok ? { ok: true, value: result.value } : result;
580
746
  }
581
747
  if (subcommand === "claims") {
@@ -600,18 +766,57 @@ async function executeCommand(commandArguments, dependencies) {
600
766
  return result.ok ? { ok: true, value: result.value } : result;
601
767
  }
602
768
  if (subcommand === "create") {
603
- const parsed = parseOptions(rest, new Set(["--branch", "--worktree", "--base", "--label"]));
769
+ const parsed = parseOptions(rest, new Set(["--branch", "--worktree", "--worktree-root", "--base", "--label"]));
604
770
  if (!parsed.ok)
605
771
  return parsed;
772
+ if (parsed.value.worktree !== null && parsed.value.worktree_root !== null) {
773
+ return failure(usageError("INVALID_ARGUMENT", "--worktree and --worktree-root cannot be used together."));
774
+ }
606
775
  const options = {
607
776
  branch: parsed.value.branch,
608
777
  worktree: parsed.value.worktree,
778
+ worktree_root: parsed.value.worktree_root,
609
779
  base: parsed.value.base,
610
780
  label: parsed.value.label,
611
781
  };
612
782
  const result = await dependencies.backend.createSession(context, options);
613
783
  return result.ok ? { ok: true, value: result.value } : result;
614
784
  }
785
+ if (subcommand === "run" || subcommand === "exec") {
786
+ const delimiter = rest.indexOf("--");
787
+ if (delimiter === -1) {
788
+ return failure(usageError("MISSING_ARGUMENT", "session run requires a -- terminator before the command."));
789
+ }
790
+ const parsed = parseOptions(rest.slice(0, delimiter), new Set(["--session"]));
791
+ if (!parsed.ok)
792
+ return parsed;
793
+ const command = rest[delimiter + 1];
794
+ if (command === undefined || command.length === 0) {
795
+ return failure(usageError("MISSING_ARGUMENT", "session run requires a command after --."));
796
+ }
797
+ const request = await resolveSandboxExecutionRequest(dependencies.backend, context, { session_id: parsed.value.session_id, enforce: true }, dependencies.sandboxProbe, dependencies.sandboxRuntimeLayout);
798
+ if (!request.ok)
799
+ return request;
800
+ const runner = dependencies.sandboxRunner ?? runSandboxedCommand;
801
+ const result = await runner(request.value, {
802
+ command,
803
+ args: rest.slice(delimiter + 2),
804
+ });
805
+ return result.ok ? { ok: true, value: result.value } : result;
806
+ }
807
+ if (subcommand === "inspect") {
808
+ const parsed = parseOptions(rest, new Set(["--session", "--integrated-revision"]));
809
+ if (!parsed.ok)
810
+ return parsed;
811
+ if (dependencies.backend.sessionDiagnostic === undefined)
812
+ return sessionDiagnosticCapabilityUnavailable();
813
+ const options = {
814
+ session_id: parsed.value.session_id,
815
+ integrated_revision: parsed.value.integrated_revision,
816
+ };
817
+ const result = await dependencies.backend.sessionDiagnostic(context, options);
818
+ return result.ok ? { ok: true, value: result.value } : result;
819
+ }
615
820
  if (subcommand === "id" || subcommand === "show" || subcommand === "close") {
616
821
  if (subcommand === "id") {
617
822
  const parsed = noOptions(rest);
@@ -622,11 +827,14 @@ async function executeCommand(commandArguments, dependencies) {
622
827
  return selected;
623
828
  return { ok: true, value: { session_id: selected.value.session_id } };
624
829
  }
625
- const parsed = parseOptions(rest, new Set(["--session"]));
830
+ const parsed = parseOptions(rest, new Set(subcommand === "close" ? ["--session", "--integrated-revision"] : ["--session"]));
626
831
  if (!parsed.ok)
627
832
  return parsed;
628
833
  if (subcommand === "close") {
629
- const closeOptions = { session_id: parsed.value.session_id };
834
+ const closeOptions = {
835
+ session_id: parsed.value.session_id,
836
+ integrated_revision: parsed.value.integrated_revision,
837
+ };
630
838
  const selected = await dependencies.backend.closeSession(context, closeOptions);
631
839
  return selected.ok ? { ok: true, value: selected.value } : selected;
632
840
  }
@@ -647,23 +855,35 @@ async function executeCommand(commandArguments, dependencies) {
647
855
  }
648
856
  if (command === "resource") {
649
857
  const resourceSubcommand = subcommand ?? "list";
650
- if (resourceSubcommand === "claim" || resourceSubcommand === "update") {
651
- const parsed = parseOptions(rest, new Set(["--session", "--resource", "--mode", "--repository"]));
858
+ if (resourceSubcommand === "claim") {
859
+ const parsed = parseSingleClaimPair(rest);
652
860
  if (!parsed.ok)
653
861
  return parsed;
654
- if (parsed.value.resource === null || parsed.value.mode === null) {
655
- return failure(usageError("MISSING_ARGUMENT", "resource claim requires --resource and --mode."));
656
- }
657
- const operation = resourceSubcommand === "claim" ? dependencies.backend.claimResources : dependencies.backend.updateClaims;
658
- if (operation === undefined)
862
+ if (dependencies.backend.claimResources === undefined)
659
863
  return claimCapabilityUnavailable(resourceSubcommand);
660
- const result = await operation.call(dependencies.backend, context, {
864
+ const result = await dependencies.backend.claimResources(context, {
661
865
  session_id: parsed.value.session_id,
662
866
  repository: parsed.value.repository,
663
867
  claims: [{ resource: parsed.value.resource, mode: parsed.value.mode }],
664
868
  });
665
869
  return result.ok ? { ok: true, value: result.value } : result;
666
870
  }
871
+ if (resourceSubcommand === "update") {
872
+ const parsed = parseClaimReplacementPairs(rest);
873
+ if (!parsed.ok)
874
+ return parsed;
875
+ if (dependencies.backend.updateClaims === undefined)
876
+ return claimCapabilityUnavailable(resourceSubcommand);
877
+ const result = await dependencies.backend.updateClaims(context, {
878
+ session_id: parsed.value.session_id,
879
+ repository: parsed.value.repository,
880
+ claims: parsed.value.pairs.map((pair) => ({
881
+ resource: pair.resource,
882
+ mode: pair.mode,
883
+ })),
884
+ });
885
+ return result.ok ? { ok: true, value: result.value } : result;
886
+ }
667
887
  if (resourceSubcommand === "list" || resourceSubcommand === "claims") {
668
888
  const parsed = parseOptions(rest, new Set(["--session"]));
669
889
  if (!parsed.ok)
@@ -926,6 +1146,11 @@ function checkpointCapabilityUnavailable() {
926
1146
  operation: "checkpoint",
927
1147
  }));
928
1148
  }
1149
+ function sessionDiagnosticCapabilityUnavailable() {
1150
+ return failure(new DomainError("BACKEND_UNAVAILABLE", "Session diagnostic capability is not available.", {
1151
+ operation: "session.inspect",
1152
+ }));
1153
+ }
929
1154
  function repositoryEvidenceCapabilityUnavailable(operation) {
930
1155
  return failure(new DomainError("BACKEND_UNAVAILABLE", "Repository evidence capability is not available.", { operation }));
931
1156
  }
@@ -961,9 +1186,62 @@ function emitFailure(mode, command, error, io) {
961
1186
  io[mode === "json" ? "stdout" : "stderr"](renderFailure(mode, command, error));
962
1187
  return error.exitCode;
963
1188
  }
1189
+ /** Extract the raw `--session` value a rejected INVALID_SESSION_ID carried, regardless of which command path threw it. */
1190
+ function invalidSessionIdQuery(error) {
1191
+ const details = error.details;
1192
+ if (details === null)
1193
+ return null;
1194
+ if (typeof details.sessionId === "string")
1195
+ return details.sessionId;
1196
+ const nested = details.details;
1197
+ if (nested === null || typeof nested !== "object" || Array.isArray(nested))
1198
+ return null;
1199
+ const nestedSessionId = nested.sessionId;
1200
+ return typeof nestedSessionId === "string" ? nestedSessionId : null;
1201
+ }
1202
+ /**
1203
+ * `--session` stays machine-ID based: an invalid value is never silently
1204
+ * reinterpreted as a label. But when it exactly and unambiguously matches
1205
+ * one active session's label, expose that session's canonical ID as a
1206
+ * bounded, non-authoritative hint instead of forcing a separate `session
1207
+ * list` round trip. Ambiguous or absent matches never guess.
1208
+ */
1209
+ async function enrichInvalidSessionIdError(error, backend, context) {
1210
+ if (error.code !== "INVALID_SESSION_ID")
1211
+ return error;
1212
+ const query = invalidSessionIdQuery(error);
1213
+ if (query === null || query.length === 0)
1214
+ return error;
1215
+ const listing = await backend.listSessions(context, { include_closed: false, limit: MAX_SESSION_LIST_LIMIT });
1216
+ if (!listing.ok)
1217
+ return error;
1218
+ const matches = listing.value.sessions.filter((session) => session.state === "active" && session.label === query);
1219
+ const hint = matches.length === 1
1220
+ ? {
1221
+ session_label_query: query,
1222
+ session_label_match: "unique",
1223
+ session_id_hint: matches[0].session_id,
1224
+ safe_actions: ["retry-with-session-id-hint"],
1225
+ }
1226
+ : matches.length > 1
1227
+ ? {
1228
+ session_label_query: query,
1229
+ session_label_match: "ambiguous",
1230
+ session_label_match_count: matches.length,
1231
+ safe_actions: ["disambiguate-session-label", "list-sessions"],
1232
+ }
1233
+ : {
1234
+ session_label_query: query,
1235
+ session_label_match: "none",
1236
+ safe_actions: ["list-sessions"],
1237
+ };
1238
+ return new DomainError(error.code, error.message, { ...error.details, ...hint }, error.exitCode);
1239
+ }
964
1240
  export async function runCli(argv, dependencies = {}) {
965
1241
  const io = dependencies.io ?? defaultCliIO();
966
- const mode = argv.includes("--json") ? "json" : "human";
1242
+ const delimiter = argv.indexOf("--");
1243
+ const globalArguments = delimiter === -1 ? argv : argv.slice(0, delimiter);
1244
+ const mode = globalArguments.includes("--json") ? "json" : "human";
967
1245
  const parsed = parseGlobalArguments(argv);
968
1246
  if (!parsed.ok)
969
1247
  return emitFailure(mode, "cli", parsed.error, io);
@@ -1004,10 +1282,25 @@ export async function runCli(argv, dependencies = {}) {
1004
1282
  const cwd = dependencies.cwd ?? process.cwd();
1005
1283
  try {
1006
1284
  const backend = dependencies.backend ?? createLocalSessionBackend();
1007
- const result = await executeCommand(parsed.value.commandArguments, { backend, cwd });
1008
- if (!result.ok)
1009
- return emitFailure(mode, command, result.error, io);
1285
+ const result = await executeCommand(parsed.value.commandArguments, {
1286
+ backend,
1287
+ cwd,
1288
+ sandboxRunner: dependencies.sandboxRunner,
1289
+ sandboxProbe: dependencies.sandboxProbe,
1290
+ sandboxRuntimeLayout: dependencies.sandboxRuntimeLayout,
1291
+ });
1292
+ if (!result.ok) {
1293
+ const enriched = await enrichInvalidSessionIdError(result.error, backend, sessionContext(cwd));
1294
+ return emitFailure(mode, command, enriched, io);
1295
+ }
1010
1296
  io.stdout(renderSuccess(mode, command, result.value));
1297
+ if ((command === "session run" || command === "session exec") &&
1298
+ typeof result.value.exit_code === "number" &&
1299
+ result.value.exit_code !== 0) {
1300
+ return result.value.exit_code >= 1 && result.value.exit_code <= 255
1301
+ ? result.value.exit_code
1302
+ : EXIT_CODES.rejected;
1303
+ }
1011
1304
  return EXIT_CODES.success;
1012
1305
  }
1013
1306
  catch {