anon-pi 0.13.0 → 0.15.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/src/anon-pi.ts CHANGED
@@ -1579,21 +1579,22 @@ export function keyProject(fields: KeptKeyFields): string {
1579
1579
  }
1580
1580
 
1581
1581
  /**
1582
- * PURE: pick the RUNNING anon-pi containers a `forward`/`ports` should offer.
1583
- * Filters the supplied running managed containers (each with its decoded key
1584
- * fields) to those on `machine`, optionally narrowed to `project` (its leaf cwd
1585
- * name). With no project, every anon-pi container on the machine qualifies. The
1586
- * caller resolves 0 (error) / 1 (auto) / many (picker).
1582
+ * PURE: pick the RUNNING anon-pi containers a `forward`/`ports`/`snapshot` should
1583
+ * offer. Filters the supplied running managed containers (each with its decoded
1584
+ * key fields) OPTIONALLY by `machine` (undefined = every machine qualifies, used
1585
+ * by `snapshot` where the machine is only a narrowing filter) and OPTIONALLY by
1586
+ * `project` (its leaf cwd name). The caller resolves 0 (error) / 1 (auto) / many
1587
+ * (picker).
1587
1588
  */
1588
1589
  export function resolveManagedMatches(args: {
1589
1590
  containers: readonly ManagedContainer[];
1590
- machine: string;
1591
+ machine?: string;
1591
1592
  project?: string;
1592
1593
  }): ManagedContainer[] {
1593
1594
  const {containers, machine, project} = args;
1594
1595
  return containers.filter((c) => {
1595
1596
  const f = parseKeptKey(c.key);
1596
- if (f.machine !== machine) return false;
1597
+ if (machine !== undefined && f.machine !== machine) return false;
1597
1598
  if (project !== undefined && keyProject(f) !== project) return false;
1598
1599
  return true;
1599
1600
  });
@@ -2032,6 +2033,71 @@ export function deriveProjectUsage(args: {
2032
2033
  });
2033
2034
  }
2034
2035
 
2036
+ /**
2037
+ * ONE session group a `machine snapshot` can carry over: a `sessions/<slug>/`
2038
+ * dir in the source home. `project` is the project name when the slug matches a
2039
+ * known project's `projectSessionSlug` (else undefined: an ORPHAN slug with no
2040
+ * matching project, still offered, labelled by its raw slug so nothing hides).
2041
+ * `label` is the human row text. `slug` is the exact dir name to copy/delete.
2042
+ */
2043
+ export interface SnapshotSessionGroup {
2044
+ slug: string;
2045
+ project?: string;
2046
+ label: string;
2047
+ }
2048
+
2049
+ /**
2050
+ * PURE: the cpSync filter predicate for a snapshot's "copy the home MINUS the
2051
+ * sessions subtree" copy: true = copy `src`, false = skip it. It rejects the
2052
+ * sessions dir itself and everything beneath it (`<sessionsDir>` and
2053
+ * `<sessionsDir>/...`), and copies everything else. Extracted so the
2054
+ * home-minus-sessions contract is unit-testable without the fs.
2055
+ */
2056
+ export function copyIncludesForHomeMinusSessions(
2057
+ src: string,
2058
+ sessionsDir: string,
2059
+ ): boolean {
2060
+ return src !== sessionsDir && !src.startsWith(sessionsDir + '/');
2061
+ }
2062
+
2063
+ /**
2064
+ * PURE: map the session-dir slugs PRESENT under a source machine's `sessions/`
2065
+ * to per-project rows a snapshot's carry-over picker offers. For each present
2066
+ * slug, if it equals `projectSessionSlug(<project>)` for a known project, it is a
2067
+ * PROJECT row (labelled by the project name); otherwise an ORPHAN-slug row
2068
+ * (labelled by the raw slug, so a session with no current project folder is
2069
+ * still shown, never silently dropped). Rows are sorted: named projects first
2070
+ * (case-insensitive by name), then orphan slugs (by slug), for a stable picker.
2071
+ * The caller (CLI) does the actual copy/delete of each chosen slug dir.
2072
+ */
2073
+ export function snapshotSessionGroups(args: {
2074
+ presentSlugs: readonly string[];
2075
+ projects: readonly string[];
2076
+ }): SnapshotSessionGroup[] {
2077
+ const slugToProject = new Map<string, string>();
2078
+ for (const p of args.projects) {
2079
+ // projectSessionSlug validates the name; a bad project name throws, which is
2080
+ // correct (the projects list comes from real folder names).
2081
+ slugToProject.set(projectSessionSlug(p), p);
2082
+ }
2083
+ const rows: SnapshotSessionGroup[] = args.presentSlugs.map((slug) => {
2084
+ const project = slugToProject.get(slug);
2085
+ return project !== undefined
2086
+ ? {slug, project, label: project}
2087
+ : {slug, label: `${slug} (no current project folder)`};
2088
+ });
2089
+ const lc = (s: string): string => s.toLowerCase();
2090
+ return rows.sort((a, b) => {
2091
+ // named projects before orphan slugs; within each, by their label key.
2092
+ const an = a.project !== undefined ? 0 : 1;
2093
+ const bn = b.project !== undefined ? 0 : 1;
2094
+ if (an !== bn) return an - bn;
2095
+ const ak = lc(a.project ?? a.slug);
2096
+ const bk = lc(b.project ?? b.slug);
2097
+ return ak < bk ? -1 : ak > bk ? 1 : 0;
2098
+ });
2099
+ }
2100
+
2035
2101
  /**
2036
2102
  * What ONE selectable menu row launches, so the CLI can dispatch a chosen entry
2037
2103
  * without re-deriving anything:
@@ -2907,16 +2973,18 @@ export function anonPiVersion(): string | undefined {
2907
2973
  * - `set-image <name> <ref>`: name validated; the new image ref (non-empty).
2908
2974
  * - `rm <name> [--yes]`: name validated; `yes` skips the confirm (the CLI
2909
2975
  * still enforces the non-TTY abort when `yes` is false).
2910
- * - `snapshot <machine> <new-name> [--image-tag <ref>]`: both names validated;
2911
- * commits the source machine's running container into a new image and
2912
- * creates <new-name> pinned to it (the CLI does the netcage commit + create).
2976
+ * - `snapshot <new-name> [-m <machine>] [--image-tag <ref>]`: the sole
2977
+ * positional is the NEW machine name (validated); `-m <machine>` is an
2978
+ * OPTIONAL filter (which running container to commit when several are up),
2979
+ * NOT a required source. The CLI auto-detects the running container (picker
2980
+ * when several match), commits it, and creates <new-name> pinned to it.
2913
2981
  */
2914
2982
  export type MachineCommand =
2915
2983
  | {verb: 'create'; name: string; image?: string}
2916
2984
  | {verb: 'list'}
2917
2985
  | {verb: 'set-image'; name: string; image: string}
2918
2986
  | {verb: 'rm'; name: string; yes: boolean}
2919
- | {verb: 'snapshot'; source: string; name: string; imageTag?: string};
2987
+ | {verb: 'snapshot'; name: string; machine?: string; imageTag?: string};
2920
2988
 
2921
2989
  /**
2922
2990
  * PURE: parse the tokens AFTER `machine` into a MachineCommand. Validates the
@@ -3013,15 +3081,22 @@ export function parseMachineArgs(args: readonly string[]): MachineCommand {
3013
3081
  }
3014
3082
 
3015
3083
  if (verb === 'snapshot') {
3016
- // snapshot <source-machine> <new-name> [--image-tag <ref>]: commit the
3017
- // source machine's running container into a new image and create <new-name>
3018
- // pinned to it. Both names are validated (safe machine segments); the
3019
- // image tag, if given, is a free-form ref.
3020
- let source: string | undefined;
3084
+ // snapshot <new-name> [-m <machine>] [--image-tag <ref>]: commit a RUNNING
3085
+ // container into a new image and create <new-name> pinned to it. The sole
3086
+ // positional is the new machine name; `-m` is an OPTIONAL filter (which
3087
+ // container when several are up), not a required source. The CLI auto-detects
3088
+ // the container (picker when several match).
3021
3089
  let name: string | undefined;
3090
+ let machine: string | undefined;
3022
3091
  let imageTag: string | undefined;
3023
3092
  for (let i = 0; i < rest.length; i++) {
3024
3093
  const a = rest[i];
3094
+ if (a === '-m' || a === '--machine') {
3095
+ const v = rest[++i];
3096
+ if (v === undefined) fail(`${a} needs a machine name`);
3097
+ machine = validateName(v as string, 'machine');
3098
+ continue;
3099
+ }
3025
3100
  if (a === '--image-tag') {
3026
3101
  const v = rest[++i];
3027
3102
  if (v === undefined) fail('--image-tag needs an image ref');
@@ -3029,20 +3104,15 @@ export function parseMachineArgs(args: readonly string[]): MachineCommand {
3029
3104
  continue;
3030
3105
  }
3031
3106
  if (a.startsWith('-')) fail(`unknown option: ${a}`);
3032
- if (source === undefined) {
3033
- source = validateName(a, 'machine');
3034
- } else if (name === undefined) {
3035
- name = validateName(a, 'machine');
3036
- } else {
3037
- fail(`machine snapshot takes <machine> <new-name>, got extra: ${a}`);
3038
- }
3107
+ if (name !== undefined)
3108
+ fail(`machine snapshot takes one <new-name>, got extra: ${a}`);
3109
+ name = validateName(a, 'machine');
3039
3110
  }
3040
- if (source === undefined || name === undefined)
3041
- fail('machine snapshot needs a <machine> and a <new-name>');
3111
+ if (name === undefined) fail('machine snapshot needs a <new-name>');
3042
3112
  return {
3043
3113
  verb: 'snapshot',
3044
- source: source as string,
3045
3114
  name: name as string,
3115
+ machine: nonEmpty(machine),
3046
3116
  imageTag: nonEmpty(imageTag),
3047
3117
  };
3048
3118
  }
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // egress.
13
13
 
14
14
  import {
15
+ cpSync,
15
16
  existsSync,
16
17
  mkdirSync,
17
18
  readdirSync,
@@ -80,6 +81,9 @@ import {
80
81
  resolveRunVsStart,
81
82
  serializeMachineJson,
82
83
  snapshotImageRef,
84
+ snapshotSessionGroups,
85
+ copyIncludesForHomeMinusSessions,
86
+ type SnapshotSessionGroup,
83
87
  serializeConfigJson,
84
88
  setImageWarning,
85
89
  keptContainerKey,
@@ -682,7 +686,7 @@ function runMachine(machineArgs: string[]): number {
682
686
  case 'rm':
683
687
  return machineRm(env, cmd.name, cmd.yes);
684
688
  case 'snapshot':
685
- return machineSnapshot(env, cmd.source, cmd.name, cmd.imageTag);
689
+ return machineSnapshot(env, cmd.name, cmd.machine, cmd.imageTag);
686
690
  }
687
691
  } catch (e) {
688
692
  return reportAnonPiError(e);
@@ -831,21 +835,23 @@ function machineRm(env: AnonPiEnv, name: string, yes: boolean): number {
831
835
  }
832
836
 
833
837
  /**
834
- * `machine snapshot <source> <new-name> [--image-tag <ref>]`: commit the source
835
- * machine's RUNNING jailed container into a new image and create <new-name>
836
- * pinned to it. The user does interactive system work in a session (e.g.
837
- * `sudo apt install`), then, WITHOUT having exited (the default `--rm` would
838
- * have destroyed the container), preserves that exact environment as a new
839
- * machine. podman pauses the container during commit and unpauses, so the live
840
- * session survives. The new machine gets a FRESH home (image and home are
841
- * orthogonal; snapshot keeps the SOFTWARE, not the conversations). Forced egress
842
- * is untouched: commit is a local podman op, and the snapshot machine relaunches
838
+ * `machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]`: commit a
839
+ * RUNNING jailed container into a new image and create <new-name> pinned to it.
840
+ * The user does interactive system work in a session (e.g. `sudo apt install`),
841
+ * then, WITHOUT having exited (the default `--rm` would have destroyed the
842
+ * container), preserves that exact environment as a new machine. The container
843
+ * to commit is AUTO-DETECTED from the running anon-pi containers (a picker when
844
+ * several are up); `-m <machine>` is an OPTIONAL filter, not a required source.
845
+ * podman pauses the container during commit and unpauses, so the live session
846
+ * survives. The new machine gets a FRESH home (image and home are orthogonal;
847
+ * snapshot keeps the SOFTWARE, not the conversations). Forced egress is
848
+ * untouched: commit is a local podman op, and the snapshot machine relaunches
843
849
  * through the same forced-egress jail.
844
850
  */
845
851
  function machineSnapshot(
846
852
  env: AnonPiEnv,
847
- source: string,
848
853
  name: string,
854
+ machine: string | undefined,
849
855
  imageTag: string | undefined,
850
856
  ): number {
851
857
  // Refuse to clobber an existing target machine FIRST (before netcage / any
@@ -862,9 +868,9 @@ function machineSnapshot(
862
868
 
863
869
  if (!hasNetcage()) return netcageMissing();
864
870
 
865
- // Resolve the ONE running anon-pi container for the SOURCE machine (reuse the
866
- // forward/ports running-container resolution, scoped by machine).
867
- const target = resolveRunningContainer(source, 'snapshot');
871
+ // Auto-detect the running anon-pi container to commit (optionally filtered by
872
+ // -m <machine>); reuses the forward/ports running-container resolution.
873
+ const target = resolveRunningContainer(machine, 'snapshot');
868
874
  if (target === undefined) return 1;
869
875
 
870
876
  const imageRef = imageTag ?? snapshotImageRef(name, new Date());
@@ -879,21 +885,133 @@ function machineSnapshot(
879
885
  return committed;
880
886
  }
881
887
 
882
- // Create the machine pinned to the committed image (its home seeds on first
883
- // launch, exactly like `machine create`).
888
+ // Create the machine pinned to the committed image.
884
889
  mkdirSync(machineHomeDir(env, name), {recursive: true});
885
890
  writeFileSync(
886
891
  machineJsonPath(env, name),
887
892
  serializeMachineJson({image: imageRef}),
888
893
  );
894
+
895
+ // The source machine is the machine of the container we committed (its stamped
896
+ // key carries it). Copy its home into the new machine's home, EXCEPT the
897
+ // sessions subtree (conversations are handled separately below). Copying the
898
+ // config/extensions is safe + preferable to a fresh seed here: the new image IS
899
+ // the committed source filesystem, so the home's extensions/binaries are
900
+ // correct-for-the-new-image (and the copied seed marker means no reseed).
901
+ const sourceMachine = parseKeptKey(target.key).machine;
902
+ if (sourceMachine !== undefined) {
903
+ copyHomeMinusSessions(env, sourceMachine, name);
904
+ }
905
+
889
906
  process.stdout.write(
890
- `anon-pi: snapshotted machine ${JSON.stringify(source)} into ${JSON.stringify(name)} ` +
907
+ `anon-pi: snapshotted ${target.name} into machine ${JSON.stringify(name)} ` +
891
908
  `(image ${imageRef}) at ${targetDir}.\n` +
892
- `Its home seeds fresh on first launch, e.g. \`anon-pi -m ${name} --shell\`.\n`,
909
+ (sourceMachine !== undefined
910
+ ? `Copied ${JSON.stringify(sourceMachine)}'s home (config + extensions) into it.\n`
911
+ : 'Its home seeds fresh on first launch.\n'),
893
912
  );
913
+
914
+ // Offer the source's conversation history, grouped by project, opt-in per
915
+ // project (default none). TTY only; a non-TTY snapshot carries no sessions.
916
+ if (sourceMachine !== undefined) {
917
+ carryOverSessions(env, sourceMachine, name);
918
+ }
894
919
  return 0;
895
920
  }
896
921
 
922
+ /**
923
+ * Recursively copy machine <source>'s home into machine <dest>'s home, EXCLUDING
924
+ * the `.pi/agent/sessions/` subtree (conversations are carried over separately,
925
+ * per-project, opt-in). Best-effort: an absent source home is a no-op (the dest
926
+ * just stays fresh). Uses cpSync with a filter that rejects the sessions dir and
927
+ * anything under it.
928
+ */
929
+ function copyHomeMinusSessions(
930
+ env: AnonPiEnv,
931
+ source: string,
932
+ dest: string,
933
+ ): void {
934
+ const srcHome = machineHomeDir(env, source);
935
+ if (!existsSync(srcHome)) return;
936
+ const destHome = machineHomeDir(env, dest);
937
+ const sessionsPath = machineSessionsDir(env, source);
938
+ cpSync(srcHome, destHome, {
939
+ recursive: true,
940
+ // Exclude the sessions dir itself and everything beneath it (pure predicate).
941
+ filter: (src) => copyIncludesForHomeMinusSessions(src, sessionsPath),
942
+ });
943
+ }
944
+
945
+ /**
946
+ * Offer the source machine's pi conversation history (grouped BY PROJECT) as an
947
+ * opt-in carry-over into the new machine. Each present `sessions/<slug>/` group
948
+ * is a project row (or an orphan-slug row); DEFAULT all UNSELECTED, per-project
949
+ * COPY or SKIP. Copy duplicates that session dir into the new home. There is NO
950
+ * per-row move; after the copies, ONE confirmed (default No) step can delete the
951
+ * copied groups from the SOURCE home (the only "move"). No-TTY: copy nothing.
952
+ */
953
+ function carryOverSessions(env: AnonPiEnv, source: string, dest: string): void {
954
+ const presentSlugs = readDirNames(machineSessionsDir(env, source));
955
+ if (presentSlugs.length === 0) return;
956
+
957
+ if (!process.stdin.isTTY) {
958
+ process.stderr.write(
959
+ `anon-pi: ${presentSlugs.length} conversation group(s) on ${JSON.stringify(source)} ` +
960
+ 'were NOT copied (no TTY to choose). The new machine starts with no history.\n',
961
+ );
962
+ return;
963
+ }
964
+
965
+ // Label rows by project name (matching the machine-invariant slug); an orphan
966
+ // slug with no current project folder is still offered by its raw slug.
967
+ const config = readJsonConfig(env);
968
+ const projectsRoot = resolveProjectsRoot({env, config});
969
+ const groups = snapshotSessionGroups({
970
+ presentSlugs,
971
+ projects: readDirNames(projectsRoot),
972
+ });
973
+
974
+ process.stderr.write(
975
+ `anon-pi: ${JSON.stringify(source)} has ${groups.length} conversation group(s) ` +
976
+ '(by project). Choose COPY or SKIP for each (default SKIP):\n',
977
+ );
978
+ const copied: SnapshotSessionGroup[] = [];
979
+ for (const g of groups) {
980
+ const ans = promptLine(` ${g.label} [copy/SKIP]: `);
981
+ if (ans !== undefined && /^c(opy)?$/i.test(ans.trim())) {
982
+ const from = join(machineSessionsDir(env, source), g.slug);
983
+ const to = join(machineSessionsDir(env, dest), g.slug);
984
+ mkdirSync(machineSessionsDir(env, dest), {recursive: true});
985
+ cpSync(from, to, {recursive: true});
986
+ copied.push(g);
987
+ }
988
+ }
989
+
990
+ if (copied.length === 0) {
991
+ process.stderr.write('anon-pi: no conversation groups copied.\n');
992
+ return;
993
+ }
994
+ process.stderr.write(
995
+ `anon-pi: copied ${copied.length} conversation group(s) into ${JSON.stringify(dest)}.\n`,
996
+ );
997
+
998
+ // The ONLY "move": an explicit, confirmed, default-No delete from the SOURCE.
999
+ const ans = promptLine(
1000
+ `Also DELETE the ${copied.length} copied group(s) from source machine ${JSON.stringify(source)}? [y/N] `,
1001
+ );
1002
+ if (ans !== undefined && /^y(es)?$/i.test(ans.trim())) {
1003
+ for (const g of copied) {
1004
+ rmSync(join(machineSessionsDir(env, source), g.slug), {
1005
+ recursive: true,
1006
+ force: true,
1007
+ });
1008
+ }
1009
+ process.stderr.write(
1010
+ `anon-pi: removed ${copied.length} group(s) from ${JSON.stringify(source)}.\n`,
1011
+ );
1012
+ }
1013
+ }
1014
+
897
1015
  // --- the destructive cleanup verbs (thin I/O over the pure resolvers) --------
898
1016
  //
899
1017
  // `--delete-home [<machine>]` and `--delete-project <project>` REPLACE the old
@@ -2114,22 +2232,29 @@ USAGE
2114
2232
  anon-pi machine list list machines and their images
2115
2233
  anon-pi machine set-image <name> <ref> re-pin the image (WARNS; no reseed)
2116
2234
  anon-pi machine rm <name> [--yes] delete the machine + its home
2117
- anon-pi machine snapshot <machine> <new-name> [--image-tag <ref>]
2118
- commit <machine>'s RUNNING container
2119
- into a new image + create <new-name>
2235
+ anon-pi machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]
2236
+ commit a RUNNING container into a
2237
+ new image + create <new-name>
2120
2238
 
2121
2239
  A machine is an image + a persistent host home (machines/<name>/{machine.json,home/}).
2122
2240
  The home is seeded on FIRST LAUNCH, not at create. \`set-image\` re-pins only and
2123
2241
  warns (the home was built for the old image); \`rm\` confirms on a TTY, skips with
2124
2242
  \`--yes\`, and aborts non-interactively without it.
2125
2243
 
2126
- \`snapshot\` captures the CURRENT filesystem of <machine>'s running jailed
2127
- container (e.g. after \`sudo apt install\`) into a new image and creates
2128
- <new-name> pinned to it, so you can preserve an environment you built
2129
- interactively WITHOUT having pre-decided \`--keep\`. The container must still be
2130
- RUNNING (do not exit the session); podman pauses it briefly during the commit.
2131
- The new machine gets a FRESH home (the image is the software, the home is
2132
- separate). Same forced-egress jail on relaunch.
2244
+ \`snapshot\` captures the CURRENT filesystem of a RUNNING jailed container (e.g.
2245
+ after \`sudo apt install\`) into a new image and creates <new-name> pinned to it,
2246
+ so you can preserve an environment you built interactively WITHOUT having
2247
+ pre-decided \`--keep\`. The container is auto-detected from the running anon-pi
2248
+ containers (a picker when several are up); \`-m <machine>\` is an OPTIONAL filter,
2249
+ not a required source. The container must still be RUNNING (do not exit the
2250
+ session); podman pauses it briefly during the commit. Same forced-egress jail on
2251
+ relaunch.
2252
+
2253
+ The source machine's HOME is copied into the new machine (config + extensions +
2254
+ dotfiles), MINUS its conversations. Conversations are offered separately, grouped
2255
+ BY PROJECT, opt-in per project (default SKIP), COPY or SKIP each (no TTY => none
2256
+ copied). After copying, one confirmed step (default No) can DELETE the copied
2257
+ groups from the source machine (the only "move"); COPY never touches the source.
2133
2258
  `;
2134
2259
 
2135
2260
  // --- impure helpers ---------------------------------------------------------
@@ -2303,14 +2428,16 @@ function resolveForwardTarget(
2303
2428
  }
2304
2429
 
2305
2430
  /**
2306
- * Resolve the ONE running anon-pi container for a MACHINE (no project scope, no
2307
- * port hints), for `machine snapshot`. Filters the running managed containers by
2308
- * machine: 0 => error (start a session first), 1 => it, many => an arrow-key
2309
- * picker labelled by each container's project + name. Returns undefined on
2310
- * no-match or a cancelled pick (reason printed). TTY needed only for the picker.
2431
+ * Resolve the ONE running anon-pi container to act on, for `machine snapshot`.
2432
+ * The container is what matters; `machine` is an OPTIONAL narrowing filter
2433
+ * (undefined = every running anon-pi container qualifies). 0 => error (start a
2434
+ * session first), 1 => it, many => an arrow-key picker labelled by each
2435
+ * container's machine + project + name (so a cross-machine list is
2436
+ * distinguishable). Returns undefined on no-match or a cancelled pick (reason
2437
+ * printed). TTY needed only for the picker.
2311
2438
  */
2312
2439
  function resolveRunningContainer(
2313
- machine: string,
2440
+ machine: string | undefined,
2314
2441
  verb: string,
2315
2442
  ): ManagedContainer | undefined {
2316
2443
  const matches = resolveManagedMatches({
@@ -2318,10 +2445,12 @@ function resolveRunningContainer(
2318
2445
  machine,
2319
2446
  project: undefined,
2320
2447
  });
2448
+ const scope =
2449
+ machine !== undefined ? ` for machine ${JSON.stringify(machine)}` : '';
2321
2450
  if (matches.length === 0) {
2322
2451
  process.stderr.write(
2323
- `anon-pi: no running anon-pi container for machine ${JSON.stringify(machine)}. ` +
2324
- `Start a session (e.g. \`anon-pi -m ${machine} --shell\`), do your work, and ` +
2452
+ `anon-pi: no running anon-pi container${scope}. ` +
2453
+ 'Start a session (e.g. `anon-pi <project>`), do your work, and ' +
2325
2454
  `WITHOUT exiting run \`anon-pi machine ${verb}\` from another terminal.\n`,
2326
2455
  );
2327
2456
  return undefined;
@@ -2330,15 +2459,20 @@ function resolveRunningContainer(
2330
2459
 
2331
2460
  if (!process.stdin.isTTY) {
2332
2461
  process.stderr.write(
2333
- `anon-pi: ${matches.length} running containers on machine ${JSON.stringify(machine)}; ` +
2334
- 'a terminal is needed to pick one.\n',
2462
+ `anon-pi: ${matches.length} running containers${scope}; a terminal is needed ` +
2463
+ 'to pick one (or narrow with `-m <machine>`).\n',
2335
2464
  );
2336
2465
  return undefined;
2337
2466
  }
2338
2467
  const entries: MenuEntry[] = matches.map((c) => {
2339
- const proj = keyProject(parseKeptKey(c.key));
2468
+ const f = parseKeptKey(c.key);
2469
+ const proj = keyProject(f);
2340
2470
  const label = proj === '' ? '(shell)' : proj;
2341
- return {kind: 'project', project: c.ref, label: `${label} [${c.name}]`};
2471
+ return {
2472
+ kind: 'project',
2473
+ project: c.ref,
2474
+ label: `${f.machine ?? '?'} / ${label} [${c.name}]`,
2475
+ };
2342
2476
  });
2343
2477
  const picked = select(entries, {
2344
2478
  header: `anon-pi: pick a container to ${verb} (\u2191/\u2193 move, Enter select, Ctrl-C quit)`,