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/README.md +40 -5
- package/dist/anon-pi.d.ts +63 -3
- package/dist/anon-pi.d.ts.map +1 -1
- package/dist/anon-pi.js +205 -11
- package/dist/anon-pi.js.map +1 -1
- package/dist/cli.js +97 -26
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/anon-pi.ts +240 -13
- package/src/cli.ts +119 -32
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.
|
|
731
|
-
*
|
|
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
|
-
|
|
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' &&
|
|
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
|
|
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
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
263
|
-
settingsSeed
|
|
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 (
|
|
271
|
-
// NOT.
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
1056
|
-
//
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
|
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:
|
|
1078
|
-
// set.
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
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
|
|