remote-pi 0.4.2 → 0.5.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.
Files changed (77) hide show
  1. package/README.md +33 -0
  2. package/dist/daemon/client.js +5 -2
  3. package/dist/daemon/client.js.map +1 -1
  4. package/dist/daemon/control_protocol.d.ts +68 -0
  5. package/dist/daemon/control_protocol.js.map +1 -1
  6. package/dist/daemon/cron_log.d.ts +45 -0
  7. package/dist/daemon/cron_log.js +72 -0
  8. package/dist/daemon/cron_log.js.map +1 -0
  9. package/dist/daemon/cron_registry.d.ts +80 -0
  10. package/dist/daemon/cron_registry.js +194 -0
  11. package/dist/daemon/cron_registry.js.map +1 -0
  12. package/dist/daemon/id.d.ts +6 -0
  13. package/dist/daemon/id.js +6 -0
  14. package/dist/daemon/id.js.map +1 -1
  15. package/dist/daemon/install.d.ts +9 -2
  16. package/dist/daemon/install.js +54 -10
  17. package/dist/daemon/install.js.map +1 -1
  18. package/dist/daemon/registry.d.ts +17 -1
  19. package/dist/daemon/registry.js +34 -4
  20. package/dist/daemon/registry.js.map +1 -1
  21. package/dist/daemon/rpc_child.d.ts +64 -1
  22. package/dist/daemon/rpc_child.js +164 -8
  23. package/dist/daemon/rpc_child.js.map +1 -1
  24. package/dist/daemon/supervisor.d.ts +44 -0
  25. package/dist/daemon/supervisor.js +265 -22
  26. package/dist/daemon/supervisor.js.map +1 -1
  27. package/dist/index.d.ts +68 -11
  28. package/dist/index.js +0 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/mcp/mesh_server.js +19 -10
  31. package/dist/mcp/mesh_server.js.map +1 -1
  32. package/dist/pairing/qr.d.ts +8 -1
  33. package/dist/pairing/qr.js +13 -3
  34. package/dist/pairing/qr.js.map +1 -1
  35. package/dist/protocol/codec.js +1 -0
  36. package/dist/protocol/codec.js.map +1 -1
  37. package/dist/protocol/types.d.ts +11 -0
  38. package/dist/rooms.d.ts +20 -0
  39. package/dist/rooms.js +35 -0
  40. package/dist/rooms.js.map +1 -1
  41. package/dist/session/address.d.ts +49 -0
  42. package/dist/session/address.js +58 -0
  43. package/dist/session/address.js.map +1 -0
  44. package/dist/session/broker.d.ts +79 -3
  45. package/dist/session/broker.js +155 -28
  46. package/dist/session/broker.js.map +1 -1
  47. package/dist/session/broker_remote.d.ts +30 -10
  48. package/dist/session/broker_remote.js +77 -39
  49. package/dist/session/broker_remote.js.map +1 -1
  50. package/dist/session/cwd_lock.d.ts +7 -2
  51. package/dist/session/cwd_lock.js +39 -9
  52. package/dist/session/cwd_lock.js.map +1 -1
  53. package/dist/session/global_config.d.ts +12 -2
  54. package/dist/session/global_config.js +16 -3
  55. package/dist/session/global_config.js.map +1 -1
  56. package/dist/session/ipc.d.ts +27 -0
  57. package/dist/session/ipc.js +22 -0
  58. package/dist/session/ipc.js.map +1 -0
  59. package/dist/session/leader_election.js +8 -2
  60. package/dist/session/leader_election.js.map +1 -1
  61. package/dist/session/local_config.d.ts +36 -6
  62. package/dist/session/local_config.js +103 -28
  63. package/dist/session/local_config.js.map +1 -1
  64. package/dist/session/mesh_message.d.ts +20 -0
  65. package/dist/session/mesh_message.js +28 -0
  66. package/dist/session/mesh_message.js.map +1 -0
  67. package/dist/session/mesh_node.d.ts +15 -1
  68. package/dist/session/mesh_node.js +26 -5
  69. package/dist/session/mesh_node.js.map +1 -1
  70. package/dist/session/peer.d.ts +19 -2
  71. package/dist/session/peer.js +45 -9
  72. package/dist/session/peer.js.map +1 -1
  73. package/dist/session/tools.js +14 -12
  74. package/dist/session/tools.js.map +1 -1
  75. package/package.json +2 -1
  76. package/service-templates/task-scheduler.xml.template +38 -0
  77. package/skills/agent-network/SKILL.md +63 -26
@@ -1,6 +1,16 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import qrTerminal from "qrcode-terminal";
3
- const TOKEN_TTL_MS = 60_000;
3
+ /** Default ephemeral-token lifetime (also the QR rotation period). */
4
+ export const TOKEN_TTL_MS = 60_000;
5
+ /** Bounds for a caller-supplied pairing TTL (e.g. `/remote-pi pair --ttl <s>`). */
6
+ export const PAIR_TTL_MIN_MS = 10_000;
7
+ export const PAIR_TTL_MAX_MS = 600_000;
8
+ /** Clamp an arbitrary ttl (ms) into the safe pairing range; NaN → default. */
9
+ export function clampPairTtlMs(ttlMs) {
10
+ if (!Number.isFinite(ttlMs))
11
+ return TOKEN_TTL_MS;
12
+ return Math.min(PAIR_TTL_MAX_MS, Math.max(PAIR_TTL_MIN_MS, Math.floor(ttlMs)));
13
+ }
4
14
  /** Encapsulates the single active QR token. One instance per Pi process. */
5
15
  export class QRSession {
6
16
  active = null;
@@ -12,9 +22,9 @@ export class QRSession {
12
22
  * Issues a new active token, invalidating any previous one.
13
23
  * Returns the token and its expiry timestamp.
14
24
  */
15
- issueToken() {
25
+ issueToken(ttlMs = TOKEN_TTL_MS) {
16
26
  const token = this.generateToken();
17
- const expiresAt = Date.now() + TOKEN_TTL_MS;
27
+ const expiresAt = Date.now() + ttlMs;
18
28
  this.active = { token, expiresAt, consumed: false };
19
29
  return { token, expiresAt };
20
30
  }
@@ -1 +1 @@
1
- {"version":3,"file":"qr.js","sourceRoot":"","sources":["../../src/pairing/qr.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"qr.js","sourceRoot":"","sources":["../../src/pairing/qr.ts"],"names":[],"mappings":"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"}
@@ -2,6 +2,7 @@ const SERVER_TYPES = new Set([
2
2
  "pair_ok",
3
3
  "pair_error",
4
4
  "user_input",
5
+ "queued_message_state",
5
6
  "agent_chunk",
6
7
  "agent_done",
7
8
  "agent_message",
@@ -1 +1 @@
1
- {"version":3,"file":"codec.js","sourceRoot":"","sources":["../../src/protocol/codec.ts"],"names":[],"mappings":"AAEA,MAAM,YAAY,GAAG,IAAI,GAAG,CAAwB;IAClD,SAAS;IACT,YAAY;IACZ,YAAY;IACZ,aAAa;IACb,YAAY;IACZ,eAAe;IACf,cAAc;IACd,aAAa;IACb,OAAO;IACP,WAAW;IACX,MAAM;IACN,KAAK;IACL,iBAAiB;CAClB,CAAC,CAAC;AAEH,MAAM,OAAO,WAAY,SAAQ,KAAK;IAElB;IADlB,YACkB,IAA4C,EAC5D,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAHC,SAAI,GAAJ,IAAI,CAAwC;QAI5D,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF;AAED,MAAM,UAAU,YAAY,CAAC,GAAkB;IAC7C,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;AACpC,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,IAAI,GAAY,CAAC;IACjB,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAChC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,MAAM,IAAI,WAAW,CAAC,iBAAiB,EAAE,aAAc,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;IAChF,CAAC;IACD,IACE,CAAC,GAAG;QACJ,OAAO,GAAG,KAAK,QAAQ;QACvB,OAAQ,GAA+B,CAAC,IAAI,KAAK,QAAQ,EACzD,CAAC;QACD,MAAM,IAAI,WAAW,CAAC,iBAAiB,EAAE,gBAAgB,CAAC,CAAC;IAC7D,CAAC;IACD,MAAM,CAAC,GAAI,GAA+B,CAAC,IAAc,CAAC;IAC1D,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,CAA0B,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,WAAW,CAAC,kBAAkB,EAAE,iBAAiB,CAAC,EAAE,CAAC,CAAC;IAClE,CAAC;IACD,OAAO,GAAoB,CAAC;AAC9B,CAAC"}
1
+ {"version":3,"file":"codec.js","sourceRoot":"","sources":["../../src/protocol/codec.ts"],"names":[],"mappings":"AAEA,MAAM,YAAY,GAAG,IAAI,GAAG,CAAwB;IAClD,SAAS;IACT,YAAY;IACZ,YAAY;IACZ,sBAAsB;IACtB,aAAa;IACb,YAAY;IACZ,eAAe;IACf,cAAc;IACd,aAAa;IACb,OAAO;IACP,WAAW;IACX,MAAM;IACN,KAAK;IACL,iBAAiB;CAClB,CAAC,CAAC;AAEH,MAAM,OAAO,WAAY,SAAQ,KAAK;IAElB;IADlB,YACkB,IAA4C,EAC5D,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAHC,SAAI,GAAJ,IAAI,CAAwC;QAI5D,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF;AAED,MAAM,UAAU,YAAY,CAAC,GAAkB;IAC7C,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;AACpC,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,IAAI,GAAY,CAAC;IACjB,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAChC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,MAAM,IAAI,WAAW,CAAC,iBAAiB,EAAE,aAAc,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;IAChF,CAAC;IACD,IACE,CAAC,GAAG;QACJ,OAAO,GAAG,KAAK,QAAQ;QACvB,OAAQ,GAA+B,CAAC,IAAI,KAAK,QAAQ,EACzD,CAAC;QACD,MAAM,IAAI,WAAW,CAAC,iBAAiB,EAAE,gBAAgB,CAAC,CAAC;IAC7D,CAAC;IACD,MAAM,CAAC,GAAI,GAA+B,CAAC,IAAc,CAAC;IAC1D,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,CAA0B,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,WAAW,CAAC,kBAAkB,EAAE,iBAAiB,CAAC,EAAE,CAAC,CAAC;IAClE,CAAC;IACD,OAAO,GAAoB,CAAC;AAC9B,CAAC"}
@@ -9,6 +9,13 @@ export type ClientMessage = {
9
9
  id: string;
10
10
  text: string;
11
11
  images?: WireImage[];
12
+ } | {
13
+ type: "queued_message_set";
14
+ id: string;
15
+ text: string;
16
+ } | {
17
+ type: "queued_message_clear";
18
+ id: string;
12
19
  } | {
13
20
  type: "approve_tool";
14
21
  id: string;
@@ -132,6 +139,10 @@ export type ServerMessage = {
132
139
  id: string;
133
140
  text: string;
134
141
  images?: WireImage[];
142
+ } | {
143
+ type: "queued_message_state";
144
+ id?: string;
145
+ text?: string;
135
146
  } | {
136
147
  type: "agent_chunk";
137
148
  in_reply_to: string;
package/dist/rooms.d.ts CHANGED
@@ -7,3 +7,23 @@
7
7
  * Format: first 12 chars of base64url(sha256(realpath)).
8
8
  */
9
9
  export declare function roomIdForCwd(cwd: string): string;
10
+ /**
11
+ * THE single derivation of the App↔Pi `room_id` (plan/41) — keyed by
12
+ * `(cwd, name)` so several agents in the SAME folder get distinct rooms (the
13
+ * app then renders one tile per agent instead of merging them into one).
14
+ *
15
+ * Default-preserving: when `name` is absent OR equals `defaultAgentName(cwd)`
16
+ * (an agent with no custom `agent_name`), it returns the LEGACY `roomIdForCwd`
17
+ * EXACTLY — so a single unnamed agent's existing conversation is NOT re-keyed
18
+ * on upgrade. A custom or `#N`-suffixed name → a name-scoped id (same formula
19
+ * the cwd-lock uses).
20
+ *
21
+ * Using the ASSIGNED leaf name (the broker's `#N` on collision) disambiguates
22
+ * even two unnamed agents: the 1st stays `folder` (== default → legacy room),
23
+ * the 2nd becomes `folder#2` (≠ default → name-scoped room).
24
+ *
25
+ * INVARIANT: every callsite that derives the App↔Pi room for the same agent
26
+ * MUST go through this function — otherwise the app would pair on a room the
27
+ * Pi never announces.
28
+ */
29
+ export declare function roomIdFor(cwd: string, name?: string): string;
package/dist/rooms.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
+ import { defaultAgentName } from "./session/local_config.js";
3
4
  /**
4
5
  * Deterministic room id derived from a cwd. Two Pi processes in the same
5
6
  * directory produce the same id; different cwds produce different ids
@@ -19,4 +20,38 @@ export function roomIdForCwd(cwd) {
19
20
  }
20
21
  return createHash("sha256").update(target).digest("base64url").slice(0, 12);
21
22
  }
23
+ /**
24
+ * THE single derivation of the App↔Pi `room_id` (plan/41) — keyed by
25
+ * `(cwd, name)` so several agents in the SAME folder get distinct rooms (the
26
+ * app then renders one tile per agent instead of merging them into one).
27
+ *
28
+ * Default-preserving: when `name` is absent OR equals `defaultAgentName(cwd)`
29
+ * (an agent with no custom `agent_name`), it returns the LEGACY `roomIdForCwd`
30
+ * EXACTLY — so a single unnamed agent's existing conversation is NOT re-keyed
31
+ * on upgrade. A custom or `#N`-suffixed name → a name-scoped id (same formula
32
+ * the cwd-lock uses).
33
+ *
34
+ * Using the ASSIGNED leaf name (the broker's `#N` on collision) disambiguates
35
+ * even two unnamed agents: the 1st stays `folder` (== default → legacy room),
36
+ * the 2nd becomes `folder#2` (≠ default → name-scoped room).
37
+ *
38
+ * INVARIANT: every callsite that derives the App↔Pi room for the same agent
39
+ * MUST go through this function — otherwise the app would pair on a room the
40
+ * Pi never announces.
41
+ */
42
+ export function roomIdFor(cwd, name) {
43
+ if (!name || name === defaultAgentName(cwd))
44
+ return roomIdForCwd(cwd);
45
+ let target;
46
+ try {
47
+ target = realpathSync(cwd);
48
+ }
49
+ catch {
50
+ target = cwd;
51
+ }
52
+ // NUL separator (U+0000): impossible in a POSIX path and stripped from any
53
+ // sanitized name, so the cwd/name boundary is unambiguous.
54
+ const sep = String.fromCharCode(0);
55
+ return createHash("sha256").update(target + sep + name).digest("base64url").slice(0, 12);
56
+ }
22
57
  //# sourceMappingURL=rooms.js.map
package/dist/rooms.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"rooms.js","sourceRoot":"","sources":["../src/rooms.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvC;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,IAAI,MAAc,CAAC;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,qEAAqE;QACrE,MAAM,GAAG,GAAG,CAAC;IACf,CAAC;IACD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAC9E,CAAC"}
1
+ {"version":3,"file":"rooms.js","sourceRoot":"","sources":["../src/rooms.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAE7D;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,IAAI,MAAc,CAAC;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,qEAAqE;QACrE,MAAM,GAAG,GAAG,CAAC;IACf,CAAC;IACD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAC9E,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,SAAS,CAAC,GAAW,EAAE,IAAa;IAClD,IAAI,CAAC,IAAI,IAAI,IAAI,KAAK,gBAAgB,CAAC,GAAG,CAAC;QAAE,OAAO,YAAY,CAAC,GAAG,CAAC,CAAC;IACtE,IAAI,MAAc,CAAC;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,GAAG,GAAG,CAAC;IACf,CAAC;IACD,2EAA2E;IAC3E,2DAA2D;IAC3D,MAAM,GAAG,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACnC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAC3F,CAAC"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Mesh addressing for plan/35 (malha direta). An "agent" ≡ a cwd (cwd_lock
3
+ * guarantees at most one Pi per folder), so the folder *is* the address:
4
+ *
5
+ * - **local** → an absolute path on this machine. A local peer is reached by
6
+ * `POST`ing its inbox socket directly (see `apiSockPath`).
7
+ * - **remote** → `<label>:/path`, where `<label>` names a sibling machine
8
+ * (resolved to a pubkey via `mesh_versions`/siblings in Wave C) and `/path`
9
+ * is the folder on that machine. Routed cross-PC through the relay.
10
+ *
11
+ * The address an agent passes is ALWAYS the absolute folder path; the `.sock`
12
+ * file itself lives OUTSIDE the cwd (decisão fixada — "Transporte local"). The
13
+ * folder is hashed to the file name, so the mapping is deterministic and both
14
+ * the inbox (its own socket) and a sender (the peer's socket) land on the same
15
+ * file without coordinating.
16
+ *
17
+ * Wave 0 only *parses* what it is handed — contacts (known routes) live in the
18
+ * agent's context/CLAUDE.md (decisão Q5), not in an allowlist module. The
19
+ * `<label>→pubkey` resolution and remote send arrive in Wave C; here we keep
20
+ * the parser and the local route→socket mapping.
21
+ */
22
+ export type Address = {
23
+ kind: "local";
24
+ path: string;
25
+ } | {
26
+ kind: "remote";
27
+ label: string;
28
+ path: string;
29
+ };
30
+ export declare class AddressError extends Error {
31
+ constructor(message: string);
32
+ }
33
+ /**
34
+ * Parses a mesh address. A `:` separates a remote `<label>` from its `/path`;
35
+ * with no `:` the whole string is a local absolute path (decisão fixada:
36
+ * "Sem ':' → local"). Both the local path and the remote path must be
37
+ * absolute — the folder is the canonical route.
38
+ */
39
+ export declare function parseAddress(raw: string): Address;
40
+ /**
41
+ * The inbox socket path for a folder. Single source of truth shared by the
42
+ * inbox server (its own socket) and the send client (a local peer's socket).
43
+ *
44
+ * `~/.pi/remote-pi/socks/<roomId>.sock`, where `roomId = roomIdForCwd(folder)`
45
+ * (`sha256(realpath(folder))[:12]` — the same hash `cwd_lock` uses). The folder
46
+ * is the address; the hash is only the file name. `realpath` canonicalization
47
+ * means `/a` and a symlink to `/a` map to the same socket.
48
+ */
49
+ export declare function apiSockPath(folderPath: string): string;
@@ -0,0 +1,58 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { roomIdForCwd } from "../rooms.js";
4
+ export class AddressError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "AddressError";
8
+ }
9
+ }
10
+ /**
11
+ * Parses a mesh address. A `:` separates a remote `<label>` from its `/path`;
12
+ * with no `:` the whole string is a local absolute path (decisão fixada:
13
+ * "Sem ':' → local"). Both the local path and the remote path must be
14
+ * absolute — the folder is the canonical route.
15
+ */
16
+ export function parseAddress(raw) {
17
+ const trimmed = raw.trim();
18
+ if (!trimmed)
19
+ throw new AddressError("empty address");
20
+ const colon = trimmed.indexOf(":");
21
+ if (colon === -1) {
22
+ if (!isAbsolute(trimmed)) {
23
+ throw new AddressError(`local address must be an absolute path: ${trimmed}`);
24
+ }
25
+ return { kind: "local", path: trimmed };
26
+ }
27
+ const label = trimmed.slice(0, colon);
28
+ const path = trimmed.slice(colon + 1);
29
+ if (!label)
30
+ throw new AddressError(`remote address missing label: ${trimmed}`);
31
+ if (!path)
32
+ throw new AddressError(`remote address missing path: ${trimmed}`);
33
+ if (!isAbsolute(path)) {
34
+ throw new AddressError(`remote address path must be absolute: ${trimmed}`);
35
+ }
36
+ return { kind: "remote", label, path };
37
+ }
38
+ /** Directory holding inbox sockets, resolved at call time (not module load) so
39
+ * tests can redirect it via `REMOTE_PI_HOME` — the same override `cwd_lock`
40
+ * honors. Kept OUT of the cwd: `sun_path` is capped at 104 chars on macOS
41
+ * (EINVAL at ≥105), so a socket nested inside a deep cwd would overflow. */
42
+ function socksDir() {
43
+ const root = process.env["REMOTE_PI_HOME"] || homedir();
44
+ return join(root, ".pi", "remote-pi", "socks");
45
+ }
46
+ /**
47
+ * The inbox socket path for a folder. Single source of truth shared by the
48
+ * inbox server (its own socket) and the send client (a local peer's socket).
49
+ *
50
+ * `~/.pi/remote-pi/socks/<roomId>.sock`, where `roomId = roomIdForCwd(folder)`
51
+ * (`sha256(realpath(folder))[:12]` — the same hash `cwd_lock` uses). The folder
52
+ * is the address; the hash is only the file name. `realpath` canonicalization
53
+ * means `/a` and a symlink to `/a` map to the same socket.
54
+ */
55
+ export function apiSockPath(folderPath) {
56
+ return join(socksDir(), `${roomIdForCwd(folderPath)}.sock`);
57
+ }
58
+ //# sourceMappingURL=address.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address.js","sourceRoot":"","sources":["../../src/session/address.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA4B3C,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC7B,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IAC3B,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,YAAY,CAAC,eAAe,CAAC,CAAC;IAEtD,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACnC,IAAI,KAAK,KAAK,CAAC,CAAC,EAAE,CAAC;QACjB,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,YAAY,CAAC,2CAA2C,OAAO,EAAE,CAAC,CAAC;QAC/E,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;IAC1C,CAAC;IAED,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;IACtC,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,YAAY,CAAC,iCAAiC,OAAO,EAAE,CAAC,CAAC;IAC/E,IAAI,CAAC,IAAI;QAAE,MAAM,IAAI,YAAY,CAAC,gCAAgC,OAAO,EAAE,CAAC,CAAC;IAC7E,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,YAAY,CAAC,yCAAyC,OAAO,EAAE,CAAC,CAAC;IAC7E,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AACzC,CAAC;AAED;;;6EAG6E;AAC7E,SAAS,QAAQ;IACf,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,IAAI,OAAO,EAAE,CAAC;IACxD,OAAO,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC;AACjD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAAC,UAAkB;IAC5C,OAAO,IAAI,CAAC,QAAQ,EAAE,EAAE,GAAG,YAAY,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;AAC9D,CAAC"}
@@ -1,5 +1,47 @@
1
1
  import type { Server } from "node:net";
2
2
  import { type Envelope } from "./envelope.js";
3
+ /**
4
+ * Structured view of one mesh peer (plan/38). The `address` is the canonical
5
+ * routing key; the other fields let a client group/label peers WITHOUT parsing
6
+ * the address string. `pc` is undefined for local peers (filled cross-PC in
7
+ * Fase 2). Returned by `list_peers` as `peers_detailed`.
8
+ */
9
+ export interface PeerInfo {
10
+ /** Cross-PC label; undefined for a local peer. */
11
+ pc?: string;
12
+ /** Working directory (realpath). Empty string for a legacy peer (no cwd). */
13
+ cwd: string;
14
+ /** Clean leaf name (carries a `#N` only on a same-(cwd,name) collision). */
15
+ name: string;
16
+ /** Canonical address — the broker's Map key and the `to`/`from` on the wire. */
17
+ address: string;
18
+ }
19
+ /**
20
+ * THE sole encoder of a peer address (plan/38): `[<pc>:]<cwd>@<nome>`.
21
+ *
22
+ * - `cwd` present → `<cwd>@<nome>` (the `@` separates name from path so a `/`
23
+ * in the path never confuses lookup, which is exact-match anyway).
24
+ * - `cwd` empty (legacy peer that sent no cwd) → `address == name`, preserving
25
+ * pre-plan/38 behavior so a mixed mesh keeps routing.
26
+ * - `pc` present (cross-PC, Fase 2) → prefixed `<pc>:`.
27
+ *
28
+ * Does NOT sanitize — callers sanitize the `name` once (see `sanitizeMeshName`)
29
+ * before composing, so an already-appended `#N` collision suffix survives.
30
+ * Everyone else ECHOES `peer.address` verbatim; only the broker composes.
31
+ */
32
+ export declare function composeAddress(parts: {
33
+ pc?: string;
34
+ cwd: string;
35
+ name: string;
36
+ }): string;
37
+ /**
38
+ * Sanitize a requested mesh name to a safe leaf while PRESERVING a trailing
39
+ * `#N` collision suffix (which the cwd-lock or a prior assignment may have
40
+ * added — `sanitizeSegment` alone would mangle `#`→`-`). The base is run through
41
+ * `sanitizeSegment` (af66d04); an unusable base (empty / reserved keyword) falls
42
+ * back to `"agent"`.
43
+ */
44
+ export declare function sanitizeMeshName(raw: string): string;
3
45
  /**
4
46
  * Broker hosted by the session leader. Accepts UDS connections, maintains a
5
47
  * `name → connection` map, routes envelopes per the `to` field, and appends
@@ -50,9 +92,15 @@ export interface RemoteRouter {
50
92
  * prefix at all.
51
93
  */
52
94
  tryRouteOutbound(env: Envelope): boolean;
53
- /** Aggregated remote peer names (`<pc_label>:<peer_name>`) for the
54
- * `list_peers` reply. Returns empty when nothing is known yet. */
95
+ /** Aggregated remote peer addresses (`<pc_label>:<cwd>@<nome>`) for the
96
+ * `list_peers` reply's `peers` (string) field. Empty when nothing known. */
55
97
  listRemotePeers(): string[];
98
+ /** Structured remote roster (plan/38 Fase 2): one `PeerInfo` per cross-PC
99
+ * peer with `pc` filled (the sibling label), `cwd`/`name` from the sibling's
100
+ * inventory, and `address` prefixed `<pc>:<cwd>@<nome>`. Powers the
101
+ * `peers_detailed` half of `list_peers` so clients group by `pc`/`cwd`
102
+ * without parsing. Empty when nothing known. */
103
+ listRemotePeerInfos(): PeerInfo[];
56
104
  }
57
105
  /** Local outcome of a cross-PC envelope injection. broker_remote uses this
58
106
  * to construct the ACK envelope it sends back via the relay. plan/34: `busy`
@@ -90,7 +138,35 @@ export declare class Broker {
90
138
  private _onData;
91
139
  private _handleLine;
92
140
  private _handleRegister;
93
- private _uniqueName;
141
+ /**
142
+ * Answer a read-only `list_peers` request from an UNREGISTERED connection
143
+ * (the `remote-pi peers` CLI probe). Returns true when the line was such a
144
+ * probe — the reply is written and the connection stays unregistered: no
145
+ * name assigned, no `peer_joined`/`peer_left` broadcast, no sibling push, so
146
+ * querying the roster from the shell never perturbs the mesh. Returns false
147
+ * (not a probe) so the caller falls through to the register handshake.
148
+ */
149
+ private _tryObserverProbe;
150
+ /** Local UDS peer names plus cross-PC `<pc>:<peer>` entries from the remote
151
+ * router (empty when no bridge). Shared by the registered `list_peers`
152
+ * handler and the unregistered observer probe. */
153
+ private _allPeerNames;
154
+ /** Structured roster of LOCAL UDS peers (plan/38): one `PeerInfo` each, no
155
+ * `pc` (they're on this machine). Public so the cross-PC router
156
+ * (`broker_remote`) can read the authoritative local inventory directly to
157
+ * push to siblings — no `list_peers` round-trip, no stale cache. */
158
+ localPeerInfos(): PeerInfo[];
159
+ /** Structured roster (plan/38): local peers (no `pc`) + cross-PC peers with
160
+ * `pc`/`cwd`/`name` filled by the remote router (Fase 2). */
161
+ private _allPeerInfos;
162
+ /**
163
+ * Resolve a free `(name, address)` for a register, keyed by **(cwd, name)**
164
+ * (plan/38): the collision check is on the composed ADDRESS, so a name only
165
+ * collides with another peer in the SAME cwd. `#N` is appended to the name
166
+ * (matching the cwd-lock's suffix scheme) until the address is free; for a
167
+ * legacy peer (cwd "") the address is the name, preserving global-name `#N`.
168
+ */
169
+ private _uniqueIdentity;
94
170
  private _onClose;
95
171
  private _route;
96
172
  private _resolveTargets;
@@ -1,6 +1,36 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { parse, serialize, uuidv7, EnvelopeError } from "./envelope.js";
4
+ import { sanitizeSegment } from "./local_config.js";
5
+ /**
6
+ * THE sole encoder of a peer address (plan/38): `[<pc>:]<cwd>@<nome>`.
7
+ *
8
+ * - `cwd` present → `<cwd>@<nome>` (the `@` separates name from path so a `/`
9
+ * in the path never confuses lookup, which is exact-match anyway).
10
+ * - `cwd` empty (legacy peer that sent no cwd) → `address == name`, preserving
11
+ * pre-plan/38 behavior so a mixed mesh keeps routing.
12
+ * - `pc` present (cross-PC, Fase 2) → prefixed `<pc>:`.
13
+ *
14
+ * Does NOT sanitize — callers sanitize the `name` once (see `sanitizeMeshName`)
15
+ * before composing, so an already-appended `#N` collision suffix survives.
16
+ * Everyone else ECHOES `peer.address` verbatim; only the broker composes.
17
+ */
18
+ export function composeAddress(parts) {
19
+ const base = parts.cwd ? `${parts.cwd}@${parts.name}` : parts.name;
20
+ return parts.pc ? `${parts.pc}:${base}` : base;
21
+ }
22
+ /**
23
+ * Sanitize a requested mesh name to a safe leaf while PRESERVING a trailing
24
+ * `#N` collision suffix (which the cwd-lock or a prior assignment may have
25
+ * added — `sanitizeSegment` alone would mangle `#`→`-`). The base is run through
26
+ * `sanitizeSegment` (af66d04); an unusable base (empty / reserved keyword) falls
27
+ * back to `"agent"`.
28
+ */
29
+ export function sanitizeMeshName(raw) {
30
+ const m = /^(.*?)(#\d+)?$/.exec(raw);
31
+ const base = sanitizeSegment(m?.[1] ?? raw) ?? "agent";
32
+ return m?.[2] ? base + m[2] : base;
33
+ }
4
34
  const BROKER_NAME = "broker";
5
35
  export class Broker {
6
36
  peers = new Map();
@@ -65,7 +95,7 @@ export class Broker {
65
95
  }
66
96
  // ── connection lifecycle ──────────────────────────────────────────────────
67
97
  _handleConnection(socket) {
68
- const conn = { name: "", socket, buf: "" };
98
+ const conn = { name: "", cwd: "", address: "", socket, buf: "" };
69
99
  socket.setEncoding("utf8");
70
100
  socket.on("data", (chunk) => this._onData(conn, chunk));
71
101
  socket.on("close", () => this._onClose(conn));
@@ -83,8 +113,12 @@ export class Broker {
83
113
  }
84
114
  }
85
115
  async _handleLine(conn, line) {
86
- // Unregistered conn must send a `register` control message first.
116
+ // Unregistered conn: a read-only `list_peers` probe (the `remote-pi peers`
117
+ // CLI — answered without registering, so it leaves no trace on the mesh) or
118
+ // the mandatory `register` handshake. Anything else `_handleRegister` drops.
87
119
  if (!conn.name) {
120
+ if (this._tryObserverProbe(conn, line))
121
+ return;
88
122
  this._handleRegister(conn, line);
89
123
  return;
90
124
  }
@@ -98,8 +132,9 @@ export class Broker {
98
132
  return; // malformed; drop silently
99
133
  throw e;
100
134
  }
101
- // Force `from` to the registered name (security: peer can't spoof).
102
- env.from = conn.name;
135
+ // Force `from` to the registered ADDRESS (security: peer can't spoof; and
136
+ // replies/ACKs address back by the same canonical key the Map is keyed on).
137
+ env.from = conn.address;
103
138
  await this._route(env);
104
139
  }
105
140
  _handleRegister(conn, line) {
@@ -119,32 +154,114 @@ export class Broker {
119
154
  conn.socket.destroy();
120
155
  return;
121
156
  }
122
- const assigned = this._uniqueName(req.name);
123
- conn.name = assigned;
124
- this.peers.set(assigned, conn);
125
- const ack = { type: "register_ack", name_assigned: assigned };
157
+ // (cwd, name) identity (plan/38). The cwd is the first-class axis: the
158
+ // address embeds it, so two same-named agents in DIFFERENT folders get
159
+ // distinct addresses and never collide — `#N` fires only on the same cwd +
160
+ // same name. A legacy peer (no cwd) → cwd "" `address == name`, preserving
161
+ // the old global-name behavior so a mixed mesh keeps routing.
162
+ conn.cwd = typeof req.cwd === "string" ? req.cwd : "";
163
+ const { name, address } = this._uniqueIdentity(conn.cwd, req.name);
164
+ conn.name = name;
165
+ conn.address = address;
166
+ this.peers.set(address, conn);
167
+ // `name_assigned` doubles as the compat alias: for a legacy peer it equals
168
+ // `address_assigned` (cwd empty → address == name), so old clients that read
169
+ // `name_assigned` still get a routable identity.
170
+ const ack = { type: "register_ack", address_assigned: address, name_assigned: name };
126
171
  try {
127
172
  conn.socket.write(JSON.stringify(ack) + "\n");
128
173
  }
129
174
  catch { /* peer hung up */ }
130
- // Notify others (peer_joined broadcast).
131
- this._broadcastSystem({ type: "peer_joined", name: assigned }, assigned);
175
+ // Notify others (peer_joined broadcast). The field carries the ADDRESS.
176
+ this._broadcastSystem({ type: "peer_joined", name: address, address }, address);
132
177
  }
133
- _uniqueName(requested) {
134
- if (!this.peers.has(requested))
135
- return requested;
178
+ /**
179
+ * Answer a read-only `list_peers` request from an UNREGISTERED connection
180
+ * (the `remote-pi peers` CLI probe). Returns true when the line was such a
181
+ * probe — the reply is written and the connection stays unregistered: no
182
+ * name assigned, no `peer_joined`/`peer_left` broadcast, no sibling push, so
183
+ * querying the roster from the shell never perturbs the mesh. Returns false
184
+ * (not a probe) so the caller falls through to the register handshake.
185
+ */
186
+ _tryObserverProbe(conn, line) {
187
+ let parsed;
188
+ try {
189
+ parsed = JSON.parse(line);
190
+ }
191
+ catch {
192
+ return false; // not JSON → let _handleRegister destroy it
193
+ }
194
+ if (!parsed || typeof parsed !== "object" || parsed.type !== "list_peers") {
195
+ return false;
196
+ }
197
+ const reply = {
198
+ from: BROKER_NAME,
199
+ to: "observer", // synthetic: the conn has no registered name
200
+ id: uuidv7(),
201
+ re: null,
202
+ body: {
203
+ type: "list_peers_reply",
204
+ peers: this._allPeerNames(),
205
+ peers_detailed: this._allPeerInfos(),
206
+ },
207
+ };
208
+ try {
209
+ conn.socket.write(serialize(reply));
210
+ }
211
+ catch { /* probe hung up */ }
212
+ return true;
213
+ }
214
+ /** Local UDS peer names plus cross-PC `<pc>:<peer>` entries from the remote
215
+ * router (empty when no bridge). Shared by the registered `list_peers`
216
+ * handler and the unregistered observer probe. */
217
+ _allPeerNames() {
218
+ const remote = this.remoteRouter ? this.remoteRouter.listRemotePeers() : [];
219
+ return [...this.peerNames(), ...remote];
220
+ }
221
+ /** Structured roster of LOCAL UDS peers (plan/38): one `PeerInfo` each, no
222
+ * `pc` (they're on this machine). Public so the cross-PC router
223
+ * (`broker_remote`) can read the authoritative local inventory directly to
224
+ * push to siblings — no `list_peers` round-trip, no stale cache. */
225
+ localPeerInfos() {
226
+ return [...this.peers.values()].map((p) => ({
227
+ cwd: p.cwd,
228
+ name: p.name,
229
+ address: p.address,
230
+ }));
231
+ }
232
+ /** Structured roster (plan/38): local peers (no `pc`) + cross-PC peers with
233
+ * `pc`/`cwd`/`name` filled by the remote router (Fase 2). */
234
+ _allPeerInfos() {
235
+ const remote = this.remoteRouter?.listRemotePeerInfos() ?? [];
236
+ return [...this.localPeerInfos(), ...remote];
237
+ }
238
+ /**
239
+ * Resolve a free `(name, address)` for a register, keyed by **(cwd, name)**
240
+ * (plan/38): the collision check is on the composed ADDRESS, so a name only
241
+ * collides with another peer in the SAME cwd. `#N` is appended to the name
242
+ * (matching the cwd-lock's suffix scheme) until the address is free; for a
243
+ * legacy peer (cwd "") the address is the name, preserving global-name `#N`.
244
+ */
245
+ _uniqueIdentity(cwd, requested) {
246
+ const sanitized = sanitizeMeshName(requested);
247
+ let address = composeAddress({ cwd, name: sanitized });
248
+ if (!this.peers.has(address))
249
+ return { name: sanitized, address };
250
+ // Collision: strip any client-provided `#N`, then re-suffix from #2.
251
+ const base = sanitized.replace(/#\d+$/, "");
136
252
  for (let n = 2; n < 1000; n++) {
137
- const candidate = `${requested}#${n}`;
138
- if (!this.peers.has(candidate))
139
- return candidate;
253
+ const name = `${base}#${n}`;
254
+ address = composeAddress({ cwd, name });
255
+ if (!this.peers.has(address))
256
+ return { name, address };
140
257
  }
141
- throw new Error(`name space exhausted for ${requested}`);
258
+ throw new Error(`name space exhausted for ${base} in ${cwd || "(no cwd)"}`);
142
259
  }
143
260
  _onClose(conn) {
144
- if (!conn.name)
261
+ if (!conn.address)
145
262
  return;
146
- this.peers.delete(conn.name);
147
- this._broadcastSystem({ type: "peer_left", name: conn.name }, conn.name);
263
+ this.peers.delete(conn.address);
264
+ this._broadcastSystem({ type: "peer_left", name: conn.address, address: conn.address }, conn.address);
148
265
  }
149
266
  // ── routing ───────────────────────────────────────────────────────────────
150
267
  async _route(env) {
@@ -193,7 +310,15 @@ export class Broker {
193
310
  }
194
311
  _resolveTargets(env) {
195
312
  if (env.to === "broadcast") {
196
- return this.peerNames().filter((n) => n !== env.from);
313
+ // plan/38 decision C: broadcast is scoped to the sender's cwd (folder
314
+ // colleagues), local-only. A peer in /a/b never hears /a/c. The sender is
315
+ // keyed by its address (= env.from); legacy peers (cwd "") broadcast among
316
+ // other cwd-less peers, matching pre-plan/38 behavior.
317
+ const sender = this.peers.get(env.from);
318
+ const scope = sender?.cwd ?? "";
319
+ return [...this.peers.values()]
320
+ .filter((p) => p.address !== env.from && p.cwd === scope)
321
+ .map((p) => p.address);
197
322
  }
198
323
  if (Array.isArray(env.to)) {
199
324
  return env.to.filter((n) => n !== env.from);
@@ -234,14 +359,16 @@ export class Broker {
234
359
  if (!body || typeof body !== "object")
235
360
  return;
236
361
  if (body.type === "list_peers") {
237
- const remote = this.remoteRouter ? this.remoteRouter.listRemotePeers() : [];
238
- const peers = [...this.peerNames(), ...remote];
239
362
  const reply = {
240
363
  from: BROKER_NAME,
241
364
  to: env.from,
242
365
  id: uuidv7(),
243
366
  re: env.id,
244
- body: { type: "list_peers_reply", peers },
367
+ body: {
368
+ type: "list_peers_reply",
369
+ peers: this._allPeerNames(), // addresses — legacy clients route by these
370
+ peers_detailed: this._allPeerInfos(), // plan/38 — clients group without parsing
371
+ },
245
372
  };
246
373
  const peer = this.peers.get(env.from);
247
374
  if (peer) {
@@ -256,13 +383,13 @@ export class Broker {
256
383
  // delivery on busy state. The Pi extension still publishes working state
257
384
  // as room_meta over the relay (index.ts), independent of the broker.
258
385
  }
259
- _broadcastSystem(body, excludeName) {
260
- for (const [name, peer] of this.peers) {
261
- if (name === excludeName)
386
+ _broadcastSystem(body, excludeAddress) {
387
+ for (const [address, peer] of this.peers) {
388
+ if (address === excludeAddress)
262
389
  continue;
263
390
  const env = {
264
391
  from: BROKER_NAME,
265
- to: name,
392
+ to: address,
266
393
  id: uuidv7(),
267
394
  re: null,
268
395
  body,