patchcord 0.6.25 → 0.6.27
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/.claude-plugin/plugin.json +1 -1
- package/bin/patchcord.mjs +188 -32
- package/package.json +1 -1
- package/scripts/lib/resolve-project-bearer.mjs +47 -8
- package/scripts/subscribe.mjs +142 -35
package/bin/patchcord.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "fs";
|
|
3
|
+
import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, writeFileSync, unlinkSync, rmSync, chmodSync } from "fs";
|
|
4
4
|
import { join, dirname, basename } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { execSync } from "child_process";
|
|
@@ -14,6 +14,13 @@ const pluginRoot = join(__dirname, "..");
|
|
|
14
14
|
import { resolveProjectBearer, harnessContext } from "../scripts/lib/resolve-project-bearer.mjs";
|
|
15
15
|
const cmd = process.argv[2];
|
|
16
16
|
|
|
17
|
+
/** Write a file then chmod 0600 (token-bearing configs must not be group/world readable). */
|
|
18
|
+
function writeSecureFile(path, contents) {
|
|
19
|
+
writeFileSync(path, contents);
|
|
20
|
+
try { chmodSync(path, 0o600); } catch {}
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
if (cmd === "--version" || cmd === "-v") {
|
|
18
25
|
const { readFileSync: _rf } = await import("fs");
|
|
19
26
|
const { version } = JSON.parse(_rf(join(__dirname, "..", "package.json"), "utf-8"));
|
|
@@ -147,7 +154,8 @@ setup:
|
|
|
147
154
|
patchcord --rename <name> [--agent-type <t>] rename this agent
|
|
148
155
|
|
|
149
156
|
agent:
|
|
150
|
-
patchcord whoami
|
|
157
|
+
patchcord whoami [--tool <harness>] [--json]
|
|
158
|
+
this agent's identity + project
|
|
151
159
|
patchcord whoami --propose "<text>" set self-description (two-shot confirm)
|
|
152
160
|
patchcord agents [name] list agents in your namespace, or show one
|
|
153
161
|
patchcord subscribe [interval] [--kimi] realtime listener (Kimi: poll + exit)
|
|
@@ -166,12 +174,16 @@ team (run from the project root):
|
|
|
166
174
|
patchcord provision <agent> --tool <X> --role <Y> --namespace <ns>
|
|
167
175
|
[--dir <sub/>] [--project <name>] [--lead]
|
|
168
176
|
create a worker agent (identity + config)
|
|
169
|
-
patchcord pull <agent> --tool <X> --namespace <ns> [--dir <sub/>]
|
|
170
|
-
|
|
171
|
-
(same identity
|
|
177
|
+
patchcord pull <agent> --tool <X> --namespace <ns> [--dir <sub/>] [--lead]
|
|
178
|
+
place an existing agent into a folder
|
|
179
|
+
(same identity; supersedes prior token —
|
|
180
|
+
only ONE live credential per identity)
|
|
172
181
|
patchcord provision revoke <agent> --namespace <ns>
|
|
173
182
|
revoke a worker agent
|
|
174
|
-
patchcord team list [--namespace <ns>]
|
|
183
|
+
patchcord team list [--namespace <ns>] [--json]
|
|
184
|
+
provisioned agents (server view)
|
|
185
|
+
patchcord team blueprint current --namespace <ns> [--json]
|
|
186
|
+
fetch current team blueprint revision
|
|
175
187
|
patchcord team status reconcile folder ↔ identity ↔ tmux ↔ token
|
|
176
188
|
patchcord team launch launch each worker in mux
|
|
177
189
|
|
|
@@ -335,8 +347,10 @@ function _purgeCursorSkillDuplicates() {
|
|
|
335
347
|
}
|
|
336
348
|
}
|
|
337
349
|
|
|
338
|
-
// Codex global skills live at ~/.agents/skills
|
|
339
|
-
//
|
|
350
|
+
// Codex global skills live at ~/.agents/skills/. Cursor CLI also indexes that
|
|
351
|
+
// tree (no exclude setting — see cursor.com/docs/skills). Use ONE hyphenated
|
|
352
|
+
// wait skill here so Cursor does not also show /patchcordwait (colon) +
|
|
353
|
+
// /patchcord-wait (skills-cursor). Claude plugin skills keep colon names.
|
|
340
354
|
function _restoreCodexGlobalSkills() {
|
|
341
355
|
const codexConfig = join(HOME, ".codex", "config.toml");
|
|
342
356
|
if (!existsSync(codexConfig)) return;
|
|
@@ -347,11 +361,20 @@ function _restoreCodexGlobalSkills() {
|
|
|
347
361
|
writeFileSync(join(globalSkillDir, "SKILL.md"),
|
|
348
362
|
readFileSync(join(pluginRoot, "per-project-skills", "codex", "SKILL.md"), "utf-8"));
|
|
349
363
|
mkdirSync(globalWaitDir, { recursive: true });
|
|
364
|
+
// Shared hyphenated wait (same MCP wait_for_message flow as Cursor).
|
|
350
365
|
writeFileSync(join(globalWaitDir, "SKILL.md"),
|
|
351
|
-
readFileSync(join(pluginRoot, "skills", "wait", "SKILL.md"), "utf-8"));
|
|
366
|
+
readFileSync(join(pluginRoot, "per-project-skills", "cursor", "wait", "SKILL.md"), "utf-8"));
|
|
352
367
|
} catch {}
|
|
353
368
|
}
|
|
354
369
|
|
|
370
|
+
/** True when Codex is on this machine (agents skills would be indexed by Cursor). */
|
|
371
|
+
function _hasCodexAgentsSkills() {
|
|
372
|
+
if (existsSync(join(HOME, ".codex", "config.toml"))) return true;
|
|
373
|
+
if (existsSync(join(HOME, ".agents", "skills", "patchcord"))) return true;
|
|
374
|
+
if (existsSync(join(HOME, ".agents", "skills", "patchcord-wait"))) return true;
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
355
378
|
// preserving the rest of the user's YAML. Block-style only (what Hermes writes).
|
|
356
379
|
function upsertHermesConfig(existing, url, token) {
|
|
357
380
|
const block = [
|
|
@@ -601,12 +624,31 @@ async function _httpJSON(method, url, token, body) {
|
|
|
601
624
|
// ── whoami ────────────────────────────────────────────────────
|
|
602
625
|
if (cmd === "whoami") {
|
|
603
626
|
const args = process.argv.slice(3);
|
|
604
|
-
const
|
|
627
|
+
const wantJson = args.includes("--json");
|
|
628
|
+
const toolIdx = args.indexOf("--tool");
|
|
629
|
+
const toolFlag = toolIdx !== -1 ? (args[toolIdx + 1] || "").trim() : "";
|
|
630
|
+
// Canonical harness aliases → installer tool slug for config resolution.
|
|
631
|
+
const CANON_TOOL = {
|
|
632
|
+
claude: "claude_code", claude_code: "claude_code",
|
|
633
|
+
codex: "codex", kimi: "kimi", "kimi-code": "kimi",
|
|
634
|
+
opencode: "opencode", agy: "antigravity", antigravity: "antigravity",
|
|
635
|
+
cursor: "cursor", grok: "grok", hermes: "hermes",
|
|
636
|
+
};
|
|
637
|
+
const resolveOpts = {};
|
|
638
|
+
if (toolFlag) {
|
|
639
|
+
const mapped = CANON_TOOL[toolFlag.toLowerCase()];
|
|
640
|
+
if (!mapped) {
|
|
641
|
+
console.error(`Unknown --tool '${toolFlag}'. Expected one of: ${Object.keys(CANON_TOOL).filter((k, i, a) => a.indexOf(k) === i).join(", ")}`);
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
resolveOpts.forceTool = mapped;
|
|
645
|
+
}
|
|
646
|
+
const found = await _resolveBearer(resolveOpts);
|
|
605
647
|
if (!found) {
|
|
606
648
|
console.error("No patchcord config found in current directory or any parent. Run `npx patchcord@latest` from a project directory first.");
|
|
607
649
|
process.exit(1);
|
|
608
650
|
}
|
|
609
|
-
if (found.scope === "global") {
|
|
651
|
+
if (found.scope === "global" && !wantJson) {
|
|
610
652
|
console.error(`\x1b[33m⚠ No project patchcord config in ${process.cwd()} or any parent.`);
|
|
611
653
|
console.error(` Falling back to a GLOBAL config: ${found.configFile} (tool: ${found.tool}).`);
|
|
612
654
|
console.error(` If this isn't the agent you expected, you're in a directory without its own .mcp.json —`);
|
|
@@ -658,12 +700,25 @@ if (cmd === "whoami") {
|
|
|
658
700
|
process.exit(1);
|
|
659
701
|
}
|
|
660
702
|
|
|
661
|
-
// plain whoami (no
|
|
703
|
+
// plain whoami (no propose)
|
|
662
704
|
const { status, json, body } = await _httpJSON("GET", `${baseUrl}/api/agent/whoami`, token);
|
|
663
705
|
if (status !== "200") {
|
|
664
706
|
console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
|
|
665
707
|
process.exit(1);
|
|
666
708
|
}
|
|
709
|
+
if (wantJson) {
|
|
710
|
+
const out = {
|
|
711
|
+
agent: json.agent,
|
|
712
|
+
namespace: json.namespace,
|
|
713
|
+
project_summary: json.project_summary ?? null,
|
|
714
|
+
whoami: json.whoami ?? null,
|
|
715
|
+
tool: found.tool || null,
|
|
716
|
+
config_file: found.configFile || null,
|
|
717
|
+
scope: found.scope || null,
|
|
718
|
+
};
|
|
719
|
+
process.stdout.write(JSON.stringify(out) + "\n");
|
|
720
|
+
process.exit(0);
|
|
721
|
+
}
|
|
667
722
|
console.log(`agent: ${json.agent}`);
|
|
668
723
|
console.log(`namespace: ${json.namespace}`);
|
|
669
724
|
if (json.project_summary) console.log(`project: ${json.project_summary}`);
|
|
@@ -1124,7 +1179,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1124
1179
|
});
|
|
1125
1180
|
if (!token) { console.error("\n Login timed out or failed."); process.exit(1); }
|
|
1126
1181
|
mkdirSync(dirname(AUTH_CONFIG), { recursive: true });
|
|
1127
|
-
|
|
1182
|
+
writeSecureFile(AUTH_CONFIG, JSON.stringify({ token, baseUrl: DEFAULT_API }, null, 2) + "\n");
|
|
1128
1183
|
console.log(` ${M.green}✓${M.rst} Logged in ${M.dim}(${AUTH_CONFIG})${M.rst}\n`);
|
|
1129
1184
|
return { token, baseUrl: DEFAULT_API };
|
|
1130
1185
|
};
|
|
@@ -1172,14 +1227,54 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1172
1227
|
}
|
|
1173
1228
|
return { ...r, m };
|
|
1174
1229
|
};
|
|
1230
|
+
|
|
1231
|
+
/** After reminting into one harness file, update OTHER existing project
|
|
1232
|
+
* patchcord configs in the same dir so subscribe/hooks don't keep a
|
|
1233
|
+
* superseded pct- (dead .mcp.json next to live .cursor/mcp.json). */
|
|
1234
|
+
const syncSiblingPatchcordTokens = (dir, baseUrl, token, hostname, primaryTool) => {
|
|
1235
|
+
const hdr = { Authorization: `Bearer ${token}`, "X-Patchcord-Machine": hostname };
|
|
1236
|
+
const touchJson = (path, mutate) => {
|
|
1237
|
+
if (!existsSync(path)) return;
|
|
1238
|
+
try {
|
|
1239
|
+
let obj = {};
|
|
1240
|
+
try { obj = JSON.parse(readFileSync(path, "utf-8")); } catch { return; }
|
|
1241
|
+
// Only rewrite if a patchcord entry already exists — never invent configs.
|
|
1242
|
+
const before = JSON.stringify(obj);
|
|
1243
|
+
mutate(obj);
|
|
1244
|
+
if (JSON.stringify(obj) === before) return;
|
|
1245
|
+
writeSecureFile(path, JSON.stringify(obj, null, 2) + "\n");
|
|
1246
|
+
} catch {}
|
|
1247
|
+
};
|
|
1248
|
+
// Claude Code project config
|
|
1249
|
+
if (primaryTool !== "claude_code") {
|
|
1250
|
+
touchJson(join(dir, ".mcp.json"), (o) => {
|
|
1251
|
+
if (!o.mcpServers?.patchcord) return;
|
|
1252
|
+
o.mcpServers.patchcord = {
|
|
1253
|
+
...(o.mcpServers.patchcord.type ? { type: o.mcpServers.patchcord.type } : { type: "http" }),
|
|
1254
|
+
url: `${baseUrl}/mcp/bearer`,
|
|
1255
|
+
headers: hdr,
|
|
1256
|
+
};
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
// Cursor project config
|
|
1260
|
+
if (primaryTool !== "cursor") {
|
|
1261
|
+
touchJson(join(dir, ".cursor", "mcp.json"), (o) => {
|
|
1262
|
+
if (!o.mcpServers?.patchcord) return;
|
|
1263
|
+
o.mcpServers.patchcord = { url: `${baseUrl}/mcp/bearer`, headers: hdr };
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1175
1268
|
const writeWorkerConfig = (tool, dir, baseUrl, token, hostname) => {
|
|
1176
1269
|
mkdirSync(dir, { recursive: true });
|
|
1177
1270
|
const hdr = { Authorization: `Bearer ${token}`, "X-Patchcord-Machine": hostname };
|
|
1271
|
+
// Centralized token-bearing config write: always chmod 0600 after write
|
|
1272
|
+
// (authoritative supersession — one live credential per identity).
|
|
1178
1273
|
const writeJson = (p, mutate) => {
|
|
1179
1274
|
let obj = {};
|
|
1180
1275
|
try { obj = JSON.parse(readFileSync(p, "utf-8")); } catch {}
|
|
1181
1276
|
mutate(obj);
|
|
1182
|
-
|
|
1277
|
+
writeSecureFile(p, JSON.stringify(obj, null, 2) + "\n");
|
|
1183
1278
|
return p;
|
|
1184
1279
|
};
|
|
1185
1280
|
if (tool === "codex") {
|
|
@@ -1188,7 +1283,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1188
1283
|
let ex = existsSync(p) ? readFileSync(p, "utf-8") : "";
|
|
1189
1284
|
ex = ex.replace(/\[mcp_servers\.patchcord[\w.-]*\][^\[]*/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
1190
1285
|
ex = (ex ? ex + "\n\n" : "") + `[mcp_servers.patchcord-codex]\nurl = "${baseUrl}/mcp"\nhttp_headers = { "Authorization" = "Bearer ${token}", "X-Patchcord-Machine" = "${hostname}" }\ntool_timeout_sec = 300\n`;
|
|
1191
|
-
|
|
1286
|
+
writeSecureFile(p, ex);
|
|
1192
1287
|
return p;
|
|
1193
1288
|
}
|
|
1194
1289
|
if (tool === "grok") {
|
|
@@ -1197,7 +1292,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1197
1292
|
let ex = existsSync(p) ? readFileSync(p, "utf-8") : "";
|
|
1198
1293
|
ex = ex.replace(/\[mcp_servers\.patchcord(?:[-\w]*)?\][\s\S]*?(?=\n\[(?!mcp_servers\.patchcord(?:\.|[-\w]*\]))|$)/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
1199
1294
|
ex = (ex ? ex + "\n\n" : "") + `[mcp_servers.patchcord]\nurl = "${baseUrl}/mcp"\nenabled = true\n\n[mcp_servers.patchcord.headers]\nAuthorization = "Bearer ${token}"\nX-Patchcord-Machine = "${hostname}"\n`;
|
|
1200
|
-
|
|
1295
|
+
writeSecureFile(p, ex);
|
|
1201
1296
|
return p;
|
|
1202
1297
|
}
|
|
1203
1298
|
if (tool === "kimi" || tool === "kimi-code") {
|
|
@@ -1218,7 +1313,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1218
1313
|
// Cursor IDE + Cursor CLI (cursor-agent) share the same project config —
|
|
1219
1314
|
// same file, same schema (see patchcord.dev/console/connect writer).
|
|
1220
1315
|
const cdir = join(dir, ".cursor"); mkdirSync(cdir, { recursive: true });
|
|
1221
|
-
|
|
1316
|
+
const written = writeJson(join(cdir, "mcp.json"), (o) => {
|
|
1222
1317
|
o.mcpServers = o.mcpServers || {};
|
|
1223
1318
|
// NO "type" field — cursor-agent silently drops the entire server entry
|
|
1224
1319
|
// when "type" is set to any value here (verified: tools simply never
|
|
@@ -1231,6 +1326,8 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1231
1326
|
headers: hdr,
|
|
1232
1327
|
};
|
|
1233
1328
|
});
|
|
1329
|
+
syncSiblingPatchcordTokens(dir, baseUrl, token, hostname, tool);
|
|
1330
|
+
return written;
|
|
1234
1331
|
}
|
|
1235
1332
|
if (tool === "antigravity" || tool === "agy") {
|
|
1236
1333
|
// Antigravity CLI: PROJECT-scoped config at .agents/mcp_config.json (one
|
|
@@ -1253,7 +1350,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1253
1350
|
mkdirSync(dirname(hermesPath), { recursive: true });
|
|
1254
1351
|
let existingYaml = "";
|
|
1255
1352
|
try { existingYaml = existsSync(hermesPath) ? readFileSync(hermesPath, "utf-8") : ""; } catch {}
|
|
1256
|
-
|
|
1353
|
+
writeSecureFile(hermesPath, upsertHermesConfig(existingYaml, `${baseUrl}/mcp`, token));
|
|
1257
1354
|
// Install the Hermes patchcord skills (inbox/subscribe/wait) so the worker
|
|
1258
1355
|
// knows the messaging flow, same as the installer does.
|
|
1259
1356
|
try {
|
|
@@ -1269,10 +1366,12 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1269
1366
|
// default: claude_code. type:"http" is REQUIRED — without it Claude Code
|
|
1270
1367
|
// defaults to stdio transport and rejects the entry ("command: expected
|
|
1271
1368
|
// string, received undefined").
|
|
1272
|
-
|
|
1369
|
+
const primary = writeJson(join(dir, ".mcp.json"), (o) => {
|
|
1273
1370
|
o.mcpServers = o.mcpServers || {};
|
|
1274
1371
|
o.mcpServers.patchcord = { type: "http", url: `${baseUrl}/mcp`, headers: hdr };
|
|
1275
1372
|
});
|
|
1373
|
+
syncSiblingPatchcordTokens(dir, baseUrl, token, hostname, tool);
|
|
1374
|
+
return primary;
|
|
1276
1375
|
};
|
|
1277
1376
|
|
|
1278
1377
|
if (cmd === "login") {
|
|
@@ -1377,17 +1476,20 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1377
1476
|
|
|
1378
1477
|
if (cmd === "pull") {
|
|
1379
1478
|
// Inverse of provision: place an EXISTING agent identity into a folder.
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1479
|
+
// Server supersedes the prior active token for this identity (exactly ONE
|
|
1480
|
+
// live credential) and PRESERVES is_lead from that active token — request
|
|
1481
|
+
// body --lead / role cannot escalate a worker. CLI --lead is typed
|
|
1482
|
+
// symmetry only; server is source of truth.
|
|
1384
1483
|
const arg = process.argv[3];
|
|
1385
|
-
if (!arg || arg.startsWith("-")) { console.error("Usage: patchcord pull <agent> --namespace ns --tool X [--dir sub/]"); process.exit(1); }
|
|
1484
|
+
if (!arg || arg.startsWith("-")) { console.error("Usage: patchcord pull <agent> --namespace ns --tool X [--dir sub/] [--lead]"); process.exit(1); }
|
|
1386
1485
|
const tool = flagVal("tool", "claude_code");
|
|
1387
1486
|
const ns = flagVal("namespace");
|
|
1388
1487
|
const subdir = flagVal("dir", arg);
|
|
1488
|
+
const lead = process.argv.includes("--lead"); // typed symmetry; server ignores escalate-on-pull
|
|
1389
1489
|
if (!ns) { console.error("--namespace <project-namespace> required"); process.exit(1); }
|
|
1390
|
-
const
|
|
1490
|
+
const pullBody = { namespace_id: ns, agent_id: arg, tool, require_existing: true, label: `pull:${tool}` };
|
|
1491
|
+
if (lead) pullBody.is_lead = true; // server will still preserve, not escalate
|
|
1492
|
+
const { status, json, m } = await accountCall("POST", "/api/provision", pullBody);
|
|
1391
1493
|
if (status === "404") { console.error(`no agent ${M.green}${ns}:${arg}${M.rst} to pull — create it with: patchcord provision ${arg} --tool ${tool} --namespace ${ns}`); process.exit(1); }
|
|
1392
1494
|
if (status !== "200" || !json?.token) { console.error(`pull failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
|
|
1393
1495
|
const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp$/, "");
|
|
@@ -1478,24 +1580,66 @@ you design the team, provision its agents, launch them, and manage them.
|
|
|
1478
1580
|
if (cmd === "team") {
|
|
1479
1581
|
const sub = process.argv[3];
|
|
1480
1582
|
if (sub === "list") {
|
|
1583
|
+
const wantJson = process.argv.includes("--json");
|
|
1481
1584
|
const { status, json } = await accountCall("GET", "/api/provision/list");
|
|
1482
1585
|
if (status !== "200") { console.error(`list failed (HTTP ${status})`); process.exit(1); }
|
|
1483
1586
|
// Dedup by namespace:agent — the server returns one row per token, so a
|
|
1484
1587
|
// re-provisioned agent shows up many times. Optional --namespace filter.
|
|
1485
1588
|
const filterNs = flagVal("namespace");
|
|
1486
1589
|
const seen = new Set();
|
|
1487
|
-
|
|
1590
|
+
const rows = [];
|
|
1488
1591
|
for (const a of (json?.agents || [])) {
|
|
1489
1592
|
if (filterNs && a.namespace_id !== filterNs) continue;
|
|
1490
1593
|
const key = `${a.namespace_id}:${a.agent_id}`;
|
|
1491
1594
|
if (seen.has(key)) continue;
|
|
1492
1595
|
seen.add(key);
|
|
1493
|
-
|
|
1494
|
-
|
|
1596
|
+
rows.push({
|
|
1597
|
+
namespace_id: a.namespace_id,
|
|
1598
|
+
agent_id: a.agent_id,
|
|
1599
|
+
is_lead: Boolean(a.is_lead),
|
|
1600
|
+
tool: a.tool ?? null,
|
|
1601
|
+
harness: a.harness ?? a.tool ?? null,
|
|
1602
|
+
active: a.active !== false,
|
|
1603
|
+
label: a.label || null,
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
if (wantJson) {
|
|
1607
|
+
process.stdout.write(JSON.stringify({ agents: rows }) + "\n");
|
|
1608
|
+
process.exit(0);
|
|
1495
1609
|
}
|
|
1496
|
-
|
|
1610
|
+
for (const a of rows) {
|
|
1611
|
+
const leadTag = a.is_lead ? " lead" : "";
|
|
1612
|
+
const toolTag = a.tool ? ` ${a.tool}` : "";
|
|
1613
|
+
console.log(` ${a.namespace_id}:${a.agent_id} ${M.dim}${a.label || ""}${toolTag}${leadTag}${M.rst}`);
|
|
1614
|
+
}
|
|
1615
|
+
if (!rows.length) console.log(" (no agents)");
|
|
1497
1616
|
process.exit(0);
|
|
1498
1617
|
}
|
|
1618
|
+
if (sub === "blueprint") {
|
|
1619
|
+
const bsub = process.argv[4];
|
|
1620
|
+
if (bsub === "current") {
|
|
1621
|
+
const ns = flagVal("namespace");
|
|
1622
|
+
const wantJson = process.argv.includes("--json");
|
|
1623
|
+
if (!ns) { console.error("Usage: patchcord team blueprint current --namespace <ns> [--json]"); process.exit(1); }
|
|
1624
|
+
// pcm accountCall — cold fetch of GET /api/team/{ns}/blueprint/current
|
|
1625
|
+
const { status, json, body } = await accountCall("GET", `/api/team/${encodeURIComponent(ns)}/blueprint/current`);
|
|
1626
|
+
if (status !== "200") {
|
|
1627
|
+
console.error(`blueprint current failed (HTTP ${status}): ${(json && (json.error || json.detail)) || body || ""}`);
|
|
1628
|
+
process.exit(1);
|
|
1629
|
+
}
|
|
1630
|
+
if (wantJson) {
|
|
1631
|
+
// JSON-only stdout when --json (stable machine contract).
|
|
1632
|
+
process.stdout.write(JSON.stringify(json) + "\n");
|
|
1633
|
+
process.exit(0);
|
|
1634
|
+
}
|
|
1635
|
+
console.log(`revision_uid: ${json.revision_uid}`);
|
|
1636
|
+
console.log(`sha256: ${json.sha256}`);
|
|
1637
|
+
console.log(`canonical_json: ${JSON.stringify(json.canonical_json)}`);
|
|
1638
|
+
process.exit(0);
|
|
1639
|
+
}
|
|
1640
|
+
console.error("Usage: patchcord team blueprint current --namespace <ns> [--json]");
|
|
1641
|
+
process.exit(1);
|
|
1642
|
+
}
|
|
1499
1643
|
if (sub === "status") {
|
|
1500
1644
|
// Reconciler: folder ↔ patchcord identity ↔ tmux session ↔ token-active.
|
|
1501
1645
|
// Truth = .patchcord/team.json. For each agent we resolve the token from
|
|
@@ -1632,7 +1776,7 @@ you design the team, provision its agents, launch them, and manage them.
|
|
|
1632
1776
|
}
|
|
1633
1777
|
process.exit(0);
|
|
1634
1778
|
}
|
|
1635
|
-
console.error("Usage: patchcord team <list|launch|status>");
|
|
1779
|
+
console.error("Usage: patchcord team <list|blueprint|launch|status>");
|
|
1636
1780
|
process.exit(1);
|
|
1637
1781
|
}
|
|
1638
1782
|
|
|
@@ -1966,16 +2110,28 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
|
|
|
1966
2110
|
if (existsSync(staleCursorSkillDir)) {
|
|
1967
2111
|
try { rmSync(staleCursorSkillDir, { recursive: true, force: true }); cursorChanged = true; } catch {}
|
|
1968
2112
|
}
|
|
2113
|
+
// Always install Cursor-specific inbox + subscribe (not in ~/.agents).
|
|
1969
2114
|
mkdirSync(cursorInboxDir, { recursive: true });
|
|
1970
2115
|
cpSync(join(pluginRoot, "per-project-skills", "cursor", "inbox", "SKILL.md"), join(cursorInboxDir, "SKILL.md"));
|
|
1971
2116
|
cursorChanged = true;
|
|
1972
|
-
mkdirSync(cursorWaitDir, { recursive: true });
|
|
1973
|
-
cpSync(join(pluginRoot, "per-project-skills", "cursor", "wait", "SKILL.md"), join(cursorWaitDir, "SKILL.md"));
|
|
1974
2117
|
mkdirSync(cursorSubscribeDir, { recursive: true });
|
|
1975
2118
|
cpSync(
|
|
1976
2119
|
join(pluginRoot, "per-project-skills", "cursor", "subscribe", "SKILL.md"),
|
|
1977
2120
|
join(cursorSubscribeDir, "SKILL.md")
|
|
1978
2121
|
);
|
|
2122
|
+
// Wait is shared MCP flow. If Codex/agents already owns patchcord-wait,
|
|
2123
|
+
// keep ONE copy under ~/.agents (Cursor indexes it) — do NOT also install
|
|
2124
|
+
// skills-cursor/patchcord-wait (duplicate slash). Cursor-only machines
|
|
2125
|
+
// still get wait under skills-cursor.
|
|
2126
|
+
if (_hasCodexAgentsSkills()) {
|
|
2127
|
+
_restoreCodexGlobalSkills();
|
|
2128
|
+
if (existsSync(cursorWaitDir)) {
|
|
2129
|
+
try { rmSync(cursorWaitDir, { recursive: true, force: true }); cursorChanged = true; } catch {}
|
|
2130
|
+
}
|
|
2131
|
+
} else {
|
|
2132
|
+
mkdirSync(cursorWaitDir, { recursive: true });
|
|
2133
|
+
cpSync(join(pluginRoot, "per-project-skills", "cursor", "wait", "SKILL.md"), join(cursorWaitDir, "SKILL.md"));
|
|
2134
|
+
}
|
|
1979
2135
|
if (cursorChanged) globalChanges.push("Cursor skills installed");
|
|
1980
2136
|
|
|
1981
2137
|
if (hasCursorAgent) {
|
package/package.json
CHANGED
|
@@ -131,7 +131,12 @@ const kimiCodeReader = (cwd) => readJsonAt(join(cwd, ".kimi-code", "mcp.json"),
|
|
|
131
131
|
|
|
132
132
|
/** Detect which harness launched this process (env + explicit overrides). */
|
|
133
133
|
export function harnessContext(env = process.env, options = {}) {
|
|
134
|
-
const forceTool = (
|
|
134
|
+
const forceTool = (
|
|
135
|
+
options.forceTool
|
|
136
|
+
|| env.PATCHCORD_FORCE_TOOL
|
|
137
|
+
|| env.PATCHCORD_TOOL
|
|
138
|
+
|| ""
|
|
139
|
+
).toLowerCase();
|
|
135
140
|
const preferKimi =
|
|
136
141
|
options.preferKimi === true
|
|
137
142
|
|| forceTool === "kimi"
|
|
@@ -148,6 +153,17 @@ export function harnessContext(env = process.env, options = {}) {
|
|
|
148
153
|
|
|
149
154
|
/** Ordered per-project readers for the active harness. */
|
|
150
155
|
export function projectReadersForContext(ctx) {
|
|
156
|
+
const byTool = {
|
|
157
|
+
claude_code: claudeReader,
|
|
158
|
+
cursor: cursorReader,
|
|
159
|
+
vscode: vscodeReader,
|
|
160
|
+
opencode: opencodeReader,
|
|
161
|
+
antigravity: antigravityReader,
|
|
162
|
+
agy: antigravityReader,
|
|
163
|
+
grok: grokReader,
|
|
164
|
+
codex: codexReader,
|
|
165
|
+
kimi: kimiCodeReader,
|
|
166
|
+
};
|
|
151
167
|
const defaultReaders = [
|
|
152
168
|
claudeReader,
|
|
153
169
|
cursorReader,
|
|
@@ -159,6 +175,17 @@ export function projectReadersForContext(ctx) {
|
|
|
159
175
|
];
|
|
160
176
|
const kimiReaders = [kimiReader, kimiCodeReader];
|
|
161
177
|
|
|
178
|
+
// Explicit --tool / forceTool: prefer that harness's config first.
|
|
179
|
+
if (ctx.forceTool && byTool[ctx.forceTool]) {
|
|
180
|
+
const forced = byTool[ctx.forceTool];
|
|
181
|
+
const rest = [...defaultReaders, ...kimiReaders].filter((r) => r !== forced);
|
|
182
|
+
// kimi has two readers — prefer both when forcing kimi
|
|
183
|
+
if (ctx.forceTool === "kimi") {
|
|
184
|
+
return [...kimiReaders, ...defaultReaders];
|
|
185
|
+
}
|
|
186
|
+
return [forced, ...rest];
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
if (ctx.preferKimi) {
|
|
163
190
|
return [...kimiReaders, ...defaultReaders];
|
|
164
191
|
}
|
|
@@ -179,22 +206,34 @@ export function projectReadersForContext(ctx) {
|
|
|
179
206
|
return [...defaultReaders, ...kimiReaders];
|
|
180
207
|
}
|
|
181
208
|
|
|
182
|
-
/**
|
|
183
|
-
|
|
209
|
+
/**
|
|
210
|
+
* All project bearers found at startDir (and ancestors), in harness preference
|
|
211
|
+
* order, de-duplicated by token. Used by subscribe to skip superseded configs
|
|
212
|
+
* (e.g. dead .mcp.json next to a live .cursor/mcp.json after a Cursor remint).
|
|
213
|
+
*/
|
|
214
|
+
export function listProjectBearers(startDir, options = {}) {
|
|
184
215
|
const ctx = harnessContext(process.env, options);
|
|
185
216
|
const readers = projectReadersForContext(ctx);
|
|
217
|
+
const out = [];
|
|
218
|
+
const seen = new Set();
|
|
186
219
|
let dir = startDir;
|
|
187
220
|
while (dir && dir !== "/") {
|
|
188
221
|
for (const r of readers) {
|
|
189
222
|
const found = r(dir);
|
|
190
|
-
if (found)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
223
|
+
if (!found?.token || seen.has(found.token)) continue;
|
|
224
|
+
seen.add(found.token);
|
|
225
|
+
found.scope = "project";
|
|
226
|
+
out.push(found);
|
|
194
227
|
}
|
|
195
228
|
const parent = dirname(dir);
|
|
196
229
|
if (parent === dir) break;
|
|
197
230
|
dir = parent;
|
|
198
231
|
}
|
|
199
|
-
return
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Walk up from startDir; return first matching project bearer or null. */
|
|
236
|
+
export function resolveProjectBearer(startDir, options = {}) {
|
|
237
|
+
const listed = listProjectBearers(startDir, options);
|
|
238
|
+
return listed[0] || null;
|
|
200
239
|
}
|
package/scripts/subscribe.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { request as httpRequest } from "node:http";
|
|
|
13
13
|
import { URL } from "node:url";
|
|
14
14
|
import { dirname } from "node:path";
|
|
15
15
|
import { connect as wsConnect } from "./lib/ws.mjs";
|
|
16
|
-
import { resolveProjectBearer } from "./lib/resolve-project-bearer.mjs";
|
|
16
|
+
import { resolveProjectBearer, listProjectBearers } from "./lib/resolve-project-bearer.mjs";
|
|
17
17
|
|
|
18
18
|
// --- Hermes webhook bridge mode -------------------------------------------
|
|
19
19
|
// Default mode writes "PATCHCORD: ..." lines to stdout for Claude Code's
|
|
@@ -69,17 +69,76 @@ function die(msg, code = 1) {
|
|
|
69
69
|
process.exit(code);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function errDetail(e) {
|
|
73
|
+
// Prefer message; fall back to code / String so "pending heartbeat failed: "
|
|
74
|
+
// with an empty message is never silent again (socket hang up / 522 / etc.).
|
|
75
|
+
if (!e) return "unknown";
|
|
76
|
+
const msg = e.message || e.code || "";
|
|
77
|
+
const extra = e.code && e.message && e.code !== e.message ? ` code=${e.code}` : "";
|
|
78
|
+
return `${msg || String(e)}${extra}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
function readMcpConfig(cwd) {
|
|
73
82
|
// Prefer env vars injected by patchcord.mjs, which already ran _resolveBearer()
|
|
74
83
|
// and supports all tool configs with harness-aware ordering.
|
|
75
84
|
if (process.env.PATCHCORD_BASE_URL && process.env.PATCHCORD_BEARER_TOKEN) {
|
|
76
|
-
return { baseUrl: process.env.PATCHCORD_BASE_URL, token: process.env.PATCHCORD_BEARER_TOKEN };
|
|
85
|
+
return { baseUrl: process.env.PATCHCORD_BASE_URL, token: process.env.PATCHCORD_BEARER_TOKEN, source: "env" };
|
|
77
86
|
}
|
|
78
87
|
const found = resolveProjectBearer(cwd);
|
|
79
88
|
if (!found) {
|
|
80
89
|
die(`no patchcord config found — run from a project directory or use 'patchcord subscribe'`);
|
|
81
90
|
}
|
|
82
|
-
return { baseUrl: found.baseUrl, token: found.token };
|
|
91
|
+
return { baseUrl: found.baseUrl, token: found.token, source: found.configFile || "project" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Try every on-disk project bearer until /api/realtime/ticket returns 200.
|
|
95
|
+
* Skips superseded tokens left behind when one harness reminted and another
|
|
96
|
+
* config file still holds the old pct- (classic: dead .mcp.json + live .cursor). */
|
|
97
|
+
async function resolveLiveConfig(cwd) {
|
|
98
|
+
if (process.env.PATCHCORD_BASE_URL && process.env.PATCHCORD_BEARER_TOKEN) {
|
|
99
|
+
const cfg = {
|
|
100
|
+
baseUrl: process.env.PATCHCORD_BASE_URL,
|
|
101
|
+
token: process.env.PATCHCORD_BEARER_TOKEN,
|
|
102
|
+
source: "env",
|
|
103
|
+
};
|
|
104
|
+
// Still validate — env can go stale after a remint too.
|
|
105
|
+
const res = await httpJson(`${cfg.baseUrl}/api/realtime/ticket`, {
|
|
106
|
+
headers: { Authorization: `Bearer ${cfg.token}`, "x-patchcord-install-path": cwd },
|
|
107
|
+
});
|
|
108
|
+
if (res.status === 200) return cfg;
|
|
109
|
+
if (res.status !== 401 && res.status !== 403) {
|
|
110
|
+
throw new Error(`ticket HTTP ${res.status}: ${res.body.slice(0, 200)}`);
|
|
111
|
+
}
|
|
112
|
+
logErr(`subscribe: env token rejected (HTTP ${res.status}); scanning project configs`);
|
|
113
|
+
}
|
|
114
|
+
const candidates = listProjectBearers(cwd);
|
|
115
|
+
if (!candidates.length) {
|
|
116
|
+
die(`no patchcord config found — run from a project directory or use 'patchcord subscribe'`);
|
|
117
|
+
}
|
|
118
|
+
const rejected = [];
|
|
119
|
+
for (const c of candidates) {
|
|
120
|
+
const res = await httpJson(`${c.baseUrl}/api/realtime/ticket`, {
|
|
121
|
+
headers: { Authorization: `Bearer ${c.token}`, "x-patchcord-install-path": cwd },
|
|
122
|
+
});
|
|
123
|
+
if (res.status === 200) {
|
|
124
|
+
if (rejected.length) {
|
|
125
|
+
logErr(`subscribe: using live token from ${c.configFile} (skipped ${rejected.length} superseded)`);
|
|
126
|
+
}
|
|
127
|
+
return { baseUrl: c.baseUrl, token: c.token, source: c.configFile || "project" };
|
|
128
|
+
}
|
|
129
|
+
if (res.status === 401 || res.status === 403) {
|
|
130
|
+
rejected.push(`${c.configFile || c.tool}:${res.status}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (res.status === 501) {
|
|
134
|
+
die("ticket: server not configured for realtime (self-hosted without Supabase?)");
|
|
135
|
+
}
|
|
136
|
+
if (res.status === 404) {
|
|
137
|
+
die("ticket: namespace not owned — regenerate your token");
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`ticket HTTP ${res.status}: ${res.body.slice(0, 200)}`);
|
|
140
|
+
}
|
|
141
|
+
die(`ticket: all project tokens rejected (${rejected.join(", ") || "none"}) — remint with patchcord pull/provision`);
|
|
83
142
|
}
|
|
84
143
|
|
|
85
144
|
function httpJson(urlStr, { method = "GET", headers = {}, body = null } = {}) {
|
|
@@ -133,7 +192,7 @@ async function notify(line, meta = {}) {
|
|
|
133
192
|
}
|
|
134
193
|
}
|
|
135
194
|
|
|
136
|
-
async function fetchTicket(baseUrl, token) {
|
|
195
|
+
async function fetchTicket(baseUrl, token, { fatalAuth = true } = {}) {
|
|
137
196
|
const res = await httpJson(`${baseUrl}/api/realtime/ticket`, {
|
|
138
197
|
headers: {
|
|
139
198
|
Authorization: `Bearer ${token}`,
|
|
@@ -141,7 +200,12 @@ async function fetchTicket(baseUrl, token) {
|
|
|
141
200
|
},
|
|
142
201
|
});
|
|
143
202
|
if (res.status === 401 || res.status === 403) {
|
|
144
|
-
|
|
203
|
+
if (fatalAuth) {
|
|
204
|
+
die(`ticket: token rejected (HTTP ${res.status}) — check .mcp.json / .cursor/mcp.json`);
|
|
205
|
+
}
|
|
206
|
+
const err = new Error(`ticket HTTP ${res.status}`);
|
|
207
|
+
err.status = res.status;
|
|
208
|
+
throw err;
|
|
145
209
|
}
|
|
146
210
|
if (res.status === 501) {
|
|
147
211
|
die("ticket: server not configured for realtime (self-hosted without Supabase?)");
|
|
@@ -165,22 +229,49 @@ async function fetchTicket(baseUrl, token) {
|
|
|
165
229
|
// inbox() manually. Emit a stdout line when there's a pending queue so
|
|
166
230
|
// Monitor wakes the agent the same way a real arrival does.
|
|
167
231
|
async function drainQueueOnce(baseUrl, token) {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
232
|
+
const maxAttempts = 3;
|
|
233
|
+
let lastErr;
|
|
234
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
235
|
+
try {
|
|
236
|
+
const res = await httpJson(`${baseUrl}/api/inbox?count_only=1&limit=100`, {
|
|
237
|
+
headers: {
|
|
238
|
+
Authorization: `Bearer ${token}`,
|
|
239
|
+
"x-patchcord-install-path": process.cwd(),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
if (res.status === 401 || res.status === 403) {
|
|
243
|
+
const err = new Error(`inbox HTTP ${res.status}`);
|
|
244
|
+
err.status = res.status;
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
if (res.status !== 200) {
|
|
248
|
+
// 522/502/503 from Cloudflare when origin blips — retry.
|
|
249
|
+
if (res.status >= 500 && attempt < maxAttempts) {
|
|
250
|
+
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`inbox HTTP ${res.status}`);
|
|
254
|
+
}
|
|
255
|
+
let count = 0;
|
|
256
|
+
try {
|
|
257
|
+
count = JSON.parse(res.body).pending_count ?? 0;
|
|
258
|
+
} catch (_) {}
|
|
259
|
+
if (count > 0) {
|
|
260
|
+
await notify(`PATCHCORD: ${count} waiting in inbox`, { count, kind: "pending" });
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
} catch (e) {
|
|
264
|
+
lastErr = e;
|
|
265
|
+
if (e?.status === 401 || e?.status === 403) throw e;
|
|
266
|
+
const retryable = !e?.status || e.status >= 500 || e.code === "ECONNRESET" || e.code === "ETIMEDOUT" || /socket hang up/i.test(e?.message || "");
|
|
267
|
+
if (retryable && attempt < maxAttempts) {
|
|
268
|
+
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
throw e;
|
|
272
|
+
}
|
|
183
273
|
}
|
|
274
|
+
throw lastErr;
|
|
184
275
|
}
|
|
185
276
|
|
|
186
277
|
function writePidfile(path) {
|
|
@@ -225,8 +316,8 @@ function removePidfile(path) {
|
|
|
225
316
|
|
|
226
317
|
async function run() {
|
|
227
318
|
const cwd = process.cwd();
|
|
228
|
-
|
|
229
|
-
logErr(`subscribe: cwd=${cwd} server=${baseUrl}`);
|
|
319
|
+
let { baseUrl, token, source } = await resolveLiveConfig(cwd);
|
|
320
|
+
logErr(`subscribe: cwd=${cwd} server=${baseUrl} auth=${source}`);
|
|
230
321
|
|
|
231
322
|
if (HERMES_MODE) {
|
|
232
323
|
if (!HERMES_WEBHOOK) {
|
|
@@ -277,25 +368,41 @@ async function run() {
|
|
|
277
368
|
|
|
278
369
|
let backoffIdx = 0;
|
|
279
370
|
|
|
371
|
+
const refreshAuth = async () => {
|
|
372
|
+
try {
|
|
373
|
+
return await fetchTicket(baseUrl, token, { fatalAuth: false });
|
|
374
|
+
} catch (e) {
|
|
375
|
+
if (e?.status === 401 || e?.status === 403) {
|
|
376
|
+
// Bearer was superseded mid-flight (remint into another harness file).
|
|
377
|
+
// Re-scan disk for a live token before giving up.
|
|
378
|
+
logErr(`subscribe: bearer rejected on refresh — re-scanning configs`);
|
|
379
|
+
const live = await resolveLiveConfig(cwd);
|
|
380
|
+
baseUrl = live.baseUrl;
|
|
381
|
+
token = live.token;
|
|
382
|
+
source = live.source;
|
|
383
|
+
logErr(`subscribe: switched auth to ${source}`);
|
|
384
|
+
return await fetchTicket(baseUrl, token, { fatalAuth: false });
|
|
385
|
+
}
|
|
386
|
+
throw e;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
280
390
|
const loop = async () => {
|
|
281
391
|
while (true) {
|
|
282
392
|
try {
|
|
283
|
-
await runOnce(ticket, baseUrl,
|
|
284
|
-
ticket = await fetchTicket(baseUrl, token);
|
|
285
|
-
return ticket;
|
|
286
|
-
});
|
|
393
|
+
await runOnce(ticket, baseUrl, () => token, refreshAuth);
|
|
287
394
|
backoffIdx = 0; // clean disconnect resets backoff
|
|
288
395
|
} catch (e) {
|
|
289
|
-
logErr(`subscribe: ${e
|
|
396
|
+
logErr(`subscribe: ${errDetail(e)}`);
|
|
290
397
|
}
|
|
291
398
|
const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
|
|
292
399
|
backoffIdx++;
|
|
293
400
|
logErr(`subscribe: reconnecting in ${delay}ms`);
|
|
294
401
|
await new Promise((r) => setTimeout(r, delay));
|
|
295
402
|
try {
|
|
296
|
-
ticket = await
|
|
403
|
+
ticket = await refreshAuth();
|
|
297
404
|
} catch (e) {
|
|
298
|
-
logErr(`subscribe: ticket refresh failed: ${e
|
|
405
|
+
logErr(`subscribe: ticket refresh failed: ${errDetail(e)}`);
|
|
299
406
|
}
|
|
300
407
|
}
|
|
301
408
|
};
|
|
@@ -303,7 +410,7 @@ async function run() {
|
|
|
303
410
|
await loop();
|
|
304
411
|
}
|
|
305
412
|
|
|
306
|
-
function runOnce(ticket, baseUrl,
|
|
413
|
+
function runOnce(ticket, baseUrl, getToken, refreshTicket) {
|
|
307
414
|
return new Promise((resolve, reject) => {
|
|
308
415
|
const allowedNs = new Set(ticket.namespace_ids);
|
|
309
416
|
const wsUrl = `${ticket.realtime_url}?apikey=${encodeURIComponent(ticket.apikey)}&vsn=1.0.0`;
|
|
@@ -354,8 +461,8 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
354
461
|
// otherwise wake the agent. Fire-and-forget: a transient HTTP
|
|
355
462
|
// failure here just means we miss queued messages this round;
|
|
356
463
|
// the next reconnect retries.
|
|
357
|
-
drainQueueOnce(baseUrl,
|
|
358
|
-
logErr(`subscribe: queue check failed: ${e
|
|
464
|
+
drainQueueOnce(baseUrl, getToken()).catch((e) => {
|
|
465
|
+
logErr(`subscribe: queue check failed: ${errDetail(e)}`);
|
|
359
466
|
});
|
|
360
467
|
heartbeatTimer = setInterval(() => {
|
|
361
468
|
try {
|
|
@@ -392,8 +499,8 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
392
499
|
// inbox is non-empty so a later idle tick has another chance to
|
|
393
500
|
// wake the agent. drainQueueOnce stays silent if pending_count == 0.
|
|
394
501
|
pendingHeartbeatTimer = setInterval(() => {
|
|
395
|
-
drainQueueOnce(baseUrl,
|
|
396
|
-
logErr(`subscribe: pending heartbeat failed: ${e
|
|
502
|
+
drainQueueOnce(baseUrl, getToken()).catch((e) => {
|
|
503
|
+
logErr(`subscribe: pending heartbeat failed: ${errDetail(e)}`);
|
|
397
504
|
});
|
|
398
505
|
}, PENDING_HEARTBEAT_MS);
|
|
399
506
|
|
|
@@ -438,7 +545,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
438
545
|
// Transient network/server error — do NOT close the live
|
|
439
546
|
// connection. The current JWT is still valid for ~2 more min
|
|
440
547
|
// (JWT_REFRESH_SAFETY_MARGIN_SEC). Retry sooner.
|
|
441
|
-
logErr(`subscribe: token refresh failed, retrying in 30s: ${e
|
|
548
|
+
logErr(`subscribe: token refresh failed, retrying in 30s: ${errDetail(e)}`);
|
|
442
549
|
refreshTimer = setTimeout(doRefresh, 30_000);
|
|
443
550
|
}
|
|
444
551
|
};
|