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/src/anon-pi.ts CHANGED
@@ -748,6 +748,15 @@ export interface LaunchIntent {
748
748
  * undefined (shell-at-home / menu). Resolves the cwd via resolveCwd.
749
749
  */
750
750
  project?: string;
751
+ /**
752
+ * A RESUME-family launch's resolved session cwd (e.g. `/projects/test`), the
753
+ * cwd pi keyed the resumed session by. Set by the CLI ONLY for a NO-project
754
+ * `--session`/`--resume <id>` whose id it found in the host session store; it
755
+ * OVERRIDES the default no-project cwd (the projects root) so pi resumes in
756
+ * place instead of prompting to fork. Ignored when a project is given (the
757
+ * user is trusted) or when the id is unresolvable (pi decides, as before).
758
+ */
759
+ sessionCwd?: string;
751
760
  /**
752
761
  * `--mount <parent>`: a resolved HOST parent path. When set it adds EXACTLY
753
762
  * one mount (<parent>:/work) and re-roots the cwd there (/work[/<project>]);
@@ -843,13 +852,18 @@ export interface ParsedLaunch {
843
852
  }
844
853
 
845
854
  /**
846
- * pi flags that make sense with NO anon-pi project, so `anon-pi <flag> ...`
847
- * launches pi (at the projects root) and forwards this flag + everything after
848
- * it verbatim. Two families:
849
- * - SESSION selection (`--session <id>` etc.): pi finds the session file (in the
850
- * always-mounted machine home) and switches to its own project cwd, so no
851
- * project is needed. Mirrors pi's own resume hint (`pi --session <id>`), so
852
- * pasting `anon-pi --session <id>` just works.
855
+ * pi flags anon-pi RECOGNISES in the no-project position, so `anon-pi <flag> ...`
856
+ * forwards this flag + everything after it verbatim. Three families with three
857
+ * no-project policies:
858
+ * - RESUME (`--session`/`--session-id`/`--resume`/`-r <id>`): resume ONE session
859
+ * in place. anon-pi resolves the session's recorded cwd from the host store
860
+ * and cds there (isPiResumeFlag / resumeSessionId), so pi resumes cleanly.
861
+ * Mirrors pi's own resume hint (`pi --session <id>`), so pasting `anon-pi
862
+ * --session <id>` just works.
863
+ * - NEEDS-PROJECT (`--fork`, `--continue`/`-c`): REFUSED without a project
864
+ * (isPiNeedsProjectFlag) so the (new / newest) conversation never lands in
865
+ * the projects root by surprise. Add a project (`.` for the root; created on
866
+ * demand): `anon-pi <project> --fork <id>`.
853
867
  * - QUERY (`--list-models`/`--models`): pi prints + exits, no project relevant.
854
868
  * For arbitrary pi flags with no project (e.g. `--model x`), use the explicit
855
869
  * `anon-pi pi <args…>` passthrough instead.
@@ -873,6 +887,111 @@ function isPiNoProjectFlag(a: string): boolean {
873
887
  return PI_NO_PROJECT_FLAGS.has(a);
874
888
  }
875
889
 
890
+ /**
891
+ * The RESUME family: session-selecting flags that resume ONE existing session in
892
+ * place (`--session`/`--session-id <id>`, `--resume`/`-r <id>`). With NO project,
893
+ * anon-pi resolves the session's recorded cwd from the host session store and
894
+ * cds THERE (setSessionCwd), so pi resumes cleanly instead of prompting to fork
895
+ * (its guard fires when the launch cwd differs from the session cwd). With an
896
+ * explicit project the user is trusted verbatim: anon-pi cds into that project
897
+ * and lets pi's own fork-prompt guard a mismatch. `--continue`/`--fork` are NOT
898
+ * here: they need a project (see PI_RESUME_NEEDS_PROJECT_FLAGS).
899
+ */
900
+ const PI_RESUME_FLAGS: ReadonlySet<string> = new Set([
901
+ '--session',
902
+ '--session-id',
903
+ '--resume',
904
+ '-r',
905
+ ]);
906
+
907
+ /**
908
+ * Session flags that REQUIRE an explicit project with no-project: `--fork` and
909
+ * `--continue`/`-c`. `--fork` writes a NEW session and would otherwise land it
910
+ * silently in the projects ROOT (a surprise); `--continue`/`-c` resumes the
911
+ * newest session for the launch cwd, so at the root it resolves ambiguously.
912
+ * anon-pi refuses both without a project (the project may be `.` for the root,
913
+ * and is created on demand), so where the conversation lands is always explicit.
914
+ */
915
+ const PI_RESUME_NEEDS_PROJECT_FLAGS: ReadonlySet<string> = new Set([
916
+ '--fork',
917
+ '--continue',
918
+ '-c',
919
+ ]);
920
+
921
+ /** True iff `a` is a RESUME-family flag (resolve session cwd; see PI_RESUME_FLAGS). */
922
+ export function isPiResumeFlag(a: string): boolean {
923
+ return PI_RESUME_FLAGS.has(a);
924
+ }
925
+
926
+ /** True iff `a` is a session flag that needs an explicit project (--fork/--continue). */
927
+ export function isPiNeedsProjectFlag(a: string): boolean {
928
+ return PI_RESUME_NEEDS_PROJECT_FLAGS.has(a);
929
+ }
930
+
931
+ /**
932
+ * PURE: the human name a --fork/--continue no-project refusal quotes. `-c` is
933
+ * spelled as its long form `--continue` in the message (clearer guidance).
934
+ */
935
+ export function needsProjectFlagName(flag: string): string {
936
+ return flag === '-c' ? '--continue' : flag;
937
+ }
938
+
939
+ /**
940
+ * PURE: the leading session-id `--fork <id>` / `--continue <id>` accepts, or
941
+ * undefined. Used only to build a copy-pasteable "add a project" hint in the
942
+ * refusal (`anon-pi . --fork <id>`); the id is the token right after the flag
943
+ * when it is not itself another flag.
944
+ */
945
+ export function resumeFlagId(piArgs: readonly string[]): string | undefined {
946
+ if (piArgs.length < 2) return undefined;
947
+ const id = piArgs[1];
948
+ return id.startsWith('-') ? undefined : id;
949
+ }
950
+
951
+ /**
952
+ * PURE: extract the session id a RESUME-family launch selects, so the CLI can
953
+ * look its cwd up in the host session store. Scans forwarded pi args for a
954
+ * resume flag (isPiResumeFlag) and returns the NEXT token when it is an id (not
955
+ * another flag). Returns undefined when there is no resume flag or no id after
956
+ * it (e.g. a bare `--resume` picker), in which case the CLI cds nowhere and pi
957
+ * decides as today.
958
+ */
959
+ export function resumeSessionId(
960
+ piArgs: readonly string[] | undefined,
961
+ ): string | undefined {
962
+ if (!piArgs) return undefined;
963
+ for (let i = 0; i < piArgs.length; i++) {
964
+ if (isPiResumeFlag(piArgs[i])) {
965
+ const next = piArgs[i + 1];
966
+ if (next !== undefined && !next.startsWith('-')) return next;
967
+ return undefined;
968
+ }
969
+ }
970
+ return undefined;
971
+ }
972
+
973
+ /**
974
+ * PURE: read a pi session's recorded cwd from its session-file HEADER line (the
975
+ * first JSONL record, `{"type":"session","id":"…","cwd":"/projects/x"}`). This
976
+ * is the authoritative cwd (what pi keys the conversation by), better than
977
+ * reversing the lossy `--…--` dir slug. Returns the cwd string, or undefined if
978
+ * the line is not the expected session header with a non-empty string cwd. The
979
+ * caller (CLI) supplies the file's first line; this stays pure + testable.
980
+ */
981
+ export function sessionHeaderCwd(headerLine: string): string | undefined {
982
+ let parsed: unknown;
983
+ try {
984
+ parsed = JSON.parse(headerLine);
985
+ } catch {
986
+ return undefined;
987
+ }
988
+ if (!parsed || typeof parsed !== 'object') return undefined;
989
+ const rec = parsed as Record<string, unknown>;
990
+ if (rec.type !== 'session') return undefined;
991
+ const cwd = rec.cwd;
992
+ return typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined;
993
+ }
994
+
876
995
  /**
877
996
  * The explicit pi-passthrough token: `anon-pi pi <args…>` runs pi with the given
878
997
  * args and NO project (the general escape hatch for any pi flag). It is a
@@ -1013,13 +1132,34 @@ export function parseLaunchArgs(args: readonly string[]): ParsedLaunch {
1013
1132
  });
1014
1133
  }
1015
1134
  if (isPiNoProjectFlag(a)) {
1016
- // A pi flag that needs NO anon-pi project (`--session <id>`/`--continue`/
1017
- // `--fork` resume by id; `--list-models`/`--models` print + exit). pi
1018
- // resolves its own cwd (or just prints), so anon-pi launches pi at the
1019
- // projects root and forwards this flag + everything after it verbatim.
1020
- // This makes pi's own "To resume: pi --session <id>" hint usable as
1021
- // `anon-pi --session <id>`. (For ARBITRARY pi flags with no project, use
1022
- // the explicit `anon-pi pi <args…>` passthrough.)
1135
+ // A pi flag that needs NO anon-pi project (RESUME family `--session <id>`/
1136
+ // `--resume <id>`; `--list-models`/`--models` print + exit). pi resolves
1137
+ // its own cwd (or just prints), so anon-pi launches pi at the projects
1138
+ // root and forwards this flag + everything after it verbatim. For the
1139
+ // RESUME family the CLI then resolves the session's recorded cwd and cds
1140
+ // there so pi resumes in place (no fork prompt). This makes pi's own "To
1141
+ // resume: pi --session <id>" hint usable as `anon-pi --session <id>`. (For
1142
+ // ARBITRARY pi flags with no project, use `anon-pi pi <args…>`.)
1143
+ //
1144
+ // --fork / --continue are REFUSED with no project: they would land a
1145
+ // (new / newest) conversation in the projects ROOT silently. Require an
1146
+ // explicit project (created on demand; `.` for the root) so where the
1147
+ // conversation lands is never a surprise.
1148
+ if (isPiNeedsProjectFlag(a)) {
1149
+ const rest = args.slice(i);
1150
+ const name = needsProjectFlagName(a);
1151
+ const id = resumeFlagId(rest);
1152
+ const example = id
1153
+ ? `anon-pi <project> ${name} ${id}` +
1154
+ ` (or \`anon-pi . ${name} ${id}\` for the root)`
1155
+ : `anon-pi <project> ${name} …` +
1156
+ ` (or \`anon-pi . ${name} …\` for the root)`;
1157
+ fail(
1158
+ `${name} needs a project so the conversation lands in a known ` +
1159
+ `directory, not the projects root. Add one (it is created on ` +
1160
+ `demand): ${example}.`,
1161
+ );
1162
+ }
1023
1163
  piArgs = args.slice(i);
1024
1164
  project = undefined;
1025
1165
  i = args.length;
@@ -1148,7 +1288,15 @@ export function resolveRunPlan(
1148
1288
  // Which root the cwd resolves under: /work when --mount, else /projects.
1149
1289
  const rootKind: RootKind = mounted ? 'mount' : 'projects';
1150
1290
 
1151
- const cwd = launchCwd(mode, rootKind, project);
1291
+ // A RESUME-family launch with NO project overrides the default no-project cwd
1292
+ // (the projects root) with the session's own recorded cwd, so pi resumes in
1293
+ // place. Only honoured for a projectless pi launch; a given project always
1294
+ // wins (the user is trusted, pi guards a mismatch).
1295
+ const sessionCwd = nonEmpty(intent.sessionCwd);
1296
+ const cwd =
1297
+ mode === 'pi' && project === undefined && sessionCwd !== undefined
1298
+ ? sessionCwd
1299
+ : launchCwd(mode, rootKind, project);
1152
1300
 
1153
1301
  const fresh = homeFresh(machine.home);
1154
1302
  const seedVersion = intent.seedVersion ?? SEED_VERSION;
@@ -1360,6 +1508,318 @@ export function resolveRunVsStart(
1360
1508
  return match ? {action: 'start', ref: match.ref} : {action: 'run'};
1361
1509
  }
1362
1510
 
1511
+ // --- `forward` / `ports`: reach an in-jail server from the host --------------
1512
+ //
1513
+ // netcage owns two host-access verbs (>= 0.9.0): `netcage forward <container>
1514
+ // [<hostPort>:]<jailPort>` stands up ONE host->jail inbound forward, and `netcage
1515
+ // ports <container> --json` lists the jail's TCP LISTEN sockets image-independently
1516
+ // (it reads the sidecar's /proc/net/tcp*, so a minimal image with no ss/netstat/nc
1517
+ // still works). anon-pi wraps them so the user never handles the raw netcage
1518
+ // container name: it resolves the RUNNING anon-pi container(s) by the identity key
1519
+ // it now stamps on EVERY launch (withKeyLabel, not just --keep), disambiguates with
1520
+ // a picker annotated by the open listeners, and shells out to `netcage forward`.
1521
+ // The forced-egress invariant is untouched: `forward` adds no OUTPUT rule (ADR-0014)
1522
+ // and `ports` only reads /proc; anon-pi composes neither egress flag here.
1523
+
1524
+ /**
1525
+ * PURE: the decoded fields of a stamped keptContainerKey (the reverse of
1526
+ * keptContainerKey's `k=v\n` record). Used by `forward`/`ports` to filter the
1527
+ * running managed containers by machine + project WITHOUT reconstructing the
1528
+ * exact key (which would couple to launchCwd). Unknown/missing fields are ''.
1529
+ */
1530
+ export interface KeptKeyFields {
1531
+ machine: string;
1532
+ projectsRoot: string;
1533
+ mountParent: string;
1534
+ cwd: string;
1535
+ }
1536
+
1537
+ /** PURE: parse a stamped keptContainerKey back into its fields (best-effort). */
1538
+ export function parseKeptKey(key: string): KeptKeyFields {
1539
+ const out: KeptKeyFields = {
1540
+ machine: '',
1541
+ projectsRoot: '',
1542
+ mountParent: '',
1543
+ cwd: '',
1544
+ };
1545
+ for (const line of key.split('\n')) {
1546
+ const eq = line.indexOf('=');
1547
+ if (eq < 0) continue;
1548
+ const k = line.slice(0, eq);
1549
+ const v = line.slice(eq + 1);
1550
+ if (k === 'machine') out.machine = v;
1551
+ else if (k === 'projectsRoot') out.projectsRoot = v;
1552
+ else if (k === 'mountParent') out.mountParent = v;
1553
+ else if (k === 'cwd') out.cwd = v;
1554
+ }
1555
+ return out;
1556
+ }
1557
+
1558
+ /**
1559
+ * PURE: the leaf name of a stamped key's cwd, i.e. the project a container hosts
1560
+ * (`/projects/recon` -> `recon`, `/projects` -> '.', `/work/x` -> `x`, `/root`
1561
+ * -> '' for a bare shell). Used to filter the picker by `<project>` and to label
1562
+ * each row. A root cwd (`/projects`, `/work`) maps to the `.` root token.
1563
+ */
1564
+ export function keyProject(fields: KeptKeyFields): string {
1565
+ const cwd = fields.cwd;
1566
+ if (cwd === CONTAINER_PROJECTS_ROOT || cwd === CONTAINER_MOUNT_ROOT) {
1567
+ return ROOT_TOKEN;
1568
+ }
1569
+ if (cwd === CONTAINER_HOME_ROOT) return ''; // a bare shell, no project
1570
+ const slash = cwd.lastIndexOf('/');
1571
+ return slash < 0 ? cwd : cwd.slice(slash + 1);
1572
+ }
1573
+
1574
+ /**
1575
+ * PURE: pick the RUNNING anon-pi containers a `forward`/`ports` should offer.
1576
+ * Filters the supplied running managed containers (each with its decoded key
1577
+ * fields) to those on `machine`, optionally narrowed to `project` (its leaf cwd
1578
+ * name). With no project, every anon-pi container on the machine qualifies. The
1579
+ * caller resolves 0 (error) / 1 (auto) / many (picker).
1580
+ */
1581
+ export function resolveManagedMatches(args: {
1582
+ containers: readonly ManagedContainer[];
1583
+ machine: string;
1584
+ project?: string;
1585
+ }): ManagedContainer[] {
1586
+ const {containers, machine, project} = args;
1587
+ return containers.filter((c) => {
1588
+ const f = parseKeptKey(c.key);
1589
+ if (f.machine !== machine) return false;
1590
+ if (project !== undefined && keyProject(f) !== project) return false;
1591
+ return true;
1592
+ });
1593
+ }
1594
+
1595
+ /**
1596
+ * A RUNNING netcage-managed container the CLI surfaces to the pure forward/ports
1597
+ * resolution: its anon-pi identity `key` (stamped label, decoded), the `ref` to
1598
+ * pass to `netcage forward`/`ports` (id or name), and a human `name` for the
1599
+ * picker. Mirrors KeptContainer with the display name added.
1600
+ */
1601
+ export interface ManagedContainer {
1602
+ key: string;
1603
+ ref: string;
1604
+ name: string;
1605
+ }
1606
+
1607
+ /**
1608
+ * A parsed, validated port argument for `forward`: the in-jail port to reach and
1609
+ * the host port to bind it on (equal to the jail port unless a `<hostPort>:`
1610
+ * prefix remapped it). `raw` is the exact token to hand to netcage verbatim
1611
+ * (`3001` or `8080:3001`), so anon-pi never re-serialises netcage's grammar.
1612
+ */
1613
+ export interface ForwardPort {
1614
+ hostPort: number;
1615
+ jailPort: number;
1616
+ raw: string;
1617
+ }
1618
+
1619
+ /**
1620
+ * PURE: parse a `forward` port token `[<hostPort>:]<jailPort>` (docker/kubectl
1621
+ * host-first order). One port `3001` maps host 3001 -> jail 3001; `8080:3001`
1622
+ * maps host 8080 -> jail 3001. Both sides must be integers in 1..65535. Throws
1623
+ * AnonPiError on a bad shape / out-of-range / extra colon, with copy-pasteable
1624
+ * guidance. `raw` is normalised to `<host>:<jail>` only when they differ, else
1625
+ * the bare jail port, matching netcage's own accepted forms.
1626
+ */
1627
+ export function parsePortArg(token: string): ForwardPort {
1628
+ const bad = (why: string): never => {
1629
+ throw new AnonPiError(
1630
+ `anon-pi: invalid --port ${JSON.stringify(token)}: ${why}. ` +
1631
+ 'Use <jailPort> (e.g. 3001) or <hostPort>:<jailPort> (e.g. 8080:3001), ' +
1632
+ 'each 1..65535.',
1633
+ );
1634
+ };
1635
+ const parts = token.split(':');
1636
+ if (parts.length > 2) bad('too many colons');
1637
+ const toPort = (s: string): number => {
1638
+ if (!/^[0-9]+$/.test(s)) bad(`${JSON.stringify(s)} is not a port number`);
1639
+ const n = Number(s);
1640
+ if (n < 1 || n > 65535) bad(`${s} is out of range (1..65535)`);
1641
+ return n;
1642
+ };
1643
+ if (parts.length === 1) {
1644
+ const p = toPort(parts[0]);
1645
+ return {hostPort: p, jailPort: p, raw: String(p)};
1646
+ }
1647
+ const hostPort = toPort(parts[0]);
1648
+ const jailPort = toPort(parts[1]);
1649
+ const raw =
1650
+ hostPort === jailPort ? String(jailPort) : `${hostPort}:${jailPort}`;
1651
+ return {hostPort, jailPort, raw};
1652
+ }
1653
+
1654
+ /** A parsed `anon-pi forward` command (pure; the CLI does the netcage I/O). */
1655
+ export interface ForwardCommand {
1656
+ project?: string;
1657
+ machine: string;
1658
+ machineExplicit: boolean;
1659
+ /** The parsed port, or undefined to prompt from the container's listeners. */
1660
+ port?: ForwardPort;
1661
+ /** `--bind <addr>` passed through to netcage verbatim (undefined => netcage default). */
1662
+ bind?: string;
1663
+ }
1664
+
1665
+ /**
1666
+ * PURE: parse `anon-pi forward [<project>] [--port <[hostPort:]jailPort>]
1667
+ * [--bind <addr>] [-m <machine>]`. The bare positional is ALWAYS the project (so
1668
+ * a numeric name like `3001` is a project, never a port); the port is the
1669
+ * `--port`/`-p` flag, removing the number-vs-project ambiguity. `--bind` is
1670
+ * passed through to netcage (which validates 127.0.0.1 / 0.0.0.0). Throws
1671
+ * AnonPiError on an unknown flag, a missing flag argument, a second positional,
1672
+ * or a bad port.
1673
+ */
1674
+ export function parseForwardArgs(args: readonly string[]): ForwardCommand {
1675
+ const fail = (msg: string): never => {
1676
+ throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi forward --help\`.`);
1677
+ };
1678
+ let project: string | undefined;
1679
+ let machine = DEFAULT_MACHINE;
1680
+ let machineExplicit = false;
1681
+ let port: ForwardPort | undefined;
1682
+ let bind: string | undefined;
1683
+ for (let i = 0; i < args.length; i++) {
1684
+ const a = args[i];
1685
+ if (a === '-m' || a === '--machine') {
1686
+ const v = args[++i];
1687
+ if (v === undefined) fail(`${a} needs a machine name`);
1688
+ machine = validateName(v as string, 'machine');
1689
+ machineExplicit = true;
1690
+ } else if (a === '-p' || a === '--port') {
1691
+ const v = args[++i];
1692
+ if (v === undefined) fail(`${a} needs a port ([hostPort:]jailPort)`);
1693
+ port = parsePortArg(v as string);
1694
+ } else if (a === '--bind') {
1695
+ const v = args[++i];
1696
+ if (v === undefined)
1697
+ fail('--bind needs an address (127.0.0.1 or 0.0.0.0)');
1698
+ bind = v as string;
1699
+ } else if (a.startsWith('-')) {
1700
+ fail(`unknown option: ${a}`);
1701
+ } else if (project === undefined) {
1702
+ project = validateName(a, 'project');
1703
+ } else {
1704
+ fail(`unexpected argument: ${a} (forward takes at most one project)`);
1705
+ }
1706
+ }
1707
+ return {project, machine, machineExplicit, port, bind};
1708
+ }
1709
+
1710
+ /** A parsed `anon-pi ports` command (pure). */
1711
+ export interface PortsCommand {
1712
+ project?: string;
1713
+ machine: string;
1714
+ machineExplicit: boolean;
1715
+ }
1716
+
1717
+ /**
1718
+ * PURE: parse `anon-pi ports [<project>] [-m <machine>]`. Like forward but with
1719
+ * no port/bind: it lists a container's open in-jail listeners. The bare
1720
+ * positional is the project filter.
1721
+ */
1722
+ export function parsePortsArgs(args: readonly string[]): PortsCommand {
1723
+ const fail = (msg: string): never => {
1724
+ throw new AnonPiError(`anon-pi: ${msg}\nRun \`anon-pi ports --help\`.`);
1725
+ };
1726
+ let project: string | undefined;
1727
+ let machine = DEFAULT_MACHINE;
1728
+ let machineExplicit = false;
1729
+ for (let i = 0; i < args.length; i++) {
1730
+ const a = args[i];
1731
+ if (a === '-m' || a === '--machine') {
1732
+ const v = args[++i];
1733
+ if (v === undefined) fail(`${a} needs a machine name`);
1734
+ machine = validateName(v as string, 'machine');
1735
+ machineExplicit = true;
1736
+ } else if (a.startsWith('-')) {
1737
+ fail(`unknown option: ${a}`);
1738
+ } else if (project === undefined) {
1739
+ project = validateName(a, 'project');
1740
+ } else {
1741
+ fail(`unexpected argument: ${a} (ports takes at most one project)`);
1742
+ }
1743
+ }
1744
+ return {project, machine, machineExplicit};
1745
+ }
1746
+
1747
+ /**
1748
+ * A jail TCP LISTEN socket, as netcage's `ports --json` reports it: the bind
1749
+ * `address`, the `port`, and `loopbackOnly` (bound 127.0.0.0/8 or ::1). The
1750
+ * contract is netcage's (ADR-0015); anon-pi only consumes it.
1751
+ */
1752
+ export interface NetcageListener {
1753
+ address: string;
1754
+ port: number;
1755
+ loopbackOnly: boolean;
1756
+ }
1757
+
1758
+ /**
1759
+ * PURE: parse `netcage ports --json` output into listeners (best-effort). Keeps
1760
+ * only well-formed `{address:string, port:int, loopbackOnly:bool}` entries;
1761
+ * anything else (a netcage version drift, a non-array) yields []. The caller
1762
+ * treats [] as "no hint", never an error.
1763
+ */
1764
+ export function parseNetcagePortsJson(stdout: string): NetcageListener[] {
1765
+ let parsed: unknown;
1766
+ try {
1767
+ parsed = JSON.parse(stdout);
1768
+ } catch {
1769
+ return [];
1770
+ }
1771
+ if (!Array.isArray(parsed)) return [];
1772
+ const out: NetcageListener[] = [];
1773
+ for (const e of parsed) {
1774
+ if (!e || typeof e !== 'object') continue;
1775
+ const r = e as Record<string, unknown>;
1776
+ if (
1777
+ typeof r.address === 'string' &&
1778
+ typeof r.port === 'number' &&
1779
+ typeof r.loopbackOnly === 'boolean'
1780
+ ) {
1781
+ out.push({
1782
+ address: r.address,
1783
+ port: r.port,
1784
+ loopbackOnly: r.loopbackOnly,
1785
+ });
1786
+ }
1787
+ }
1788
+ return out;
1789
+ }
1790
+
1791
+ /** netcage's in-jail DNS forwarder always listens here; anon-pi hides it from the port hint. */
1792
+ export const NETCAGE_DNS_PORT = 53;
1793
+
1794
+ /**
1795
+ * PURE: the in-jail ports worth offering as forward targets: the listeners with
1796
+ * netcage's own `127.0.0.1:53` DNS forwarder dropped (it is never something a
1797
+ * user forwards), de-duplicated by port, sorted ascending. A server bound on
1798
+ * both IPv4 and IPv6 (two listeners, same port) collapses to one entry.
1799
+ */
1800
+ export function forwardablePorts(
1801
+ listeners: readonly NetcageListener[],
1802
+ ): number[] {
1803
+ const ports = new Set<number>();
1804
+ for (const l of listeners) {
1805
+ if (l.port === NETCAGE_DNS_PORT && l.loopbackOnly) continue;
1806
+ ports.add(l.port);
1807
+ }
1808
+ return [...ports].sort((a, b) => a - b);
1809
+ }
1810
+
1811
+ /**
1812
+ * PURE: a compact one-line hint of a container's forwardable in-jail ports for
1813
+ * the picker / the pre-forward confirmation, e.g. `open: 3001, 5173` or
1814
+ * `open: (none detected)`. Never includes the DNS forwarder (forwardablePorts).
1815
+ */
1816
+ export function formatPortsHint(listeners: readonly NetcageListener[]): string {
1817
+ const ports = forwardablePorts(listeners);
1818
+ return ports.length === 0
1819
+ ? 'open: (none detected)'
1820
+ : `open: ${ports.join(', ')}`;
1821
+ }
1822
+
1363
1823
  // --- The bare-launch menu: choice-list + per-machine project-usage record ----
1364
1824
  //
1365
1825
  // anon-pi's bare launch shows a HOST-side arrow-key menu of a machine's
@@ -2550,12 +3010,14 @@ USAGE
2550
3010
  anon-pi MENU: pick a project (pi), a shell, or a new project
2551
3011
  anon-pi <project> pi in the project (${CONTAINER_PROJECTS_ROOT}/<project>); exit pi -> host
2552
3012
  anon-pi <project> <pi-args…> forward args to pi (e.g. -p for a headless one-shot)
2553
- anon-pi --session <id> resume a pi session by id (forwarded to pi; no project needed)
2554
- anon-pi --continue continue your most recent pi session (also -r/--resume, --fork)
3013
+ anon-pi --session <id> resume a pi session by id, in its own project (also -r/--resume)
3014
+ anon-pi <project> --fork <id> fork a session into <project> (\`.\`=root; --continue too; project required)
2555
3015
  anon-pi --list-models list the models pi sees (also --models; no project needed)
2556
3016
  anon-pi pi <pi-args…> run pi with ANY args and no project (the passthrough)
2557
3017
  anon-pi --version print anon-pi's version (also -V)
2558
3018
  anon-pi --shell [<project>] a jailed bash (at ~, or cd'd into <project>) - the project-hopper
3019
+ anon-pi forward [<p>] [--port …] open a host port onto a running container's in-jail server
3020
+ anon-pi ports [<project>] list a running container's open in-jail TCP listeners
2559
3021
  anon-pi -m <machine> [<p>] the same, on <machine> (its own image + home + conversations)
2560
3022
  anon-pi --mount <parent> [<p>] root at a HOST parent folder instead of the projects root
2561
3023
  anon-pi init onboard: verify your proxy, capture your local model, pick an image