anon-pi 0.14.0 → 0.16.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/anon-pi.js CHANGED
@@ -14,9 +14,10 @@
14
14
  // /projects (the projects root). `--mount <parent>` adds EXACTLY one more
15
15
  // mount at the DISTINCT /work and re-roots cwd there; nothing else changes,
16
16
  // so we never remount a running container.
17
- // - Throwaway (`--rm`) is the DEFAULT; `--keep` leaves the container kept so
18
- // its filesystem survives (found + resumed by netcage's `netcage.managed`
19
- // label via `netcage start`). The machine home persists either way.
17
+ // - Every launch is THROWAWAY (the container is always `--rm`): it is removed
18
+ // on exit. Durable state is EXPLICIT and image-based (snapshot a running
19
+ // container into a named image, then a machine pinned to it); the machine
20
+ // home persists regardless (it is a host mount). See docs/adr/0004.
20
21
  // - Open exactly ONE direct hole (--allow-direct <llm>) so pi can reach a
21
22
  // local model while ALL other egress stays forced through the socks5h proxy
22
23
  // (fail-closed; the proxy is REQUIRED and never guessed).
@@ -26,9 +27,9 @@
26
27
  //
27
28
  // This module holds every DECISION as a pure function (config load + precedence,
28
29
  // machine/project resolvers, name validation, the RunPlan argv, the menu
29
- // choice-list, project usage, the run-vs-start rule, models.json generation,
30
- // init's proxy detect/verify decisions). cli.ts owns only the impure edges (fs,
31
- // the interactive TUI, the netcage query, the spawn).
30
+ // choice-list, project usage, models.json generation, init's proxy detect/verify
31
+ // decisions). cli.ts owns only the impure edges (fs, the interactive TUI, the
32
+ // netcage query, the spawn).
32
33
  import { existsSync, readFileSync } from 'node:fs';
33
34
  import { homedir } from 'node:os';
34
35
  import { dirname, join, resolve } from 'node:path';
@@ -291,14 +292,29 @@ export function resolveDeleteProject(args) {
291
292
  */
292
293
  export const ROOT_TOKEN = '.';
293
294
  /**
294
- * Reserved names that a machine/project may NOT take (case-sensitive). Kept
295
- * DELIBERATELY minimal: only the two structural path tokens. `.` is the root
296
- * token (see ROOT_TOKEN); `..` is parent-traversal. Both are also rejected by
297
- * the leading-dot / `..` structural checks below, but are listed here so the
298
- * reserved-name concept is explicit and extendable. `--mount`'s `/work` is a
299
- * CONTAINER path, not a name in this namespace, so it needs no reservation.
300
- */
301
- export const RESERVED_NAMES = ['.', '..', 'pi'];
295
+ * Reserved names that a machine/project/image may NOT take (case-sensitive).
296
+ * `.` is the root token (see ROOT_TOKEN); `..` is parent-traversal (both are
297
+ * also rejected by the structural checks below, but listed here so the
298
+ * reserved-name concept is explicit). `pi` is the passthrough token. The
299
+ * SUBCOMMAND NOUN words (`machine`, `image`, `init`, `forward`, `ports`) are
300
+ * reserved too: each is dispatched BEFORE the launch grammar, so a folder so
301
+ * named would be UNREACHABLE by bare name (a latent trap). Reserving them makes
302
+ * validateName refuse such a name up front with a clear error, closing the
303
+ * trap. `--mount`'s `/work` is a CONTAINER path, not a name here, so it needs no
304
+ * reservation. The reservation is GLOBAL (validateName is the one validator);
305
+ * the menu tolerates a pre-existing folder now reserved by FILTERING it out via
306
+ * the try/catch isProjectName, so a now-reserved folder is skipped, not a crash.
307
+ */
308
+ export const RESERVED_NAMES = [
309
+ '.',
310
+ '..',
311
+ 'pi',
312
+ 'machine',
313
+ 'image',
314
+ 'init',
315
+ 'forward',
316
+ 'ports',
317
+ ];
302
318
  /**
303
319
  * PURE: validate a machine or project name as a safe single path segment, and
304
320
  * return it unchanged on success. Rejects (with AnonPiError):
@@ -387,7 +403,7 @@ export function resolveCwd(kind, token) {
387
403
  * and files written under the home land in the machine's config home on the
388
404
  * host; a shell is the project-hopper, so `/projects` is the natural landing.
389
405
  * The machine home is one `cd ~` away for the rare case. `menu` never reaches
390
- * here (it is argv-less). Shared by resolveRunPlan + keptContainerKey so the run
406
+ * here (it is argv-less). Shared by resolveRunPlan + launchIdentityKey so the run
391
407
  * cwd and the container-identity key always agree.
392
408
  */
393
409
  export function launchCwd(_mode, kind, project) {
@@ -516,6 +532,22 @@ export function resolveProxy(args) {
516
532
  export function resolveLlm(args) {
517
533
  return nonEmpty(args.env.llmDirect) ?? nonEmpty(args.config?.llm);
518
534
  }
535
+ /**
536
+ * PURE: resolve the IMAGE a launch runs against, highest-priority first:
537
+ * a per-launch `-i`/`--image` override > the machine's pinned image
538
+ * (machine.json) > `ANON_PI_IMAGE` (the env fallback) > undefined (the CLL then
539
+ * errors). The `-i` override is STRICTLY EPHEMERAL: it selects the image for
540
+ * THIS launch only and is NEVER written back to machine.json (that persistent
541
+ * pin is `machine set-image` / `machine create --image`). No mismatch warning is
542
+ * ever emitted (ADR-0003 section 3: `-i` is explicit + ephemeral, so a warning
543
+ * carries no information the user lacks). Empty strings are treated as unset at
544
+ * every tier (nonEmpty), so a blank env/pin falls through cleanly.
545
+ */
546
+ export function resolveLaunchImage(args) {
547
+ return (nonEmpty(args.override) ??
548
+ nonEmpty(args.machineImage) ??
549
+ nonEmpty(args.envImage));
550
+ }
519
551
  // --- Grammar A: the pure argv -> ParsedLaunch parser -------------------------
520
552
  //
521
553
  // A bare positional is a PROJECT; `-m` picks the machine. The CLI (cli.ts)
@@ -666,6 +698,24 @@ export function sessionHeaderCwd(headerLine) {
666
698
  * RESERVED project name (see RESERVED_NAMES) so a project can never shadow it.
667
699
  */
668
700
  export const PI_PASSTHROUGH_TOKEN = 'pi';
701
+ /**
702
+ * The retired launch flags. `--keep`/`--rm` are GONE (ADR-0004): every launch is
703
+ * throwaway now, so there is no flag to toggle. `--keep`'s exploratory
704
+ * "apt install, quit, re-enter" use case is served, better, by snapshotting a
705
+ * running container into a named image and pinning a machine to it (explicit +
706
+ * named, no inference). The label a launch is passed one of these RETIRED flags
707
+ * gets a clear error pointing there.
708
+ */
709
+ export const RETIRED_LAUNCH_FLAGS = ['--keep', '--rm'];
710
+ /** PURE: the error message for a retired `--keep`/`--rm` flag, pointing at the image-based replacement. */
711
+ export function retiredKeepRmMessage(flag) {
712
+ return (`${flag} is gone: every launch is throwaway now (the container is always ` +
713
+ `removed on exit). To persist system state you set up in a session (e.g. after ` +
714
+ `\`apt install\`), snapshot the RUNNING container into a named image and use it:\n` +
715
+ ` anon-pi image snapshot <name> (freeze the running container -> anon-pi/<name>:latest)\n` +
716
+ ` anon-pi machine create <m> --image anon-pi/<name>:latest (a durable machine pinned to it)\n` +
717
+ `Your pi config + conversations live in the machine home (a host mount) and persist regardless.`);
718
+ }
669
719
  /**
670
720
  * PURE: whether forwarded pi args request pi's NON-INTERACTIVE (print) mode,
671
721
  * i.e. contain `-p`/`--print`. This is the ONLY headless shape (it needs no
@@ -683,9 +733,6 @@ export function isHeadlessPiArgs(piArgs) {
683
733
  * `--shell` is incompatible (a shell forwards no pi args).
684
734
  */
685
735
  function finishPiNoProjectLaunch(args) {
686
- if (args.keep && args.rm) {
687
- args.fail('--keep and --rm are contradictory (pick one; --rm is the default)');
688
- }
689
736
  if (args.shell) {
690
737
  args.fail('--shell forwards no pi args (a shell has no session/query). Drop --shell.');
691
738
  }
@@ -695,14 +742,14 @@ function finishPiNoProjectLaunch(args) {
695
742
  machineExplicit: args.machineExplicit,
696
743
  project: undefined,
697
744
  mountParent: args.mountParent,
698
- keep: args.keep,
745
+ image: args.image,
699
746
  piArgs: args.piArgs,
700
747
  };
701
748
  }
702
749
  /**
703
750
  * PURE: parse grammar A into a ParsedLaunch. Consumes the anon-pi flags
704
- * (`-m <machine>`, `--shell`, `--mount <parent>`, `--keep`/`--rm`) LEFT of the
705
- * project positional; the FIRST bare positional is the project (`.` allowed as
751
+ * (`-m <machine>`, `--shell`, `--mount <parent>`, `-i`/`--image <ref>`) LEFT of
752
+ * the project positional; the FIRST bare positional is the project (`.` allowed as
706
753
  * the root token). In pi mode every token AFTER the project is forwarded to pi
707
754
  * verbatim (so `anon-pi recon -p '...'` works) — anon-pi flags must come before
708
755
  * the project. A pi session-resume flag (`--session <id>`, `--continue`,
@@ -715,15 +762,14 @@ function finishPiNoProjectLaunch(args) {
715
762
  * reserved-name guard); `--mount <parent>` is a HOST path in its own namespace,
716
763
  * distinct from the project-name namespace (NAME vs `--mount` exclusivity), so
717
764
  * it is NOT name-validated here. Throws AnonPiError for an unknown option, a
718
- * missing `-m`/`--mount` argument, a contradictory `--keep --rm`, or a bad name.
765
+ * missing `-m`/`--mount` argument, a RETIRED `--keep`/`--rm` flag, or a bad name.
719
766
  */
720
767
  export function parseLaunchArgs(args) {
721
768
  let machine = DEFAULT_MACHINE;
722
769
  let machineSet = false;
723
770
  let shell = false;
724
771
  let mountParent;
725
- let keepSeen = false;
726
- let rmSeen = false;
772
+ let image;
727
773
  let project;
728
774
  let piArgs;
729
775
  const fail = (msg) => {
@@ -751,13 +797,21 @@ export function parseLaunchArgs(args) {
751
797
  mountParent = v;
752
798
  continue;
753
799
  }
754
- if (a === '--keep') {
755
- keepSeen = true;
800
+ if (a === '-i' || a === '--image') {
801
+ // The EPHEMERAL per-launch image override. A raw ref, NOT a
802
+ // name-namespaced anon-pi token (it resolves in netcage's private
803
+ // image store, so any podman ref / `anon-pi/<name>:latest` snapshot tag
804
+ // is valid): not name-validated here. It never mutates machine.json.
805
+ const v = args[++i];
806
+ if (v === undefined)
807
+ fail(`${a} needs an image ref`);
808
+ image = v;
756
809
  continue;
757
810
  }
758
- if (a === '--rm') {
759
- rmSeen = true;
760
- continue;
811
+ if (RETIRED_LAUNCH_FLAGS.includes(a)) {
812
+ // `--keep`/`--rm` are retired (ADR-0004): throwaway is the only
813
+ // behaviour now. Point at the image-based replacement.
814
+ fail(retiredKeepRmMessage(a));
761
815
  }
762
816
  if (a === '.') {
763
817
  // the root token is a valid project positional (not a name).
@@ -774,8 +828,7 @@ export function parseLaunchArgs(args) {
774
828
  machine,
775
829
  machineExplicit: machineSet,
776
830
  mountParent,
777
- keep: keepSeen,
778
- rm: rmSeen,
831
+ image,
779
832
  shell,
780
833
  piArgs: args.slice(i + 1),
781
834
  fail,
@@ -815,8 +868,7 @@ export function parseLaunchArgs(args) {
815
868
  machine,
816
869
  machineExplicit: machineSet,
817
870
  mountParent,
818
- keep: keepSeen,
819
- rm: rmSeen,
871
+ image,
820
872
  shell,
821
873
  piArgs,
822
874
  fail,
@@ -830,9 +882,6 @@ export function parseLaunchArgs(args) {
830
882
  i++;
831
883
  break;
832
884
  }
833
- if (keepSeen && rmSeen) {
834
- fail('--keep and --rm are contradictory (pick one; --rm is the default)');
835
- }
836
885
  // tokens remaining after the project.
837
886
  const rest = args.slice(i);
838
887
  if (shell) {
@@ -846,7 +895,7 @@ export function parseLaunchArgs(args) {
846
895
  machineExplicit: machineSet,
847
896
  project,
848
897
  mountParent,
849
- keep: keepSeen,
898
+ image,
850
899
  };
851
900
  }
852
901
  if (project === undefined) {
@@ -859,7 +908,7 @@ export function parseLaunchArgs(args) {
859
908
  machineExplicit: machineSet,
860
909
  project: undefined,
861
910
  mountParent,
862
- keep: keepSeen,
911
+ image,
863
912
  };
864
913
  }
865
914
  // pi mode: every token after the project is forwarded to pi verbatim.
@@ -871,7 +920,7 @@ export function parseLaunchArgs(args) {
871
920
  machineExplicit: machineSet,
872
921
  project,
873
922
  mountParent,
874
- keep: keepSeen,
923
+ image,
875
924
  piArgs,
876
925
  };
877
926
  }
@@ -885,7 +934,7 @@ export function parseLaunchArgs(args) {
885
934
  * - the two mounts <home>:/root and <projectsRoot>:/projects, always;
886
935
  * - --mount adds EXACTLY <parent>:/work and re-roots cwd, nothing else;
887
936
  * - --proxy <p> + exactly one --allow-direct <llm> (forced egress, fail-closed);
888
- * - --rm by default, omitted only under --keep.
937
+ * - --rm on EVERY launch (throwaway always; ADR-0004).
889
938
  *
890
939
  * Throws AnonPiError (a plan is NEVER produced) when the image, the machine
891
940
  * home, the proxy, or the direct-hole llm is missing.
@@ -938,9 +987,9 @@ export function resolveRunPlan(intent, homeFresh) {
938
987
  // task; here the argv simply omits -it for the one headless shape.
939
988
  const headless = mode === 'pi' && isHeadlessPiArgs(intent.piArgs);
940
989
  const netcageArgs = ['run'];
941
- // --rm by DEFAULT (throwaway); --keep leaves the container kept.
942
- if (intent.keep !== true)
943
- netcageArgs.push('--rm');
990
+ // Throwaway ALWAYS: every launch is `--rm` (ADR-0004). Durable state is
991
+ // image-based (snapshot + a pinned machine), never an accreting container.
992
+ netcageArgs.push('--rm');
944
993
  // Forced egress: the proxy + the ONE direct hole. Never omitted.
945
994
  netcageArgs.push('--proxy', proxy, '--allow-direct', directTarget);
946
995
  if (!headless)
@@ -1018,42 +1067,45 @@ function containerSeedThen(seedVersion, exec) {
1018
1067
  `fi && ` +
1019
1068
  `${exec}`);
1020
1069
  }
1021
- /**
1022
- * PURE: the launch-identity match key for a kept container, derived ENTIRELY
1023
- * from the (machine, projects-root, project) identity (ADR-0002). It is what
1024
- * decides whether an existing kept `netcage.managed` container IS the one a
1025
- * `--keep` launch should resume.
1026
- *
1027
- * The fields, and why each is load-bearing:
1028
- * - `machine.name`: a kept container mounts THIS machine's home at /root; a
1029
- * same-project container on another machine is a different environment.
1030
- * - `projectsRoot`: the host dir mounted at /projects; two launches with the
1031
- * same project name but different roots are different working trees.
1032
- * - `mountParent` (or '' when absent): `--mount` re-roots into a DIFFERENT
1033
- * host parent at /work, so a `--mount` launch is a distinct identity from
1034
- * the projects-root launch of the same name.
1035
- * - the resolved container `cwd`: this already encodes the project token
1036
- * (`/projects/<p>`, `/work/<p>`, or a root `/projects`/`/work`; legacy kept
1037
- * containers may still carry /root from the pre-0.12 bare-shell-at-home)
1038
- * AND which root it sits under, so it is pi's conversation key too. Using
1039
- * the cwd keeps the container identity aligned with the conversation the
1040
- * kept container hosts.
1070
+ // --- The per-launch identity label (for `forward`/`ports`/`snapshot`) --------
1071
+ //
1072
+ // anon-pi stamps an identity key onto EVERY launch's container (an additive
1073
+ // netcage label; see withKeyLabel in cli.ts). It is NOT a kept-container match
1074
+ // key (there are no kept containers: every launch is throwaway, ADR-0004). Its
1075
+ // sole job is to let `forward`/`ports`/`snapshot` find a RUNNING container and
1076
+ // read back which machine + project it hosts (parseKeptKey -> keyProject),
1077
+ // while the container is up (the label goes away with the `--rm` container on
1078
+ // exit). netcage's `netcage.managed` label marks the container managed; this
1079
+ // adds anon-pi's own identity on top. anon-pi invents NO registry file.
1080
+ /**
1081
+ * PURE: the anon-pi launch-identity key stamped on EVERY (throwaway) launch,
1082
+ * derived from the (machine, projects-root, project) identity (ADR-0002's
1083
+ * cwd/project reasoning still underpins it). It is NOT a kept-container MATCH
1084
+ * key (every launch is throwaway; nothing is ever resumed). It exists ONLY so
1085
+ * `forward`/`ports`/`snapshot` can resolve a RUNNING container by machine +
1086
+ * project: the CLI stamps it onto a netcage label and reads it back with
1087
+ * parseKeptKey -> keyProject.
1041
1088
  *
1042
- * DELIBERATELY EXCLUDED (not part of identity): `--keep`/`--rm` (the throwaway
1043
- * choice for THIS run), the proxy + the direct-hole llm (forced-egress inputs),
1044
- * forwarded pi args, and the seed. Two launches that differ only in those must
1045
- * resolve to the SAME kept container.
1089
+ * The fields, and why each is retained:
1090
+ * - `machine.name`: the forward/ports filter scopes by machine.
1091
+ * - `cwd` (the resolved container cwd, via launchCwd): encodes the project
1092
+ * token (`/projects/<p>`, `/work/<p>`, or a root), so keyProject can name
1093
+ * the project a running container hosts.
1094
+ * - `projectsRoot` + `mountParent`: kept in the record for stability of the
1095
+ * decode shape (parseKeptKey reads them best-effort); no consumer filters on
1096
+ * them today, but they cost nothing and keep the label self-describing.
1046
1097
  *
1047
- * The key is a single opaque string (a `\n`-joined, field-tagged record) so the
1048
- * CLI can stamp it verbatim onto a netcage label and match on string equality;
1049
- * its internal shape is not a contract (compare only keys this function makes).
1098
+ * Independent of the forced-egress inputs and forwarded pi args (identity only).
1099
+ * The key is a single opaque string (a `\n`-joined, field-tagged record) the CLI
1100
+ * stamps verbatim onto a netcage label; its internal shape is not a contract
1101
+ * (decode only with parseKeptKey).
1050
1102
  */
1051
- export function keptContainerKey(intent) {
1103
+ export function launchIdentityKey(intent) {
1052
1104
  const { machine, mode, projectsRoot, project, mountParent } = intent;
1053
1105
  const mounted = nonEmpty(mountParent) !== undefined;
1054
1106
  const rootKind = mounted ? 'mount' : 'projects';
1055
- // The same cwd resolution resolveRunPlan uses, so the key names the exact
1056
- // container a matching launch would run in (its conversation key).
1107
+ // The same cwd resolution resolveRunPlan uses, so keyProject names the exact
1108
+ // project the running container hosts (its conversation key).
1057
1109
  const cwd = launchCwd(mode, rootKind, project);
1058
1110
  return [
1059
1111
  `machine=${machine.name}`,
@@ -1062,29 +1114,7 @@ export function keptContainerKey(intent) {
1062
1114
  `cwd=${cwd}`,
1063
1115
  ].join('\n');
1064
1116
  }
1065
- /**
1066
- * PURE: decide run-vs-start for a launch given a SUPPLIED listing of kept
1067
- * `netcage.managed` containers (the CLI's netcage query result).
1068
- *
1069
- * - `--rm` (throwaway, `intent.keep !== true`): ALWAYS a fresh `run`. The
1070
- * listing is NOT consulted (a throwaway launch never resumes a kept box).
1071
- * - `--keep`: a kept container whose `key` equals this launch's
1072
- * keptContainerKey is present -> `start` it (by its `ref`); else -> `run`
1073
- * (resolveRunPlan leaves it kept because `--keep` omits `--rm`).
1074
- *
1075
- * Never spawns, never queries netcage: the listing is injected, so the whole
1076
- * decision is a pure function of (intent, listing).
1077
- */
1078
- export function resolveRunVsStart(intent, kept) {
1079
- // Throwaway short-circuit: a `--rm` launch is always a fresh run and never
1080
- // consults the listing (it must not resume a kept container).
1081
- if (intent.keep !== true)
1082
- return { action: 'run' };
1083
- const want = keptContainerKey(intent);
1084
- const match = kept.find((c) => c.key === want);
1085
- return match ? { action: 'start', ref: match.ref } : { action: 'run' };
1086
- }
1087
- /** PURE: parse a stamped keptContainerKey back into its fields (best-effort). */
1117
+ /** PURE: parse a stamped launchIdentityKey back into its fields (best-effort). */
1088
1118
  export function parseKeptKey(key) {
1089
1119
  const out = {
1090
1120
  machine: '',
@@ -1308,7 +1338,7 @@ export const ANON_PI_KEY_LABEL = 'anon-pi.key';
1308
1338
  * ref: <Id>, name: <first Names entry or Id>}. When `runningOnly`, entries whose
1309
1339
  * State is not "running" are dropped (forward/ports can only reach a live jail).
1310
1340
  * The base64 DECODE of `key` is the CLI's job (Buffer), so this stays pure; the
1311
- * caller decodes before matching against a keptContainerKey. [] on bad JSON.
1341
+ * caller decodes before matching against a launchIdentityKey. [] on bad JSON.
1312
1342
  */
1313
1343
  export function parseNetcagePsJson(stdout, opts = {}) {
1314
1344
  let parsed;
@@ -1461,6 +1491,51 @@ export function deriveProjectUsage(args) {
1461
1491
  return { project, machines, currentMachineIsNew };
1462
1492
  });
1463
1493
  }
1494
+ /**
1495
+ * PURE: the cpSync filter predicate for a snapshot's "copy the home MINUS the
1496
+ * sessions subtree" copy: true = copy `src`, false = skip it. It rejects the
1497
+ * sessions dir itself and everything beneath it (`<sessionsDir>` and
1498
+ * `<sessionsDir>/...`), and copies everything else. Extracted so the
1499
+ * home-minus-sessions contract is unit-testable without the fs.
1500
+ */
1501
+ export function copyIncludesForHomeMinusSessions(src, sessionsDir) {
1502
+ return src !== sessionsDir && !src.startsWith(sessionsDir + '/');
1503
+ }
1504
+ /**
1505
+ * PURE: map the session-dir slugs PRESENT under a source machine's `sessions/`
1506
+ * to per-project rows a snapshot's carry-over picker offers. For each present
1507
+ * slug, if it equals `projectSessionSlug(<project>)` for a known project, it is a
1508
+ * PROJECT row (labelled by the project name); otherwise an ORPHAN-slug row
1509
+ * (labelled by the raw slug, so a session with no current project folder is
1510
+ * still shown, never silently dropped). Rows are sorted: named projects first
1511
+ * (case-insensitive by name), then orphan slugs (by slug), for a stable picker.
1512
+ * The caller (CLI) does the actual copy/delete of each chosen slug dir.
1513
+ */
1514
+ export function snapshotSessionGroups(args) {
1515
+ const slugToProject = new Map();
1516
+ for (const p of args.projects) {
1517
+ // projectSessionSlug validates the name; a bad project name throws, which is
1518
+ // correct (the projects list comes from real folder names).
1519
+ slugToProject.set(projectSessionSlug(p), p);
1520
+ }
1521
+ const rows = args.presentSlugs.map((slug) => {
1522
+ const project = slugToProject.get(slug);
1523
+ return project !== undefined
1524
+ ? { slug, project, label: project }
1525
+ : { slug, label: `${slug} (no current project folder)` };
1526
+ });
1527
+ const lc = (s) => s.toLowerCase();
1528
+ return rows.sort((a, b) => {
1529
+ // named projects before orphan slugs; within each, by their label key.
1530
+ const an = a.project !== undefined ? 0 : 1;
1531
+ const bn = b.project !== undefined ? 0 : 1;
1532
+ if (an !== bn)
1533
+ return an - bn;
1534
+ const ak = lc(a.project ?? a.slug);
1535
+ const bk = lc(b.project ?? b.slug);
1536
+ return ak < bk ? -1 : ak > bk ? 1 : 0;
1537
+ });
1538
+ }
1464
1539
  /** The fixed labels for the non-project affordances (one source, so the TUI + its test agree). */
1465
1540
  export const MENU_HERE_LABEL = '. (here: a scratch pi at the root)';
1466
1541
  export const MENU_NEW_LABEL = '+ new project\u2026';
@@ -2126,7 +2201,7 @@ export function parseMachineArgs(args) {
2126
2201
  };
2127
2202
  const verb = args[0];
2128
2203
  if (verb === undefined) {
2129
- fail('`machine` needs a subcommand: create | list | set-image | rm | snapshot');
2204
+ fail('`machine` needs a subcommand: create | list | set-image | rm');
2130
2205
  }
2131
2206
  const rest = args.slice(1);
2132
2207
  if (verb === 'list') {
@@ -2200,15 +2275,37 @@ export function parseMachineArgs(args) {
2200
2275
  fail('machine rm needs a <name>');
2201
2276
  return { verb: 'rm', name: name, yes };
2202
2277
  }
2278
+ return fail(`unknown machine subcommand: ${verb} (create | list | set-image | rm)`);
2279
+ }
2280
+ /**
2281
+ * PURE: parse the tokens AFTER `image` into an ImageCommand. Validates the image
2282
+ * name + the `-m` / `--create-machine` machine names via validateName (the
2283
+ * reserved-name / traversal guard), so the CLI only ever joins safe segments.
2284
+ * Throws AnonPiError (printed verbatim, exit 1) for an unknown/missing verb, a
2285
+ * missing or extra positional, an unknown flag, or a bad name.
2286
+ *
2287
+ * `<name>` is validated with the `machine` kind: it shares the same
2288
+ * folder-safe / reserved-name rules, and a snapshot name is an image-tag
2289
+ * segment (`anon-pi/<name>:latest`), so the same guard applies.
2290
+ */
2291
+ export function parseImageArgs(args) {
2292
+ const fail = (msg) => {
2293
+ throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi image --help\` or \`anon-pi --help\`.`);
2294
+ };
2295
+ const verb = args[0];
2296
+ if (verb === undefined) {
2297
+ fail('`image` needs a subcommand: snapshot | list');
2298
+ }
2299
+ const rest = args.slice(1);
2300
+ if (verb === 'list') {
2301
+ if (rest.length > 0)
2302
+ fail(`image list takes no arguments, got: ${rest.join(' ')}`);
2303
+ return { verb: 'list' };
2304
+ }
2203
2305
  if (verb === 'snapshot') {
2204
- // snapshot <new-name> [-m <machine>] [--image-tag <ref>]: commit a RUNNING
2205
- // container into a new image and create <new-name> pinned to it. The sole
2206
- // positional is the new machine name; `-m` is an OPTIONAL filter (which
2207
- // container when several are up), not a required source. The CLI auto-detects
2208
- // the container (picker when several match).
2209
2306
  let name;
2210
2307
  let machine;
2211
- let imageTag;
2308
+ let createMachine;
2212
2309
  for (let i = 0; i < rest.length; i++) {
2213
2310
  const a = rest[i];
2214
2311
  if (a === '-m' || a === '--machine') {
@@ -2218,41 +2315,81 @@ export function parseMachineArgs(args) {
2218
2315
  machine = validateName(v, 'machine');
2219
2316
  continue;
2220
2317
  }
2221
- if (a === '--image-tag') {
2318
+ if (a === '--create-machine') {
2222
2319
  const v = rest[++i];
2223
2320
  if (v === undefined)
2224
- fail('--image-tag needs an image ref');
2225
- imageTag = v;
2321
+ fail('--create-machine needs a machine name');
2322
+ createMachine = validateName(v, 'machine');
2226
2323
  continue;
2227
2324
  }
2228
2325
  if (a.startsWith('-'))
2229
2326
  fail(`unknown option: ${a}`);
2230
2327
  if (name !== undefined)
2231
- fail(`machine snapshot takes one <new-name>, got extra: ${a}`);
2328
+ fail(`image snapshot takes one <name>, got extra: ${a}`);
2232
2329
  name = validateName(a, 'machine');
2233
2330
  }
2234
2331
  if (name === undefined)
2235
- fail('machine snapshot needs a <new-name>');
2332
+ fail('image snapshot needs a <name>');
2236
2333
  return {
2237
2334
  verb: 'snapshot',
2238
2335
  name: name,
2239
2336
  machine: nonEmpty(machine),
2240
- imageTag: nonEmpty(imageTag),
2337
+ createMachine: nonEmpty(createMachine),
2241
2338
  };
2242
2339
  }
2243
- return fail(`unknown machine subcommand: ${verb} (create | list | set-image | rm | snapshot)`);
2244
- }
2245
- /**
2246
- * PURE: the default image ref a `machine snapshot` writes when `--image-tag` is
2247
- * not given: `anon-pi/<name>:snapshot-<ts>`, where <ts> is a compact UTC stamp
2248
- * (YYYYMMDDHHMMSS) derived from `now`. Deterministic in `now` so it is unit
2249
- * testable. The name is a validated machine name (a safe image-path segment).
2250
- */
2251
- export function snapshotImageRef(name, now) {
2252
- const p = (n, w = 2) => String(n).padStart(w, '0');
2253
- const ts = `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` +
2254
- `${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}`;
2255
- return `anon-pi/${name}:snapshot-${ts}`;
2340
+ return fail(`unknown image subcommand: ${verb} (snapshot | list)`);
2341
+ }
2342
+ /**
2343
+ * PURE: the clean image tag a `image snapshot <name>` writes:
2344
+ * `anon-pi/<name>:latest`. A same-name re-snapshot OVERWRITES this tag (that is
2345
+ * what `:latest` means); the previous image becomes dangling but keeps its
2346
+ * provenance label. The name is a validated image/machine name (a safe
2347
+ * image-path segment).
2348
+ */
2349
+ export function snapshotImageTag(name) {
2350
+ return `anon-pi/${validateName(name, 'machine')}:latest`;
2351
+ }
2352
+ /** The podman/anon-pi provenance label keys baked into a snapshot image. */
2353
+ export const PROVENANCE_LABEL_SOURCE_MACHINE = 'anon-pi.source-machine';
2354
+ export const PROVENANCE_LABEL_SOURCE_IMAGE = 'anon-pi.source-image';
2355
+ export const PROVENANCE_LABEL_SNAPSHOT_AT = 'anon-pi.snapshot-at';
2356
+ /**
2357
+ * PURE: build the `LABEL k=v` change instructions a `netcage commit -c '…'`
2358
+ * bakes into a snapshot image (ADR-0003 §2). Provenance is best-effort HISTORY:
2359
+ * a label whose value is undefined/empty is OMITTED (a missing label beats a
2360
+ * wrong one). `at` is required (the snapshot time is always known). Each string
2361
+ * is ONE `LABEL key=value` instruction (the CLI passes each as a `-c` argv
2362
+ * element; podman round-trips `/` and `:` in the value un-quoted, verified).
2363
+ */
2364
+ export function snapshotProvenanceLabels(args) {
2365
+ const labels = [];
2366
+ const push = (key, value) => {
2367
+ const v = nonEmpty(value);
2368
+ if (v !== undefined)
2369
+ labels.push(`LABEL ${key}=${v}`);
2370
+ };
2371
+ push(PROVENANCE_LABEL_SOURCE_MACHINE, args.sourceMachine);
2372
+ push(PROVENANCE_LABEL_SOURCE_IMAGE, args.sourceImage);
2373
+ push(PROVENANCE_LABEL_SNAPSHOT_AT, args.at);
2374
+ return labels;
2375
+ }
2376
+ /**
2377
+ * PURE: parse the anon-pi provenance labels read back off an image (the CLI
2378
+ * supplies the label map from `inspect --format '{{json .Config.Labels}}'`).
2379
+ * Returns only the anon-pi provenance fields (a missing/empty label => an
2380
+ * undefined field). Tolerant: any non-string / absent value is dropped, so a
2381
+ * hand-edited or partial label set never throws.
2382
+ */
2383
+ export function parseImageProvenance(labels) {
2384
+ const get = (key) => {
2385
+ const v = labels?.[key];
2386
+ return typeof v === 'string' ? nonEmpty(v) : undefined;
2387
+ };
2388
+ return {
2389
+ sourceMachine: get(PROVENANCE_LABEL_SOURCE_MACHINE),
2390
+ sourceImage: get(PROVENANCE_LABEL_SOURCE_IMAGE),
2391
+ snapshotAt: get(PROVENANCE_LABEL_SNAPSHOT_AT),
2392
+ };
2256
2393
  }
2257
2394
  /**
2258
2395
  * PURE: the JSON body a machine.json carries, given the pinned image (and an
@@ -2316,26 +2453,44 @@ USAGE
2316
2453
  anon-pi forward [<p>] [--port …] open a host port onto a running container's in-jail server
2317
2454
  anon-pi ports [<project>] list a running container's open in-jail TCP listeners
2318
2455
  anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
2456
+ anon-pi -i <ref> [<p>] run against <ref> for THIS launch only (also --image; ephemeral)
2319
2457
  anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
2320
2458
  anon-pi init onboard: verify your proxy, capture your local model, pick an image
2321
- anon-pi machine … manage machines (create / list / set-image / rm / snapshot)
2459
+ anon-pi machine … manage machines (create / list / set-image / rm)
2460
+ anon-pi image … snapshot a running container into an image; list anon-pi images
2322
2461
  anon-pi --delete-home [<m>] delete a machine's home (config + convos); keep its image pin + files
2323
2462
  anon-pi --delete-project <p> delete a project's files + its per-machine sessions; keep the homes
2324
2463
 
2325
2464
  <project> a folder under the projects root (mounted at ${CONTAINER_PROJECTS_ROOT}; pi's cwd). \`.\` means
2326
2465
  the root itself (a scratch pi at ${CONTAINER_PROJECTS_ROOT}, ${CONTAINER_MOUNT_ROOT} for --mount, or ~).
2327
2466
 
2328
- [--rm] throwaway container this run (the DEFAULT; deleted on exit).
2329
- [--keep] leave the container KEPT so its filesystem survives (apt install,
2330
- quit, re-enter). anon-pi finds it by netcage's managed label and
2331
- \`netcage start\`s it on re-entry.
2467
+ -i <ref>, --image <ref> EPHEMERAL per-launch image override, highest priority
2468
+ (\`-i\` > the machine's machine.json image > ANON_PI_IMAGE). It picks the
2469
+ IMAGE for this launch only; \`-m\` picks the HOME, and the two compose.
2470
+ It NEVER changes machine.json (to re-pin a machine's image, use
2471
+ \`anon-pi machine set-image\` / \`machine create --image\`). No mismatch
2472
+ warning is printed. <ref> resolves in NETCAGE'S private image store
2473
+ (\`anon-pi/<name>:latest\` snapshots + \`init\`-built images live there),
2474
+ NOT your default podman store; anon-pi does NOT pre-check it and does
2475
+ NOT auto-pull (an anonymity tool must not silently fetch a remote
2476
+ image). A "not found" means the ref is not in netcage's store: snapshot
2477
+ it (\`anon-pi image snapshot <name>\`) or build it into that store.
2478
+ On a FRESH machine home \`-i\` is REFUSED (it would seed the home from
2479
+ the wrong image); establish the machine's image with
2480
+ \`anon-pi machine create <m> --image <ref>\` first.
2481
+
2482
+ Every launch is THROWAWAY: the container is removed on exit. To persist system
2483
+ state you built in a session, snapshot the running container into a named image
2484
+ (\`anon-pi image snapshot <name>\`) and pin a machine to it (\`anon-pi machine
2485
+ create <m> --image anon-pi/<name>:latest\`). Your pi config + conversations live
2486
+ in the machine home (a host mount) and persist regardless.
2332
2487
 
2333
2488
  WHAT IT DOES
2334
2489
  Runs pi inside netcage with all web/DNS egress forced through the socks5h proxy
2335
2490
  (fail-closed) and ONE direct hole to your local model (ANON_PI_LLM). A MACHINE
2336
2491
  is an image + a persistent HOST home (bind-mounted at ${CONTAINER_HOME_ROOT}) holding your pi
2337
- config, extensions, and conversations; the container is disposable, so \`--rm\`
2338
- loses nothing. Files (projects) are global by default; conversations are
2492
+ config, extensions, and conversations; the container is disposable (throwaway),
2493
+ so it loses nothing. Files (projects) are global by default; conversations are
2339
2494
  per-machine. On a FRESH machine home the image's staged defaults + your
2340
2495
  models.json are seeded in once; after that pi owns the home. Requires \`netcage\`.
2341
2496