@pipemd-core/pipemd 1.0.1 → 1.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/CHANGELOG.md +25 -0
- package/README.md +29 -0
- package/dist/index.js +725 -30
- package/dist/plugins/opencode-server.js +23 -1
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to PipeMD.
|
|
4
4
|
|
|
5
|
+
## [1.1.0] — 2026-05-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`pmd link` — Cross-machine crew federation** — connects PipeMD daemons across machines and Docker containers so crew sessions (agent coordination data) are shared in real time
|
|
10
|
+
- `pmd-linkd` relay server — one per machine, aggregates crew sessions from all local daemons, syncs with remote relays
|
|
11
|
+
- `POST /crew` endpoint — daemons push local sessions, receive merged remote sessions for their group
|
|
12
|
+
- `POST /sync` endpoint — relay-to-relay bidirectional sync of all groups, bearer token auth
|
|
13
|
+
- `GET /status` endpoint — monitoring: group counts, peer connection status
|
|
14
|
+
- `GET /health` endpoint — liveness check
|
|
15
|
+
- Named groups — coordination scopes that route sessions to the right project daemon (default: repo directory name, configurable via `link.group` or `PMD_GROUP` env var)
|
|
16
|
+
- Daemon relay client — embedded in each daemon, auto-starts when `PMD_RELAY` or `link.relay` is configured
|
|
17
|
+
- Remote session merge — `listSessions()` returns local + remote sessions; `renderCrewBlock()` tags remote agents with `· remote: <hostname>`
|
|
18
|
+
- Cross-machine conflict detection — `findConflicts()` detects file claim conflicts across machines
|
|
19
|
+
- Session expiry — remote sessions not refreshed within 15s are evicted from the relay's in-memory store
|
|
20
|
+
- Docker support — container daemons connect to relay via `PMD_RELAY=http://relay:9741` (Docker DNS)
|
|
21
|
+
- Zero new dependencies — pure Node.js `http` module
|
|
22
|
+
|
|
23
|
+
### Test Suite
|
|
24
|
+
|
|
25
|
+
- 121 assertions, 0 failures across 10 suites
|
|
26
|
+
- `test:unit` (19) — reverseInject + link relay unit tests
|
|
27
|
+
- `test:link` (17) — relay lifecycle, cross-origin exchange, group isolation, conflict detection, token auth
|
|
28
|
+
- All existing suites unchanged: e2e (36), bidir (27), arch (63), compose (17), crew (92), scripts (79), inject (47)
|
|
29
|
+
|
|
5
30
|
## [1.0.0] — 2026-05-21
|
|
6
31
|
|
|
7
32
|
### Added
|
package/README.md
CHANGED
|
@@ -281,6 +281,35 @@ Crew hooks are available for Claude Code, OpenCode, and Gemini CLI — agents au
|
|
|
281
281
|
|
|
282
282
|
---
|
|
283
283
|
|
|
284
|
+
## Known Limitations
|
|
285
|
+
|
|
286
|
+
### FIFO (Named Pipe) Read Errors with Some Agents
|
|
287
|
+
|
|
288
|
+
Agents backed by Effect.js (e.g. OpenCode) may fail to read pipe-mode context files with errors like:
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
Unknown: FileSystem.readAlloc (30)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Root cause:** These agents use Effect.js's `FileSystem.readAlloc`, which expects regular file semantics (seekable, known size). PipeMD's named pipes are FIFOs — stream-oriented, unseekable, and `stat()` reports size 0. When the agent passes `limit`/`offset`, Effect.js tries to `seek()` on the FIFO and fails.
|
|
295
|
+
|
|
296
|
+
**Mitigation:** The PipeMD OpenCode plugin automatically detects FIFO reads and redirects them to a regular temp file rendered by `pmd run`. This should resolve the error for most setups. Make sure your plugin is up to date (`pmd crew install-hooks` or `pmd init`).
|
|
297
|
+
|
|
298
|
+
**Fallback workaround:** If the plugin fix doesn't cover your case, switch the affected pipe to legacy mode in `.pipemd/config.yml`:
|
|
299
|
+
|
|
300
|
+
```yaml
|
|
301
|
+
pipes:
|
|
302
|
+
- file: AGENTS.md
|
|
303
|
+
render: .pipemd/template.md
|
|
304
|
+
mode: legacy # writes a regular file instead of a FIFO
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Legacy mode writes a real file to disk, so agents read it normally. You lose zero-disk-write semantics, but context stays live.
|
|
308
|
+
|
|
309
|
+
**Tracking:** If you hit this, please comment on or open an issue so we can track which agents are affected.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
284
313
|
## Contributing
|
|
285
314
|
|
|
286
315
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for build instructions, source layout, and architecture details.
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
5
|
-
import
|
|
4
|
+
import { Command as Command15 } from "commander";
|
|
5
|
+
import chalk19 from "chalk";
|
|
6
6
|
|
|
7
7
|
// src/commands/init.ts
|
|
8
8
|
import { Command } from "commander";
|
|
@@ -1257,6 +1257,7 @@ function storePayload(trigger, payload) {
|
|
|
1257
1257
|
try {
|
|
1258
1258
|
const tool = (input && input.tool) || "";
|
|
1259
1259
|
const args = (output && output.args) || {};
|
|
1260
|
+
if (tool === "read") resolveFifoRead(args);
|
|
1260
1261
|
const trigger = isEditTool(tool) ? "before-edit" : "before-read";
|
|
1261
1262
|
const filePath = extractFilePath(args);
|
|
1262
1263
|
join(); heartbeat();
|
|
@@ -1279,12 +1280,14 @@ function storePayload(trigger, payload) {
|
|
|
1279
1280
|
try {
|
|
1280
1281
|
const tool = (input && input.tool) || "";
|
|
1281
1282
|
const args = (output && output.args) || {};
|
|
1283
|
+
if (tool === "read") resolveFifoRead(args);
|
|
1282
1284
|
join(); heartbeat();
|
|
1283
1285
|
pushEvent("before", tool, extractFilePath(args), "ok", 0);
|
|
1284
1286
|
} catch (e) { logPluginError("tool.execute.before", e); }
|
|
1285
1287
|
},`;
|
|
1286
1288
|
const afterHandler = withInjection ? `"tool.execute.after": async (input, output) => {
|
|
1287
1289
|
try {
|
|
1290
|
+
cleanupFifoTemp();
|
|
1288
1291
|
const tool = (input && input.tool) || "";
|
|
1289
1292
|
const isEdit = isEditTool(tool);
|
|
1290
1293
|
if (isEdit) {
|
|
@@ -1308,6 +1311,7 @@ function storePayload(trigger, payload) {
|
|
|
1308
1311
|
} catch (e) { logPluginError("tool.execute.after", e); }
|
|
1309
1312
|
},` : `"tool.execute.after": async (input, output) => {
|
|
1310
1313
|
try {
|
|
1314
|
+
cleanupFifoTemp();
|
|
1311
1315
|
const tool = (input && input.tool) || "";
|
|
1312
1316
|
if (!isEditTool(tool)) return;
|
|
1313
1317
|
const args = (output && output.args) || (input && input.args) || {};
|
|
@@ -1705,7 +1709,9 @@ function generateInjectionYml(config) {
|
|
|
1705
1709
|
|
|
1706
1710
|
// src/core/paths.ts
|
|
1707
1711
|
import path10 from "path";
|
|
1712
|
+
import os3 from "os";
|
|
1708
1713
|
var PIPEMD_DIR = ".pipemd";
|
|
1714
|
+
var HOME_LINK_DIR = path10.join(os3.homedir(), ".pipemd", "link");
|
|
1709
1715
|
var LIVE_DIR = path10.join(PIPEMD_DIR, "live");
|
|
1710
1716
|
var PID_FILE = path10.join(PIPEMD_DIR, ".daemon.pid");
|
|
1711
1717
|
var STATUS_FILE = path10.join(PIPEMD_DIR, ".status.json");
|
|
@@ -3170,7 +3176,7 @@ function listSessions() {
|
|
|
3170
3176
|
try {
|
|
3171
3177
|
files = fs10.readdirSync(CREW_DIR2);
|
|
3172
3178
|
} catch {
|
|
3173
|
-
|
|
3179
|
+
files = [];
|
|
3174
3180
|
}
|
|
3175
3181
|
const out = [];
|
|
3176
3182
|
for (const f of files) {
|
|
@@ -3181,6 +3187,7 @@ function listSessions() {
|
|
|
3181
3187
|
} catch {
|
|
3182
3188
|
}
|
|
3183
3189
|
}
|
|
3190
|
+
out.push(...remoteSessionsCache);
|
|
3184
3191
|
return out;
|
|
3185
3192
|
}
|
|
3186
3193
|
function deleteSession(id) {
|
|
@@ -3189,6 +3196,7 @@ function deleteSession(id) {
|
|
|
3189
3196
|
} catch {
|
|
3190
3197
|
}
|
|
3191
3198
|
}
|
|
3199
|
+
var remoteSessionsCache = [];
|
|
3192
3200
|
function isPidAlive(pid) {
|
|
3193
3201
|
if (!pid || pid <= 0) return false;
|
|
3194
3202
|
try {
|
|
@@ -3240,8 +3248,8 @@ function resolveProcessCwd(pid) {
|
|
|
3240
3248
|
if (isWindows()) return void 0;
|
|
3241
3249
|
try {
|
|
3242
3250
|
const target = `/proc/${pid}/cwd`;
|
|
3243
|
-
const
|
|
3244
|
-
return typeof
|
|
3251
|
+
const link2 = fs10.readlinkSync(target);
|
|
3252
|
+
return typeof link2 === "string" ? link2 : void 0;
|
|
3245
3253
|
} catch {
|
|
3246
3254
|
return void 0;
|
|
3247
3255
|
}
|
|
@@ -3507,8 +3515,9 @@ function renderCrewBlock(opts = {}) {
|
|
|
3507
3515
|
lines.push("_No active PipeMD crew sessions._");
|
|
3508
3516
|
}
|
|
3509
3517
|
for (const coord of coordinators) {
|
|
3518
|
+
const remoteTag = coord._remote && coord._origin ? ` \xB7 remote: ${coord._origin}` : "";
|
|
3510
3519
|
lines.push("");
|
|
3511
|
-
lines.push(`\u25B8 ${coord.harness} (coordinator ${coord.id} \xB7 pid ${coord.pid})`);
|
|
3520
|
+
lines.push(`\u25B8 ${coord.harness} (coordinator ${coord.id} \xB7 pid ${coord.pid}${remoteTag})`);
|
|
3512
3521
|
if (coord.note) lines.push(` \xB7 note: ${coord.note}`);
|
|
3513
3522
|
const cc = claimList(coord);
|
|
3514
3523
|
if (cc) lines.push(` \xB7 claimed: ${cc}`);
|
|
@@ -3519,7 +3528,8 @@ function renderCrewBlock(opts = {}) {
|
|
|
3519
3528
|
const claimStr = claimed ? `claimed: ${claimed}` : "no claim";
|
|
3520
3529
|
const noteStr = w.note ? ` "${w.note}"` : "";
|
|
3521
3530
|
const flag = w.claimedFiles.some((c) => conflictPaths.has(c.path)) ? " \u26A0\uFE0F" : "";
|
|
3522
|
-
|
|
3531
|
+
const rmt = w._remote && w._origin ? ` \xB7 remote: ${w._origin}` : "";
|
|
3532
|
+
lines.push(` ${branch} ${w.label || w.id} ${claimStr}${noteStr}${flag}${rmt}`);
|
|
3523
3533
|
});
|
|
3524
3534
|
}
|
|
3525
3535
|
const unattached = workers.filter(
|
|
@@ -3702,12 +3712,12 @@ function ensureCacheDir() {
|
|
|
3702
3712
|
}
|
|
3703
3713
|
}
|
|
3704
3714
|
function readCache(key) {
|
|
3705
|
-
const
|
|
3706
|
-
if (!existsSync(
|
|
3715
|
+
const path27 = entryPath(key);
|
|
3716
|
+
if (!existsSync(path27)) {
|
|
3707
3717
|
return null;
|
|
3708
3718
|
}
|
|
3709
3719
|
try {
|
|
3710
|
-
const raw = readFileSync(
|
|
3720
|
+
const raw = readFileSync(path27, "utf-8");
|
|
3711
3721
|
const entry = JSON.parse(raw);
|
|
3712
3722
|
if (Date.now() - entry.timestamp > entry.ttl) {
|
|
3713
3723
|
return null;
|
|
@@ -3728,17 +3738,17 @@ function writeCache(key, data, ttl, metadata) {
|
|
|
3728
3738
|
ttl,
|
|
3729
3739
|
...metadata ? { metadata } : {}
|
|
3730
3740
|
};
|
|
3731
|
-
const
|
|
3732
|
-
atomicWrite(
|
|
3741
|
+
const path27 = entryPath(key);
|
|
3742
|
+
atomicWrite(path27, JSON.stringify(entry));
|
|
3733
3743
|
return entry;
|
|
3734
3744
|
}
|
|
3735
3745
|
function isFresh(key) {
|
|
3736
3746
|
return readCache(key) !== null;
|
|
3737
3747
|
}
|
|
3738
3748
|
function invalidate(key) {
|
|
3739
|
-
const
|
|
3740
|
-
if (existsSync(
|
|
3741
|
-
unlinkSync(
|
|
3749
|
+
const path27 = entryPath(key);
|
|
3750
|
+
if (existsSync(path27)) {
|
|
3751
|
+
unlinkSync(path27);
|
|
3742
3752
|
}
|
|
3743
3753
|
}
|
|
3744
3754
|
|
|
@@ -3761,18 +3771,18 @@ function loadSession(sessionId) {
|
|
|
3761
3771
|
return {};
|
|
3762
3772
|
}
|
|
3763
3773
|
}
|
|
3764
|
-
function saveSession(sessionId,
|
|
3774
|
+
function saveSession(sessionId, store2) {
|
|
3765
3775
|
ensureInjectedDir();
|
|
3766
|
-
atomicWrite(sessionPath(sessionId), JSON.stringify(
|
|
3776
|
+
atomicWrite(sessionPath(sessionId), JSON.stringify(store2));
|
|
3767
3777
|
}
|
|
3768
3778
|
function recordInjection(sessionId, source, content) {
|
|
3769
|
-
const
|
|
3770
|
-
|
|
3771
|
-
saveSession(sessionId,
|
|
3779
|
+
const store2 = loadSession(sessionId);
|
|
3780
|
+
store2[source] = { hash: computePayloadHash(content), timestamp: Date.now() };
|
|
3781
|
+
saveSession(sessionId, store2);
|
|
3772
3782
|
}
|
|
3773
3783
|
function checkInjectionStatus(sessionId, source, content) {
|
|
3774
|
-
const
|
|
3775
|
-
const entry =
|
|
3784
|
+
const store2 = loadSession(sessionId);
|
|
3785
|
+
const entry = store2[source];
|
|
3776
3786
|
if (!entry) return "new";
|
|
3777
3787
|
const hash = computePayloadHash(content);
|
|
3778
3788
|
return hash === entry.hash ? "unchanged" : "changed";
|
|
@@ -3836,6 +3846,115 @@ function tailLog(lines = 20) {
|
|
|
3836
3846
|
}
|
|
3837
3847
|
}
|
|
3838
3848
|
|
|
3849
|
+
// src/core/net/daemon-client.ts
|
|
3850
|
+
import http from "http";
|
|
3851
|
+
import os4 from "os";
|
|
3852
|
+
|
|
3853
|
+
// src/core/net/protocol.ts
|
|
3854
|
+
var DEFAULT_PORT = 9741;
|
|
3855
|
+
var POLL_INTERVAL_MS = 5e3;
|
|
3856
|
+
var SESSION_EXPIRY_MS = 15e3;
|
|
3857
|
+
|
|
3858
|
+
// src/core/net/daemon-client.ts
|
|
3859
|
+
var remoteCache = [];
|
|
3860
|
+
var pollTimer = null;
|
|
3861
|
+
function setRemoteSessions(sessions) {
|
|
3862
|
+
remoteCache = sessions;
|
|
3863
|
+
}
|
|
3864
|
+
function clearRemoteSessions() {
|
|
3865
|
+
remoteCache = [];
|
|
3866
|
+
}
|
|
3867
|
+
function relayUrl() {
|
|
3868
|
+
return process.env.PMD_RELAY || null;
|
|
3869
|
+
}
|
|
3870
|
+
function postToRelay(url, body) {
|
|
3871
|
+
return new Promise((resolve2, reject) => {
|
|
3872
|
+
const data = JSON.stringify(body);
|
|
3873
|
+
const req = http.request(
|
|
3874
|
+
{
|
|
3875
|
+
hostname: url.hostname,
|
|
3876
|
+
port: url.port || 9741,
|
|
3877
|
+
path: url.pathname || "/crew",
|
|
3878
|
+
method: "POST",
|
|
3879
|
+
timeout: 5e3,
|
|
3880
|
+
headers: {
|
|
3881
|
+
"Content-Type": "application/json",
|
|
3882
|
+
"Content-Length": Buffer.byteLength(data)
|
|
3883
|
+
}
|
|
3884
|
+
},
|
|
3885
|
+
(res) => {
|
|
3886
|
+
const chunks = [];
|
|
3887
|
+
res.on("data", (c) => chunks.push(c));
|
|
3888
|
+
res.on("end", () => {
|
|
3889
|
+
if (res.statusCode !== 200) {
|
|
3890
|
+
reject(new Error(`relay responded ${res.statusCode}`));
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
try {
|
|
3894
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
3895
|
+
const sessions = (parsed.sessions || []).map((s) => ({
|
|
3896
|
+
...s,
|
|
3897
|
+
_remote: true,
|
|
3898
|
+
_origin: s._origin || "remote"
|
|
3899
|
+
}));
|
|
3900
|
+
resolve2({ sessions });
|
|
3901
|
+
} catch (e) {
|
|
3902
|
+
reject(e);
|
|
3903
|
+
}
|
|
3904
|
+
});
|
|
3905
|
+
}
|
|
3906
|
+
);
|
|
3907
|
+
req.on("error", reject);
|
|
3908
|
+
req.on("timeout", () => {
|
|
3909
|
+
req.destroy();
|
|
3910
|
+
reject(new Error("relay timeout"));
|
|
3911
|
+
});
|
|
3912
|
+
req.write(data);
|
|
3913
|
+
req.end();
|
|
3914
|
+
});
|
|
3915
|
+
}
|
|
3916
|
+
async function syncWithRelay(group, sessions) {
|
|
3917
|
+
const urlStr = relayUrl();
|
|
3918
|
+
if (!urlStr) return [];
|
|
3919
|
+
try {
|
|
3920
|
+
const url = new URL(urlStr);
|
|
3921
|
+
const msg = {
|
|
3922
|
+
group,
|
|
3923
|
+
hostname: os4.hostname(),
|
|
3924
|
+
sessions
|
|
3925
|
+
};
|
|
3926
|
+
const result = await postToRelay(url, msg);
|
|
3927
|
+
return result.sessions;
|
|
3928
|
+
} catch (e) {
|
|
3929
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
3930
|
+
if (!msg.includes("ECONNREFUSED") && !msg.includes("timeout")) {
|
|
3931
|
+
log.warn(`Relay client: ${msg}`);
|
|
3932
|
+
}
|
|
3933
|
+
return [];
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
function startRelayClient(group, getLocalSessions) {
|
|
3937
|
+
if (pollTimer) return;
|
|
3938
|
+
if (!relayUrl()) return;
|
|
3939
|
+
const poll = async () => {
|
|
3940
|
+
try {
|
|
3941
|
+
const local = getLocalSessions();
|
|
3942
|
+
const remote = await syncWithRelay(group, local);
|
|
3943
|
+
setRemoteSessions(remote);
|
|
3944
|
+
} catch {
|
|
3945
|
+
}
|
|
3946
|
+
};
|
|
3947
|
+
poll();
|
|
3948
|
+
pollTimer = setInterval(poll, POLL_INTERVAL_MS);
|
|
3949
|
+
}
|
|
3950
|
+
function stopRelayClient() {
|
|
3951
|
+
if (pollTimer) {
|
|
3952
|
+
clearInterval(pollTimer);
|
|
3953
|
+
pollTimer = null;
|
|
3954
|
+
}
|
|
3955
|
+
clearRemoteSessions();
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3839
3958
|
// src/core/daemon.ts
|
|
3840
3959
|
var WRITE_BUFFER_DEBOUNCE_MS = 1e3;
|
|
3841
3960
|
var INJECTION_LOG_MAX_AGE_MS = 36e5;
|
|
@@ -4474,10 +4593,25 @@ function runDaemon() {
|
|
|
4474
4593
|
} else if (legacyModePipes.length === 0) {
|
|
4475
4594
|
startLegacyWatcher(config);
|
|
4476
4595
|
}
|
|
4477
|
-
process.on("SIGTERM", () =>
|
|
4478
|
-
|
|
4596
|
+
process.on("SIGTERM", () => {
|
|
4597
|
+
stopRelayClient();
|
|
4598
|
+
shutdown(allPipePaths);
|
|
4599
|
+
});
|
|
4600
|
+
process.on("SIGINT", () => {
|
|
4601
|
+
stopRelayClient();
|
|
4602
|
+
shutdown(allPipePaths);
|
|
4603
|
+
});
|
|
4479
4604
|
process.on("SIGHUP", () => {
|
|
4480
4605
|
});
|
|
4606
|
+
const relayUrl2 = config.link?.relay || process.env.PMD_RELAY;
|
|
4607
|
+
if (relayUrl2) {
|
|
4608
|
+
const groupName = config.link?.group || path15.basename(process.cwd());
|
|
4609
|
+
startRelayClient(groupName, () => {
|
|
4610
|
+
const all = listSessions();
|
|
4611
|
+
return all.filter((s) => !s._remote);
|
|
4612
|
+
});
|
|
4613
|
+
log.info(`Relay client started: ${relayUrl2} (group: ${groupName})`);
|
|
4614
|
+
}
|
|
4481
4615
|
process.on("uncaughtException", (err) => {
|
|
4482
4616
|
log.error(`Uncaught exception: ${err.message}`);
|
|
4483
4617
|
shutdown(allPipePaths);
|
|
@@ -6551,8 +6685,561 @@ var trace = new Command13("trace").description("Live resolution tree \u2014 debu
|
|
|
6551
6685
|
});
|
|
6552
6686
|
var traceCommand = trace;
|
|
6553
6687
|
|
|
6688
|
+
// src/commands/link.ts
|
|
6689
|
+
import { Command as Command14 } from "commander";
|
|
6690
|
+
import chalk18 from "chalk";
|
|
6691
|
+
import http2 from "http";
|
|
6692
|
+
import crypto3 from "crypto";
|
|
6693
|
+
import fs25 from "fs";
|
|
6694
|
+
import path25 from "path";
|
|
6695
|
+
import os5 from "os";
|
|
6696
|
+
import { spawn as spawn3, execSync as sleepExec } from "child_process";
|
|
6697
|
+
var LINK_DIR = path25.join(os5.homedir(), ".pipemd", "link");
|
|
6698
|
+
var PID_FILE3 = path25.join(LINK_DIR, "relay.pid");
|
|
6699
|
+
var TOKEN_FILE = path25.join(LINK_DIR, "relay.token");
|
|
6700
|
+
var PORT_FILE = path25.join(LINK_DIR, "relay.port");
|
|
6701
|
+
var PEERS_FILE = path25.join(LINK_DIR, "peers.json");
|
|
6702
|
+
function ensureLinkDir() {
|
|
6703
|
+
fs25.mkdirSync(LINK_DIR, { recursive: true });
|
|
6704
|
+
}
|
|
6705
|
+
function readRelayPid() {
|
|
6706
|
+
try {
|
|
6707
|
+
return parseInt(fs25.readFileSync(PID_FILE3, "utf-8").trim(), 10) || null;
|
|
6708
|
+
} catch {
|
|
6709
|
+
return null;
|
|
6710
|
+
}
|
|
6711
|
+
}
|
|
6712
|
+
function isRelayRunning() {
|
|
6713
|
+
const pid = readRelayPid();
|
|
6714
|
+
if (!pid) return false;
|
|
6715
|
+
try {
|
|
6716
|
+
process.kill(pid, 0);
|
|
6717
|
+
return true;
|
|
6718
|
+
} catch {
|
|
6719
|
+
try {
|
|
6720
|
+
fs25.unlinkSync(PID_FILE3);
|
|
6721
|
+
} catch {
|
|
6722
|
+
}
|
|
6723
|
+
return false;
|
|
6724
|
+
}
|
|
6725
|
+
}
|
|
6726
|
+
function readRelayPort() {
|
|
6727
|
+
try {
|
|
6728
|
+
return parseInt(fs25.readFileSync(PORT_FILE, "utf-8").trim(), 10) || DEFAULT_PORT;
|
|
6729
|
+
} catch {
|
|
6730
|
+
return DEFAULT_PORT;
|
|
6731
|
+
}
|
|
6732
|
+
}
|
|
6733
|
+
function readOrGenerateToken() {
|
|
6734
|
+
try {
|
|
6735
|
+
if (fs25.existsSync(TOKEN_FILE)) {
|
|
6736
|
+
return fs25.readFileSync(TOKEN_FILE, "utf-8").trim();
|
|
6737
|
+
}
|
|
6738
|
+
} catch {
|
|
6739
|
+
}
|
|
6740
|
+
const token = crypto3.randomBytes(16).toString("hex");
|
|
6741
|
+
ensureLinkDir();
|
|
6742
|
+
fs25.writeFileSync(TOKEN_FILE, token, "utf-8");
|
|
6743
|
+
return token;
|
|
6744
|
+
}
|
|
6745
|
+
function readPeers() {
|
|
6746
|
+
try {
|
|
6747
|
+
if (!fs25.existsSync(PEERS_FILE)) return [];
|
|
6748
|
+
return JSON.parse(fs25.readFileSync(PEERS_FILE, "utf-8"));
|
|
6749
|
+
} catch {
|
|
6750
|
+
return [];
|
|
6751
|
+
}
|
|
6752
|
+
}
|
|
6753
|
+
function writePeers(peers) {
|
|
6754
|
+
ensureLinkDir();
|
|
6755
|
+
fs25.writeFileSync(PEERS_FILE, JSON.stringify(peers, null, 2), "utf-8");
|
|
6756
|
+
}
|
|
6757
|
+
function startRelayProcess() {
|
|
6758
|
+
const selfPath = process.argv[1];
|
|
6759
|
+
const child = spawn3(process.execPath, [selfPath, "_linkd"], {
|
|
6760
|
+
cwd: process.cwd(),
|
|
6761
|
+
detached: true,
|
|
6762
|
+
stdio: "ignore"
|
|
6763
|
+
});
|
|
6764
|
+
child.unref();
|
|
6765
|
+
return child.pid;
|
|
6766
|
+
}
|
|
6767
|
+
function httpGet(urlStr) {
|
|
6768
|
+
return new Promise((resolve2) => {
|
|
6769
|
+
try {
|
|
6770
|
+
const url = new URL(urlStr);
|
|
6771
|
+
const req = http2.get(
|
|
6772
|
+
{ hostname: url.hostname, port: url.port || DEFAULT_PORT, path: url.pathname || "/health", timeout: 3e3 },
|
|
6773
|
+
(res) => {
|
|
6774
|
+
const chunks = [];
|
|
6775
|
+
res.on("data", (c) => chunks.push(c));
|
|
6776
|
+
res.on("end", () => {
|
|
6777
|
+
try {
|
|
6778
|
+
resolve2({ ok: res.statusCode === 200, data: JSON.parse(Buffer.concat(chunks).toString("utf-8")) });
|
|
6779
|
+
} catch {
|
|
6780
|
+
resolve2({ ok: res.statusCode === 200, data: null });
|
|
6781
|
+
}
|
|
6782
|
+
});
|
|
6783
|
+
}
|
|
6784
|
+
);
|
|
6785
|
+
req.on("error", () => resolve2({ ok: false, data: null }));
|
|
6786
|
+
req.on("timeout", () => {
|
|
6787
|
+
req.destroy();
|
|
6788
|
+
resolve2({ ok: false, data: null });
|
|
6789
|
+
});
|
|
6790
|
+
} catch {
|
|
6791
|
+
resolve2({ ok: false, data: null });
|
|
6792
|
+
}
|
|
6793
|
+
});
|
|
6794
|
+
}
|
|
6795
|
+
function httpGetStatus(host, token) {
|
|
6796
|
+
return new Promise((resolve2) => {
|
|
6797
|
+
try {
|
|
6798
|
+
const [h, portStr] = host.split(":");
|
|
6799
|
+
const port = parseInt(portStr || "9741", 10);
|
|
6800
|
+
const headers = {};
|
|
6801
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
6802
|
+
const req = http2.get({ hostname: h, port, path: "/status", timeout: 3e3, headers }, (res) => {
|
|
6803
|
+
const chunks = [];
|
|
6804
|
+
res.on("data", (c) => chunks.push(c));
|
|
6805
|
+
res.on("end", () => {
|
|
6806
|
+
try {
|
|
6807
|
+
resolve2(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
6808
|
+
} catch {
|
|
6809
|
+
resolve2(null);
|
|
6810
|
+
}
|
|
6811
|
+
});
|
|
6812
|
+
});
|
|
6813
|
+
req.on("error", () => resolve2(null));
|
|
6814
|
+
req.on("timeout", () => {
|
|
6815
|
+
req.destroy();
|
|
6816
|
+
resolve2(null);
|
|
6817
|
+
});
|
|
6818
|
+
} catch {
|
|
6819
|
+
resolve2(null);
|
|
6820
|
+
}
|
|
6821
|
+
});
|
|
6822
|
+
}
|
|
6823
|
+
function doStart() {
|
|
6824
|
+
if (isRelayRunning()) {
|
|
6825
|
+
return chalk18.dim(`Relay already running (PID ${readRelayPid()}, port ${readRelayPort()})`);
|
|
6826
|
+
}
|
|
6827
|
+
ensureLinkDir();
|
|
6828
|
+
const pid = startRelayProcess();
|
|
6829
|
+
for (let i = 0; i < 20; i++) {
|
|
6830
|
+
const check = readRelayPid();
|
|
6831
|
+
if (check && (() => {
|
|
6832
|
+
try {
|
|
6833
|
+
process.kill(check, 0);
|
|
6834
|
+
return true;
|
|
6835
|
+
} catch {
|
|
6836
|
+
return false;
|
|
6837
|
+
}
|
|
6838
|
+
})()) {
|
|
6839
|
+
const port = readRelayPort();
|
|
6840
|
+
return chalk18.green(`\u2714 Relay started (PID ${check}, port ${port})`);
|
|
6841
|
+
}
|
|
6842
|
+
sleepExec("sleep 0.25", { stdio: "ignore" });
|
|
6843
|
+
}
|
|
6844
|
+
return chalk18.yellow("\u26A0 Relay may not have started. Check with `pmd link --list`");
|
|
6845
|
+
}
|
|
6846
|
+
function formatLinkHelp() {
|
|
6847
|
+
const lines = [];
|
|
6848
|
+
const w = (name, desc) => ` ${chalk18.cyan(name.padEnd(22))}${chalk18.dim(desc)}`;
|
|
6849
|
+
lines.push(chalk18.bold("Usage:"));
|
|
6850
|
+
lines.push(w("pmd link", "Start relay and show invite command"));
|
|
6851
|
+
lines.push(w("pmd link <host:port>", "Connect to a remote relay"));
|
|
6852
|
+
lines.push("");
|
|
6853
|
+
lines.push(chalk18.bold("Options:"));
|
|
6854
|
+
lines.push(w("--token <token>", "Auth token for the remote relay"));
|
|
6855
|
+
lines.push(w("--list", "Show relay status and connected peers"));
|
|
6856
|
+
lines.push(w("--disconnect <host>", "Remove a peer connection"));
|
|
6857
|
+
lines.push(w("--stop", "Stop the relay process"));
|
|
6858
|
+
lines.push("");
|
|
6859
|
+
return "\n" + lines.join("\n") + "\n";
|
|
6860
|
+
}
|
|
6861
|
+
var link = new Command14("link").description("Connect PipeMD daemons across machines and Docker containers").configureHelp({ visibleCommands: () => [] }).addHelpText("after", formatLinkHelp());
|
|
6862
|
+
link.command("start", { hidden: true }).action(() => {
|
|
6863
|
+
const msg = doStart();
|
|
6864
|
+
if (msg) console.log(msg);
|
|
6865
|
+
});
|
|
6866
|
+
link.option("--token <token>", "auth token for remote relay").option("--list", "show relay status and connected peers").option("--disconnect <host>", "remove a peer connection").option("--stop", "stop the relay process").argument("[host]", "remote relay address (host:port)").action(async (host, opts) => {
|
|
6867
|
+
if (opts.stop) {
|
|
6868
|
+
const pid = readRelayPid();
|
|
6869
|
+
if (pid) {
|
|
6870
|
+
try {
|
|
6871
|
+
process.kill(pid, "SIGTERM");
|
|
6872
|
+
} catch {
|
|
6873
|
+
}
|
|
6874
|
+
console.log(chalk18.green(`\u2714 Relay stopped (PID ${pid})`));
|
|
6875
|
+
} else {
|
|
6876
|
+
console.log(chalk18.dim("No relay running."));
|
|
6877
|
+
}
|
|
6878
|
+
return;
|
|
6879
|
+
}
|
|
6880
|
+
if (opts.disconnect) {
|
|
6881
|
+
const peers = readPeers().filter((p) => p.host !== opts.disconnect);
|
|
6882
|
+
writePeers(peers);
|
|
6883
|
+
console.log(chalk18.green(`\u2714 Disconnected from ${opts.disconnect}`));
|
|
6884
|
+
return;
|
|
6885
|
+
}
|
|
6886
|
+
if (opts.list) {
|
|
6887
|
+
const running = isRelayRunning();
|
|
6888
|
+
const port2 = readRelayPort();
|
|
6889
|
+
const pid = readRelayPid();
|
|
6890
|
+
console.log(chalk18.bold("Relay:"));
|
|
6891
|
+
if (running) {
|
|
6892
|
+
console.log(chalk18.green(` \u2714 Running (PID ${pid}, port ${port2})`));
|
|
6893
|
+
} else {
|
|
6894
|
+
console.log(chalk18.dim(" Not running"));
|
|
6895
|
+
}
|
|
6896
|
+
const peers = readPeers();
|
|
6897
|
+
if (peers.length > 0) {
|
|
6898
|
+
console.log(chalk18.bold("\nPeers:"));
|
|
6899
|
+
for (const p of peers) {
|
|
6900
|
+
const status = await httpGetStatus(p.host, p.token);
|
|
6901
|
+
if (status && status.ok) {
|
|
6902
|
+
const groupNames = Object.keys(status.groups || {});
|
|
6903
|
+
const totalAgents = Object.values(status.groups || {}).reduce((sum, g) => sum + (g.local || 0) + (g.remote || 0), 0);
|
|
6904
|
+
console.log(chalk18.green(` \u2714 ${p.host}`) + chalk18.dim(` \u2014 ${totalAgents} agents, groups: ${groupNames.join(", ") || "none"}`));
|
|
6905
|
+
} else {
|
|
6906
|
+
console.log(chalk18.red(` \u2716 ${p.host}`) + chalk18.dim(" \u2014 unreachable"));
|
|
6907
|
+
}
|
|
6908
|
+
}
|
|
6909
|
+
} else {
|
|
6910
|
+
console.log(chalk18.dim("\n No peers configured."));
|
|
6911
|
+
}
|
|
6912
|
+
if (running) {
|
|
6913
|
+
const localStatus = await httpGetStatus(`localhost:${port2}`);
|
|
6914
|
+
if (localStatus && localStatus.groups) {
|
|
6915
|
+
const groups = localStatus.groups;
|
|
6916
|
+
const names = Object.keys(groups);
|
|
6917
|
+
if (names.length > 0) {
|
|
6918
|
+
console.log(chalk18.bold("\nGroups:"));
|
|
6919
|
+
for (const [name, info] of Object.entries(groups)) {
|
|
6920
|
+
const g = info;
|
|
6921
|
+
console.log(` ${chalk18.cyan(name)} \u2014 ${g.local} local, ${g.remote} remote`);
|
|
6922
|
+
}
|
|
6923
|
+
}
|
|
6924
|
+
}
|
|
6925
|
+
}
|
|
6926
|
+
return;
|
|
6927
|
+
}
|
|
6928
|
+
if (host) {
|
|
6929
|
+
const { ok } = await httpGet(`http://${host}/health`);
|
|
6930
|
+
if (!ok) {
|
|
6931
|
+
console.log(chalk18.red(`\u2716 Cannot reach ${host}. Is the relay running there?`));
|
|
6932
|
+
process.exit(1);
|
|
6933
|
+
}
|
|
6934
|
+
const peerToken = opts.token || "";
|
|
6935
|
+
const peers = readPeers();
|
|
6936
|
+
if (!peers.find((p) => p.host === host)) {
|
|
6937
|
+
peers.push({ host, token: peerToken });
|
|
6938
|
+
writePeers(peers);
|
|
6939
|
+
}
|
|
6940
|
+
if (!isRelayRunning()) {
|
|
6941
|
+
doStart();
|
|
6942
|
+
}
|
|
6943
|
+
console.log(chalk18.green(`\u2714 Connected to ${host}`));
|
|
6944
|
+
console.log(chalk18.dim(" Crew sessions will sync bidirectionally within 5 seconds."));
|
|
6945
|
+
return;
|
|
6946
|
+
}
|
|
6947
|
+
if (!isRelayRunning()) {
|
|
6948
|
+
doStart();
|
|
6949
|
+
}
|
|
6950
|
+
const token = readOrGenerateToken();
|
|
6951
|
+
const port = readRelayPort();
|
|
6952
|
+
const h = os5.hostname();
|
|
6953
|
+
console.log();
|
|
6954
|
+
console.log(chalk18.green(`\u2714 Token: ${token}`));
|
|
6955
|
+
console.log(chalk18.green(`\u2714 Relay: ${h}:${port}`));
|
|
6956
|
+
console.log();
|
|
6957
|
+
console.log("On the other machine, run:");
|
|
6958
|
+
console.log(chalk18.cyan(` pmd link ${h}:${port} --token ${token}`));
|
|
6959
|
+
console.log();
|
|
6960
|
+
});
|
|
6961
|
+
var linkCommand = link;
|
|
6962
|
+
|
|
6963
|
+
// src/core/net/relay.ts
|
|
6964
|
+
import http3 from "http";
|
|
6965
|
+
import os6 from "os";
|
|
6966
|
+
import fs26 from "fs";
|
|
6967
|
+
import path26 from "path";
|
|
6968
|
+
var store = /* @__PURE__ */ new Map();
|
|
6969
|
+
var peerLastSync = /* @__PURE__ */ new Map();
|
|
6970
|
+
var syncTimer = null;
|
|
6971
|
+
var server = null;
|
|
6972
|
+
function hostname() {
|
|
6973
|
+
return os6.hostname();
|
|
6974
|
+
}
|
|
6975
|
+
function readBody(req) {
|
|
6976
|
+
return new Promise((resolve2, reject) => {
|
|
6977
|
+
const chunks = [];
|
|
6978
|
+
req.on("data", (c) => chunks.push(c));
|
|
6979
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
6980
|
+
req.on("error", reject);
|
|
6981
|
+
});
|
|
6982
|
+
}
|
|
6983
|
+
function jsonResponse(res, code, data) {
|
|
6984
|
+
const body = JSON.stringify(data);
|
|
6985
|
+
res.writeHead(code, {
|
|
6986
|
+
"Content-Type": "application/json",
|
|
6987
|
+
"Content-Length": Buffer.byteLength(body)
|
|
6988
|
+
});
|
|
6989
|
+
res.end(body);
|
|
6990
|
+
}
|
|
6991
|
+
function mergeSessionsFor(group, excludeOrigin) {
|
|
6992
|
+
const origins = store.get(group);
|
|
6993
|
+
if (!origins) return [];
|
|
6994
|
+
const out = [];
|
|
6995
|
+
for (const [origin, entry] of origins) {
|
|
6996
|
+
if (origin === excludeOrigin) continue;
|
|
6997
|
+
out.push(
|
|
6998
|
+
...entry.sessions.map((s) => ({
|
|
6999
|
+
...s,
|
|
7000
|
+
_remote: true,
|
|
7001
|
+
_origin: origin
|
|
7002
|
+
}))
|
|
7003
|
+
);
|
|
7004
|
+
}
|
|
7005
|
+
return out;
|
|
7006
|
+
}
|
|
7007
|
+
function expireStaleGroups() {
|
|
7008
|
+
const now = Date.now();
|
|
7009
|
+
for (const [group, origins] of store) {
|
|
7010
|
+
for (const [origin, entry] of origins) {
|
|
7011
|
+
if (now - entry.lastSeen > SESSION_EXPIRY_MS) {
|
|
7012
|
+
origins.delete(origin);
|
|
7013
|
+
log.info(`Relay: expired ${origin}/${group} (stale ${Math.round((now - entry.lastSeen) / 1e3)}s)`);
|
|
7014
|
+
}
|
|
7015
|
+
}
|
|
7016
|
+
if (origins.size === 0) {
|
|
7017
|
+
store.delete(group);
|
|
7018
|
+
}
|
|
7019
|
+
}
|
|
7020
|
+
}
|
|
7021
|
+
function readPeers2() {
|
|
7022
|
+
try {
|
|
7023
|
+
const homeDir = os6.homedir();
|
|
7024
|
+
const peersFile = path26.join(homeDir, ".pipemd", "link", "peers.json");
|
|
7025
|
+
if (!fs26.existsSync(peersFile)) return [];
|
|
7026
|
+
return JSON.parse(fs26.readFileSync(peersFile, "utf-8"));
|
|
7027
|
+
} catch {
|
|
7028
|
+
return [];
|
|
7029
|
+
}
|
|
7030
|
+
}
|
|
7031
|
+
function readToken() {
|
|
7032
|
+
try {
|
|
7033
|
+
const homeDir = os6.homedir();
|
|
7034
|
+
const tokenFile = path26.join(homeDir, ".pipemd", "link", "relay.token");
|
|
7035
|
+
if (fs26.existsSync(tokenFile)) {
|
|
7036
|
+
return fs26.readFileSync(tokenFile, "utf-8").trim();
|
|
7037
|
+
}
|
|
7038
|
+
} catch {
|
|
7039
|
+
}
|
|
7040
|
+
return "";
|
|
7041
|
+
}
|
|
7042
|
+
function syncWithPeers() {
|
|
7043
|
+
expireStaleGroups();
|
|
7044
|
+
const peers = readPeers2();
|
|
7045
|
+
if (peers.length === 0) return;
|
|
7046
|
+
const localToken = readToken();
|
|
7047
|
+
const allGroups = {};
|
|
7048
|
+
for (const [group, origins] of store) {
|
|
7049
|
+
const sessions = [];
|
|
7050
|
+
for (const [, entry] of origins) {
|
|
7051
|
+
sessions.push(...entry.sessions);
|
|
7052
|
+
}
|
|
7053
|
+
allGroups[group] = sessions;
|
|
7054
|
+
}
|
|
7055
|
+
const myHostname = hostname();
|
|
7056
|
+
for (const peer of peers) {
|
|
7057
|
+
const [host, portStr] = peer.host.split(":");
|
|
7058
|
+
const port = parseInt(portStr || "9741", 10);
|
|
7059
|
+
const payload = { hostname: myHostname, groups: allGroups };
|
|
7060
|
+
const body = JSON.stringify(payload);
|
|
7061
|
+
const req = http3.request(
|
|
7062
|
+
{ hostname: host, port, path: "/sync", method: "POST", timeout: 5e3, headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), Authorization: `Bearer ${localToken || peer.token}` } },
|
|
7063
|
+
(res) => {
|
|
7064
|
+
const chunks = [];
|
|
7065
|
+
res.on("data", (c) => chunks.push(c));
|
|
7066
|
+
res.on("end", () => {
|
|
7067
|
+
if (res.statusCode !== 200) return;
|
|
7068
|
+
try {
|
|
7069
|
+
const remote = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
7070
|
+
const now = Date.now();
|
|
7071
|
+
for (const [group, sessions] of Object.entries(remote.groups)) {
|
|
7072
|
+
let origins = store.get(group);
|
|
7073
|
+
if (!origins) {
|
|
7074
|
+
origins = /* @__PURE__ */ new Map();
|
|
7075
|
+
store.set(group, origins);
|
|
7076
|
+
}
|
|
7077
|
+
origins.set(remote.hostname, { sessions, lastSeen: now });
|
|
7078
|
+
}
|
|
7079
|
+
peerLastSync.set(peer.host, now);
|
|
7080
|
+
} catch {
|
|
7081
|
+
log.warn(`Relay: failed to parse sync response from ${peer.host}`);
|
|
7082
|
+
}
|
|
7083
|
+
});
|
|
7084
|
+
}
|
|
7085
|
+
);
|
|
7086
|
+
req.on("error", () => {
|
|
7087
|
+
});
|
|
7088
|
+
req.on("timeout", () => req.destroy());
|
|
7089
|
+
req.write(body);
|
|
7090
|
+
req.end();
|
|
7091
|
+
}
|
|
7092
|
+
}
|
|
7093
|
+
function handleCrew(req, res) {
|
|
7094
|
+
readBody(req).then((raw) => {
|
|
7095
|
+
const msg = JSON.parse(raw);
|
|
7096
|
+
const { group, hostname: origin, sessions } = msg;
|
|
7097
|
+
let origins = store.get(group);
|
|
7098
|
+
if (!origins) {
|
|
7099
|
+
origins = /* @__PURE__ */ new Map();
|
|
7100
|
+
store.set(group, origins);
|
|
7101
|
+
}
|
|
7102
|
+
origins.set(origin, { sessions, lastSeen: Date.now() });
|
|
7103
|
+
log.info(`Relay: received ${sessions.length} session(s) for ${group} from ${origin}`);
|
|
7104
|
+
const remoteSessions = mergeSessionsFor(group, origin);
|
|
7105
|
+
jsonResponse(res, 200, { sessions: remoteSessions });
|
|
7106
|
+
}).catch(() => jsonResponse(res, 400, { error: "invalid body" }));
|
|
7107
|
+
}
|
|
7108
|
+
function handleSync(req, res) {
|
|
7109
|
+
const token = readToken();
|
|
7110
|
+
const auth = req.headers.authorization;
|
|
7111
|
+
if (token && auth !== `Bearer ${token}`) {
|
|
7112
|
+
jsonResponse(res, 403, { error: "unauthorized" });
|
|
7113
|
+
return;
|
|
7114
|
+
}
|
|
7115
|
+
readBody(req).then((raw) => {
|
|
7116
|
+
const msg = JSON.parse(raw);
|
|
7117
|
+
const now = Date.now();
|
|
7118
|
+
for (const [group, sessions] of Object.entries(msg.groups)) {
|
|
7119
|
+
let origins = store.get(group);
|
|
7120
|
+
if (!origins) {
|
|
7121
|
+
origins = /* @__PURE__ */ new Map();
|
|
7122
|
+
store.set(group, origins);
|
|
7123
|
+
}
|
|
7124
|
+
origins.set(msg.hostname, { sessions, lastSeen: now });
|
|
7125
|
+
}
|
|
7126
|
+
const myGroups = {};
|
|
7127
|
+
for (const [group, origins] of store) {
|
|
7128
|
+
const sessions = [];
|
|
7129
|
+
for (const [origin, entry] of origins) {
|
|
7130
|
+
if (origin !== msg.hostname) {
|
|
7131
|
+
sessions.push(...entry.sessions);
|
|
7132
|
+
}
|
|
7133
|
+
}
|
|
7134
|
+
if (sessions.length > 0) myGroups[group] = sessions;
|
|
7135
|
+
}
|
|
7136
|
+
peerLastSync.set(msg.hostname, now);
|
|
7137
|
+
jsonResponse(res, 200, { hostname: hostname(), groups: myGroups });
|
|
7138
|
+
}).catch(() => jsonResponse(res, 400, { error: "invalid body" }));
|
|
7139
|
+
}
|
|
7140
|
+
function handleStatus(_req, res) {
|
|
7141
|
+
const groups = {};
|
|
7142
|
+
const myHost = hostname();
|
|
7143
|
+
for (const [group, origins] of store) {
|
|
7144
|
+
let local = 0;
|
|
7145
|
+
let remote = 0;
|
|
7146
|
+
for (const [origin, entry] of origins) {
|
|
7147
|
+
if (origin === myHost) local += entry.sessions.length;
|
|
7148
|
+
else remote += entry.sessions.length;
|
|
7149
|
+
}
|
|
7150
|
+
groups[group] = { local, remote };
|
|
7151
|
+
}
|
|
7152
|
+
const peers = readPeers2().map((p) => {
|
|
7153
|
+
const ts = peerLastSync.get(p.host);
|
|
7154
|
+
return { host: p.host, lastSync: ts ? new Date(ts).toISOString() : null };
|
|
7155
|
+
});
|
|
7156
|
+
jsonResponse(res, 200, { ok: true, hostname: myHost, groups, peers });
|
|
7157
|
+
}
|
|
7158
|
+
function requestHandler(req, res) {
|
|
7159
|
+
if (req.method === "POST" && req.url === "/crew") {
|
|
7160
|
+
handleCrew(req, res);
|
|
7161
|
+
} else if (req.method === "POST" && req.url === "/sync") {
|
|
7162
|
+
handleSync(req, res);
|
|
7163
|
+
} else if (req.method === "GET" && req.url === "/status") {
|
|
7164
|
+
handleStatus(req, res);
|
|
7165
|
+
} else if (req.method === "GET" && req.url === "/health") {
|
|
7166
|
+
jsonResponse(res, 200, { ok: true, hostname: hostname() });
|
|
7167
|
+
} else {
|
|
7168
|
+
jsonResponse(res, 404, { error: "not found" });
|
|
7169
|
+
}
|
|
7170
|
+
}
|
|
7171
|
+
function startRelay(port = DEFAULT_PORT) {
|
|
7172
|
+
return new Promise((resolve2, reject) => {
|
|
7173
|
+
server = http3.createServer(requestHandler);
|
|
7174
|
+
server.on("error", (err) => {
|
|
7175
|
+
if (err.code === "EADDRINUSE") {
|
|
7176
|
+
server = null;
|
|
7177
|
+
reject(err);
|
|
7178
|
+
} else {
|
|
7179
|
+
log.error(`Relay error: ${err.message}`);
|
|
7180
|
+
}
|
|
7181
|
+
});
|
|
7182
|
+
server.listen(port, () => {
|
|
7183
|
+
const addr = server.address();
|
|
7184
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
7185
|
+
log.info(`Relay listening on port ${actualPort}`);
|
|
7186
|
+
syncTimer = setInterval(syncWithPeers, POLL_INTERVAL_MS);
|
|
7187
|
+
setInterval(expireStaleGroups, POLL_INTERVAL_MS * 3);
|
|
7188
|
+
resolve2(actualPort);
|
|
7189
|
+
});
|
|
7190
|
+
});
|
|
7191
|
+
}
|
|
7192
|
+
function stopRelay() {
|
|
7193
|
+
if (syncTimer) {
|
|
7194
|
+
clearInterval(syncTimer);
|
|
7195
|
+
syncTimer = null;
|
|
7196
|
+
}
|
|
7197
|
+
if (server) {
|
|
7198
|
+
server.close();
|
|
7199
|
+
server = null;
|
|
7200
|
+
}
|
|
7201
|
+
log.info("Relay stopped");
|
|
7202
|
+
}
|
|
7203
|
+
function runRelay() {
|
|
7204
|
+
const homeDir = os6.homedir();
|
|
7205
|
+
const linkDir = path26.join(homeDir, ".pipemd", "link");
|
|
7206
|
+
fs26.mkdirSync(linkDir, { recursive: true });
|
|
7207
|
+
const pidFile = path26.join(linkDir, "relay.pid");
|
|
7208
|
+
fs26.writeFileSync(pidFile, String(process.pid), "utf-8");
|
|
7209
|
+
process.on("SIGTERM", () => {
|
|
7210
|
+
try {
|
|
7211
|
+
fs26.unlinkSync(pidFile);
|
|
7212
|
+
} catch {
|
|
7213
|
+
}
|
|
7214
|
+
stopRelay();
|
|
7215
|
+
process.exit(0);
|
|
7216
|
+
});
|
|
7217
|
+
process.on("SIGINT", () => {
|
|
7218
|
+
try {
|
|
7219
|
+
fs26.unlinkSync(pidFile);
|
|
7220
|
+
} catch {
|
|
7221
|
+
}
|
|
7222
|
+
stopRelay();
|
|
7223
|
+
process.exit(0);
|
|
7224
|
+
});
|
|
7225
|
+
const envPort = parseInt(process.env.PMD_LINK_PORT || "", 10);
|
|
7226
|
+
let port = isNaN(envPort) ? DEFAULT_PORT : envPort;
|
|
7227
|
+
startRelay(port).then((actualPort) => {
|
|
7228
|
+
log.info(`Relay running on port ${actualPort}`);
|
|
7229
|
+
const portFile = path26.join(linkDir, "relay.port");
|
|
7230
|
+
fs26.writeFileSync(portFile, String(actualPort), "utf-8");
|
|
7231
|
+
}).catch((err) => {
|
|
7232
|
+
log.error(`Relay failed to start: ${err.message}`);
|
|
7233
|
+
try {
|
|
7234
|
+
fs26.unlinkSync(pidFile);
|
|
7235
|
+
} catch {
|
|
7236
|
+
}
|
|
7237
|
+
process.exit(1);
|
|
7238
|
+
});
|
|
7239
|
+
}
|
|
7240
|
+
|
|
6554
7241
|
// src/index.ts
|
|
6555
|
-
var program = new
|
|
7242
|
+
var program = new Command15();
|
|
6556
7243
|
var GROUPS = [
|
|
6557
7244
|
{
|
|
6558
7245
|
title: "Setup",
|
|
@@ -6590,6 +7277,10 @@ var GROUPS = [
|
|
|
6590
7277
|
{
|
|
6591
7278
|
name: "trace",
|
|
6592
7279
|
desc: "Live resolution tree \u2014 debug crew coordination"
|
|
7280
|
+
},
|
|
7281
|
+
{
|
|
7282
|
+
name: "link",
|
|
7283
|
+
desc: "Connect daemons across machines and Docker containers"
|
|
6593
7284
|
}
|
|
6594
7285
|
]
|
|
6595
7286
|
},
|
|
@@ -6609,17 +7300,17 @@ function formatHelp() {
|
|
|
6609
7300
|
...GROUPS.flatMap((g) => g.commands.map((c) => c.name.length))
|
|
6610
7301
|
);
|
|
6611
7302
|
for (const group of GROUPS) {
|
|
6612
|
-
lines.push(
|
|
7303
|
+
lines.push(chalk19.bold(group.title + ":"));
|
|
6613
7304
|
for (const cmd of group.commands) {
|
|
6614
7305
|
lines.push(
|
|
6615
|
-
` ${
|
|
7306
|
+
` ${chalk19.cyan(padRight(cmd.name, maxName))} ${chalk19.dim(cmd.desc)}`
|
|
6616
7307
|
);
|
|
6617
7308
|
}
|
|
6618
7309
|
lines.push("");
|
|
6619
7310
|
}
|
|
6620
7311
|
lines.push(
|
|
6621
|
-
|
|
6622
|
-
`Run ${
|
|
7312
|
+
chalk19.dim(
|
|
7313
|
+
`Run ${chalk19.reset("pmd <command> --help")} for usage on any command.`
|
|
6623
7314
|
)
|
|
6624
7315
|
);
|
|
6625
7316
|
lines.push("");
|
|
@@ -6627,7 +7318,7 @@ function formatHelp() {
|
|
|
6627
7318
|
}
|
|
6628
7319
|
program.name("pmd").description(
|
|
6629
7320
|
"PipeMD \u2014 The Dynamic Context Harness for AI Coding Agents"
|
|
6630
|
-
).version("1.0
|
|
7321
|
+
).version("1.1.0").configureHelp({ visibleCommands: () => [] }).addHelpText("after", formatHelp());
|
|
6631
7322
|
program.addCommand(initCommand);
|
|
6632
7323
|
program.addCommand(startCommand);
|
|
6633
7324
|
program.addCommand(stopCommand);
|
|
@@ -6639,9 +7330,13 @@ program.addCommand(doctorCommand);
|
|
|
6639
7330
|
program.addCommand(uninstallCommand);
|
|
6640
7331
|
program.addCommand(crewCommand);
|
|
6641
7332
|
program.addCommand(traceCommand);
|
|
7333
|
+
program.addCommand(linkCommand);
|
|
6642
7334
|
program.addCommand(injectCommand, { hidden: true });
|
|
6643
7335
|
program.addCommand(statuslineCommand, { hidden: true });
|
|
6644
7336
|
program.command("_daemon", { hidden: true }).description("(internal) Run the daemon process").action(() => {
|
|
6645
7337
|
runDaemon();
|
|
6646
7338
|
});
|
|
7339
|
+
program.command("_linkd", { hidden: true }).description("(internal) Run the link relay process").action(() => {
|
|
7340
|
+
runRelay();
|
|
7341
|
+
});
|
|
6647
7342
|
program.parse();
|
|
@@ -8,9 +8,31 @@
|
|
|
8
8
|
// event(session.idle / session.status) → heartbeat + worker cleanup
|
|
9
9
|
// experimental.chat.system.transform → sub-agent detection + LLM context injection
|
|
10
10
|
import { execFile, execFileSync } from "node:child_process";
|
|
11
|
-
import { existsSync, writeFileSync, readFileSync, mkdirSync, renameSync, appendFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, writeFileSync, readFileSync, mkdirSync, renameSync, appendFileSync, statSync, unlinkSync } from "node:fs";
|
|
12
12
|
import { resolve as resolvePath, join as joinPath } from "node:path";
|
|
13
13
|
|
|
14
|
+
const FIFO_TEMP = joinPath("/tmp", "pmd-fifo-read-" + process.pid + ".md");
|
|
15
|
+
|
|
16
|
+
function isFifoFile(filePath) {
|
|
17
|
+
if (!filePath) return false;
|
|
18
|
+
try { return statSync(resolvePath(filePath)).isFIFO(); } catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveFifoRead(args) {
|
|
22
|
+
const filePath = extractFilePath(args);
|
|
23
|
+
if (!isFifoFile(filePath)) return;
|
|
24
|
+
try {
|
|
25
|
+
execFileSync(getPmdBin(), ["run", "-o", FIFO_TEMP], { encoding: "utf-8", timeout: 10000, stdio: "ignore" });
|
|
26
|
+
if ("filePath" in args) args.filePath = FIFO_TEMP;
|
|
27
|
+
if ("path" in args) args.path = FIFO_TEMP;
|
|
28
|
+
if ("file_path" in args) args.file_path = FIFO_TEMP;
|
|
29
|
+
} catch (e) { logPluginError("resolveFifoRead", e); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cleanupFifoTemp() {
|
|
33
|
+
try { unlinkSync(FIFO_TEMP); } catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
14
36
|
function resolvePmd() {
|
|
15
37
|
const local = resolvePath(process.cwd(), "node_modules/.bin/pmd");
|
|
16
38
|
if (existsSync(local)) return local;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipemd-core/pipemd",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "The Dynamic Context Harness for AI Coding Agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
"test:compose": "bash tests/e2e-compose.sh",
|
|
19
19
|
"test:crew": "bash tests/e2e-crew.sh",
|
|
20
20
|
"test:inject": "bash tests/e2e-inject.sh",
|
|
21
|
-
"test:unit": "node tests/test-reverse-inject.mjs",
|
|
22
|
-
"test": "
|
|
21
|
+
"test:unit": "node tests/test-reverse-inject.mjs && node tests/test-link.mjs",
|
|
22
|
+
"test:link": "bash tests/e2e-link.sh",
|
|
23
|
+
"test": "pnpm test:unit && pnpm test:e2e && pnpm test:bidir && pnpm test:scripts && pnpm test:arch && pnpm test:compose && pnpm test:crew && pnpm test:inject && pnpm test:link"
|
|
23
24
|
},
|
|
24
25
|
"engines": {
|
|
25
26
|
"node": ">=18.0.0"
|