anon-pi 0.6.0 → 0.8.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
@@ -30,7 +30,7 @@
30
30
  // init's proxy detect/verify decisions). cli.ts owns only the impure edges (fs,
31
31
  // the interactive TUI, the netcage query, the spawn).
32
32
 
33
- import {existsSync} from 'node:fs';
33
+ import {existsSync, readFileSync} from 'node:fs';
34
34
  import {homedir} from 'node:os';
35
35
  import {dirname, join, resolve} from 'node:path';
36
36
  import {fileURLToPath} from 'node:url';
@@ -237,6 +237,67 @@ export function machineJsonPath(env: AnonPiEnv, name: string): string {
237
237
  return join(machineDir(env, name), 'machine.json');
238
238
  }
239
239
 
240
+ /**
241
+ * The GLOBAL local-model models.json seed: `<home>/models.json`. The local model
242
+ * is a WORKSPACE-level thing (config.json holds ONE global `llm`, the single
243
+ * `--allow-direct` hole shared by every machine), so its generated models.json
244
+ * lives once at the workspace root and seeds EVERY machine's fresh home. A
245
+ * machine may override it with its own `machines/<M>/models.json` (see
246
+ * resolveModelsSeedPath) for the rare "this machine uses a different local
247
+ * model" case; by default all machines share this one.
248
+ */
249
+ export function globalModelsSeedPath(env: AnonPiEnv): string {
250
+ return join(resolveAnonPiHome(env), MODELS_FILE);
251
+ }
252
+
253
+ /** The GLOBAL settings seed (the default-model selection): `<home>/settings-seed.json`. */
254
+ export function globalSettingsSeedPath(env: AnonPiEnv): string {
255
+ return join(resolveAnonPiHome(env), SETTINGS_SEED_FILE);
256
+ }
257
+
258
+ /** A machine's OPTIONAL per-machine models.json override: `machines/<M>/models.json`. */
259
+ export function machineModelsSeedPath(env: AnonPiEnv, name: string): string {
260
+ return join(machineDir(env, name), MODELS_FILE);
261
+ }
262
+
263
+ /** A machine's OPTIONAL per-machine settings seed override: `machines/<M>/settings-seed.json`. */
264
+ export function machineSettingsSeedPath(env: AnonPiEnv, name: string): string {
265
+ return join(machineDir(env, name), SETTINGS_SEED_FILE);
266
+ }
267
+
268
+ /**
269
+ * PURE: resolve the models.json SEED path for a machine, per-machine override
270
+ * first, else the global one. `exists` is injected (the CLI passes existsSync)
271
+ * so this stays pure/testable. Returns the chosen path, or undefined when
272
+ * NEITHER exists (a machine with no local-model seed at all — pi starts with no
273
+ * models). The precedence is: `machines/<M>/models.json` (a deliberate
274
+ * per-machine override) > `<home>/models.json` (the global default).
275
+ */
276
+ export function resolveModelsSeedPath(
277
+ env: AnonPiEnv,
278
+ machine: string,
279
+ exists: (p: string) => boolean,
280
+ ): string | undefined {
281
+ const perMachine = machineModelsSeedPath(env, machine);
282
+ if (exists(perMachine)) return perMachine;
283
+ const global = globalModelsSeedPath(env);
284
+ if (exists(global)) return global;
285
+ return undefined;
286
+ }
287
+
288
+ /** PURE: the settings-seed path for a machine (per-machine override > global), or undefined. */
289
+ export function resolveSettingsSeedPath(
290
+ env: AnonPiEnv,
291
+ machine: string,
292
+ exists: (p: string) => boolean,
293
+ ): string | undefined {
294
+ const perMachine = machineSettingsSeedPath(env, machine);
295
+ if (exists(perMachine)) return perMachine;
296
+ const global = globalSettingsSeedPath(env);
297
+ if (exists(global)) return global;
298
+ return undefined;
299
+ }
300
+
240
301
  /** The sessions dirname pi keeps its per-cwd conversation dirs under (in the agent dir). */
241
302
  export const SESSIONS_DIRNAME = 'sessions';
242
303
 
@@ -372,7 +433,9 @@ export const ROOT_TOKEN = '.';
372
433
  * reserved-name concept is explicit and extendable. `--mount`'s `/work` is a
373
434
  * CONTAINER path, not a name in this namespace, so it needs no reservation.
374
435
  */
375
- export const RESERVED_NAMES: readonly string[] = ['.', '..'];
436
+ export const RESERVED_NAMES: readonly string[] = ['.', '..', 'pi'];
437
+ // NOTE: `pi` is reserved so the `anon-pi pi <args…>` passthrough token
438
+ // (PI_PASSTHROUGH_TOKEN) can never be shadowed by a project named `pi`.
376
439
 
377
440
  /** What a name names, for a clear validation error. */
378
441
  export type NameKind = 'machine' | 'project';
@@ -462,6 +525,24 @@ export function resolveCwd(kind: RootKind, token: string): string {
462
525
  return `${rootCwd(kind)}/${validateName(token, 'project')}`;
463
526
  }
464
527
 
528
+ /**
529
+ * PURE: the launch cwd for a resolved (mode, rootKind, project). With a project
530
+ * token it resolves under the active root (resolveCwd). With NO project: a
531
+ * `shell` sits at the machine home (`/root`) — the "sit on the machine" mode —
532
+ * while `pi` (a `--session`/`--resume` launch that pi cwd-switches itself) starts
533
+ * at the projects root (`rootCwd`), a real pi launch position. `menu` never
534
+ * reaches here (it is argv-less). Shared by resolveRunPlan + keptContainerKey so
535
+ * the run cwd and the container-identity key always agree.
536
+ */
537
+ export function launchCwd(
538
+ mode: LaunchMode,
539
+ kind: RootKind,
540
+ project: string | undefined,
541
+ ): string {
542
+ if (project !== undefined) return resolveCwd(kind, project);
543
+ return mode === 'shell' ? CONTAINER_HOME_ROOT : rootCwd(kind);
544
+ }
545
+
465
546
  /** Parsed shape of config.json. All fields optional (a hand-edited file may omit any). */
466
547
  export interface AnonPiConfig {
467
548
  /** socks5h proxy URL. */
@@ -721,14 +802,105 @@ export interface ParsedLaunch {
721
802
  piArgs?: string[];
722
803
  }
723
804
 
805
+ /**
806
+ * pi flags that make sense with NO anon-pi project, so `anon-pi <flag> ...`
807
+ * launches pi (at the projects root) and forwards this flag + everything after
808
+ * it verbatim. Two families:
809
+ * - SESSION selection (`--session <id>` etc.): pi finds the session file (in the
810
+ * always-mounted machine home) and switches to its own project cwd, so no
811
+ * project is needed. Mirrors pi's own resume hint (`pi --session <id>`), so
812
+ * pasting `anon-pi --session <id>` just works.
813
+ * - QUERY (`--list-models`/`--models`): pi prints + exits, no project relevant.
814
+ * For arbitrary pi flags with no project (e.g. `--model x`), use the explicit
815
+ * `anon-pi pi <args…>` passthrough instead.
816
+ */
817
+ const PI_NO_PROJECT_FLAGS: ReadonlySet<string> = new Set([
818
+ // session selection
819
+ '--session',
820
+ '--session-id',
821
+ '--resume',
822
+ '-r',
823
+ '--continue',
824
+ '-c',
825
+ '--fork',
826
+ // query-and-exit
827
+ '--list-models',
828
+ '--models',
829
+ ]);
830
+
831
+ /** True iff `a` is a pi flag anon-pi accepts with no project (see PI_NO_PROJECT_FLAGS). */
832
+ function isPiNoProjectFlag(a: string): boolean {
833
+ return PI_NO_PROJECT_FLAGS.has(a);
834
+ }
835
+
836
+ /**
837
+ * The explicit pi-passthrough token: `anon-pi pi <args…>` runs pi with the given
838
+ * args and NO project (the general escape hatch for any pi flag). It is a
839
+ * RESERVED project name (see RESERVED_NAMES) so a project can never shadow it.
840
+ */
841
+ export const PI_PASSTHROUGH_TOKEN = 'pi';
842
+
843
+ /**
844
+ * PURE: whether forwarded pi args request pi's NON-INTERACTIVE (print) mode,
845
+ * i.e. contain `-p`/`--print`. This is the ONLY headless shape (it needs no
846
+ * TTY): other forwarded args (`--session <id>`, `--model x`, ...) are still
847
+ * INTERACTIVE and need a TTY + `-it`. Shared by the CLI's no-TTY discipline and
848
+ * the RunPlan's `-it` decision so they agree.
849
+ */
850
+ export function isHeadlessPiArgs(
851
+ piArgs: readonly string[] | undefined,
852
+ ): boolean {
853
+ return !!piArgs && piArgs.some((a) => a === '-p' || a === '--print');
854
+ }
855
+
856
+ /**
857
+ * Finish parsing a NO-PROJECT pi launch (`anon-pi --session <id> ...`,
858
+ * `anon-pi --list-models`, or the explicit `anon-pi pi <args…>`): pi mode, NO
859
+ * project (pi picks its own cwd / prints + exits), the flag(s) + rest forwarded.
860
+ * `--shell` is incompatible (a shell forwards no pi args).
861
+ */
862
+ function finishPiNoProjectLaunch(args: {
863
+ machine: string;
864
+ machineExplicit: boolean;
865
+ mountParent?: string;
866
+ keep: boolean;
867
+ rm: boolean;
868
+ shell: boolean;
869
+ piArgs: string[];
870
+ fail: (msg: string) => never;
871
+ }): ParsedLaunch {
872
+ if (args.keep && args.rm) {
873
+ args.fail(
874
+ '--keep and --rm are contradictory (pick one; --rm is the default)',
875
+ );
876
+ }
877
+ if (args.shell) {
878
+ args.fail(
879
+ '--shell forwards no pi args (a shell has no session/query). Drop --shell.',
880
+ );
881
+ }
882
+ return {
883
+ mode: 'pi',
884
+ machine: args.machine,
885
+ machineExplicit: args.machineExplicit,
886
+ project: undefined,
887
+ mountParent: args.mountParent,
888
+ keep: args.keep,
889
+ piArgs: args.piArgs,
890
+ };
891
+ }
892
+
724
893
  /**
725
894
  * PURE: parse grammar A into a ParsedLaunch. Consumes the anon-pi flags
726
895
  * (`-m <machine>`, `--shell`, `--mount <parent>`, `--keep`/`--rm`) LEFT of the
727
896
  * project positional; the FIRST bare positional is the project (`.` allowed as
728
897
  * the root token). In pi mode every token AFTER the project is forwarded to pi
729
898
  * verbatim (so `anon-pi recon -p '...'` works) — anon-pi flags must come before
730
- * the project. In shell/menu mode a stray extra positional is an error (bash has
731
- * no forwarded-args grammar; the menu takes no project).
899
+ * the project. A pi session-resume flag (`--session <id>`, `--continue`,
900
+ * `--resume`, `--fork <id>`) in the project position starts a NO-project pi
901
+ * launch that forwards to pi (pi resolves the session + cwd itself). In
902
+ * shell/menu mode a stray extra positional is an error (bash has no
903
+ * forwarded-args grammar; the menu takes no project).
732
904
  *
733
905
  * Validates the project name and the `-m` machine name via validateName (the
734
906
  * reserved-name guard); `--mount <parent>` is a HOST path in its own namespace,
@@ -784,6 +956,44 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
784
956
  i++;
785
957
  break;
786
958
  }
959
+ if (a === PI_PASSTHROUGH_TOKEN) {
960
+ // `anon-pi pi <args…>`: the explicit passthrough. Run pi with the
961
+ // following args and NO project (pi picks its own cwd, or prints + exits
962
+ // for a query). The general escape hatch for ANY pi flag with no project
963
+ // (`anon-pi pi --model x`, `anon-pi pi --export out.html --session <id>`).
964
+ return finishPiNoProjectLaunch({
965
+ machine,
966
+ machineExplicit: machineSet,
967
+ mountParent,
968
+ keep: keepSeen,
969
+ rm: rmSeen,
970
+ shell,
971
+ piArgs: args.slice(i + 1),
972
+ fail,
973
+ });
974
+ }
975
+ if (isPiNoProjectFlag(a)) {
976
+ // A pi flag that needs NO anon-pi project (`--session <id>`/`--continue`/
977
+ // `--fork` resume by id; `--list-models`/`--models` print + exit). pi
978
+ // resolves its own cwd (or just prints), so anon-pi launches pi at the
979
+ // projects root and forwards this flag + everything after it verbatim.
980
+ // This makes pi's own "To resume: pi --session <id>" hint usable as
981
+ // `anon-pi --session <id>`. (For ARBITRARY pi flags with no project, use
982
+ // the explicit `anon-pi pi <args…>` passthrough.)
983
+ piArgs = args.slice(i);
984
+ project = undefined;
985
+ i = args.length;
986
+ return finishPiNoProjectLaunch({
987
+ machine,
988
+ machineExplicit: machineSet,
989
+ mountParent,
990
+ keep: keepSeen,
991
+ rm: rmSeen,
992
+ shell,
993
+ piArgs,
994
+ fail,
995
+ });
996
+ }
787
997
  if (a.startsWith('-')) {
788
998
  fail(`unknown option: ${a}`);
789
999
  }
@@ -898,10 +1108,7 @@ export function resolveRunPlan(
898
1108
  // Which root the cwd resolves under: /work when --mount, else /projects.
899
1109
  const rootKind: RootKind = mounted ? 'mount' : 'projects';
900
1110
 
901
- // cwd: shell with no project sits at the machine home (/root); otherwise the
902
- // project token (a name or `.`) resolves under the active root uniformly.
903
- const cwd =
904
- project === undefined ? CONTAINER_HOME_ROOT : resolveCwd(rootKind, project);
1111
+ const cwd = launchCwd(mode, rootKind, project);
905
1112
 
906
1113
  const fresh = homeFresh(machine.home);
907
1114
  const seedVersion = intent.seedVersion ?? SEED_VERSION;
@@ -913,7 +1120,7 @@ export function resolveRunPlan(
913
1120
  // (podman fails to allocate a TTY on a non-tty stdin). The CLI's broader
914
1121
  // no-TTY discipline (erroring when an interactive mode has no TTY) is a later
915
1122
  // task; here the argv simply omits -it for the one headless shape.
916
- const headless = mode === 'pi' && !!intent.piArgs && intent.piArgs.length > 0;
1123
+ const headless = mode === 'pi' && isHeadlessPiArgs(intent.piArgs);
917
1124
 
918
1125
  const netcageArgs: string[] = ['run'];
919
1126
  // --rm by DEFAULT (throwaway); --keep leaves the container kept.
@@ -1073,13 +1280,12 @@ export type RunVsStart = {action: 'run'} | {action: 'start'; ref: string};
1073
1280
  * its internal shape is not a contract (compare only keys this function makes).
1074
1281
  */
1075
1282
  export function keptContainerKey(intent: LaunchIntent): string {
1076
- const {machine, projectsRoot, project, mountParent} = intent;
1283
+ const {machine, mode, projectsRoot, project, mountParent} = intent;
1077
1284
  const mounted = nonEmpty(mountParent) !== undefined;
1078
1285
  const rootKind: RootKind = mounted ? 'mount' : 'projects';
1079
1286
  // The same cwd resolution resolveRunPlan uses, so the key names the exact
1080
1287
  // container a matching launch would run in (its conversation key).
1081
- const cwd =
1082
- project === undefined ? CONTAINER_HOME_ROOT : resolveCwd(rootKind, project);
1288
+ const cwd = launchCwd(mode, rootKind, project);
1083
1289
  return [
1084
1290
  `machine=${machine.name}`,
1085
1291
  `projectsRoot=${projectsRoot}`,
@@ -2035,6 +2241,22 @@ function shippedFile(rel: string): string | undefined {
2035
2241
  return undefined;
2036
2242
  }
2037
2243
 
2244
+ /**
2245
+ * anon-pi's own version, read from the package.json shipped in the package root
2246
+ * (resolved via shippedFile). Returns undefined if it cannot be found/parsed, so
2247
+ * `--version` can fall back to a placeholder. Read-only.
2248
+ */
2249
+ export function anonPiVersion(): string | undefined {
2250
+ const pkg = shippedFile('package.json');
2251
+ if (!pkg) return undefined;
2252
+ try {
2253
+ const parsed = JSON.parse(readFileSync(pkg, 'utf8')) as {version?: unknown};
2254
+ return typeof parsed.version === 'string' ? parsed.version : undefined;
2255
+ } catch {
2256
+ return undefined;
2257
+ }
2258
+ }
2259
+
2038
2260
  // --- The `machine {create,list,set-image,rm}` verbs (pure parts) -------------
2039
2261
  //
2040
2262
  // Machines are first-class: an image + a persistent host home
@@ -2220,7 +2442,12 @@ export const HELP = `anon-pi - run pi on anonymized, jailed machines (netcage: f
2220
2442
  USAGE
2221
2443
  anon-pi MENU: pick a project (pi), a shell, or a new project
2222
2444
  anon-pi <project> pi in the project (${CONTAINER_PROJECTS_ROOT}/<project>); exit pi -> host
2223
- anon-pi <project> <pi-args…> forward args to pi (headless/one-shot; no TTY needed)
2445
+ anon-pi <project> <pi-args…> forward args to pi (e.g. -p for a headless one-shot)
2446
+ anon-pi --session <id> resume a pi session by id (forwarded to pi; no project needed)
2447
+ anon-pi --continue continue your most recent pi session (also -r/--resume, --fork)
2448
+ anon-pi --list-models list the models pi sees (also --models; no project needed)
2449
+ anon-pi pi <pi-args…> run pi with ANY args and no project (the passthrough)
2450
+ anon-pi --version print anon-pi's version (also -V)
2224
2451
  anon-pi --shell [<project>] a jailed bash (at ~, or cd'd into <project>) - the project-hopper
2225
2452
  anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
2226
2453
  anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
package/src/cli.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  AnonPiError,
27
27
  HELP,
28
28
  MODELS_FILE,
29
+ SETTINGS_FILE,
29
30
  SETTINGS_SEED_FILE,
30
31
  SEED_MARKER,
31
32
  DEFAULT_MACHINE,
@@ -34,15 +35,24 @@ import {
34
35
  buildMenuEntries,
35
36
  builtinProjectsRoot,
36
37
  deriveProjectUsage,
38
+ globalModelsSeedPath,
39
+ globalSettingsSeedPath,
40
+ machineAgentDir,
37
41
  machineDir,
38
42
  machineHomeDir,
39
43
  machineJsonPath,
44
+ machineModelsSeedPath,
40
45
  machineSessionsDir,
46
+ mergeModelSelection,
47
+ resolveModelsSeedPath,
48
+ resolveSettingsSeedPath,
41
49
  validateName,
42
50
  resolveDeleteHome,
43
51
  resolveDeleteProject,
44
52
  parseConfigJson,
45
53
  parseLaunchArgs,
54
+ isHeadlessPiArgs,
55
+ anonPiVersion,
46
56
  parseMachineArgs,
47
57
  parseMachineJson,
48
58
  projectHostDir,
@@ -83,6 +93,7 @@ import {
83
93
  type AnonPiEnv,
84
94
  type GeneratedModel,
85
95
  type ModelCandidate,
96
+ type ModelSelection,
86
97
  type KeptContainer,
87
98
  type LaunchIntent,
88
99
  type Machine,
@@ -101,6 +112,14 @@ const ANON_PI_KEY_LABEL = 'anon-pi.key';
101
112
  function main(argv: string[]): number {
102
113
  const args = argv.slice(2);
103
114
 
115
+ // `--version`/`-V` prints anon-pi's own version and exits (before the launch
116
+ // grammar, so it is never parsed as a project/flag). For pi's version inside
117
+ // the jail, forward it: `anon-pi pi --version`.
118
+ if (args[0] === '--version' || args[0] === '-V') {
119
+ process.stdout.write(`anon-pi ${anonPiVersion() ?? '(unknown)'}\n`);
120
+ return 0;
121
+ }
122
+
104
123
  // The global `--help`/`-h` prints the top-level HELP, EXCEPT when the first
105
124
  // token is a subcommand that owns its own `--help` (so `anon-pi init --help`
106
125
  // and `anon-pi machine --help` show THEIR help, not the global one). Those
@@ -244,10 +263,12 @@ function runLaunch(parsed: ParsedLaunch): number {
244
263
  const home = machineHomeDir(env, machineName);
245
264
  const machine: Machine = {name: machineName, home, image};
246
265
 
247
- // The generated models.json + settings seed for this machine (mounted
248
- // read-only for the first-launch seed) when present. Keyed per machine.
249
- const modelsSeed = join(machineDir(env, machineName), MODELS_FILE);
250
- const settingsSeed = join(machineDir(env, machineName), SETTINGS_SEED_FILE);
266
+ // The local-model models.json + settings seed for this machine's FRESH-home
267
+ // promotion. GLOBAL by default (<home>/models.json, shared across every
268
+ // machine because the `llm` endpoint is global), with an optional
269
+ // per-machine override (machines/<M>/models.json). Mounted read-only.
270
+ const modelsSeed = resolveModelsSeedPath(env, machineName, existsSync);
271
+ const settingsSeed = resolveSettingsSeedPath(env, machineName, existsSync);
251
272
 
252
273
  intent = {
253
274
  machine,
@@ -259,18 +280,18 @@ function runLaunch(parsed: ParsedLaunch): number {
259
280
  keep: parsed.keep,
260
281
  proxy,
261
282
  llmDirect: llm,
262
- modelsSeed: existsSync(modelsSeed) ? modelsSeed : undefined,
263
- settingsSeed: existsSync(settingsSeed) ? settingsSeed : undefined,
283
+ modelsSeed,
284
+ settingsSeed,
264
285
  };
265
286
  } catch (e) {
266
287
  return reportAnonPiError(e);
267
288
  }
268
289
 
269
290
  // No-TTY discipline: the bare MENU and every INTERACTIVE launch (interactive
270
- // pi, or a shell) need a TTY; a HEADLESS pi run (`<project> <pi-args…>`) does
271
- // NOT. Check BEFORE we mutate anything or spawn.
272
- const headless =
273
- parsed.mode === 'pi' && !!parsed.piArgs && parsed.piArgs.length > 0;
291
+ // pi, or a shell) need a TTY; only a HEADLESS pi run (forwarded `-p`/`--print`)
292
+ // does NOT. Forwarded args that stay interactive (e.g. `--session <id>`,
293
+ // `--model x`) still require a TTY. Check BEFORE we mutate anything or spawn.
294
+ const headless = parsed.mode === 'pi' && isHeadlessPiArgs(parsed.piArgs);
274
295
  if (!headless && !process.stdin.isTTY) {
275
296
  if (parsed.mode === 'menu') {
276
297
  process.stderr.write(
@@ -1052,45 +1073,75 @@ function runInit(args: string[]): number {
1052
1073
  // pin/re-pin its image when one was chosen. Its home seeds on first launch.
1053
1074
  initWriteDefaultMachine(env, image);
1054
1075
 
1055
- // The per-machine models.json + settings.json seed for the default machine,
1056
- // generated from the captured endpoint + the CHOSEN models (this is the
1057
- // `import` replacement). Written next to the machine so the first-launch seed
1058
- // promotes them into the fresh home.
1076
+ // The GLOBAL local-model models.json + settings seed, generated from the
1077
+ // captured endpoint + the CHOSEN models (this is the `import` replacement).
1059
1078
  const endpoint = llm ?? current.llm;
1060
1079
  if (endpoint !== undefined) {
1061
- const mdir = machineDir(env, DEFAULT_MACHINE);
1062
- mkdirSync(mdir, {recursive: true});
1063
1080
  const models = generateModelsJson(
1064
1081
  endpoint,
1065
1082
  llmResult.models,
1066
1083
  llmResult.apiKey,
1067
1084
  );
1068
- writeFileSync(
1069
- join(mdir, MODELS_FILE),
1070
- JSON.stringify(models, null, '\t') + '\n',
1071
- );
1085
+ const modelsBody = JSON.stringify(models, null, '\t') + '\n';
1086
+ // GLOBAL seed: the local model is a workspace-level thing (config.llm is
1087
+ // global), so its models.json lives once at the workspace root and seeds
1088
+ // EVERY machine's fresh home. A machine may still override with its own
1089
+ // machines/<M>/models.json.
1090
+ mkdirSync(resolveAnonPiHome(env), {recursive: true});
1091
+ writeFileSync(globalModelsSeedPath(env), modelsBody);
1092
+
1093
+ // Migration: earlier versions wrote this seed under machines/default/. Now
1094
+ // that it is global, remove the old default-machine copy so `default`
1095
+ // picks up the global seed like every other machine (leaving it would look
1096
+ // like a deliberate per-machine override and shadow the global one). Only
1097
+ // the `default` machine's init-generated copy is migrated; a per-machine
1098
+ // override you created for ANY OTHER machine is left untouched.
1099
+ for (const stale of [
1100
+ machineModelsSeedPath(env, DEFAULT_MACHINE),
1101
+ join(machineDir(env, DEFAULT_MACHINE), SETTINGS_SEED_FILE),
1102
+ ]) {
1103
+ if (existsSync(stale)) rmSync(stale, {force: true});
1104
+ }
1072
1105
  process.stdout.write(
1073
- `anon-pi: wrote the local-model models.json for machine "${DEFAULT_MACHINE}" ` +
1074
- `(${llmResult.models.length} model${llmResult.models.length === 1 ? '' : 's'}).\n`,
1106
+ `anon-pi: wrote the global local-model models.json ` +
1107
+ `(${llmResult.models.length} model${llmResult.models.length === 1 ? '' : 's'}; shared by all machines).\n`,
1075
1108
  );
1076
1109
 
1077
- // settings.json: set the default model + enabledModels for the imported
1078
- // set. Written as a SEED that the first-launch promotion merges into the
1079
- // home's settings (so image-staged packages/extensions survive). Only when
1080
- // the user picked at least one model + a default.
1081
- if (llmResult.defaultId !== undefined && llmResult.models.length > 0) {
1082
- const selection = generateModelSelection(
1083
- llmResult.models.map((m) => m.id),
1084
- llmResult.defaultId,
1085
- );
1110
+ // settings.json seed: the default model + enabledModels for the imported
1111
+ // set. The first-launch promotion merges it into each home's settings (so
1112
+ // image-staged packages/extensions survive). Only when the user picked at
1113
+ // least one model + a default.
1114
+ const selection =
1115
+ llmResult.defaultId !== undefined && llmResult.models.length > 0
1116
+ ? generateModelSelection(
1117
+ llmResult.models.map((m) => m.id),
1118
+ llmResult.defaultId,
1119
+ )
1120
+ : undefined;
1121
+ if (selection) {
1086
1122
  writeFileSync(
1087
- join(mdir, SETTINGS_SEED_FILE),
1123
+ globalSettingsSeedPath(env),
1088
1124
  JSON.stringify(selection, null, '\t') + '\n',
1089
1125
  );
1090
1126
  process.stdout.write(
1091
1127
  `anon-pi: default model set to "${llmResult.defaultId}".\n`,
1092
1128
  );
1093
1129
  }
1130
+
1131
+ // Re-run reconfigure: the seed above only takes effect on a FRESH home
1132
+ // (the first-launch promotion is marker-guarded). Apply the new
1133
+ // models.json + settings selection DIRECTLY to EVERY already-seeded machine
1134
+ // home now, so re-running `init` updates existing environments without
1135
+ // wiping conversations (init runs on the host; homes are host dirs). Fresh
1136
+ // (unseeded) homes are left to the launch-time seed. A machine with its OWN
1137
+ // per-machine models.json override is skipped (its local model differs).
1138
+ const updated = applyModelsToSeededHomes(env, modelsBody, selection);
1139
+ if (updated.length > 0) {
1140
+ process.stdout.write(
1141
+ `anon-pi: updated ${updated.length} existing machine home${updated.length === 1 ? '' : 's'} ` +
1142
+ `(${updated.join(', ')}); conversations untouched.\n`,
1143
+ );
1144
+ }
1094
1145
  }
1095
1146
 
1096
1147
  process.stdout.write(
@@ -1100,6 +1151,42 @@ function runInit(args: string[]): number {
1100
1151
  return 0;
1101
1152
  }
1102
1153
 
1154
+ /**
1155
+ * Apply the freshly-generated GLOBAL local-model config to EVERY already-seeded
1156
+ * machine home directly (init runs on the host; homes are host dirs). The
1157
+ * launch-time seed only promotes on a FRESH home (marker-guarded), so without
1158
+ * this a re-run of `init` would update the global seed but never reach existing
1159
+ * homes. For each machine: skip a FRESH home (no marker — the launch seed will
1160
+ * pick up the new global seed), skip a machine with its OWN per-machine
1161
+ * models.json override (its local model differs on purpose), else overwrite the
1162
+ * home's models.json and merge the settings selection. Conversations/sessions
1163
+ * are untouched. Returns the machine names updated.
1164
+ */
1165
+ function applyModelsToSeededHomes(
1166
+ env: AnonPiEnv,
1167
+ modelsBody: string,
1168
+ selection: ModelSelection | undefined,
1169
+ ): string[] {
1170
+ const updated: string[] = [];
1171
+ for (const machine of listMachineNames(env).sort()) {
1172
+ const agentDir = machineAgentDir(env, machine);
1173
+ // Only an already-seeded home (marker present).
1174
+ if (!existsSync(join(agentDir, SEED_MARKER))) continue;
1175
+ // A machine that deliberately overrides the global models.json keeps its
1176
+ // own local model; do not clobber its home with the global one.
1177
+ if (existsSync(machineModelsSeedPath(env, machine))) continue;
1178
+
1179
+ writeFileSync(join(agentDir, MODELS_FILE), modelsBody);
1180
+ if (selection) {
1181
+ const settingsPath = join(agentDir, SETTINGS_FILE);
1182
+ const merged = mergeModelSelection(readJsonFile(settingsPath), selection);
1183
+ writeFileSync(settingsPath, JSON.stringify(merged, null, '\t') + '\n');
1184
+ }
1185
+ updated.push(machine);
1186
+ }
1187
+ return updated;
1188
+ }
1189
+
1103
1190
  /** A sentinel a step returns when the user aborted (distinct from "skipped"). */
1104
1191
  const ABORT = Symbol('abort');
1105
1192