pi-post 0.1.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/DESIGN.md +132 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/pi-post.mjs +272 -0
- package/extensions/pi-post.ts +237 -0
- package/package.json +53 -0
- package/src/address.ts +62 -0
- package/src/format.ts +40 -0
- package/src/letter.ts +69 -0
- package/src/mailbox.ts +149 -0
- package/src/policy.ts +45 -0
- package/src/registry.ts +95 -0
- package/src/resolve.ts +60 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { registryDir } from "./mailbox.ts";
|
|
4
|
+
|
|
5
|
+
export interface SessionRecord {
|
|
6
|
+
v: 1;
|
|
7
|
+
address: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
/** Display name: pi session name when set, else the cwd's basename. */
|
|
10
|
+
name: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
/** Standing address of the session's canonical cwd. */
|
|
13
|
+
standing: string;
|
|
14
|
+
pid?: number;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
lastSeen: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Presence = "live" | "offline";
|
|
20
|
+
|
|
21
|
+
/** A record outlives the process that wrote it; shutdown marks, never removes. */
|
|
22
|
+
export function writeRecord(root: string, record: SessionRecord): void {
|
|
23
|
+
const dir = registryDir(root);
|
|
24
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
const path = join(dir, `${record.address}.json`);
|
|
26
|
+
const tmp = `${path}.tmp`;
|
|
27
|
+
writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
28
|
+
renameSync(tmp, path);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readRecord(root: string, address: string): SessionRecord | null {
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(join(registryDir(root), `${address}.json`), "utf8");
|
|
34
|
+
const record = JSON.parse(raw) as SessionRecord;
|
|
35
|
+
return record.v === 1 && typeof record.address === "string" ? record : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function listRecords(root: string): SessionRecord[] {
|
|
42
|
+
let names: string[];
|
|
43
|
+
try {
|
|
44
|
+
names = readdirSync(registryDir(root));
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const records: SessionRecord[] = [];
|
|
49
|
+
for (const name of names) {
|
|
50
|
+
if (!name.endsWith(".json")) continue;
|
|
51
|
+
const record = readRecord(root, name.slice(0, -".json".length));
|
|
52
|
+
if (record) records.push(record);
|
|
53
|
+
}
|
|
54
|
+
return records;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function touchRecord(root: string, address: string): void {
|
|
58
|
+
const record = readRecord(root, address);
|
|
59
|
+
if (record) writeRecord(root, { ...record, lastSeen: Date.now() });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function markOffline(root: string, address: string): void {
|
|
63
|
+
const record = readRecord(root, address);
|
|
64
|
+
if (record) {
|
|
65
|
+
const { pid: _pid, ...rest } = record;
|
|
66
|
+
writeRecord(root, { ...rest, lastSeen: Date.now() });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pidAlive(pid: number): boolean {
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, 0);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function presence(record: SessionRecord): Presence {
|
|
80
|
+
return record.pid !== undefined && pidAlive(record.pid) ? "live" : "offline";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Remove registry records for sessions that are offline and stale. Mail is never touched. */
|
|
84
|
+
export function sweepRegistry(root: string, maxAgeMs = 30 * 24 * 60 * 60 * 1000): void {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
for (const record of listRecords(root)) {
|
|
87
|
+
if (presence(record) === "offline" && now - record.lastSeen > maxAgeMs) {
|
|
88
|
+
try {
|
|
89
|
+
unlinkSync(join(registryDir(root), `${record.address}.json`));
|
|
90
|
+
} catch {
|
|
91
|
+
// already gone
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { canonicalPath, isAddress, looksLikePath, standingAddress } from "./address.ts";
|
|
3
|
+
import { listRecords, presence, type SessionRecord } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
export interface ResolvedTarget {
|
|
6
|
+
address: string;
|
|
7
|
+
/** Human-readable description of what the address names. */
|
|
8
|
+
display: string;
|
|
9
|
+
record?: SessionRecord;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class AmbiguousTargetError extends Error {
|
|
13
|
+
constructor(target: string, candidates: SessionRecord[]) {
|
|
14
|
+
const lines = candidates.map((r) => ` ${r.name} (${r.address}) — ${r.cwd}`);
|
|
15
|
+
super(`"${target}" matches more than one session; use an address:\n${lines.join("\n")}`);
|
|
16
|
+
this.name = "AmbiguousTargetError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class UnknownTargetError extends Error {
|
|
21
|
+
constructor(target: string) {
|
|
22
|
+
super(`"${target}" is not an address, a directory path, or a known session name`);
|
|
23
|
+
this.name = "UnknownTargetError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a target string to an address. Refuses rather than guesses:
|
|
29
|
+
* - an explicit address is taken as-is
|
|
30
|
+
* - anything path-shaped becomes the directory's standing address
|
|
31
|
+
* - otherwise it must match exactly one registered session by name
|
|
32
|
+
* (live sessions outrank offline ones before ambiguity is declared)
|
|
33
|
+
*/
|
|
34
|
+
export function resolveTarget(root: string, target: string, cwd?: string): ResolvedTarget {
|
|
35
|
+
const trimmed = target.trim();
|
|
36
|
+
if (isAddress(trimmed)) {
|
|
37
|
+
const record = listRecords(root).find((r) => r.address === trimmed);
|
|
38
|
+
return { address: trimmed, display: record ? `${record.name} (${record.cwd})` : trimmed, record };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (looksLikePath(trimmed)) {
|
|
42
|
+
const canonical = canonicalPath(trimmed, cwd);
|
|
43
|
+
return { address: standingAddress(canonical), display: canonical };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const records = listRecords(root);
|
|
47
|
+
const byName = records.filter((r) => r.name === trimmed);
|
|
48
|
+
const byBase = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === trimmed);
|
|
49
|
+
let matches = byBase;
|
|
50
|
+
if (matches.length > 1) {
|
|
51
|
+
const live = matches.filter((r) => presence(r) === "live");
|
|
52
|
+
if (live.length === 1) matches = live;
|
|
53
|
+
}
|
|
54
|
+
if (matches.length === 1) {
|
|
55
|
+
const record = matches[0]!;
|
|
56
|
+
return { address: record.address, display: `${record.name} (${record.cwd})`, record };
|
|
57
|
+
}
|
|
58
|
+
if (matches.length > 1) throw new AmbiguousTargetError(trimmed, matches);
|
|
59
|
+
throw new UnknownTargetError(trimmed);
|
|
60
|
+
}
|