anon-pi 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/anon-pi.js CHANGED
@@ -522,13 +522,18 @@ export function resolveLlm(args) {
522
522
  /** The machine bare `anon-pi` launches when no `-m` and no config default. */
523
523
  export const DEFAULT_MACHINE = 'default';
524
524
  /**
525
- * pi flags that make sense with NO anon-pi project, so `anon-pi <flag> ...`
526
- * launches pi (at the projects root) and forwards this flag + everything after
527
- * it verbatim. Two families:
528
- * - SESSION selection (`--session <id>` etc.): pi finds the session file (in the
529
- * always-mounted machine home) and switches to its own project cwd, so no
530
- * project is needed. Mirrors pi's own resume hint (`pi --session <id>`), so
531
- * pasting `anon-pi --session <id>` just works.
525
+ * pi flags anon-pi RECOGNISES in the no-project position, so `anon-pi <flag> ...`
526
+ * forwards this flag + everything after it verbatim. Three families with three
527
+ * no-project policies:
528
+ * - RESUME (`--session`/`--session-id`/`--resume`/`-r <id>`): resume ONE session
529
+ * in place. anon-pi resolves the session's recorded cwd from the host store
530
+ * and cds there (isPiResumeFlag / resumeSessionId), so pi resumes cleanly.
531
+ * Mirrors pi's own resume hint (`pi --session <id>`), so pasting `anon-pi
532
+ * --session <id>` just works.
533
+ * - NEEDS-PROJECT (`--fork`, `--continue`/`-c`): REFUSED without a project
534
+ * (isPiNeedsProjectFlag) so the (new / newest) conversation never lands in
535
+ * the projects root by surprise. Add a project (`.` for the root; created on
536
+ * demand): `anon-pi <project> --fork <id>`.
532
537
  * - QUERY (`--list-models`/`--models`): pi prints + exits, no project relevant.
533
538
  * For arbitrary pi flags with no project (e.g. `--model x`), use the explicit
534
539
  * `anon-pi pi <args…>` passthrough instead.
@@ -550,6 +555,107 @@ const PI_NO_PROJECT_FLAGS = new Set([
550
555
  function isPiNoProjectFlag(a) {
551
556
  return PI_NO_PROJECT_FLAGS.has(a);
552
557
  }
558
+ /**
559
+ * The RESUME family: session-selecting flags that resume ONE existing session in
560
+ * place (`--session`/`--session-id <id>`, `--resume`/`-r <id>`). With NO project,
561
+ * anon-pi resolves the session's recorded cwd from the host session store and
562
+ * cds THERE (setSessionCwd), so pi resumes cleanly instead of prompting to fork
563
+ * (its guard fires when the launch cwd differs from the session cwd). With an
564
+ * explicit project the user is trusted verbatim: anon-pi cds into that project
565
+ * and lets pi's own fork-prompt guard a mismatch. `--continue`/`--fork` are NOT
566
+ * here: they need a project (see PI_RESUME_NEEDS_PROJECT_FLAGS).
567
+ */
568
+ const PI_RESUME_FLAGS = new Set([
569
+ '--session',
570
+ '--session-id',
571
+ '--resume',
572
+ '-r',
573
+ ]);
574
+ /**
575
+ * Session flags that REQUIRE an explicit project with no-project: `--fork` and
576
+ * `--continue`/`-c`. `--fork` writes a NEW session and would otherwise land it
577
+ * silently in the projects ROOT (a surprise); `--continue`/`-c` resumes the
578
+ * newest session for the launch cwd, so at the root it resolves ambiguously.
579
+ * anon-pi refuses both without a project (the project may be `.` for the root,
580
+ * and is created on demand), so where the conversation lands is always explicit.
581
+ */
582
+ const PI_RESUME_NEEDS_PROJECT_FLAGS = new Set([
583
+ '--fork',
584
+ '--continue',
585
+ '-c',
586
+ ]);
587
+ /** True iff `a` is a RESUME-family flag (resolve session cwd; see PI_RESUME_FLAGS). */
588
+ export function isPiResumeFlag(a) {
589
+ return PI_RESUME_FLAGS.has(a);
590
+ }
591
+ /** True iff `a` is a session flag that needs an explicit project (--fork/--continue). */
592
+ export function isPiNeedsProjectFlag(a) {
593
+ return PI_RESUME_NEEDS_PROJECT_FLAGS.has(a);
594
+ }
595
+ /**
596
+ * PURE: the human name a --fork/--continue no-project refusal quotes. `-c` is
597
+ * spelled as its long form `--continue` in the message (clearer guidance).
598
+ */
599
+ export function needsProjectFlagName(flag) {
600
+ return flag === '-c' ? '--continue' : flag;
601
+ }
602
+ /**
603
+ * PURE: the leading session-id `--fork <id>` / `--continue <id>` accepts, or
604
+ * undefined. Used only to build a copy-pasteable "add a project" hint in the
605
+ * refusal (`anon-pi . --fork <id>`); the id is the token right after the flag
606
+ * when it is not itself another flag.
607
+ */
608
+ export function resumeFlagId(piArgs) {
609
+ if (piArgs.length < 2)
610
+ return undefined;
611
+ const id = piArgs[1];
612
+ return id.startsWith('-') ? undefined : id;
613
+ }
614
+ /**
615
+ * PURE: extract the session id a RESUME-family launch selects, so the CLI can
616
+ * look its cwd up in the host session store. Scans forwarded pi args for a
617
+ * resume flag (isPiResumeFlag) and returns the NEXT token when it is an id (not
618
+ * another flag). Returns undefined when there is no resume flag or no id after
619
+ * it (e.g. a bare `--resume` picker), in which case the CLI cds nowhere and pi
620
+ * decides as today.
621
+ */
622
+ export function resumeSessionId(piArgs) {
623
+ if (!piArgs)
624
+ return undefined;
625
+ for (let i = 0; i < piArgs.length; i++) {
626
+ if (isPiResumeFlag(piArgs[i])) {
627
+ const next = piArgs[i + 1];
628
+ if (next !== undefined && !next.startsWith('-'))
629
+ return next;
630
+ return undefined;
631
+ }
632
+ }
633
+ return undefined;
634
+ }
635
+ /**
636
+ * PURE: read a pi session's recorded cwd from its session-file HEADER line (the
637
+ * first JSONL record, `{"type":"session","id":"…","cwd":"/projects/x"}`). This
638
+ * is the authoritative cwd (what pi keys the conversation by), better than
639
+ * reversing the lossy `--…--` dir slug. Returns the cwd string, or undefined if
640
+ * the line is not the expected session header with a non-empty string cwd. The
641
+ * caller (CLI) supplies the file's first line; this stays pure + testable.
642
+ */
643
+ export function sessionHeaderCwd(headerLine) {
644
+ let parsed;
645
+ try {
646
+ parsed = JSON.parse(headerLine);
647
+ }
648
+ catch {
649
+ return undefined;
650
+ }
651
+ if (!parsed || typeof parsed !== 'object')
652
+ return undefined;
653
+ const rec = parsed;
654
+ if (rec.type !== 'session')
655
+ return undefined;
656
+ const cwd = rec.cwd;
657
+ return typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined;
658
+ }
553
659
  /**
554
660
  * The explicit pi-passthrough token: `anon-pi pi <args…>` runs pi with the given
555
661
  * args and NO project (the general escape hatch for any pi flag). It is a
@@ -672,13 +778,32 @@ export function parseLaunchArgs(args) {
672
778
  });
673
779
  }
674
780
  if (isPiNoProjectFlag(a)) {
675
- // A pi flag that needs NO anon-pi project (`--session <id>`/`--continue`/
676
- // `--fork` resume by id; `--list-models`/`--models` print + exit). pi
677
- // resolves its own cwd (or just prints), so anon-pi launches pi at the
678
- // projects root and forwards this flag + everything after it verbatim.
679
- // This makes pi's own "To resume: pi --session <id>" hint usable as
680
- // `anon-pi --session <id>`. (For ARBITRARY pi flags with no project, use
681
- // the explicit `anon-pi pi <args…>` passthrough.)
781
+ // A pi flag that needs NO anon-pi project (RESUME family `--session <id>`/
782
+ // `--resume <id>`; `--list-models`/`--models` print + exit). pi resolves
783
+ // its own cwd (or just prints), so anon-pi launches pi at the projects
784
+ // root and forwards this flag + everything after it verbatim. For the
785
+ // RESUME family the CLI then resolves the session's recorded cwd and cds
786
+ // there so pi resumes in place (no fork prompt). This makes pi's own "To
787
+ // resume: pi --session <id>" hint usable as `anon-pi --session <id>`. (For
788
+ // ARBITRARY pi flags with no project, use `anon-pi pi <args…>`.)
789
+ //
790
+ // --fork / --continue are REFUSED with no project: they would land a
791
+ // (new / newest) conversation in the projects ROOT silently. Require an
792
+ // explicit project (created on demand; `.` for the root) so where the
793
+ // conversation lands is never a surprise.
794
+ if (isPiNeedsProjectFlag(a)) {
795
+ const rest = args.slice(i);
796
+ const name = needsProjectFlagName(a);
797
+ const id = resumeFlagId(rest);
798
+ const example = id
799
+ ? `anon-pi <project> ${name} ${id}` +
800
+ ` (or \`anon-pi . ${name} ${id}\` for the root)`
801
+ : `anon-pi <project> ${name} …` +
802
+ ` (or \`anon-pi . ${name} …\` for the root)`;
803
+ fail(`${name} needs a project so the conversation lands in a known ` +
804
+ `directory, not the projects root. Add one (it is created on ` +
805
+ `demand): ${example}.`);
806
+ }
682
807
  piArgs = args.slice(i);
683
808
  project = undefined;
684
809
  i = args.length;
@@ -790,7 +915,14 @@ export function resolveRunPlan(intent, homeFresh) {
790
915
  const mounted = nonEmpty(mountParent) !== undefined;
791
916
  // Which root the cwd resolves under: /work when --mount, else /projects.
792
917
  const rootKind = mounted ? 'mount' : 'projects';
793
- const cwd = launchCwd(mode, rootKind, project);
918
+ // A RESUME-family launch with NO project overrides the default no-project cwd
919
+ // (the projects root) with the session's own recorded cwd, so pi resumes in
920
+ // place. Only honoured for a projectless pi launch; a given project always
921
+ // wins (the user is trusted, pi guards a mismatch).
922
+ const sessionCwd = nonEmpty(intent.sessionCwd);
923
+ const cwd = mode === 'pi' && project === undefined && sessionCwd !== undefined
924
+ ? sessionCwd
925
+ : launchCwd(mode, rootKind, project);
794
926
  const fresh = homeFresh(machine.home);
795
927
  const seedVersion = intent.seedVersion ?? SEED_VERSION;
796
928
  const directTarget = hostPortKey(llm);
@@ -947,6 +1079,244 @@ export function resolveRunVsStart(intent, kept) {
947
1079
  const match = kept.find((c) => c.key === want);
948
1080
  return match ? { action: 'start', ref: match.ref } : { action: 'run' };
949
1081
  }
1082
+ /** PURE: parse a stamped keptContainerKey back into its fields (best-effort). */
1083
+ export function parseKeptKey(key) {
1084
+ const out = {
1085
+ machine: '',
1086
+ projectsRoot: '',
1087
+ mountParent: '',
1088
+ cwd: '',
1089
+ };
1090
+ for (const line of key.split('\n')) {
1091
+ const eq = line.indexOf('=');
1092
+ if (eq < 0)
1093
+ continue;
1094
+ const k = line.slice(0, eq);
1095
+ const v = line.slice(eq + 1);
1096
+ if (k === 'machine')
1097
+ out.machine = v;
1098
+ else if (k === 'projectsRoot')
1099
+ out.projectsRoot = v;
1100
+ else if (k === 'mountParent')
1101
+ out.mountParent = v;
1102
+ else if (k === 'cwd')
1103
+ out.cwd = v;
1104
+ }
1105
+ return out;
1106
+ }
1107
+ /**
1108
+ * PURE: the leaf name of a stamped key's cwd, i.e. the project a container hosts
1109
+ * (`/projects/recon` -> `recon`, `/projects` -> '.', `/work/x` -> `x`, `/root`
1110
+ * -> '' for a bare shell). Used to filter the picker by `<project>` and to label
1111
+ * each row. A root cwd (`/projects`, `/work`) maps to the `.` root token.
1112
+ */
1113
+ export function keyProject(fields) {
1114
+ const cwd = fields.cwd;
1115
+ if (cwd === CONTAINER_PROJECTS_ROOT || cwd === CONTAINER_MOUNT_ROOT) {
1116
+ return ROOT_TOKEN;
1117
+ }
1118
+ if (cwd === CONTAINER_HOME_ROOT)
1119
+ return ''; // a bare shell, no project
1120
+ const slash = cwd.lastIndexOf('/');
1121
+ return slash < 0 ? cwd : cwd.slice(slash + 1);
1122
+ }
1123
+ /**
1124
+ * PURE: pick the RUNNING anon-pi containers a `forward`/`ports` should offer.
1125
+ * Filters the supplied running managed containers (each with its decoded key
1126
+ * fields) to those on `machine`, optionally narrowed to `project` (its leaf cwd
1127
+ * name). With no project, every anon-pi container on the machine qualifies. The
1128
+ * caller resolves 0 (error) / 1 (auto) / many (picker).
1129
+ */
1130
+ export function resolveManagedMatches(args) {
1131
+ const { containers, machine, project } = args;
1132
+ return containers.filter((c) => {
1133
+ const f = parseKeptKey(c.key);
1134
+ if (f.machine !== machine)
1135
+ return false;
1136
+ if (project !== undefined && keyProject(f) !== project)
1137
+ return false;
1138
+ return true;
1139
+ });
1140
+ }
1141
+ /**
1142
+ * PURE: parse a `forward` port token `[<hostPort>:]<jailPort>` (docker/kubectl
1143
+ * host-first order). One port `3001` maps host 3001 -> jail 3001; `8080:3001`
1144
+ * maps host 8080 -> jail 3001. Both sides must be integers in 1..65535. Throws
1145
+ * AnonPiError on a bad shape / out-of-range / extra colon, with copy-pasteable
1146
+ * guidance. `raw` is normalised to `<host>:<jail>` only when they differ, else
1147
+ * the bare jail port, matching netcage's own accepted forms.
1148
+ */
1149
+ export function parsePortArg(token) {
1150
+ const bad = (why) => {
1151
+ throw new AnonPiError(`anon-pi: invalid --port ${JSON.stringify(token)}: ${why}. ` +
1152
+ 'Use <jailPort> (e.g. 3001) or <hostPort>:<jailPort> (e.g. 8080:3001), ' +
1153
+ 'each 1..65535.');
1154
+ };
1155
+ const parts = token.split(':');
1156
+ if (parts.length > 2)
1157
+ bad('too many colons');
1158
+ const toPort = (s) => {
1159
+ if (!/^[0-9]+$/.test(s))
1160
+ bad(`${JSON.stringify(s)} is not a port number`);
1161
+ const n = Number(s);
1162
+ if (n < 1 || n > 65535)
1163
+ bad(`${s} is out of range (1..65535)`);
1164
+ return n;
1165
+ };
1166
+ if (parts.length === 1) {
1167
+ const p = toPort(parts[0]);
1168
+ return { hostPort: p, jailPort: p, raw: String(p) };
1169
+ }
1170
+ const hostPort = toPort(parts[0]);
1171
+ const jailPort = toPort(parts[1]);
1172
+ const raw = hostPort === jailPort ? String(jailPort) : `${hostPort}:${jailPort}`;
1173
+ return { hostPort, jailPort, raw };
1174
+ }
1175
+ /**
1176
+ * PURE: parse `anon-pi forward [<project>] [--port <[hostPort:]jailPort>]
1177
+ * [--bind <addr>] [-m <machine>]`. The bare positional is ALWAYS the project (so
1178
+ * a numeric name like `3001` is a project, never a port); the port is the
1179
+ * `--port`/`-p` flag, removing the number-vs-project ambiguity. `--bind` is
1180
+ * passed through to netcage (which validates 127.0.0.1 / 0.0.0.0). Throws
1181
+ * AnonPiError on an unknown flag, a missing flag argument, a second positional,
1182
+ * or a bad port.
1183
+ */
1184
+ export function parseForwardArgs(args) {
1185
+ const fail = (msg) => {
1186
+ throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi forward --help\`.`);
1187
+ };
1188
+ let project;
1189
+ let machine = DEFAULT_MACHINE;
1190
+ let machineExplicit = false;
1191
+ let port;
1192
+ let bind;
1193
+ for (let i = 0; i < args.length; i++) {
1194
+ const a = args[i];
1195
+ if (a === '-m' || a === '--machine') {
1196
+ const v = args[++i];
1197
+ if (v === undefined)
1198
+ fail(`${a} needs a machine name`);
1199
+ machine = validateName(v, 'machine');
1200
+ machineExplicit = true;
1201
+ }
1202
+ else if (a === '-p' || a === '--port') {
1203
+ const v = args[++i];
1204
+ if (v === undefined)
1205
+ fail(`${a} needs a port ([hostPort:]jailPort)`);
1206
+ port = parsePortArg(v);
1207
+ }
1208
+ else if (a === '--bind') {
1209
+ const v = args[++i];
1210
+ if (v === undefined)
1211
+ fail('--bind needs an address (127.0.0.1 or 0.0.0.0)');
1212
+ bind = v;
1213
+ }
1214
+ else if (a.startsWith('-')) {
1215
+ fail(`unknown option: ${a}`);
1216
+ }
1217
+ else if (project === undefined) {
1218
+ project = validateName(a, 'project');
1219
+ }
1220
+ else {
1221
+ fail(`unexpected argument: ${a} (forward takes at most one project)`);
1222
+ }
1223
+ }
1224
+ return { project, machine, machineExplicit, port, bind };
1225
+ }
1226
+ /**
1227
+ * PURE: parse `anon-pi ports [<project>] [-m <machine>]`. Like forward but with
1228
+ * no port/bind: it lists a container's open in-jail listeners. The bare
1229
+ * positional is the project filter.
1230
+ */
1231
+ export function parsePortsArgs(args) {
1232
+ const fail = (msg) => {
1233
+ throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi ports --help\`.`);
1234
+ };
1235
+ let project;
1236
+ let machine = DEFAULT_MACHINE;
1237
+ let machineExplicit = false;
1238
+ for (let i = 0; i < args.length; i++) {
1239
+ const a = args[i];
1240
+ if (a === '-m' || a === '--machine') {
1241
+ const v = args[++i];
1242
+ if (v === undefined)
1243
+ fail(`${a} needs a machine name`);
1244
+ machine = validateName(v, 'machine');
1245
+ machineExplicit = true;
1246
+ }
1247
+ else if (a.startsWith('-')) {
1248
+ fail(`unknown option: ${a}`);
1249
+ }
1250
+ else if (project === undefined) {
1251
+ project = validateName(a, 'project');
1252
+ }
1253
+ else {
1254
+ fail(`unexpected argument: ${a} (ports takes at most one project)`);
1255
+ }
1256
+ }
1257
+ return { project, machine, machineExplicit };
1258
+ }
1259
+ /**
1260
+ * PURE: parse `netcage ports --json` output into listeners (best-effort). Keeps
1261
+ * only well-formed `{address:string, port:int, loopbackOnly:bool}` entries;
1262
+ * anything else (a netcage version drift, a non-array) yields []. The caller
1263
+ * treats [] as "no hint", never an error.
1264
+ */
1265
+ export function parseNetcagePortsJson(stdout) {
1266
+ let parsed;
1267
+ try {
1268
+ parsed = JSON.parse(stdout);
1269
+ }
1270
+ catch {
1271
+ return [];
1272
+ }
1273
+ if (!Array.isArray(parsed))
1274
+ return [];
1275
+ const out = [];
1276
+ for (const e of parsed) {
1277
+ if (!e || typeof e !== 'object')
1278
+ continue;
1279
+ const r = e;
1280
+ if (typeof r.address === 'string' &&
1281
+ typeof r.port === 'number' &&
1282
+ typeof r.loopbackOnly === 'boolean') {
1283
+ out.push({
1284
+ address: r.address,
1285
+ port: r.port,
1286
+ loopbackOnly: r.loopbackOnly,
1287
+ });
1288
+ }
1289
+ }
1290
+ return out;
1291
+ }
1292
+ /** netcage's in-jail DNS forwarder always listens here; anon-pi hides it from the port hint. */
1293
+ export const NETCAGE_DNS_PORT = 53;
1294
+ /**
1295
+ * PURE: the in-jail ports worth offering as forward targets: the listeners with
1296
+ * netcage's own `127.0.0.1:53` DNS forwarder dropped (it is never something a
1297
+ * user forwards), de-duplicated by port, sorted ascending. A server bound on
1298
+ * both IPv4 and IPv6 (two listeners, same port) collapses to one entry.
1299
+ */
1300
+ export function forwardablePorts(listeners) {
1301
+ const ports = new Set();
1302
+ for (const l of listeners) {
1303
+ if (l.port === NETCAGE_DNS_PORT && l.loopbackOnly)
1304
+ continue;
1305
+ ports.add(l.port);
1306
+ }
1307
+ return [...ports].sort((a, b) => a - b);
1308
+ }
1309
+ /**
1310
+ * PURE: a compact one-line hint of a container's forwardable in-jail ports for
1311
+ * the picker / the pre-forward confirmation, e.g. `open: 3001, 5173` or
1312
+ * `open: (none detected)`. Never includes the DNS forwarder (forwardablePorts).
1313
+ */
1314
+ export function formatPortsHint(listeners) {
1315
+ const ports = forwardablePorts(listeners);
1316
+ return ports.length === 0
1317
+ ? 'open: (none detected)'
1318
+ : `open: ${ports.join(', ')}`;
1319
+ }
950
1320
  // --- The bare-launch menu: choice-list + per-machine project-usage record ----
951
1321
  //
952
1322
  // anon-pi's bare launch shows a HOST-side arrow-key menu of a machine's
@@ -1831,12 +2201,14 @@ USAGE
1831
2201
  anon-pi MENU: pick a project (pi), a shell, or a new project
1832
2202
  anon-pi <project> pi in the project (${CONTAINER_PROJECTS_ROOT}/<project>); exit pi -> host
1833
2203
  anon-pi <project> <pi-args…> forward args to pi (e.g. -p for a headless one-shot)
1834
- anon-pi --session <id> resume a pi session by id (forwarded to pi; no project needed)
1835
- anon-pi --continue continue your most recent pi session (also -r/--resume, --fork)
2204
+ anon-pi --session <id> resume a pi session by id, in its own project (also -r/--resume)
2205
+ anon-pi <project> --fork <id> fork a session into <project> (\`.\`=root; --continue too; project required)
1836
2206
  anon-pi --list-models list the models pi sees (also --models; no project needed)
1837
2207
  anon-pi pi <pi-args…> run pi with ANY args and no project (the passthrough)
1838
2208
  anon-pi --version print anon-pi's version (also -V)
1839
2209
  anon-pi --shell [<project>] a jailed bash (at ~, or cd'd into <project>) - the project-hopper
2210
+ anon-pi forward [<p>] [--port …] open a host port onto a running container's in-jail server
2211
+ anon-pi ports [<project>] list a running container's open in-jail TCP listeners
1840
2212
  anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
1841
2213
  anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
1842
2214
  anon-pi init onboard: verify your proxy, capture your local model, pick an image