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/README.md CHANGED
@@ -68,13 +68,14 @@ anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + co
68
68
  anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
69
69
  anon-pi init onboard: verify your proxy, capture your local model, pick an image
70
70
  anon-pi machine … manage machines (create / list / set-image / rm)
71
+ anon-pi image … snapshot a running container into an image; list anon-pi images
71
72
  anon-pi --delete-home [<m>] delete a machine's home (config + convos); keep its image pin + files
72
73
  anon-pi --delete-project <p> delete a project's files + its per-machine sessions; keep the homes
73
74
  ```
74
75
 
75
76
  A `<project>` is a folder under the projects root (mounted at `/projects`; pi's cwd). The token `.` means the root itself (a scratch pi at `/projects`, at `/work` under `--mount`, or at `~` for a shell). A named project is created on the host if it does not exist yet.
76
77
 
77
- Every subcommand carries its own help: `anon-pi --help` (the launch surface), `anon-pi init --help`, and `anon-pi machine --help`.
78
+ Every subcommand carries its own help: `anon-pi --help` (the launch surface), `anon-pi init --help`, `anon-pi machine --help`, and `anon-pi image --help`.
78
79
 
79
80
  ### Common tasks
80
81
 
@@ -93,7 +94,7 @@ Every subcommand carries its own help: `anon-pi --help` (the launch surface), `a
93
94
  | A scratch pi not tied to a subfolder | `anon-pi .` |
94
95
  | Use a separate anonymized environment | `anon-pi -m <machine> <project>` |
95
96
  | Jail pi into a host folder you edit with host tools | `anon-pi --mount <host-parent> <subfolder>` |
96
- | Install system tools and keep them | `anon-pi --keep --shell` (then `apt install …`) |
97
+ | Install system tools and keep them | `anon-pi --shell` (then `apt install …`), then `anon-pi image snapshot <name>` while it is still running |
97
98
  | Add a second machine | `anon-pi machine create <name> --image <ref>` |
98
99
  | Reset a machine's conversations | `anon-pi --delete-home [<machine>]` |
99
100
  | Delete a project (files + its sessions) | `anon-pi --delete-project <project>` |
@@ -154,7 +155,7 @@ The positional is always the **project** (a numeric name like `3001` is a projec
154
155
  anon-pi ports recon # the container's open in-jail TCP listeners
155
156
  ```
156
157
 
157
- `ports` reads the jail's listeners **image-independently** (netcage reads `/proc/net/tcp*` via the sidecar), so it works even for a minimal image with no `ss`/`netstat`/`nc`. An explicit `--port` may name a port that **isn't open yet**: the forward binds the host side immediately and reaches into the jail on the first connection, so you can set it up before the server starts. Forwarding works for both throwaway (`--rm`) and `--keep` containers, for as long as the container is running.
158
+ `ports` reads the jail's listeners **image-independently** (netcage reads `/proc/net/tcp*` via the sidecar), so it works even for a minimal image with no `ss`/`netstat`/`nc`. An explicit `--port` may name a port that **isn't open yet**: the forward binds the host side immediately and reaches into the jail on the first connection, so you can set it up before the server starts. Forwarding works for as long as the container is running (every launch is throwaway, so the window is the session's lifetime).
158
159
 
159
160
  ### Headless / one-shot
160
161
 
@@ -205,19 +206,23 @@ anon-pi -m webveil pi --version # pi's own version, on a machine
205
206
 
206
207
  The caveat: `<parent>` is a **host parent directory**, not a single project path. anon-pi mounts the parent and treats the positional as a name under it; it does not mount an arbitrary host folder as the project itself. Use `--mount` when you want a whole host tree available at `/work` (for example your real code checkout's parent), and pick the subfolder as the project.
207
208
 
208
- ### Kept vs throwaway (`--rm` / `--keep`)
209
+ ### Throwaway always; persist with a snapshot image
209
210
 
210
- The container is **throwaway by default** (`--rm`): it is deleted the moment pi exits. Your machine home and project files persist regardless (they are host mounts); only the container's own scratch filesystem is discarded.
211
+ Every launch is **throwaway**: the container is removed the moment pi (or the shell) exits. Your machine home and project files persist regardless (they are host mounts); only the container's own scratch filesystem is discarded. There is no flag to change this: `--keep` and `--rm` are **gone** (see [Migrating](#migrating-from-040)).
211
212
 
212
- Pass `--keep` for an exploratory flow where the container's filesystem should survive across exits (for example you `apt install` something, quit, and re-enter the same container):
213
+ To preserve system state you set up in a session (for example after you `apt install` something), **snapshot the still-running container into a named image** and pin a machine to it, giving you a real, named, immutable environment instead of a murky mutable pet container:
213
214
 
214
215
  ```sh
215
- anon-pi --keep recon # keep this container; re-entering resumes it
216
+ anon-pi --shell recon # a jailed shell; apt install / configure as you like
217
+ # in another terminal, while that session is STILL running:
218
+ anon-pi image snapshot toolbox # freeze the running container -> anon-pi/toolbox:latest
219
+ anon-pi machine create box --image anon-pi/toolbox:latest # a machine pinned to it
220
+ anon-pi -m box recon # later: launch on the snapshot-pinned machine
216
221
  ```
217
222
 
218
- anon-pi finds a kept container by netcage's managed label and `netcage start`s it on re-entry. `--keep` and `--rm` together is an error (pick one; `--rm` is the default).
223
+ The catch is timing: the snapshot commits a **running** container, so do it before you exit (once the session ends, the throwaway container is already gone). What you actually care about, your pi config and conversations, is in the machine home and survives every exit anyway.
219
224
 
220
- If you did NOT pass `--keep` but, mid-session, realise you want to preserve what you installed, you can snapshot the **still-running** container into a new machine without exiting (`anon-pi machine snapshot <new-name>`, see below). The catch is timing: once pi exits, a throwaway (`--rm`) container is already gone, so snapshot only works while the session is still up.
225
+ One step does both: `anon-pi image snapshot toolbox --create-machine box` commits the image AND creates machine `box` from it, carrying the source machine's home + conversations over (the same prompts described under [Managing images](#managing-images)). `anon-pi image list` shows your snapshots with their provenance (source machine, source image, when), including orphaned snapshots (an overwritten `:latest`) by their ID.
221
226
 
222
227
  ## Managing machines
223
228
 
@@ -226,17 +231,28 @@ anon-pi machine create <name> [--image <ref>] create a machine, pin its image
226
231
  anon-pi machine list list machines and their images
227
232
  anon-pi machine set-image <name> <ref> re-pin the image (WARNS; no reseed)
228
233
  anon-pi machine rm <name> [--yes] delete the machine + its home
229
- anon-pi machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]
230
- commit a RUNNING container into a
231
- new image + create <new-name>
232
234
  ```
233
235
 
234
- `snapshot` captures the current filesystem of a **running** jailed container (for example after you `sudo apt install` some tools) into a new image, then creates `<new-name>` pinned to it, so you can preserve an environment you built interactively **without** having pre-decided `--keep`. The container is auto-detected from your running anon-pi containers (a picker when several are up); `-m <machine>` is an **optional filter**, not a required source. The container must still be running (do not exit the session; podman pauses it briefly during the commit). The new machine gets a **fresh** home (the image is the software; the home, with your config and conversations, is a separate host mount and is not copied). It relaunches through the same forced-egress jail.
235
-
236
236
  A machine's home is seeded on FIRST LAUNCH, not at create. `set-image` re-pins the image only and **warns**: it does not reseed or touch the home, so the home's extensions were built for the old image. `rm` confirms on a TTY, skips the prompt with `--yes`, and aborts non-interactively without it (it never deletes unprompted in a script). `create` with no `--image` and no TTY is an error (a machine needs an image to launch).
237
237
 
238
+ `create --image <ref>` is **provenance-aware**: if `<ref>` was produced by `anon-pi image snapshot` (it carries an `anon-pi.source-machine` label) and that machine's home still exists, you are offered its home + conversations to carry over (opt-in; with no TTY nothing is copied). Otherwise it is a plain fresh create.
239
+
238
240
  If you never create a machine explicitly, launches use the `default` machine (which `init` creates). Give a machine its own image + home + conversations by naming it with `-m`.
239
241
 
242
+ ## Managing images
243
+
244
+ ```
245
+ anon-pi image snapshot <name> [-m <machine>] [--create-machine <m>]
246
+ commit the RUNNING container into anon-pi/<name>:latest
247
+ anon-pi image list list anon-pi images with their provenance (read-only)
248
+ ```
249
+
250
+ `image snapshot` captures the current filesystem of a **running** jailed container (for example after you `sudo apt install` some tools) into the clean tag `anon-pi/<name>:latest`, baking **provenance** as podman labels (source machine, source image, snapshot time). This is the way to keep container-level system changes (every launch is throwaway): freeze the running box into a named image, then pin a machine to it. The container is auto-detected from your running anon-pi containers (a picker when several are up); `-m <machine>` is an **optional filter**, not a required source. The container must still be running (do not exit the session; podman pauses it briefly during the commit). A same-name re-snapshot **overwrites** the `:latest` tag; the previous image becomes dangling but keeps its provenance, so `image list` still shows it by ID. To preserve a specific snapshot, snapshot it under a different name.
251
+
252
+ `--create-machine <m>` also creates machine `<m>` pinned to the fresh snapshot, **copying the source machine's home** (your pi config, extensions, and dotfiles, which are correct for the committed image) **minus its conversations**. Conversations are handled separately: you are offered each one **grouped by project**, opt-in per project (default **skip**), choosing **copy** or **skip** for each (with no TTY, none are copied). Copy never touches the source machine; after copying, a single confirmed step (default No) can **delete** the copied groups from the source (the only way to "move" a conversation out). This is equivalent to `image snapshot` followed by a provenance-aware `machine create --image`.
253
+
254
+ `image list` reads the provenance labels straight off the images (**zero stored state**): it shows every `anon-pi/*` image plus any dangling image still carrying an `anon-pi.source-machine` label (an orphaned snapshot), by its ID.
255
+
240
256
  ## Deleting data
241
257
 
242
258
  The destructive verbs replace the old `--fresh`. Each confirms on a TTY, skips with `--yes`, and aborts non-interactively without it.
@@ -349,7 +365,9 @@ anon-pi 0.4.0 was a **per-workdir** launcher: a bare positional was a host folde
349
365
  - **A bare positional is now a PROJECT, not a host path.** `anon-pi ./recon` no longer mounts host `./recon`; it means the project `recon` under the projects root (`/projects/recon`). To make a host folder available, use `--mount <host-parent>` (mounted at `/work`) and select the subfolder as the project.
350
366
  - **`anon-pi import` is GONE.** Onboarding is now `anon-pi init`, which (among other things) generates the local-model `models.json` for the default machine. There is no separate import step.
351
367
  - **`--fresh` is GONE.** To reset, use the explicit data verbs: `anon-pi --delete-home [<machine>]` (wipe a machine's home, keep its image pin + project files) and `anon-pi --delete-project <project>` (wipe a project's files + its per-machine sessions).
352
- - **`--ephemeral` / `ANON_PI_EPHEMERAL` are GONE.** The container is **throwaway by default** now (`--rm`); use `--keep` when you want it to survive across exits. There is no separate ephemeral mode.
368
+ - **`--ephemeral` / `ANON_PI_EPHEMERAL` are GONE.** The container is **throwaway** now; there is no separate ephemeral mode.
369
+ - **`--keep` and `--rm` are GONE.** Every launch is throwaway (there is no flag to toggle it): passing either is an error. The exploratory "install, quit, re-enter" flow is served by snapshotting the running container into a named image (`anon-pi image snapshot <name>`) and pinning a machine to it (`anon-pi machine create <m> --image anon-pi/<name>:latest`), which is explicit and named instead of an inferred mutable pet container. Your pi config and conversations were never in the container anyway (they live in the machine home).
370
+ - **`machine snapshot` is now `image snapshot`.** Snapshot moved off the `machine` noun onto the new `image` noun: `anon-pi image snapshot <name>` commits the running container into `anon-pi/<name>:latest` (with provenance labels), and `--create-machine <m>` also builds a machine from it. See [Managing images](#managing-images).
353
371
  - **The layout moved.** Everything now lives under `~/.anon-pi/` (`config.json` + `machines/` + `projects/`), **not** under `~/.config/anon-pi`. `ANON_PI_HOME` still overrides the root; the old `ANON_PI_CONFIG` / `ANON_PI_SOURCE_MODELS` variables are gone.
354
372
  - **Old state is NOT migrated.** anon-pi does not read or convert your old `~/.config/anon-pi/state/<slug>/` directories. Once you have moved to the new model you can delete the old tree:
355
373
 
@@ -368,7 +386,7 @@ Start fresh with `anon-pi init`, then `anon-pi` (the menu) or `anon-pi <project>
368
386
  - **`no TTY` on a bare `anon-pi`** — the menu and interactive pi need a terminal. In a script, name the project and forward args: `anon-pi <project> <pi-args…>` (that path needs no TTY).
369
387
  - **The exit IP looks like your home IP** — the proxy is not actually anonymizing. Re-run `anon-pi init`; its `netcage verify` step prints the real exit IP as proof. anon-pi never claims a provider, only shows you the exit.
370
388
  - **A destructive verb won't run in a script** — `machine rm` / `--delete-home` / `--delete-project` confirm on a TTY and abort non-interactively; pass `--yes` to proceed unattended.
371
- - **`--keep` re-entry started a fresh container** — a kept container is matched by its `(machine, projects-root, project)` identity; changing any of those (a different `-m`, a different `--mount` parent, a different project) is a different launch and gets its own container.
389
+ - **`--keep` / `--rm` say they are gone** — that is expected: every launch is throwaway now. To keep system changes you made in a session, `anon-pi image snapshot <name>` the **still-running** container into a named image, then pin a machine to it (`anon-pi machine create <m> --image anon-pi/<name>:latest`) and relaunch (`anon-pi -m <m>`). Your pi config + conversations persist regardless (they live in the machine home).
372
390
 
373
391
  ## Platform
374
392
 
package/dist/anon-pi.d.ts CHANGED
@@ -248,12 +248,18 @@ export declare function resolveDeleteProject(args: {
248
248
  */
249
249
  export declare const ROOT_TOKEN = ".";
250
250
  /**
251
- * Reserved names that a machine/project may NOT take (case-sensitive). Kept
252
- * DELIBERATELY minimal: only the two structural path tokens. `.` is the root
253
- * token (see ROOT_TOKEN); `..` is parent-traversal. Both are also rejected by
254
- * the leading-dot / `..` structural checks below, but are listed here so the
255
- * reserved-name concept is explicit and extendable. `--mount`'s `/work` is a
256
- * CONTAINER path, not a name in this namespace, so it needs no reservation.
251
+ * Reserved names that a machine/project/image may NOT take (case-sensitive).
252
+ * `.` is the root token (see ROOT_TOKEN); `..` is parent-traversal (both are
253
+ * also rejected by the structural checks below, but listed here so the
254
+ * reserved-name concept is explicit). `pi` is the passthrough token. The
255
+ * SUBCOMMAND NOUN words (`machine`, `image`, `init`, `forward`, `ports`) are
256
+ * reserved too: each is dispatched BEFORE the launch grammar, so a folder so
257
+ * named would be UNREACHABLE by bare name (a latent trap). Reserving them makes
258
+ * validateName refuse such a name up front with a clear error, closing the
259
+ * trap. `--mount`'s `/work` is a CONTAINER path, not a name here, so it needs no
260
+ * reservation. The reservation is GLOBAL (validateName is the one validator);
261
+ * the menu tolerates a pre-existing folder now reserved by FILTERING it out via
262
+ * the try/catch isProjectName, so a now-reserved folder is skipped, not a crash.
257
263
  */
258
264
  export declare const RESERVED_NAMES: readonly string[];
259
265
  /** What a name names, for a clear validation error. */
@@ -306,7 +312,7 @@ export declare function resolveCwd(kind: RootKind, token: string): string;
306
312
  * and files written under the home land in the machine's config home on the
307
313
  * host; a shell is the project-hopper, so `/projects` is the natural landing.
308
314
  * The machine home is one `cd ~` away for the rare case. `menu` never reaches
309
- * here (it is argv-less). Shared by resolveRunPlan + keptContainerKey so the run
315
+ * here (it is argv-less). Shared by resolveRunPlan + launchIdentityKey so the run
310
316
  * cwd and the container-identity key always agree.
311
317
  */
312
318
  export declare function launchCwd(_mode: LaunchMode, kind: RootKind, project: string | undefined): string;
@@ -404,6 +410,25 @@ export declare function resolveLlm(args: {
404
410
  llmDirect?: string;
405
411
  };
406
412
  }): string | undefined;
413
+ /**
414
+ * PURE: resolve the IMAGE a launch runs against, highest-priority first:
415
+ * a per-launch `-i`/`--image` override > the machine's pinned image
416
+ * (machine.json) > `ANON_PI_IMAGE` (the env fallback) > undefined (the CLL then
417
+ * errors). The `-i` override is STRICTLY EPHEMERAL: it selects the image for
418
+ * THIS launch only and is NEVER written back to machine.json (that persistent
419
+ * pin is `machine set-image` / `machine create --image`). No mismatch warning is
420
+ * ever emitted (ADR-0003 section 3: `-i` is explicit + ephemeral, so a warning
421
+ * carries no information the user lacks). Empty strings are treated as unset at
422
+ * every tier (nonEmpty), so a blank env/pin falls through cleanly.
423
+ */
424
+ export declare function resolveLaunchImage(args: {
425
+ /** The per-launch `-i`/`--image` override (ParsedLaunch.image), if given. */
426
+ override?: string;
427
+ /** The machine's pinned image (machine.json.image), if set. */
428
+ machineImage?: string;
429
+ /** The `ANON_PI_IMAGE` env fallback, if set. */
430
+ envImage?: string;
431
+ }): string | undefined;
407
432
  /** A resolved machine: its host home (bind-mounted at /root) + its image. */
408
433
  export interface Machine {
409
434
  /** The machine's name (already validated by validateName elsewhere). */
@@ -460,12 +485,6 @@ export interface LaunchIntent {
460
485
  mountParent?: string;
461
486
  /** Extra args forwarded to `pi` (headless/one-shot). Ignored for shell. */
462
487
  piArgs?: string[];
463
- /**
464
- * `--keep`: omit `--rm` so the container is left KEPT (its filesystem
465
- * survives the apt-install/re-enter flow). Default (false) => `--rm`
466
- * (throwaway); the machine home persists regardless (it is a host mount).
467
- */
468
- keep?: boolean;
469
488
  /** The resolved socks5h proxy (REQUIRED; the resolver fails closed without it). */
470
489
  proxy: string;
471
490
  /** The resolved local-model direct target (REQUIRED: the one --allow-direct hole). */
@@ -513,9 +532,10 @@ export declare const DEFAULT_MACHINE = "default";
513
532
  * project): the CLI runs the host-side menu. `pi`/`shell` carry the chosen
514
533
  * target. `project` is a validated project name, the `.` root token, or
515
534
  * undefined (menu / bare shell, which lands at the active root). `mountParent` is the `--mount` HOST parent
516
- * (a path, NOT a name-namespaced token). `keep` is `--keep` (default false =>
517
- * throwaway `--rm`). `piArgs` are the trailing tokens forwarded to pi (pi mode
518
- * only; undefined otherwise).
535
+ * (a path, NOT a name-namespaced token). `image` is the ephemeral per-launch
536
+ * `-i`/`--image` override (undefined when not given). Every launch is throwaway
537
+ * (`--rm` always; the retired `--keep`/`--rm` flags now error). `piArgs` are the
538
+ * trailing tokens forwarded to pi (pi mode only; undefined otherwise).
519
539
  */
520
540
  export interface ParsedLaunch {
521
541
  mode: LaunchMode;
@@ -528,7 +548,16 @@ export interface ParsedLaunch {
528
548
  machineExplicit: boolean;
529
549
  project?: string;
530
550
  mountParent?: string;
531
- keep: boolean;
551
+ /**
552
+ * The EPHEMERAL per-launch image override (`-i <ref>` / `--image <ref>`), or
553
+ * undefined. Highest priority in the image-resolution chain (see
554
+ * resolveLaunchImage): `-i` > machine.json.image > ANON_PI_IMAGE. It NEVER
555
+ * mutates machine.json (that persistent pin is `machine set-image` /
556
+ * `machine create --image`); `-i` picks the IMAGE for this launch while `-m`
557
+ * picks the HOME, and they compose. The ref is passed straight through to
558
+ * netcage's private image store: anon-pi does NOT pre-check it or auto-pull.
559
+ */
560
+ image?: string;
532
561
  piArgs?: string[];
533
562
  }
534
563
  /** True iff `a` is a RESUME-family flag (resolve session cwd; see PI_RESUME_FLAGS). */
@@ -571,6 +600,17 @@ export declare function sessionHeaderCwd(headerLine: string): string | undefined
571
600
  * RESERVED project name (see RESERVED_NAMES) so a project can never shadow it.
572
601
  */
573
602
  export declare const PI_PASSTHROUGH_TOKEN = "pi";
603
+ /**
604
+ * The retired launch flags. `--keep`/`--rm` are GONE (ADR-0004): every launch is
605
+ * throwaway now, so there is no flag to toggle. `--keep`'s exploratory
606
+ * "apt install, quit, re-enter" use case is served, better, by snapshotting a
607
+ * running container into a named image and pinning a machine to it (explicit +
608
+ * named, no inference). The label a launch is passed one of these RETIRED flags
609
+ * gets a clear error pointing there.
610
+ */
611
+ export declare const RETIRED_LAUNCH_FLAGS: readonly ["--keep", "--rm"];
612
+ /** PURE: the error message for a retired `--keep`/`--rm` flag, pointing at the image-based replacement. */
613
+ export declare function retiredKeepRmMessage(flag: string): string;
574
614
  /**
575
615
  * PURE: whether forwarded pi args request pi's NON-INTERACTIVE (print) mode,
576
616
  * i.e. contain `-p`/`--print`. This is the ONLY headless shape (it needs no
@@ -581,8 +621,8 @@ export declare const PI_PASSTHROUGH_TOKEN = "pi";
581
621
  export declare function isHeadlessPiArgs(piArgs: readonly string[] | undefined): boolean;
582
622
  /**
583
623
  * PURE: parse grammar A into a ParsedLaunch. Consumes the anon-pi flags
584
- * (`-m <machine>`, `--shell`, `--mount <parent>`, `--keep`/`--rm`) LEFT of the
585
- * project positional; the FIRST bare positional is the project (`.` allowed as
624
+ * (`-m <machine>`, `--shell`, `--mount <parent>`, `-i`/`--image <ref>`) LEFT of
625
+ * the project positional; the FIRST bare positional is the project (`.` allowed as
586
626
  * the root token). In pi mode every token AFTER the project is forwarded to pi
587
627
  * verbatim (so `anon-pi recon -p '...'` works) — anon-pi flags must come before
588
628
  * the project. A pi session-resume flag (`--session <id>`, `--continue`,
@@ -595,7 +635,7 @@ export declare function isHeadlessPiArgs(piArgs: readonly string[] | undefined):
595
635
  * reserved-name guard); `--mount <parent>` is a HOST path in its own namespace,
596
636
  * distinct from the project-name namespace (NAME vs `--mount` exclusivity), so
597
637
  * it is NOT name-validated here. Throws AnonPiError for an unknown option, a
598
- * missing `-m`/`--mount` argument, a contradictory `--keep --rm`, or a bad name.
638
+ * missing `-m`/`--mount` argument, a RETIRED `--keep`/`--rm` flag, or a bad name.
599
639
  */
600
640
  export declare function parseLaunchArgs(args: readonly string[]): ParsedLaunch;
601
641
  /**
@@ -608,87 +648,39 @@ export declare function parseLaunchArgs(args: readonly string[]): ParsedLaunch;
608
648
  * - the two mounts <home>:/root and <projectsRoot>:/projects, always;
609
649
  * - --mount adds EXACTLY <parent>:/work and re-roots cwd, nothing else;
610
650
  * - --proxy <p> + exactly one --allow-direct <llm> (forced egress, fail-closed);
611
- * - --rm by default, omitted only under --keep.
651
+ * - --rm on EVERY launch (throwaway always; ADR-0004).
612
652
  *
613
653
  * Throws AnonPiError (a plan is NEVER produced) when the image, the machine
614
654
  * home, the proxy, or the direct-hole llm is missing.
615
655
  */
616
656
  export declare function resolveRunPlan(intent: LaunchIntent, homeFresh: (machineHome: string) => boolean): LaunchPlan;
617
657
  /**
618
- * A kept `netcage.managed` container, as the CLI's netcage query surfaces it to
619
- * the pure decision. Only the two fields the DECISION needs are typed:
620
- * - `key`: the anon-pi launch-identity key (keptContainerKey) the CLI stamped
621
- * onto the container at `run` time (a netcage label / container name) and
622
- * reads back from the label; this is what a launch matches against.
623
- * - `ref`: how to address the container for `netcage start` (its id or name).
624
- * The CLI is free to carry more; the pure rule reads only these.
625
- */
626
- export interface KeptContainer {
627
- /** The anon-pi launch-identity key stamped on the container (keptContainerKey). */
628
- key: string;
629
- /** The container ref (id or name) to pass to `netcage start`. */
630
- ref: string;
631
- }
632
- /**
633
- * The run-vs-start decision. `run` = `netcage run` a fresh container (WITHOUT
634
- * `--rm` under `--keep`, so it is left kept; the run argv itself is
635
- * resolveRunPlan's job). `start` = `netcage start <ref>` an existing kept
636
- * container whose identity matches this launch.
637
- */
638
- export type RunVsStart = {
639
- action: 'run';
640
- } | {
641
- action: 'start';
642
- ref: string;
643
- };
644
- /**
645
- * PURE: the launch-identity match key for a kept container, derived ENTIRELY
646
- * from the (machine, projects-root, project) identity (ADR-0002). It is what
647
- * decides whether an existing kept `netcage.managed` container IS the one a
648
- * `--keep` launch should resume.
649
- *
650
- * The fields, and why each is load-bearing:
651
- * - `machine.name`: a kept container mounts THIS machine's home at /root; a
652
- * same-project container on another machine is a different environment.
653
- * - `projectsRoot`: the host dir mounted at /projects; two launches with the
654
- * same project name but different roots are different working trees.
655
- * - `mountParent` (or '' when absent): `--mount` re-roots into a DIFFERENT
656
- * host parent at /work, so a `--mount` launch is a distinct identity from
657
- * the projects-root launch of the same name.
658
- * - the resolved container `cwd`: this already encodes the project token
659
- * (`/projects/<p>`, `/work/<p>`, or a root `/projects`/`/work`; legacy kept
660
- * containers may still carry /root from the pre-0.12 bare-shell-at-home)
661
- * AND which root it sits under, so it is pi's conversation key too. Using
662
- * the cwd keeps the container identity aligned with the conversation the
663
- * kept container hosts.
664
- *
665
- * DELIBERATELY EXCLUDED (not part of identity): `--keep`/`--rm` (the throwaway
666
- * choice for THIS run), the proxy + the direct-hole llm (forced-egress inputs),
667
- * forwarded pi args, and the seed. Two launches that differ only in those must
668
- * resolve to the SAME kept container.
669
- *
670
- * The key is a single opaque string (a `\n`-joined, field-tagged record) so the
671
- * CLI can stamp it verbatim onto a netcage label and match on string equality;
672
- * its internal shape is not a contract (compare only keys this function makes).
673
- */
674
- export declare function keptContainerKey(intent: LaunchIntent): string;
675
- /**
676
- * PURE: decide run-vs-start for a launch given a SUPPLIED listing of kept
677
- * `netcage.managed` containers (the CLI's netcage query result).
658
+ * PURE: the anon-pi launch-identity key stamped on EVERY (throwaway) launch,
659
+ * derived from the (machine, projects-root, project) identity (ADR-0002's
660
+ * cwd/project reasoning still underpins it). It is NOT a kept-container MATCH
661
+ * key (every launch is throwaway; nothing is ever resumed). It exists ONLY so
662
+ * `forward`/`ports`/`snapshot` can resolve a RUNNING container by machine +
663
+ * project: the CLI stamps it onto a netcage label and reads it back with
664
+ * parseKeptKey -> keyProject.
678
665
  *
679
- * - `--rm` (throwaway, `intent.keep !== true`): ALWAYS a fresh `run`. The
680
- * listing is NOT consulted (a throwaway launch never resumes a kept box).
681
- * - `--keep`: a kept container whose `key` equals this launch's
682
- * keptContainerKey is present -> `start` it (by its `ref`); else -> `run`
683
- * (resolveRunPlan leaves it kept because `--keep` omits `--rm`).
666
+ * The fields, and why each is retained:
667
+ * - `machine.name`: the forward/ports filter scopes by machine.
668
+ * - `cwd` (the resolved container cwd, via launchCwd): encodes the project
669
+ * token (`/projects/<p>`, `/work/<p>`, or a root), so keyProject can name
670
+ * the project a running container hosts.
671
+ * - `projectsRoot` + `mountParent`: kept in the record for stability of the
672
+ * decode shape (parseKeptKey reads them best-effort); no consumer filters on
673
+ * them today, but they cost nothing and keep the label self-describing.
684
674
  *
685
- * Never spawns, never queries netcage: the listing is injected, so the whole
686
- * decision is a pure function of (intent, listing).
675
+ * Independent of the forced-egress inputs and forwarded pi args (identity only).
676
+ * The key is a single opaque string (a `\n`-joined, field-tagged record) the CLI
677
+ * stamps verbatim onto a netcage label; its internal shape is not a contract
678
+ * (decode only with parseKeptKey).
687
679
  */
688
- export declare function resolveRunVsStart(intent: LaunchIntent, kept: readonly KeptContainer[]): RunVsStart;
680
+ export declare function launchIdentityKey(intent: LaunchIntent): string;
689
681
  /**
690
- * PURE: the decoded fields of a stamped keptContainerKey (the reverse of
691
- * keptContainerKey's `k=v\n` record). Used by `forward`/`ports` to filter the
682
+ * PURE: the decoded fields of a stamped launchIdentityKey (the reverse of
683
+ * launchIdentityKey's `k=v\n` record). Used by `forward`/`ports` to filter the
692
684
  * running managed containers by machine + project WITHOUT reconstructing the
693
685
  * exact key (which would couple to launchCwd). Unknown/missing fields are ''.
694
686
  */
@@ -698,7 +690,7 @@ export interface KeptKeyFields {
698
690
  mountParent: string;
699
691
  cwd: string;
700
692
  }
701
- /** PURE: parse a stamped keptContainerKey back into its fields (best-effort). */
693
+ /** PURE: parse a stamped launchIdentityKey back into its fields (best-effort). */
702
694
  export declare function parseKeptKey(key: string): KeptKeyFields;
703
695
  /**
704
696
  * PURE: the leaf name of a stamped key's cwd, i.e. the project a container hosts
@@ -725,7 +717,7 @@ export declare function resolveManagedMatches(args: {
725
717
  * A RUNNING netcage-managed container the CLI surfaces to the pure forward/ports
726
718
  * resolution: its anon-pi identity `key` (stamped label, decoded), the `ref` to
727
719
  * pass to `netcage forward`/`ports` (id or name), and a human `name` for the
728
- * picker. Mirrors KeptContainer with the display name added.
720
+ * picker.
729
721
  */
730
722
  export interface ManagedContainer {
731
723
  key: string;
@@ -824,7 +816,7 @@ export interface NetcagePsEntry {
824
816
  * ref: <Id>, name: <first Names entry or Id>}. When `runningOnly`, entries whose
825
817
  * State is not "running" are dropped (forward/ports can only reach a live jail).
826
818
  * The base64 DECODE of `key` is the CLI's job (Buffer), so this stays pure; the
827
- * caller decodes before matching against a keptContainerKey. [] on bad JSON.
819
+ * caller decodes before matching against a launchIdentityKey. [] on bad JSON.
828
820
  */
829
821
  export declare function parseNetcagePsJson(stdout: string, opts?: {
830
822
  runningOnly?: boolean;
@@ -933,6 +925,40 @@ export declare function deriveProjectUsage(args: {
933
925
  currentMachine: string;
934
926
  sessions: SessionDirListing;
935
927
  }): ProjectUsage[];
928
+ /**
929
+ * ONE session group a snapshot's `--create-machine` carry-over can offer: a `sessions/<slug>/`
930
+ * dir in the source home. `project` is the project name when the slug matches a
931
+ * known project's `projectSessionSlug` (else undefined: an ORPHAN slug with no
932
+ * matching project, still offered, labelled by its raw slug so nothing hides).
933
+ * `label` is the human row text. `slug` is the exact dir name to copy/delete.
934
+ */
935
+ export interface SnapshotSessionGroup {
936
+ slug: string;
937
+ project?: string;
938
+ label: string;
939
+ }
940
+ /**
941
+ * PURE: the cpSync filter predicate for a snapshot's "copy the home MINUS the
942
+ * sessions subtree" copy: true = copy `src`, false = skip it. It rejects the
943
+ * sessions dir itself and everything beneath it (`<sessionsDir>` and
944
+ * `<sessionsDir>/...`), and copies everything else. Extracted so the
945
+ * home-minus-sessions contract is unit-testable without the fs.
946
+ */
947
+ export declare function copyIncludesForHomeMinusSessions(src: string, sessionsDir: string): boolean;
948
+ /**
949
+ * PURE: map the session-dir slugs PRESENT under a source machine's `sessions/`
950
+ * to per-project rows a snapshot's carry-over picker offers. For each present
951
+ * slug, if it equals `projectSessionSlug(<project>)` for a known project, it is a
952
+ * PROJECT row (labelled by the project name); otherwise an ORPHAN-slug row
953
+ * (labelled by the raw slug, so a session with no current project folder is
954
+ * still shown, never silently dropped). Rows are sorted: named projects first
955
+ * (case-insensitive by name), then orphan slugs (by slug), for a stable picker.
956
+ * The caller (CLI) does the actual copy/delete of each chosen slug dir.
957
+ */
958
+ export declare function snapshotSessionGroups(args: {
959
+ presentSlugs: readonly string[];
960
+ projects: readonly string[];
961
+ }): SnapshotSessionGroup[];
936
962
  /**
937
963
  * What ONE selectable menu row launches, so the CLI can dispatch a chosen entry
938
964
  * without re-deriving anything:
@@ -1389,11 +1415,9 @@ export declare function anonPiVersion(): string | undefined;
1389
1415
  * - `set-image <name> <ref>`: name validated; the new image ref (non-empty).
1390
1416
  * - `rm <name> [--yes]`: name validated; `yes` skips the confirm (the CLI
1391
1417
  * still enforces the non-TTY abort when `yes` is false).
1392
- * - `snapshot <new-name> [-m <machine>] [--image-tag <ref>]`: the sole
1393
- * positional is the NEW machine name (validated); `-m <machine>` is an
1394
- * OPTIONAL filter (which running container to commit when several are up),
1395
- * NOT a required source. The CLI auto-detects the running container (picker
1396
- * when several match), commits it, and creates <new-name> pinned to it.
1418
+ *
1419
+ * Snapshot moved OFF the `machine` noun to the `image` noun (ADR-0003): see
1420
+ * `parseImageArgs` / ImageCommand.
1397
1421
  */
1398
1422
  export type MachineCommand = {
1399
1423
  verb: 'create';
@@ -1409,11 +1433,6 @@ export type MachineCommand = {
1409
1433
  verb: 'rm';
1410
1434
  name: string;
1411
1435
  yes: boolean;
1412
- } | {
1413
- verb: 'snapshot';
1414
- name: string;
1415
- machine?: string;
1416
- imageTag?: string;
1417
1436
  };
1418
1437
  /**
1419
1438
  * PURE: parse the tokens AFTER `machine` into a MachineCommand. Validates the
@@ -1428,12 +1447,75 @@ export type MachineCommand = {
1428
1447
  */
1429
1448
  export declare function parseMachineArgs(args: readonly string[]): MachineCommand;
1430
1449
  /**
1431
- * PURE: the default image ref a `machine snapshot` writes when `--image-tag` is
1432
- * not given: `anon-pi/<name>:snapshot-<ts>`, where <ts> is a compact UTC stamp
1433
- * (YYYYMMDDHHMMSS) derived from `now`. Deterministic in `now` so it is unit
1434
- * testable. The name is a validated machine name (a safe image-path segment).
1450
+ * A parsed `image <verb> …` command (ADR-0003 §1). A discriminated union so the
1451
+ * CLI dispatches on `verb` with already-validated fields:
1452
+ * - `snapshot <name> [-m <machine>] [--create-machine <m>]`: commit the
1453
+ * RUNNING container into `anon-pi/<name>:latest`. `name` is a validated
1454
+ * image name (a safe tag segment). `-m <machine>` is an OPTIONAL filter
1455
+ * (which running container to commit when several are up), NOT a required
1456
+ * source. `--create-machine <m>` ALSO creates machine <m> from the fresh
1457
+ * snapshot (running the home-copy + session carry-over).
1458
+ * - `list`: no args (read-only; zero stored state).
1459
+ */
1460
+ export type ImageCommand = {
1461
+ verb: 'snapshot';
1462
+ name: string;
1463
+ machine?: string;
1464
+ createMachine?: string;
1465
+ } | {
1466
+ verb: 'list';
1467
+ };
1468
+ /**
1469
+ * PURE: parse the tokens AFTER `image` into an ImageCommand. Validates the image
1470
+ * name + the `-m` / `--create-machine` machine names via validateName (the
1471
+ * reserved-name / traversal guard), so the CLI only ever joins safe segments.
1472
+ * Throws AnonPiError (printed verbatim, exit 1) for an unknown/missing verb, a
1473
+ * missing or extra positional, an unknown flag, or a bad name.
1474
+ *
1475
+ * `<name>` is validated with the `machine` kind: it shares the same
1476
+ * folder-safe / reserved-name rules, and a snapshot name is an image-tag
1477
+ * segment (`anon-pi/<name>:latest`), so the same guard applies.
1478
+ */
1479
+ export declare function parseImageArgs(args: readonly string[]): ImageCommand;
1480
+ /**
1481
+ * PURE: the clean image tag a `image snapshot <name>` writes:
1482
+ * `anon-pi/<name>:latest`. A same-name re-snapshot OVERWRITES this tag (that is
1483
+ * what `:latest` means); the previous image becomes dangling but keeps its
1484
+ * provenance label. The name is a validated image/machine name (a safe
1485
+ * image-path segment).
1486
+ */
1487
+ export declare function snapshotImageTag(name: string): string;
1488
+ /** The podman/anon-pi provenance label keys baked into a snapshot image. */
1489
+ export declare const PROVENANCE_LABEL_SOURCE_MACHINE = "anon-pi.source-machine";
1490
+ export declare const PROVENANCE_LABEL_SOURCE_IMAGE = "anon-pi.source-image";
1491
+ export declare const PROVENANCE_LABEL_SNAPSHOT_AT = "anon-pi.snapshot-at";
1492
+ /**
1493
+ * PURE: build the `LABEL k=v` change instructions a `netcage commit -c '…'`
1494
+ * bakes into a snapshot image (ADR-0003 §2). Provenance is best-effort HISTORY:
1495
+ * a label whose value is undefined/empty is OMITTED (a missing label beats a
1496
+ * wrong one). `at` is required (the snapshot time is always known). Each string
1497
+ * is ONE `LABEL key=value` instruction (the CLI passes each as a `-c` argv
1498
+ * element; podman round-trips `/` and `:` in the value un-quoted, verified).
1499
+ */
1500
+ export declare function snapshotProvenanceLabels(args: {
1501
+ sourceMachine?: string;
1502
+ sourceImage?: string;
1503
+ at: string;
1504
+ }): string[];
1505
+ /** Provenance read back from a snapshot image's labels (any field may be absent). */
1506
+ export interface ImageProvenance {
1507
+ sourceMachine?: string;
1508
+ sourceImage?: string;
1509
+ snapshotAt?: string;
1510
+ }
1511
+ /**
1512
+ * PURE: parse the anon-pi provenance labels read back off an image (the CLI
1513
+ * supplies the label map from `inspect --format '{{json .Config.Labels}}'`).
1514
+ * Returns only the anon-pi provenance fields (a missing/empty label => an
1515
+ * undefined field). Tolerant: any non-string / absent value is dropped, so a
1516
+ * hand-edited or partial label set never throws.
1435
1517
  */
1436
- export declare function snapshotImageRef(name: string, now: Date): string;
1518
+ export declare function parseImageProvenance(labels: Record<string, unknown> | null | undefined): ImageProvenance;
1437
1519
  /**
1438
1520
  * PURE: the JSON body a machine.json carries, given the pinned image (and an
1439
1521
  * optional per-machine projects override, preserved on a re-pin). A single
@@ -1454,5 +1536,5 @@ export declare function setImageWarning(name: string, oldImage: string | undefin
1454
1536
  /** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
1455
1537
  export declare function envFromProcess(penv: Record<string, string | undefined>): AnonPiEnv;
1456
1538
  /** The --help text (kept here so it is covered by the same module). */
1457
- export declare const HELP = "anon-pi - run pi on anonymized, jailed machines (netcage: forced egress + one direct local model)\n\nUSAGE\n anon-pi MENU: pick a project (pi), a shell, or a new project\n anon-pi <project> pi in the project (/projects/<project>); exit pi -> host\n anon-pi <project> <pi-args\u2026> forward args to pi (e.g. -p for a headless one-shot)\n anon-pi --session <id> resume a pi session by id, in its own project (also -r/--resume)\n anon-pi <project> --fork <id> fork a session into <project> (`.`=root; --continue too; project required)\n anon-pi --list-models list the models pi sees (also --models; no project needed)\n anon-pi pi <pi-args\u2026> run pi with ANY args and no project (the passthrough)\n anon-pi --version print anon-pi's version (also -V)\n anon-pi --shell [<project>] a jailed bash (at /projects, or cd'd into <project>) - the project-hopper\n anon-pi forward [<p>] [--port \u2026] open a host port onto a running container's in-jail server\n anon-pi ports [<project>] list a running container's open in-jail TCP listeners\n anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)\n anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root\n anon-pi init onboard: verify your proxy, capture your local model, pick an image\n anon-pi machine \u2026 manage machines (create / list / set-image / rm / snapshot)\n anon-pi --delete-home [<m>] delete a machine's home (config + convos); keep its image pin + files\n anon-pi --delete-project <p> delete a project's files + its per-machine sessions; keep the homes\n\n <project> a folder under the projects root (mounted at /projects; pi's cwd). `.` means\n the root itself (a scratch pi at /projects, /work for --mount, or ~).\n\n [--rm] throwaway container this run (the DEFAULT; deleted on exit).\n [--keep] leave the container KEPT so its filesystem survives (apt install,\n quit, re-enter). anon-pi finds it by netcage's managed label and\n `netcage start`s it on re-entry.\n\nWHAT IT DOES\n Runs pi inside netcage with all web/DNS egress forced through the socks5h proxy\n (fail-closed) and ONE direct hole to your local model (ANON_PI_LLM). A MACHINE\n is an image + a persistent HOST home (bind-mounted at /root) holding your pi\n config, extensions, and conversations; the container is disposable, so `--rm`\n loses nothing. Files (projects) are global by default; conversations are\n per-machine. On a FRESH machine home the image's staged defaults + your\n models.json are seeded in once; after that pi owns the home. Requires `netcage`.\n\nENVIRONMENT\n ANON_PI_PROXY (required) socks5h URL of your proxy (Tor/wireproxy/ssh -D).\n No default: the proxy is what anonymizes, so it is never guessed.\n ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model\n ANON_PI_IMAGE image with `pi` on PATH, used when a machine has no image set.\n No image yet? See the README (Providing a pi image).\n ANON_PI_HOME anon-pi workspace dir (default ~/.anon-pi; NOT under ~/.config)\n ANON_PI_PROJECTS projects root override (host dir mounted at /projects)\n\nPLATFORM\n Linux only (via netcage's netns/nft jail). On macOS/Windows it works only\n inside a Linux VM, where --allow-direct to a LAN model is VM-boundary-sensitive.\n";
1539
+ export declare const HELP = "anon-pi - run pi on anonymized, jailed machines (netcage: forced egress + one direct local model)\n\nUSAGE\n anon-pi MENU: pick a project (pi), a shell, or a new project\n anon-pi <project> pi in the project (/projects/<project>); exit pi -> host\n anon-pi <project> <pi-args\u2026> forward args to pi (e.g. -p for a headless one-shot)\n anon-pi --session <id> resume a pi session by id, in its own project (also -r/--resume)\n anon-pi <project> --fork <id> fork a session into <project> (`.`=root; --continue too; project required)\n anon-pi --list-models list the models pi sees (also --models; no project needed)\n anon-pi pi <pi-args\u2026> run pi with ANY args and no project (the passthrough)\n anon-pi --version print anon-pi's version (also -V)\n anon-pi --shell [<project>] a jailed bash (at /projects, or cd'd into <project>) - the project-hopper\n anon-pi forward [<p>] [--port \u2026] open a host port onto a running container's in-jail server\n anon-pi ports [<project>] list a running container's open in-jail TCP listeners\n anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)\n anon-pi -i <ref> [<p>] run against <ref> for THIS launch only (also --image; ephemeral)\n anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root\n anon-pi init onboard: verify your proxy, capture your local model, pick an image\n anon-pi machine \u2026 manage machines (create / list / set-image / rm)\n anon-pi image \u2026 snapshot a running container into an image; list anon-pi images\n anon-pi --delete-home [<m>] delete a machine's home (config + convos); keep its image pin + files\n anon-pi --delete-project <p> delete a project's files + its per-machine sessions; keep the homes\n\n <project> a folder under the projects root (mounted at /projects; pi's cwd). `.` means\n the root itself (a scratch pi at /projects, /work for --mount, or ~).\n\n -i <ref>, --image <ref> EPHEMERAL per-launch image override, highest priority\n (`-i` > the machine's machine.json image > ANON_PI_IMAGE). It picks the\n IMAGE for this launch only; `-m` picks the HOME, and the two compose.\n It NEVER changes machine.json (to re-pin a machine's image, use\n `anon-pi machine set-image` / `machine create --image`). No mismatch\n warning is printed. <ref> resolves in NETCAGE'S private image store\n (`anon-pi/<name>:latest` snapshots + `init`-built images live there),\n NOT your default podman store; anon-pi does NOT pre-check it and does\n NOT auto-pull (an anonymity tool must not silently fetch a remote\n image). A \"not found\" means the ref is not in netcage's store: snapshot\n it (`anon-pi image snapshot <name>`) or build it into that store.\n On a FRESH machine home `-i` is REFUSED (it would seed the home from\n the wrong image); establish the machine's image with\n `anon-pi machine create <m> --image <ref>` first.\n\n Every launch is THROWAWAY: the container is removed on exit. To persist system\n state you built in a session, snapshot the running container into a named image\n (`anon-pi image snapshot <name>`) and pin a machine to it (`anon-pi machine\n create <m> --image anon-pi/<name>:latest`). Your pi config + conversations live\n in the machine home (a host mount) and persist regardless.\n\nWHAT IT DOES\n Runs pi inside netcage with all web/DNS egress forced through the socks5h proxy\n (fail-closed) and ONE direct hole to your local model (ANON_PI_LLM). A MACHINE\n is an image + a persistent HOST home (bind-mounted at /root) holding your pi\n config, extensions, and conversations; the container is disposable (throwaway),\n so it loses nothing. Files (projects) are global by default; conversations are\n per-machine. On a FRESH machine home the image's staged defaults + your\n models.json are seeded in once; after that pi owns the home. Requires `netcage`.\n\nENVIRONMENT\n ANON_PI_PROXY (required) socks5h URL of your proxy (Tor/wireproxy/ssh -D).\n No default: the proxy is what anonymizes, so it is never guessed.\n ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model\n ANON_PI_IMAGE image with `pi` on PATH, used when a machine has no image set.\n No image yet? See the README (Providing a pi image).\n ANON_PI_HOME anon-pi workspace dir (default ~/.anon-pi; NOT under ~/.config)\n ANON_PI_PROJECTS projects root override (host dir mounted at /projects)\n\nPLATFORM\n Linux only (via netcage's netns/nft jail). On macOS/Windows it works only\n inside a Linux VM, where --allow-direct to a LAN model is VM-boundary-sensitive.\n";
1458
1540
  //# sourceMappingURL=anon-pi.d.ts.map