@nicknisi/pi-relay 0.3.5 → 0.4.1
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 +6 -2
- package/core.ts +3 -1
- package/discovery.ts +152 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.test.js +23 -2
- package/dist/discovery.d.ts +48 -0
- package/dist/discovery.js +97 -0
- package/dist/discovery.test.d.ts +1 -0
- package/dist/discovery.test.js +95 -0
- package/dist/extension.test.d.ts +1 -0
- package/dist/extension.test.js +190 -0
- package/dist/filesystem.js +1 -1
- package/dist/format.d.ts +0 -2
- package/dist/format.js +0 -6
- package/dist/index.js +107 -45
- package/dist/mailbox.d.ts +9 -6
- package/dist/mailbox.js +125 -11
- package/dist/receipts.test.d.ts +1 -0
- package/dist/receipts.test.js +183 -0
- package/dist/registry.js +7 -0
- package/dist/relay.test.js +3 -7
- package/dist/routing.d.ts +18 -0
- package/dist/routing.js +58 -0
- package/filesystem.ts +1 -1
- package/format.ts +0 -8
- package/index.ts +130 -56
- package/mailbox.ts +135 -10
- package/package.json +4 -2
- package/registry.ts +6 -0
- package/routing.ts +70 -0
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ The `@nicknisi/pi-relay/core` subpath exports a narrow record and alias registry
|
|
|
21
21
|
|
|
22
22
|
### Durable core inbox claims
|
|
23
23
|
|
|
24
|
-
A non-Pi consumer can atomically detach the current inbox with `claimInbox(root, addr)`. It receives only a stable `claimToken` and stable `fileTokens`, never filesystem paths. New deposits immediately land in a fresh inbox. `readClaimedLetter` reopens and validates a claimed letter without following links; after the consumer durably writes and `fsync`s its own journal, `ackClaimedLetter`
|
|
24
|
+
A non-Pi consumer can atomically detach the current inbox with `claimInbox(root, addr)`. It receives only a stable `claimToken` and stable `fileTokens`, never filesystem paths. New deposits immediately land in a fresh inbox. `readClaimedLetter` reopens and validates a claimed letter without following links; after the consumer durably writes and `fsync`s its own journal, `ackClaimedLetter` atomically persists exact delivery proof in `<addr>.acks` before deleting the claimed file, or `requeueClaimedLetter` atomically returns it to the current inbox. `recoverInboxClaims` enumerates the same tokens after a process crash. All claim, read, ack, and requeue work is relative to pinned root/inbox/claim/ack descriptors, and empty completed claims are removed automatically. `sweep` removes proof more than seven days after acknowledgement, including for live sessions; proof never keeps an expired session's record alive. The destructive `drain` API does not acknowledge delivery.
|
|
25
25
|
|
|
26
26
|
## Audit log
|
|
27
27
|
|
|
@@ -51,10 +51,14 @@ main moved; rebase before you push.
|
|
|
51
51
|
|
|
52
52
|
- **A mailbox outlives the process.** The address is a hash of the working directory and pi's session id, so a session resumed with `pi -c` answers to the same address. Mail sent to a closed session waits on disk and is read when it resumes — the common case when you're opening and closing terminals all day.
|
|
53
53
|
- **The queue is inspectable.** Diagnosing delivery is `ls`, not instrumenting a transport.
|
|
54
|
-
- **Delivery
|
|
54
|
+
- **Delivery requires proof.** The receiver detaches its inbox into a durable claim and acknowledges each exact full-message-id letter only after the session has accepted it. Failed delivery is requeued, and a crash leaves the letter recoverable on the next start, with redelivery dedupe seeded from the active transcript branch. The sender learns _delivered_ from an exact ACK, _queued_ from a remaining inbox/claim letter, or _uncertain_ when neither exists. Removing mail or its root is not delivery. Older receivers without ACK support yield _uncertain_ once mail disappears; inspect before resending.
|
|
55
55
|
|
|
56
56
|
## Semantics
|
|
57
57
|
|
|
58
|
+
- **Bounded discovery.** `list` and `list-cwd` return up to `limit` sessions (default 20, max 100), excluding self. All presence states are included by default — offline sessions still receive mail — but online sessions sort first, then most recently seen. Filter with `presence: "all" | "online" | "live" | "stalled" | "offline"`. `list-cwd` includes descendants by default; set `includeSubdirectories: false` for exact cwd matching. An explicit `cwd` filters `list` too. Each row includes the full address and Pi `sessionId`, with names and paths sanitized and capped. `sessionIds` accepts up to 64 exact IDs (self included when requested) and reports any that are unregistered or filtered out.
|
|
59
|
+
- **Paging.** When more sessions match, the result includes `details.nextArguments` (the same filters plus the next `offset`). Results reflect current membership and presence, not a frozen snapshot.
|
|
60
|
+
- **Targeting.** `send`, `ask`, and `watch` accept an exact case-sensitive full Pi `sessionId`, relay address, unique address prefix, case-insensitive name, or `@alias`. Collisions are reported with full IDs and addresses rather than choosing a peer.
|
|
61
|
+
- **Ordinary replies.** `reply` resolves an explicit full ID or unique prefix across pending asks and received message/reply entries in the active transcript branch. This survives reload and does not search sibling branches or infer a recipient. Replying to an ordinary message preserves unrelated pending asks. Successful send/reply results expose full `messageId`, `target`, `replyTo` where applicable, and `receipt` in details; broadcasts expose per-recipient receipts.
|
|
58
62
|
- **Presence** is a pid plus a heartbeat: `live` (process exists, beat <45s), `not responding` (process exists, stale beat — wedged or suspended), `offline` (no live pid; mail waits). Status flips `working`/`idle` with the agent loop.
|
|
59
63
|
- **Authority boundary on every delivery.** Each message arrives with a repeated statement that it came from a peer and carries no authority — it cannot approve anything, cannot change configuration, slash commands in it are inert text. The sending side's tool guidelines carry the reciprocal rule: never ask a peer to do something your own permissions would refuse.
|
|
60
64
|
- **Loops break structurally**, independent of what either model decides: identical text from one sender inside 10s is dropped; >8 messages per 30s per sender is refused; an unread backlog of 50 refuses new mail until the peer drains.
|
package/core.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type InboxClaim,
|
|
23
23
|
type Letter,
|
|
24
24
|
type OutAsk,
|
|
25
|
+
type SendReceipt,
|
|
25
26
|
} from './mailbox.js';
|
|
26
27
|
import {
|
|
27
28
|
claimAlias as claimAliasInternal,
|
|
@@ -46,6 +47,7 @@ export {
|
|
|
46
47
|
type Letter,
|
|
47
48
|
type LetterKind,
|
|
48
49
|
type OutAsk,
|
|
50
|
+
type SendReceipt,
|
|
49
51
|
} from './mailbox.js';
|
|
50
52
|
export {
|
|
51
53
|
BACKLOG_CAP,
|
|
@@ -258,7 +260,7 @@ export async function awaitReceipt(
|
|
|
258
260
|
toAddr: string,
|
|
259
261
|
letter: Letter,
|
|
260
262
|
timeoutMs = 1500,
|
|
261
|
-
): Promise<
|
|
263
|
+
): Promise<SendReceipt> {
|
|
262
264
|
assertAddress(toAddr);
|
|
263
265
|
assertLetter(letter);
|
|
264
266
|
return awaitReceiptInternal(root, toAddr, letter, timeoutMs);
|
package/discovery.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { listRecords, presenceOf, type Presence, type SessionRecord } from './registry.js';
|
|
3
|
+
|
|
4
|
+
export type PresenceFilter = 'all' | 'online' | 'live' | 'stalled' | 'offline';
|
|
5
|
+
|
|
6
|
+
export interface RelayDiscoveryArguments {
|
|
7
|
+
action: 'list' | 'list-cwd';
|
|
8
|
+
cwd?: string;
|
|
9
|
+
includeSubdirectories?: boolean;
|
|
10
|
+
sessionIds?: string[];
|
|
11
|
+
presence?: PresenceFilter;
|
|
12
|
+
limit?: number;
|
|
13
|
+
offset?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RelayDiscoveryInput = RelayDiscoveryArguments & {
|
|
17
|
+
readonly root: string;
|
|
18
|
+
readonly selfAddress: string;
|
|
19
|
+
readonly activeCwd: string;
|
|
20
|
+
readonly now?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface RelayDiscoverySession {
|
|
24
|
+
readonly sessionId: string;
|
|
25
|
+
readonly address: string;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly cwd: string;
|
|
28
|
+
readonly presence: Presence;
|
|
29
|
+
readonly activity: SessionRecord['status'];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RelayDiscoveryDetails {
|
|
33
|
+
readonly outcome: 'success';
|
|
34
|
+
readonly action: 'list' | 'list-cwd';
|
|
35
|
+
readonly total: number;
|
|
36
|
+
readonly offset: number;
|
|
37
|
+
readonly returned: number;
|
|
38
|
+
readonly sessions: readonly RelayDiscoverySession[];
|
|
39
|
+
/** Requested sessionIds that are not registered or were excluded by cwd/presence. */
|
|
40
|
+
readonly missingSessionIds: readonly string[];
|
|
41
|
+
readonly hasMore: boolean;
|
|
42
|
+
readonly nextArguments?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DISCOVERY_DEFAULT_LIMIT = 20;
|
|
46
|
+
export const DISCOVERY_MAX_LIMIT = 100;
|
|
47
|
+
const DISPLAY_MAX_CHARS = 96;
|
|
48
|
+
const PRESENCE_RANK: Record<Presence, number> = { live: 0, stalled: 1, offline: 2 };
|
|
49
|
+
|
|
50
|
+
/** Strip terminal escapes/control characters and cap length — names and cwds are peer-controlled. */
|
|
51
|
+
function display(value: string): string {
|
|
52
|
+
// oxlint-disable-next-line no-control-regex
|
|
53
|
+
const cleaned = value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\u0000-\u001f\u007f]/g, ' ');
|
|
54
|
+
const collapsed = cleaned.replace(/\s+/g, ' ').trim();
|
|
55
|
+
return collapsed.length <= DISPLAY_MAX_CHARS ? collapsed : `${collapsed.slice(0, DISPLAY_MAX_CHARS - 1)}…`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchesPresence(presence: Presence, filter: PresenceFilter): boolean {
|
|
59
|
+
if (filter === 'all') return true;
|
|
60
|
+
if (filter === 'online') return presence !== 'offline';
|
|
61
|
+
return presence === filter;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function inCwd(recordCwd: string, cwd: string, includeSubdirectories: boolean): boolean {
|
|
65
|
+
if (!includeSubdirectories) return recordCwd === cwd;
|
|
66
|
+
const relative = path.relative(path.resolve(cwd), path.resolve(recordCwd));
|
|
67
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Bounded session discovery. Online sessions sort first, then most recently
|
|
72
|
+
* seen, so the default page shows reachable peers without hiding offline
|
|
73
|
+
* mailboxes (mail to a closed session is a first-class use case).
|
|
74
|
+
*/
|
|
75
|
+
export function discoverRelaySessions(input: RelayDiscoveryInput): { text: string; details: RelayDiscoveryDetails } {
|
|
76
|
+
const limit = input.limit ?? DISCOVERY_DEFAULT_LIMIT;
|
|
77
|
+
const offset = input.offset ?? 0;
|
|
78
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > DISCOVERY_MAX_LIMIT)
|
|
79
|
+
throw new Error(`limit must be an integer from 1 to ${DISCOVERY_MAX_LIMIT}.`);
|
|
80
|
+
if (!Number.isInteger(offset) || offset < 0) throw new Error('offset must be a non-negative integer.');
|
|
81
|
+
|
|
82
|
+
const now = input.now ?? Date.now();
|
|
83
|
+
const cwd = input.action === 'list-cwd' ? (input.cwd ?? input.activeCwd) : input.cwd;
|
|
84
|
+
const includeSubdirectories = input.includeSubdirectories !== false;
|
|
85
|
+
const filter = input.presence ?? 'all';
|
|
86
|
+
const wanted = input.sessionIds ? new Set(input.sessionIds) : undefined;
|
|
87
|
+
|
|
88
|
+
const matching = listRecords(input.root)
|
|
89
|
+
.filter((r) => (wanted ? wanted.has(r.sessionId) : r.addr !== input.selfAddress))
|
|
90
|
+
.filter((r) => cwd === undefined || inCwd(r.cwd, cwd, includeSubdirectories))
|
|
91
|
+
.map((record) => ({ record, presence: presenceOf(record, now) }))
|
|
92
|
+
.filter(({ presence }) => matchesPresence(presence, filter))
|
|
93
|
+
.sort(
|
|
94
|
+
(a, b) =>
|
|
95
|
+
PRESENCE_RANK[a.presence] - PRESENCE_RANK[b.presence] ||
|
|
96
|
+
b.record.lastSeenAt - a.record.lastSeenAt ||
|
|
97
|
+
a.record.addr.localeCompare(b.record.addr),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const sessions = matching.slice(offset, offset + limit).map(({ record, presence }): RelayDiscoverySession => ({
|
|
101
|
+
sessionId: record.sessionId,
|
|
102
|
+
address: record.addr,
|
|
103
|
+
name: display(record.name),
|
|
104
|
+
cwd: display(record.cwd),
|
|
105
|
+
presence,
|
|
106
|
+
activity: record.status,
|
|
107
|
+
}));
|
|
108
|
+
const found = new Set(matching.map(({ record }) => record.sessionId));
|
|
109
|
+
const missingSessionIds = input.sessionIds?.filter((id) => !found.has(id)) ?? [];
|
|
110
|
+
const hasMore = offset + sessions.length < matching.length;
|
|
111
|
+
const nextArguments = hasMore
|
|
112
|
+
? {
|
|
113
|
+
action: input.action,
|
|
114
|
+
...(input.cwd !== undefined && { cwd: input.cwd }),
|
|
115
|
+
...(input.includeSubdirectories !== undefined && { includeSubdirectories: input.includeSubdirectories }),
|
|
116
|
+
...(input.sessionIds !== undefined && { sessionIds: input.sessionIds }),
|
|
117
|
+
...(input.presence !== undefined && { presence: input.presence }),
|
|
118
|
+
limit,
|
|
119
|
+
offset: offset + sessions.length,
|
|
120
|
+
}
|
|
121
|
+
: undefined;
|
|
122
|
+
|
|
123
|
+
const scope =
|
|
124
|
+
cwd === undefined ? '' : ` in ${includeSubdirectories ? 'cwd subtree' : 'cwd'} ${JSON.stringify(display(cwd))}`;
|
|
125
|
+
const lines = [
|
|
126
|
+
`${matching.length} session${matching.length === 1 ? '' : 's'}${scope}${filter === 'all' ? '' : ` (presence=${filter})`}; showing ${sessions.length === 0 ? 'none' : `${offset + 1}-${offset + sessions.length}`}.`,
|
|
127
|
+
...sessions.map(
|
|
128
|
+
(s) =>
|
|
129
|
+
`sessionId=${JSON.stringify(s.sessionId)} address=${s.address} presence=${s.presence} activity=${s.activity} name=${JSON.stringify(s.name)}${cwd === undefined || s.cwd !== cwd ? ` cwd=${JSON.stringify(s.cwd)}` : ''}`,
|
|
130
|
+
),
|
|
131
|
+
];
|
|
132
|
+
if (missingSessionIds.length > 0)
|
|
133
|
+
lines.push(
|
|
134
|
+
`Not found (unregistered or filtered out): ${missingSessionIds.map((id) => JSON.stringify(id)).join(', ')}`,
|
|
135
|
+
);
|
|
136
|
+
if (nextArguments) lines.push(`More sessions available. Continue with ${JSON.stringify(nextArguments)}.`);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
text: lines.join('\n'),
|
|
140
|
+
details: {
|
|
141
|
+
outcome: 'success',
|
|
142
|
+
action: input.action,
|
|
143
|
+
total: matching.length,
|
|
144
|
+
offset,
|
|
145
|
+
returned: sessions.length,
|
|
146
|
+
sessions,
|
|
147
|
+
missingSessionIds,
|
|
148
|
+
hasMore,
|
|
149
|
+
...(nextArguments && { nextArguments }),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
package/dist/core.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Pi-free relay registry, mailbox, and policy API for non-extension consumers. */
|
|
2
|
-
import { type AuditRecord, type InboxClaim, type Letter, type OutAsk } from './mailbox.js';
|
|
2
|
+
import { type AuditRecord, type InboxClaim, type Letter, type OutAsk, type SendReceipt } from './mailbox.js';
|
|
3
3
|
import { type AliasRecord, type Presence, type SessionRecord } from './registry.js';
|
|
4
|
-
export { MAX_BODY_CHARS, previewBody, type AuditRecord, type InboxClaim, type Letter, type LetterKind, type OutAsk, } from './mailbox.js';
|
|
4
|
+
export { MAX_BODY_CHARS, previewBody, type AuditRecord, type InboxClaim, type Letter, type LetterKind, type OutAsk, type SendReceipt, } from './mailbox.js';
|
|
5
5
|
export { BACKLOG_CAP, DEDUPE_WINDOW_MS, OutboundPolicy, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, inboundAccepts, type OutboundVerdict, } from './policy.js';
|
|
6
6
|
export { HEARTBEAT_STALE_MS, SWEEP_MAIL_KEEP_MS, deriveAddr, isValidAliasName, type AliasRecord, type Presence, type SessionRecord, } from './registry.js';
|
|
7
7
|
export declare function readRecord(root: string, addr: string): SessionRecord | null;
|
|
@@ -21,7 +21,7 @@ export declare function readClaimedLetter(root: string, addr: string, claimToken
|
|
|
21
21
|
export declare function ackClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean;
|
|
22
22
|
export declare function requeueClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean;
|
|
23
23
|
export declare function watchInbox(root: string, addr: string, onMail: () => void): () => void;
|
|
24
|
-
export declare function awaitReceipt(root: string, toAddr: string, letter: Letter, timeoutMs?: number): Promise<
|
|
24
|
+
export declare function awaitReceipt(root: string, toAddr: string, letter: Letter, timeoutMs?: number): Promise<SendReceipt>;
|
|
25
25
|
export declare function trackIncomingAsk(root: string, addr: string, letter: Letter): void;
|
|
26
26
|
export declare function trackOutgoingAsk(root: string, addr: string, out: OutAsk): void;
|
|
27
27
|
export declare function readIncomingAsk(root: string, addr: string, askId: string): Letter | null;
|
package/dist/core.test.js
CHANGED
|
@@ -322,8 +322,27 @@ it('builds, packs, installs, and imports the real core package without Pi or TUI
|
|
|
322
322
|
const tarball = path.join(base, 'relay.tgz');
|
|
323
323
|
const appDir = path.join(base, 'app');
|
|
324
324
|
fs.mkdirSync(appDir);
|
|
325
|
-
|
|
326
|
-
|
|
325
|
+
// The repository build script rebuilds every package, even with --filter.
|
|
326
|
+
// Keep that mutation away from concurrent tests and their mocked workspace imports.
|
|
327
|
+
const sourceRoot = path.join(base, 'source');
|
|
328
|
+
const sourceRelay = path.join(sourceRoot, 'packages', 'relay');
|
|
329
|
+
fs.mkdirSync(path.join(sourceRoot, 'scripts'), { recursive: true });
|
|
330
|
+
fs.mkdirSync(sourceRelay, { recursive: true });
|
|
331
|
+
fs.writeFileSync(path.join(sourceRoot, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
|
332
|
+
fs.copyFileSync(path.join(repoRoot, 'tsconfig.json'), path.join(sourceRoot, 'tsconfig.json'));
|
|
333
|
+
fs.copyFileSync(path.join(repoRoot, 'scripts', 'build.ts'), path.join(sourceRoot, 'scripts', 'build.ts'));
|
|
334
|
+
for (const entry of fs.readdirSync(relayDir, { withFileTypes: true })) {
|
|
335
|
+
if (entry.isFile() &&
|
|
336
|
+
(entry.name.endsWith('.ts') || ['package.json', 'README.md', 'LICENSE'].includes(entry.name))) {
|
|
337
|
+
fs.copyFileSync(path.join(relayDir, entry.name), path.join(sourceRelay, entry.name));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
fs.symlinkSync(path.join(repoRoot, 'node_modules'), path.join(sourceRoot, 'node_modules'), 'dir');
|
|
341
|
+
if (fs.existsSync(path.join(relayDir, 'node_modules'))) {
|
|
342
|
+
fs.symlinkSync(path.join(relayDir, 'node_modules'), path.join(sourceRelay, 'node_modules'), 'dir');
|
|
343
|
+
}
|
|
344
|
+
execFileSync(process.execPath, [path.join(sourceRoot, 'scripts', 'build.ts')], { cwd: sourceRoot, stdio: 'pipe' });
|
|
345
|
+
execFileSync('pnpm', ['pack', '--out', tarball], { cwd: sourceRelay, stdio: 'pipe' });
|
|
327
346
|
fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({
|
|
328
347
|
private: true,
|
|
329
348
|
type: 'module',
|
|
@@ -338,6 +357,8 @@ it('builds, packs, installs, and imports the real core package without Pi or TUI
|
|
|
338
357
|
expect(fs.existsSync(path.join(installedDir, target))).toBe(true);
|
|
339
358
|
}
|
|
340
359
|
expect(fs.existsSync(path.join(installedDir, 'filesystem.ts'))).toBe(true);
|
|
360
|
+
expect(fs.existsSync(path.join(installedDir, 'discovery.ts'))).toBe(true);
|
|
361
|
+
expect(fs.existsSync(path.join(installedDir, 'routing.ts'))).toBe(true);
|
|
341
362
|
expect(packageJson.peerDependenciesMeta['@earendil-works/pi-coding-agent']?.optional).toBe(true);
|
|
342
363
|
expect(packageJson.peerDependenciesMeta['@earendil-works/pi-tui']?.optional).toBe(true);
|
|
343
364
|
expect(fs.existsSync(path.join(appDir, 'node_modules', '@earendil-works', 'pi-coding-agent'))).toBe(false);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Presence, type SessionRecord } from './registry.js';
|
|
2
|
+
export type PresenceFilter = 'all' | 'online' | 'live' | 'stalled' | 'offline';
|
|
3
|
+
export interface RelayDiscoveryArguments {
|
|
4
|
+
action: 'list' | 'list-cwd';
|
|
5
|
+
cwd?: string;
|
|
6
|
+
includeSubdirectories?: boolean;
|
|
7
|
+
sessionIds?: string[];
|
|
8
|
+
presence?: PresenceFilter;
|
|
9
|
+
limit?: number;
|
|
10
|
+
offset?: number;
|
|
11
|
+
}
|
|
12
|
+
export type RelayDiscoveryInput = RelayDiscoveryArguments & {
|
|
13
|
+
readonly root: string;
|
|
14
|
+
readonly selfAddress: string;
|
|
15
|
+
readonly activeCwd: string;
|
|
16
|
+
readonly now?: number;
|
|
17
|
+
};
|
|
18
|
+
export interface RelayDiscoverySession {
|
|
19
|
+
readonly sessionId: string;
|
|
20
|
+
readonly address: string;
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly cwd: string;
|
|
23
|
+
readonly presence: Presence;
|
|
24
|
+
readonly activity: SessionRecord['status'];
|
|
25
|
+
}
|
|
26
|
+
export interface RelayDiscoveryDetails {
|
|
27
|
+
readonly outcome: 'success';
|
|
28
|
+
readonly action: 'list' | 'list-cwd';
|
|
29
|
+
readonly total: number;
|
|
30
|
+
readonly offset: number;
|
|
31
|
+
readonly returned: number;
|
|
32
|
+
readonly sessions: readonly RelayDiscoverySession[];
|
|
33
|
+
/** Requested sessionIds that are not registered or were excluded by cwd/presence. */
|
|
34
|
+
readonly missingSessionIds: readonly string[];
|
|
35
|
+
readonly hasMore: boolean;
|
|
36
|
+
readonly nextArguments?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
export declare const DISCOVERY_DEFAULT_LIMIT = 20;
|
|
39
|
+
export declare const DISCOVERY_MAX_LIMIT = 100;
|
|
40
|
+
/**
|
|
41
|
+
* Bounded session discovery. Online sessions sort first, then most recently
|
|
42
|
+
* seen, so the default page shows reachable peers without hiding offline
|
|
43
|
+
* mailboxes (mail to a closed session is a first-class use case).
|
|
44
|
+
*/
|
|
45
|
+
export declare function discoverRelaySessions(input: RelayDiscoveryInput): {
|
|
46
|
+
text: string;
|
|
47
|
+
details: RelayDiscoveryDetails;
|
|
48
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { listRecords, presenceOf } from './registry.js';
|
|
3
|
+
export const DISCOVERY_DEFAULT_LIMIT = 20;
|
|
4
|
+
export const DISCOVERY_MAX_LIMIT = 100;
|
|
5
|
+
const DISPLAY_MAX_CHARS = 96;
|
|
6
|
+
const PRESENCE_RANK = { live: 0, stalled: 1, offline: 2 };
|
|
7
|
+
/** Strip terminal escapes/control characters and cap length — names and cwds are peer-controlled. */
|
|
8
|
+
function display(value) {
|
|
9
|
+
// oxlint-disable-next-line no-control-regex
|
|
10
|
+
const cleaned = value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\u0000-\u001f\u007f]/g, ' ');
|
|
11
|
+
const collapsed = cleaned.replace(/\s+/g, ' ').trim();
|
|
12
|
+
return collapsed.length <= DISPLAY_MAX_CHARS ? collapsed : `${collapsed.slice(0, DISPLAY_MAX_CHARS - 1)}…`;
|
|
13
|
+
}
|
|
14
|
+
function matchesPresence(presence, filter) {
|
|
15
|
+
if (filter === 'all')
|
|
16
|
+
return true;
|
|
17
|
+
if (filter === 'online')
|
|
18
|
+
return presence !== 'offline';
|
|
19
|
+
return presence === filter;
|
|
20
|
+
}
|
|
21
|
+
function inCwd(recordCwd, cwd, includeSubdirectories) {
|
|
22
|
+
if (!includeSubdirectories)
|
|
23
|
+
return recordCwd === cwd;
|
|
24
|
+
const relative = path.relative(path.resolve(cwd), path.resolve(recordCwd));
|
|
25
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Bounded session discovery. Online sessions sort first, then most recently
|
|
29
|
+
* seen, so the default page shows reachable peers without hiding offline
|
|
30
|
+
* mailboxes (mail to a closed session is a first-class use case).
|
|
31
|
+
*/
|
|
32
|
+
export function discoverRelaySessions(input) {
|
|
33
|
+
const limit = input.limit ?? DISCOVERY_DEFAULT_LIMIT;
|
|
34
|
+
const offset = input.offset ?? 0;
|
|
35
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > DISCOVERY_MAX_LIMIT)
|
|
36
|
+
throw new Error(`limit must be an integer from 1 to ${DISCOVERY_MAX_LIMIT}.`);
|
|
37
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
38
|
+
throw new Error('offset must be a non-negative integer.');
|
|
39
|
+
const now = input.now ?? Date.now();
|
|
40
|
+
const cwd = input.action === 'list-cwd' ? (input.cwd ?? input.activeCwd) : input.cwd;
|
|
41
|
+
const includeSubdirectories = input.includeSubdirectories !== false;
|
|
42
|
+
const filter = input.presence ?? 'all';
|
|
43
|
+
const wanted = input.sessionIds ? new Set(input.sessionIds) : undefined;
|
|
44
|
+
const matching = listRecords(input.root)
|
|
45
|
+
.filter((r) => (wanted ? wanted.has(r.sessionId) : r.addr !== input.selfAddress))
|
|
46
|
+
.filter((r) => cwd === undefined || inCwd(r.cwd, cwd, includeSubdirectories))
|
|
47
|
+
.map((record) => ({ record, presence: presenceOf(record, now) }))
|
|
48
|
+
.filter(({ presence }) => matchesPresence(presence, filter))
|
|
49
|
+
.sort((a, b) => PRESENCE_RANK[a.presence] - PRESENCE_RANK[b.presence] ||
|
|
50
|
+
b.record.lastSeenAt - a.record.lastSeenAt ||
|
|
51
|
+
a.record.addr.localeCompare(b.record.addr));
|
|
52
|
+
const sessions = matching.slice(offset, offset + limit).map(({ record, presence }) => ({
|
|
53
|
+
sessionId: record.sessionId,
|
|
54
|
+
address: record.addr,
|
|
55
|
+
name: display(record.name),
|
|
56
|
+
cwd: display(record.cwd),
|
|
57
|
+
presence,
|
|
58
|
+
activity: record.status,
|
|
59
|
+
}));
|
|
60
|
+
const found = new Set(matching.map(({ record }) => record.sessionId));
|
|
61
|
+
const missingSessionIds = input.sessionIds?.filter((id) => !found.has(id)) ?? [];
|
|
62
|
+
const hasMore = offset + sessions.length < matching.length;
|
|
63
|
+
const nextArguments = hasMore
|
|
64
|
+
? {
|
|
65
|
+
action: input.action,
|
|
66
|
+
...(input.cwd !== undefined && { cwd: input.cwd }),
|
|
67
|
+
...(input.includeSubdirectories !== undefined && { includeSubdirectories: input.includeSubdirectories }),
|
|
68
|
+
...(input.sessionIds !== undefined && { sessionIds: input.sessionIds }),
|
|
69
|
+
...(input.presence !== undefined && { presence: input.presence }),
|
|
70
|
+
limit,
|
|
71
|
+
offset: offset + sessions.length,
|
|
72
|
+
}
|
|
73
|
+
: undefined;
|
|
74
|
+
const scope = cwd === undefined ? '' : ` in ${includeSubdirectories ? 'cwd subtree' : 'cwd'} ${JSON.stringify(display(cwd))}`;
|
|
75
|
+
const lines = [
|
|
76
|
+
`${matching.length} session${matching.length === 1 ? '' : 's'}${scope}${filter === 'all' ? '' : ` (presence=${filter})`}; showing ${sessions.length === 0 ? 'none' : `${offset + 1}-${offset + sessions.length}`}.`,
|
|
77
|
+
...sessions.map((s) => `sessionId=${JSON.stringify(s.sessionId)} address=${s.address} presence=${s.presence} activity=${s.activity} name=${JSON.stringify(s.name)}${cwd === undefined || s.cwd !== cwd ? ` cwd=${JSON.stringify(s.cwd)}` : ''}`),
|
|
78
|
+
];
|
|
79
|
+
if (missingSessionIds.length > 0)
|
|
80
|
+
lines.push(`Not found (unregistered or filtered out): ${missingSessionIds.map((id) => JSON.stringify(id)).join(', ')}`);
|
|
81
|
+
if (nextArguments)
|
|
82
|
+
lines.push(`More sessions available. Continue with ${JSON.stringify(nextArguments)}.`);
|
|
83
|
+
return {
|
|
84
|
+
text: lines.join('\n'),
|
|
85
|
+
details: {
|
|
86
|
+
outcome: 'success',
|
|
87
|
+
action: input.action,
|
|
88
|
+
total: matching.length,
|
|
89
|
+
offset,
|
|
90
|
+
returned: sessions.length,
|
|
91
|
+
sessions,
|
|
92
|
+
missingSessionIds,
|
|
93
|
+
hasMore,
|
|
94
|
+
...(nextArguments && { nextArguments }),
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { afterEach, expect, it } from 'vitest';
|
|
5
|
+
import { discoverRelaySessions } from './discovery.js';
|
|
6
|
+
import { deriveAddr, writeRecord } from './registry.js';
|
|
7
|
+
import { resolveReplyTarget, resolveSessionTarget } from './routing.js';
|
|
8
|
+
const fixtures = [];
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const root of fixtures.splice(0))
|
|
11
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
function fixture() {
|
|
14
|
+
const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'relay-discovery-')));
|
|
15
|
+
fixtures.push(root);
|
|
16
|
+
return root;
|
|
17
|
+
}
|
|
18
|
+
function record(id, over = {}) {
|
|
19
|
+
return {
|
|
20
|
+
addr: deriveAddr('/work', id),
|
|
21
|
+
sessionId: id,
|
|
22
|
+
name: id,
|
|
23
|
+
cwd: '/work',
|
|
24
|
+
pid: process.pid,
|
|
25
|
+
startedAt: 100,
|
|
26
|
+
lastSeenAt: Date.now(),
|
|
27
|
+
status: 'idle',
|
|
28
|
+
...over,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function input(root, over = {}) {
|
|
32
|
+
return { action: 'list-cwd', root, selfAddress: deriveAddr('/work', 'self'), activeCwd: '/work', ...over };
|
|
33
|
+
}
|
|
34
|
+
it('defaults to all peers sorted online-first, includes descendants, and supports exact lookups', () => {
|
|
35
|
+
const root = fixture();
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const r of [
|
|
38
|
+
record('self'),
|
|
39
|
+
record('offline', { offline: true, lastSeenAt: now }),
|
|
40
|
+
record('older', { lastSeenAt: now - 1000 }),
|
|
41
|
+
record('live', { lastSeenAt: now }),
|
|
42
|
+
record('child', { cwd: '/work/child', lastSeenAt: now - 2000 }),
|
|
43
|
+
record('sibling', { cwd: '/workspace' }),
|
|
44
|
+
])
|
|
45
|
+
writeRecord(root, r);
|
|
46
|
+
const all = discoverRelaySessions(input(root, { now }));
|
|
47
|
+
expect(all.details.sessions.map((r) => r.sessionId)).toEqual(['live', 'older', 'child', 'offline']);
|
|
48
|
+
expect(discoverRelaySessions(input(root, { now, includeSubdirectories: false })).details.sessions.map((r) => r.sessionId)).toEqual(['live', 'older', 'offline']);
|
|
49
|
+
expect(discoverRelaySessions(input(root, { now, presence: 'online' })).details.sessions.map((r) => r.sessionId)).toEqual(['live', 'older', 'child']);
|
|
50
|
+
const exact = discoverRelaySessions(input(root, { now, sessionIds: ['offline', 'self', 'sibling', 'unknown'] }));
|
|
51
|
+
expect(exact.details.sessions.map((r) => r.sessionId)).toEqual(['self', 'offline']);
|
|
52
|
+
expect(exact.details.missingSessionIds).toEqual(['sibling', 'unknown']);
|
|
53
|
+
expect(discoverRelaySessions(input(root, { action: 'list', now })).details.sessions.map((r) => r.sessionId)).toContain('sibling');
|
|
54
|
+
});
|
|
55
|
+
it('pages with nextArguments and sanitizes peer-controlled display fields', () => {
|
|
56
|
+
const root = fixture();
|
|
57
|
+
for (let i = 0; i < 25; i++)
|
|
58
|
+
writeRecord(root, record(`session-${String(i).padStart(2, '0')}`, { name: `\u001b[31mname-${i}\u0007` }));
|
|
59
|
+
const first = discoverRelaySessions(input(root));
|
|
60
|
+
expect(first.details).toMatchObject({ returned: 20, total: 25, hasMore: true });
|
|
61
|
+
expect(first.text).not.toContain('\u001b');
|
|
62
|
+
expect(first.text).toContain(first.details.sessions[0].address);
|
|
63
|
+
const next = discoverRelaySessions(input(root, first.details.nextArguments));
|
|
64
|
+
expect(next.details).toMatchObject({ offset: 20, returned: 5, hasMore: false });
|
|
65
|
+
const seen = [...first.details.sessions, ...next.details.sessions].map((r) => r.sessionId);
|
|
66
|
+
expect(new Set(seen).size).toBe(25);
|
|
67
|
+
expect(() => discoverRelaySessions(input(root, { limit: 0 }))).toThrow(/limit/);
|
|
68
|
+
expect(() => discoverRelaySessions(input(root, { offset: -1 }))).toThrow(/offset/);
|
|
69
|
+
writeRecord(root, record('long', { name: 'x'.repeat(500) }));
|
|
70
|
+
const long = discoverRelaySessions(input(root, { sessionIds: ['long'] })).details.sessions[0];
|
|
71
|
+
expect(long.name.length).toBeLessThanOrEqual(96);
|
|
72
|
+
});
|
|
73
|
+
it('routes exact session IDs case-sensitively and refuses collisions instead of guessing', () => {
|
|
74
|
+
const peer = record('Exact-ID', { name: 'Human title' });
|
|
75
|
+
expect(resolveSessionTarget(peer.sessionId, [peer], 'self').record).toEqual(peer);
|
|
76
|
+
expect(resolveSessionTarget('exact-id', [peer], 'self').error).toBeDefined();
|
|
77
|
+
expect(resolveSessionTarget(peer.sessionId, [peer, record('other', { name: peer.sessionId })], 'self').error).toContain('ambiguous');
|
|
78
|
+
expect(resolveSessionTarget(peer.sessionId, [peer, { ...peer, addr: deriveAddr('/other', peer.sessionId) }], 'self').error).toContain('ambiguous');
|
|
79
|
+
expect(resolveSessionTarget(peer.sessionId, [peer], peer.addr).error).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
it('resolves ordinary replies from only the active branch and rejects conflicting senders and watcher entries', () => {
|
|
82
|
+
const id = 'ordinary-message';
|
|
83
|
+
const peer = record('peer');
|
|
84
|
+
const entry = {
|
|
85
|
+
type: 'custom_message',
|
|
86
|
+
customType: 'relay:delivery',
|
|
87
|
+
details: { id, kind: 'message', from: { addr: peer.addr } },
|
|
88
|
+
};
|
|
89
|
+
expect(resolveReplyTarget(id, [], [entry]).target).toEqual({ id, addr: peer.addr, ask: false });
|
|
90
|
+
expect(resolveReplyTarget(id, [], []).error).toContain('active conversation');
|
|
91
|
+
expect(resolveReplyTarget(id, [], [{ ...entry, details: { ...entry.details, kind: 'cancel' } }]).error).toBeDefined();
|
|
92
|
+
expect(resolveReplyTarget(id, [], [entry, { ...entry, details: { ...entry.details, from: { addr: record('other').addr } } }]).error).toContain('ambiguous');
|
|
93
|
+
expect(resolveReplyTarget(id, [], [entry, entry]).target?.addr).toBe(peer.addr);
|
|
94
|
+
expect(resolveReplyTarget(id, [], [entry, { ...entry, details: { ...entry.details, id: `${id}-longer` } }]).target?.id).toBe(id);
|
|
95
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|