patchcord 0.6.42 → 0.6.44

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/bin/patchcord.mjs CHANGED
@@ -13,6 +13,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
  const pluginRoot = join(__dirname, "..");
14
14
  import { resolveProjectBearer, harnessContext } from "../scripts/lib/resolve-project-bearer.mjs";
15
15
  import { detectClaudeLocalMcpOverride } from "../scripts/lib/claude-local-mcp.mjs";
16
+ import { resolveHermesConfigPath, resolveHermesSkillsDest } from "../scripts/lib/hermes-home.mjs";
16
17
  const cmd = process.argv[2];
17
18
 
18
19
  /** Write a file then chmod 0600 (token-bearing configs must not be group/world readable). */
@@ -643,7 +644,7 @@ async function _resolveBearer(options = {}) {
643
644
  () => readJsonAt(join(HOME, ".gemini", "settings.json"), ["mcpServers", "patchcord"], "gemini"),
644
645
  () => readJsonAt(zedPath, ["context_servers", "patchcord"], "zed"),
645
646
  () => readJsonAt(join(HOME, ".openclaw", "openclaw.json"), ["mcp", "servers", "patchcord"], "openclaw"),
646
- () => readHermesShape(join(HOME, ".hermes", "config.yaml")),
647
+ () => readHermesShape(resolveHermesConfigPath()),
647
648
  ...clinePaths.map((p) => () => readJsonAt(p, ["mcpServers", "patchcord"], "cline")),
648
649
  ];
649
650
  const globalCandidates = defaultGlobalCandidates;
@@ -683,6 +684,7 @@ if (cmd === "whoami") {
683
684
  codex: "codex", kimi: "kimi", "kimi-code": "kimi",
684
685
  opencode: "opencode", agy: "antigravity", antigravity: "antigravity",
685
686
  cursor: "cursor", grok: "grok", hermes: "hermes",
687
+ jcode: "jcode",
686
688
  };
687
689
  const resolveOpts = {};
688
690
  if (toolFlag) {
@@ -907,8 +909,28 @@ if (cmd === "upload") {
907
909
  console.error(`file is empty: ${filePath}`);
908
910
  process.exit(1);
909
911
  }
910
- if (stats.size > 25 * 1024 * 1024) {
911
- console.error(`file is ${(stats.size / 1024 / 1024).toFixed(1)}MB; max is 25MB for inline upload.`);
912
+ // THIS IS NOT THE UPLOAD LIMIT AND MUST NOT BE DESCRIBED AS ONE.
913
+ //
914
+ // It used to say "max is 25MB", which was wrong in both directions. The
915
+ // server's limit is PATCHCORD_ATTACHMENT_MAX_BYTES, 10 MiB by default and
916
+ // raisable on a self-hosted install. So this client refused files a
917
+ // configured server would have accepted, and accepted files every default
918
+ // server rejects: a 15 MB file passed here, got base64'd to 20 MB, was
919
+ // uploaded in full, and came back 413. A guard set above the real limit does
920
+ // not guard; it only pays the upload first.
921
+ //
922
+ // The client must not hold a copy of a number the server owns. What is left
923
+ // is a memory guard for THIS process: the file, its base64 (4/3 the size),
924
+ // and the JSON body are all resident at once. The real limit comes back from
925
+ // the server, in max_bytes, and is printed below.
926
+ const MEMORY_GUARD_BYTES = 256 * 1024 * 1024;
927
+ if (stats.size > MEMORY_GUARD_BYTES) {
928
+ console.error(
929
+ `file is ${(stats.size / 1024 / 1024).toFixed(1)}MB. This client buffers the ` +
930
+ `file and its base64 copy in memory and refuses above ` +
931
+ `${MEMORY_GUARD_BYTES / 1024 / 1024}MB. This is a client memory guard, not ` +
932
+ `the server's size limit — the server's limit is lower.`
933
+ );
912
934
  process.exit(1);
913
935
  }
914
936
 
@@ -923,7 +945,18 @@ if (cmd === "upload") {
923
945
  "POST", `${baseUrl}/api/agent/attachment/upload`, token, body
924
946
  );
925
947
  if (status !== "200") {
926
- console.error(`✗ HTTP ${status}: ${(json && json.error) || respBody}`);
948
+ // Print max_bytes/allowed when the server sends them. Without this the user
949
+ // reads "attachment exceeds maximum size" and is not told the maximum, so
950
+ // their next move is to guess — or to ask an agent, which will guess.
951
+ let detail = "";
952
+ if (json && typeof json.max_bytes === "number") {
953
+ detail = ` (server limit ${(json.max_bytes / 1024 / 1024).toFixed(1)}MiB` +
954
+ (typeof json.actual_bytes === "number"
955
+ ? `, this file ${(json.actual_bytes / 1024 / 1024).toFixed(1)}MiB)` : ")");
956
+ } else if (json && Array.isArray(json.allowed)) {
957
+ detail = ` (allowed: ${json.allowed.join(", ")})`;
958
+ }
959
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || respBody}${detail}`);
927
960
  process.exit(1);
928
961
  }
929
962
  console.log(json.path);
@@ -1500,15 +1533,95 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1500
1533
  } catch {}
1501
1534
  return agWritten;
1502
1535
  }
1536
+ if (tool === "jcode") {
1537
+ // jcode is the ONLY harness here that gets a STDIO entry, and the reason
1538
+ // is not preference: jcode has no HTTP transport. Its loader keeps an
1539
+ // entry only when `is_stdio()` holds and drops the rest at load time --
1540
+ // "MCP: Skipping non-stdio server '<name>' (http); HTTP/SSE transports
1541
+ // are not yet supported" (crates/jcode-base/src/mcp/protocol.rs). So the
1542
+ // `type: "http"` entry every other harness receives is READ, RECOGNISED
1543
+ // AND DISCARDED by jcode, with a log line and no error. A bridge process
1544
+ // is the only shape it can run today.
1545
+ //
1546
+ // NAMED patchcord-jcode, NOT patchcord, AND THAT IS LOAD-BEARING.
1547
+ // jcode merges .jcode/mcp.json, then .mcp.json, then .claude/mcp.json,
1548
+ // with later files overriding same-named servers. A guard currently saves
1549
+ // us -- a non-stdio entry never displaces a working stdio one (their
1550
+ // issue #653) -- but that guard holds only while jcode CANNOT run http.
1551
+ // The day it gains that transport, a shared name means .mcp.json wins,
1552
+ // and jcode silently starts authenticating as claude_code's agent: two
1553
+ // harnesses, one credential, no error anywhere. A distinct name cannot
1554
+ // collide in the first place. Codex already does this (patchcord-codex).
1555
+ //
1556
+ // The bridge command mirrors the OpenClaw fallback this file already
1557
+ // prints. NOT VERIFIED END TO END from here: no jcode on this machine,
1558
+ // and proving it needs a live token against the real endpoint.
1559
+ const jdir = join(dir, ".jcode"); mkdirSync(jdir, { recursive: true });
1560
+ const jcodeWritten = writeJson(join(jdir, "mcp.json"), (o) => {
1561
+ o.mcpServers = o.mcpServers || {};
1562
+ // No `type` key at all. jcode infers stdio from the presence of
1563
+ // `command`, and writing "type": "stdio" is one more string to get
1564
+ // wrong for a default that is already correct.
1565
+ o.mcpServers["patchcord-jcode"] = {
1566
+ command: "npx",
1567
+ args: [
1568
+ "mcp-remote",
1569
+ `${baseUrl}/mcp`,
1570
+ "--header",
1571
+ `Authorization: Bearer ${token}`,
1572
+ "--header",
1573
+ `X-Patchcord-Machine: ${hostname}`,
1574
+ ],
1575
+ };
1576
+ });
1577
+ // THE SKILL IS A GUARD, NOT DOCUMENTATION, AND THIS IS THE HARNESS THAT
1578
+ // PROVED WHY. jcode inherits Claude Code's plugin skills today, which
1579
+ // say "spawn the listener under Monitor" — jcode has no Monitor, so
1580
+ // that instruction produces a listener that connects, receives, and
1581
+ // never wakes the agent. Observed in production: two messages sat
1582
+ // unread behind a healthy-looking listener. Without jcode's own
1583
+ // subscribe skill telling it to use `--stall-signal` + a bash
1584
+ // background task instead, every jcode agent silently inherits the
1585
+ // wrong instructions for its own harness.
1586
+ //
1587
+ // GLOBAL, NOT per-project (Pavel's ruling: "we dont need per project
1588
+ // skills, only per project MCP settings needed") — mirrors the hermes
1589
+ // branch above, not the antigravity one. `.jcode/mcp.json` stays
1590
+ // per-project (every project = one namespace = one agent identity);
1591
+ // skills are per machine, installed once, and MUST NOT contain
1592
+ // anything project-specific as a result — no namespace, no agent name,
1593
+ // no path baked in below.
1594
+ //
1595
+ // Destination is FLAT under ~/.jcode/skills/<name>/, not nested under
1596
+ // an "integrations" folder like hermes — jcode's own loader
1597
+ // (crates/jcode-app-core/src/tool/skill.rs) reads
1598
+ // ~/.jcode/skills/<skill-name>/SKILL.md directly, verified from source,
1599
+ // not the hermes shape.
1600
+ try {
1601
+ const jcodeSkillsSrc = join(pluginRoot, "per-project-skills", "jcode");
1602
+ if (existsSync(jcodeSkillsSrc)) {
1603
+ const jcodeSkillsDest = join(HOME, ".jcode", "skills");
1604
+ for (const name of ["inbox", "subscribe", "wait"]) {
1605
+ const from = join(jcodeSkillsSrc, name, "SKILL.md");
1606
+ if (!existsSync(from)) continue;
1607
+ const to = join(jcodeSkillsDest, name);
1608
+ mkdirSync(to, { recursive: true });
1609
+ cpSync(from, join(to, "SKILL.md"));
1610
+ }
1611
+ }
1612
+ } catch {}
1613
+ return jcodeWritten;
1614
+ }
1503
1615
  if (tool === "hermes") {
1504
- // Hermes reads MCP servers ONLY from its GLOBAL ~/.hermes/config.yaml
1505
- // (mcp_servers key) — it ignores a project-local .mcp.json. So unlike the
1506
- // other tools we cannot write into `dir`; we upsert the global config,
1507
- // mirroring the `npx patchcord` installer's Hermes path. NOTE: global =
1508
- // one patchcord identity per machine, so provisioning a second hermes
1509
- // worker in another namespace overwrites the first's token. Single-hermes
1510
- // teams only until Hermes gains project-scoped MCP config.
1511
- const hermesPath = join(HOME, ".hermes", "config.yaml");
1616
+ // Hermes reads MCP servers ONLY from its GLOBAL config.yaml (mcp_servers
1617
+ // key) — it ignores a project-local .mcp.json. So unlike the other tools
1618
+ // we cannot write into `dir`; we upsert the global config, mirroring the
1619
+ // `npx patchcord` installer's Hermes path. NOTE: "global" here means one
1620
+ // patchcord identity per Hermes HOME, so provisioning a second hermes
1621
+ // worker under the SAME home overwrites the first's token. Isolated by
1622
+ // HERMES_HOME (see resolveHermesConfigPath) or by -p profile — either
1623
+ // gives a separate home, and therefore a separate identity.
1624
+ const hermesPath = resolveHermesConfigPath();
1512
1625
  mkdirSync(dirname(hermesPath), { recursive: true });
1513
1626
  let existingYaml = "";
1514
1627
  try { existingYaml = existsSync(hermesPath) ? readFileSync(hermesPath, "utf-8") : ""; } catch {}
@@ -1518,7 +1631,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1518
1631
  try {
1519
1632
  const hermesSkillsSrc = join(pluginRoot, "per-project-skills", "hermes");
1520
1633
  if (existsSync(hermesSkillsSrc)) {
1521
- const hermesSkillsDest = join(HOME, ".hermes", "skills", "integrations");
1634
+ const hermesSkillsDest = resolveHermesSkillsDest();
1522
1635
  mkdirSync(hermesSkillsDest, { recursive: true });
1523
1636
  cpSync(hermesSkillsSrc, hermesSkillsDest, { recursive: true });
1524
1637
  }
@@ -1555,7 +1668,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1555
1668
  console.error(` .mcp.json → claude_code`);
1556
1669
  console.error(` .cursor/mcp.json → cursor`);
1557
1670
  console.error(` Defaulting would overwrite an agent you did not name, so there is no default.`);
1558
- console.error(` One of: claude_code, codex, cursor, kimi, opencode, antigravity, grok, hermes`);
1671
+ console.error(` One of: claude_code, codex, cursor, kimi, opencode, antigravity, grok, hermes, jcode`);
1559
1672
  console.error(` ${usage}`);
1560
1673
  process.exit(1);
1561
1674
  };
@@ -2915,11 +3028,14 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
2915
3028
 
2916
3029
  // Hermes — refresh patchcord skills to the current version on every run, so
2917
3030
  // skill fixes land via `patchcord update` (the interactive choice 13 only
2918
- // installs them once). ~/.hermes/skills is Hermes-only, so no leak risk; only
2919
- // refresh when the integrations dir already exists (Hermes was set up before).
3031
+ // installs them once). Hermes's own skills dir is not shared with any
3032
+ // other tool's config, so no leak risk; only refresh when the integrations
3033
+ // dir already exists (Hermes was set up before) — resolved through the
3034
+ // SAME home as config.yaml, so this refresh touches the seat's own skills
3035
+ // under HERMES_HOME, not the operator's global ones.
2920
3036
  {
2921
3037
  const hermesSkillsSrc = join(pluginRoot, "per-project-skills", "hermes");
2922
- const hermesSkillsDest = join(HOME, ".hermes", "skills", "integrations");
3038
+ const hermesSkillsDest = resolveHermesSkillsDest();
2923
3039
  if (existsSync(hermesSkillsDest) && existsSync(hermesSkillsSrc)) {
2924
3040
  let hermesChanged = false;
2925
3041
  for (const name of readdirSync(hermesSkillsSrc)) {
@@ -3194,7 +3310,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
3194
3310
  // falls through to no choice instead of silently configuring a DIFFERENT
3195
3311
  // harness and writing someone's token to the wrong file.
3196
3312
  "vscode": "6", "zed": "7", "opencode": "8", "openclaw": "9", "antigravity": "10",
3197
- "cline": "11", "kimi": "12", "hermes": "13",
3313
+ "cline": "11", "kimi": "12", "hermes": "13", "jcode": "15",
3198
3314
  };
3199
3315
 
3200
3316
 
@@ -3583,6 +3699,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
3583
3699
  const isCline = choice === "11";
3584
3700
  const isKimi = choice === "12";
3585
3701
  const isHermes = choice === "13";
3702
+ const isJcode = choice === "15";
3586
3703
 
3587
3704
  // MoonshotAI ships TWO CLIs that both use the `kimi` command:
3588
3705
  // • kimi-cli (Python): supports `--mcp-config-file`, config in .kimi/mcp.json
@@ -3657,9 +3774,64 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
3657
3774
  } catch (e) {
3658
3775
  console.log(`\n ${yellow}⚠ Failed to write ${grokPath}: ${e.message}${r}`);
3659
3776
  }
3777
+ } else if (isJcode) {
3778
+ // jcode: project-local .jcode/mcp.json, and a STDIO entry rather than the
3779
+ // http one every other harness gets. jcode has no HTTP transport -- it
3780
+ // recognises `type: "http"` and drops the server at load time with a log
3781
+ // line. See the writeWorkerConfig branch above for why the entry is named
3782
+ // `patchcord-jcode` and not `patchcord`; the short version is that jcode
3783
+ // merges .jcode/mcp.json, .mcp.json and .claude/mcp.json by server NAME,
3784
+ // so a shared name lets claude_code's credential silently become jcode's
3785
+ // the day jcode gains http support.
3786
+ const jcodePath = join(cwd, ".jcode", "mcp.json");
3787
+ try {
3788
+ mkdirSync(dirname(jcodePath), { recursive: true });
3789
+ let jobj = {};
3790
+ try { jobj = JSON.parse(readFileSync(jcodePath, "utf-8")); } catch {}
3791
+ jobj.mcpServers = jobj.mcpServers || {};
3792
+ jobj.mcpServers["patchcord-jcode"] = {
3793
+ command: "npx",
3794
+ args: [
3795
+ "mcp-remote",
3796
+ `${serverUrl}/mcp`,
3797
+ "--header",
3798
+ `Authorization: Bearer ${token}`,
3799
+ ],
3800
+ };
3801
+ writeSecureFile(jcodePath, JSON.stringify(jobj, null, 2) + "\n");
3802
+ console.log(`\n ${green}✓${r} jcode configured: ${dim}${jcodePath}${r}`);
3803
+ console.log(` ${dim}Bridged over stdio — jcode does not speak HTTP MCP yet.${r}`);
3804
+ } catch (e) {
3805
+ console.log(`\n ${yellow}⚠ Failed to write ${jcodePath}: ${e.message}${r}`);
3806
+ }
3807
+ // Install jcode skills to ~/.jcode/skills/<name>/ — GLOBAL, once per
3808
+ // machine, not per project (Pavel's ruling: "we dont need per project
3809
+ // skills, only per project MCP settings needed"). See the
3810
+ // writeWorkerConfig branch above for the full guard-not-documentation
3811
+ // reasoning and why the destination is flat, not nested under
3812
+ // "integrations" the way hermes's is.
3813
+ try {
3814
+ const jcodeSkillsSrc = join(pluginRoot, "per-project-skills", "jcode");
3815
+ if (existsSync(jcodeSkillsSrc)) {
3816
+ const jcodeSkillsDest = join(HOME, ".jcode", "skills");
3817
+ for (const name of ["inbox", "subscribe", "wait"]) {
3818
+ const from = join(jcodeSkillsSrc, name, "SKILL.md");
3819
+ if (!existsSync(from)) continue;
3820
+ const to = join(jcodeSkillsDest, name);
3821
+ mkdirSync(to, { recursive: true });
3822
+ cpSync(from, join(to, "SKILL.md"));
3823
+ }
3824
+ console.log(` ${green}✓${r} jcode skills installed: ${dim}${jcodeSkillsDest}${r}`);
3825
+ }
3826
+ } catch {}
3660
3827
  } else if (isHermes) {
3661
- // Hermes: global only (~/.hermes/config.yaml, YAML, mcp_servers key)
3662
- const hermesPath = join(HOME, ".hermes", "config.yaml");
3828
+ // Hermes: global-per-home only (config.yaml, YAML, mcp_servers key). Same
3829
+ // HERMES_HOME / `hermes config path` / ~/.hermes resolution as the
3830
+ // writeWorkerConfig branch above — see resolveHermesConfigPath. Both
3831
+ // write sites and the bearer-resolution read site must agree, or a seat
3832
+ // provisioned here writes to one file while `patchcord whoami` verifies
3833
+ // against another.
3834
+ const hermesPath = resolveHermesConfigPath();
3663
3835
  mkdirSync(dirname(hermesPath), { recursive: true });
3664
3836
  let existingYaml = "";
3665
3837
  try { existingYaml = existsSync(hermesPath) ? readFileSync(hermesPath, "utf-8") : ""; } catch {}
@@ -3670,11 +3842,12 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
3670
3842
  } catch (e) {
3671
3843
  console.log(`\n ${yellow}⚠ Failed to write ${hermesPath}: ${e.message}${r}`);
3672
3844
  }
3673
- // Install Hermes skills to ~/.hermes/skills/integrations/
3845
+ // Install Hermes skills under the resolved home's skills/integrations/
3846
+ // (same home as config.yaml — see resolveHermesSkillsDest).
3674
3847
  try {
3675
3848
  const hermesSkillsSrc = join(pluginRoot, "per-project-skills", "hermes");
3676
3849
  if (existsSync(hermesSkillsSrc)) {
3677
- const hermesSkillsDest = join(HOME, ".hermes", "skills", "integrations");
3850
+ const hermesSkillsDest = resolveHermesSkillsDest();
3678
3851
  mkdirSync(hermesSkillsDest, { recursive: true });
3679
3852
  cpSync(hermesSkillsSrc, hermesSkillsDest, { recursive: true });
3680
3853
  console.log(` ${green}✓${r} Hermes skills installed: ${dim}${hermesSkillsDest}${r}`);
@@ -4324,10 +4497,11 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
4324
4497
  // Hermes is global config (~/.hermes/config.yaml) — no per-project file to ignore.
4325
4498
  if (!isWindsurf && !isGemini && !isZed && !isOpenClaw && !isCline && !isHermes) {
4326
4499
  const gitignorePath = join(cwd, ".gitignore");
4327
- const configFile = isKimiCode ? ".kimi-code/mcp.json" : isKimi ? ".kimi/mcp.json" : isCodex ? ".codex/config.toml" : isCursor ? ".cursor/mcp.json" : isGrok ? ".grok/config.toml" : isVSCode ? ".vscode/mcp.json" : isOpenCode ? "opencode.json" : isAntigravity ? ".agents/mcp_config.json" : ".mcp.json";
4500
+ const configFile = isJcode ? ".jcode/mcp.json" : isKimiCode ? ".kimi-code/mcp.json" : isKimi ? ".kimi/mcp.json" : isCodex ? ".codex/config.toml" : isCursor ? ".cursor/mcp.json" : isGrok ? ".grok/config.toml" : isVSCode ? ".vscode/mcp.json" : isOpenCode ? "opencode.json" : isAntigravity ? ".agents/mcp_config.json" : ".mcp.json";
4328
4501
  // Forms that already cover this config (its file or its dir)
4329
4502
  const patterns = [configFile];
4330
- if (isKimiCode) patterns.push(".kimi-code/");
4503
+ if (isJcode) patterns.push(".jcode/");
4504
+ else if (isKimiCode) patterns.push(".kimi-code/");
4331
4505
  else if (isKimi) patterns.push(".kimi/");
4332
4506
  else if (isCodex) patterns.push(".codex/");
4333
4507
  else if (isCursor) patterns.push(".cursor/");
@@ -4352,7 +4526,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
4352
4526
  }
4353
4527
  }
4354
4528
 
4355
- const toolName = isHermes ? "Hermes" : isKimiCode ? "Kimi Code" : isKimi ? "Kimi Code" : isAntigravity ? "Antigravity CLI" : isCline ? "Cline" : isOpenClaw ? "OpenClaw" : isOpenCode ? "OpenCode" : isZed ? "Zed" : isVSCode ? "VS Code" : isGemini ? "Gemini CLI" : isWindsurf ? "Windsurf" : isGrok ? "Grok CLI" : isCursor ? "Cursor" : isCodex ? "Codex" : "Claude Code";
4529
+ const toolName = isJcode ? "jcode" : isHermes ? "Hermes" : isKimiCode ? "Kimi Code" : isKimi ? "Kimi Code" : isAntigravity ? "Antigravity CLI" : isCline ? "Cline" : isOpenClaw ? "OpenClaw" : isOpenCode ? "OpenCode" : isZed ? "Zed" : isVSCode ? "VS Code" : isGemini ? "Gemini CLI" : isWindsurf ? "Windsurf" : isGrok ? "Grok CLI" : isCursor ? "Cursor" : isCodex ? "Codex" : "Claude Code";
4356
4530
 
4357
4531
  if (!isWindsurf && !isGemini && !isZed && !isOpenClaw && !isCline && !isKimi && !isHermes) {
4358
4532
  console.log(`\n ${dim}To connect a second agent:${r}`);
package/harnesses.json CHANGED
@@ -129,10 +129,10 @@
129
129
  "aliases": [],
130
130
  "cli": "hermes",
131
131
  "kind": "terminal",
132
- "installer_scope": "global",
133
- "installer_config": "~/.hermes/config.yaml",
134
- "harness_scope": "per-profile",
135
- "installer_defect": "The path is hardcoded to ~/.hermes/config.yaml. The installer never calls `hermes config path` and does not know profiles exist, so installing while a non-default profile is active writes the DEFAULT profile's config — the wrong file, silently. Not fixed as of 0.6.41.",
132
+ "installer_scope": "env-directed",
133
+ "installer_config": "$HERMES_HOME/config.yaml if set, else the output of `hermes config path` if the binary resolves, else ~/.hermes/config.yaml",
134
+ "harness_scope": "per-profile-or-per-home",
135
+ "installer_defect": "WAS: the path was hardcoded to ~/.hermes/config.yaml. The installer never called `hermes config path` and did not know profiles exist, so installing while a non-default profile was active wrote the DEFAULT profile's config — the wrong file, silently. FIXED (scripts/lib/hermes-home.mjs, resolveHermesHome/resolveHermesConfigPath/resolveHermesSkillsDest, all three deriving from the same resolved home): HERMES_HOME from the environment, else a shelled `hermes config path` (which itself honours HERMES_HOME — the child inherits the environment), else the unchanged ~/.hermes fallback. Applies to config.yaml AND to where Hermes skills install (~/.hermes/skills/integrations was hardcoded in three more places, unaware of HERMES_HOME — a seat isolated by home for identity but not for skills is not isolated: skills are a guard, not documentation). mux measured two independent isolation mechanisms this way — HERMES_HOME and -p/--profile, both real, both giving a separate mcp_servers AND a separate .env — which is why harness_scope reads per-profile-or-per-home rather than per-profile alone. installer_scope changed from 'global' to 'env-directed' for the same reason: our installer's write target is no longer a fixed machine-wide path, it is wherever HERMES_HOME points when mux sets it, falling back to global only when nothing directs it. PROFILE SUPPORT DELIBERATELY NOT BUILT: -p/--profile is a second, separate isolation axis this installer does not resolve, understand, or accept a flag for. Whether `hermes config path`'s output already reflects an ambient active profile (e.g. via an env var hermes itself reads) is unknown without reading Hermes's own source or an install to test against — neither available here. If a user runs Hermes under a non-default profile without ALSO setting HERMES_HOME to match, this installer's write may land in a config that profile-launched Hermes never reads — the same silent-wrong-file failure this whole fix exists to close, just on the axis we chose not to resolve. Not yet released or observed end to end; version not bumped.",
136
136
  "listener": {
137
137
  "wake": "realtime",
138
138
  "mechanism": "webhook-bridge",
@@ -217,6 +217,23 @@
217
217
  "installer_config": "cline_mcp_settings.json (VS Code globalStorage)",
218
218
  "harness_scope": "unknown",
219
219
  "listener": { "wake": "none", "mechanism": null, "self_arm": false, "survives_wake": null, "evidence": "declared" }
220
+ },
221
+ {
222
+ "id": "jcode",
223
+ "aliases": [],
224
+ "cli": "jcode",
225
+ "kind": "terminal",
226
+ "installer_scope": "project",
227
+ "installer_config": ".jcode/mcp.json",
228
+ "harness_scope": "project",
229
+ "listener": {
230
+ "wake": "stall",
231
+ "mechanism": "subscribe-skill-stall-signal",
232
+ "self_arm": true,
233
+ "survives_wake": null,
234
+ "evidence": "declared",
235
+ "note": "STDIO ONLY FOR MCP; SKILLS ARE GLOBAL, NOT PROJECT. jcode reads .jcode/mcp.json, .mcp.json and .claude/mcp.json for MCP config (per-project, unchanged by this note), so it ALREADY finds the Claude Code entry we write - and drops it, because jcode supports stdio servers only and skips type http/sse at load time with a log line (crates/jcode-base/src/mcp/protocol.rs, retain on is_stdio). Server named patchcord-jcode, NOT patchcord, for the same reason codex uses patchcord-codex: a shared name would let a later file's entry silently override by NAME the day jcode gains http transport. jcode's own skills, separately, install ONCE GLOBALLY to ~/.jcode/skills/<name>/SKILL.md (verified from crates/jcode-app-core/src/tool/skill.rs's own loader paths) - not per project. WAKE IS \"stall\", NOT \"realtime\" OR \"none\": jcode has no Monitor, so a background task wakes it only when stdout goes quiet for stall_wake_seconds - and subscribe.mjs's only stdout writer is a real-message line, so by default the pipe is ALREADY silent while idle, making an unmodified stall wake fire on a timer regardless of whether anything arrived. `patchcord subscribe --stall-signal` (scripts/lib/stall-signal.mjs) inverts that for jcode specifically: writes a HEARTBEAT keepalive while idle so the pipe is never silently quiet by accident, and suppresses it on purpose after a real message so the stall condition elapses close to when the message actually arrived. This only works if stall_wake_seconds equals --stall-signal's own quiet window (15s by default) - a mismatch silently breaks the wake in one direction or the other, which is why the subscribe skill states the number explicitly rather than leaving it to be inferred. NOT VERIFIED END TO END: no jcode install on this machine to watch it actually fire."
236
+ }
220
237
  }
221
238
  ],
222
239
  "retired": [
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.42",
3
+ "version": "0.6.44",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "scripts": {
6
- "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json"
6
+ "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json agent-plugin/plugin.json"
7
7
  },
8
8
  "author": "ppravdin",
9
9
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  "commands/",
34
34
  "README.md",
35
35
  "plugins/",
36
+ "agent-plugin/",
36
37
  "harnesses.json"
37
38
  ]
38
39
  }
@@ -99,7 +99,7 @@ To message a user outside your namespace, use `@username` as the to_agent. Examp
99
99
  ```
100
100
  patchcord upload /path/to/report.md --mime text/markdown
101
101
  ```
102
- Prints the storage path. Pass it to `send_message`. No curl, no base64 in chat. 25MB cap.
102
+ Prints the storage path. Pass it to `send_message`. No curl, no base64 in chat. The size limit belongs to the server: 10 MiB by default, raisable on a self-hosted server. Too large prints the server's own limit.
103
103
 
104
104
  **Public URLs → `attachment(relay=true, ...)`:**
105
105
  ```
@@ -105,7 +105,7 @@ To message a user outside your namespace, use `@username` as the to_agent. Examp
105
105
  ```
106
106
  patchcord upload /path/to/report.md --mime text/markdown
107
107
  ```
108
- Prints the storage path. Pass that path to `send_message`. No curl, no base64 in chat, no presigned URLs. 25MB cap.
108
+ Prints the storage path. Pass that path to `send_message`. No curl, no base64 in chat, no presigned URLs. The size limit is the server's, not a number to remember: it is 10 MiB by default and a self-hosted server can raise it. If a file is too large the command prints the server's own limit.
109
109
 
110
110
  **Public URLs → `attachment(relay=true, ...)`:**
111
111
  ```
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: patchcord-inbox
3
+ description: Read Patchcord inbox and reply to messages
4
+ ---
5
+
6
+ ## Applies only when Patchcord MCP tools are loaded
7
+
8
+ If the `inbox` MCP tool is not available in this session, this skill does not
9
+ apply. Do nothing: do not run the Patchcord CLI, read a token from a config
10
+ file, or call the HTTP API. A missing project MCP config is a normal state.
11
+
12
+ Call the `inbox` MCP tool now. In its response, the first header line is YOUR
13
+ own identity (the recipient); the real sender of each message is on a
14
+ `From X` line — never confuse the two.
15
+
16
+ For each pending message, classify it and act:
17
+
18
+ - **ACK** — short signals like thanks, noted, works, great, ok, 👍 with no
19
+ task → close it silently: `reply(message_id, resolve=true)` with NO
20
+ content. Never send a text reply to an ack (it creates infinite ack
21
+ chains).
22
+ - **BLOCKED** — you cannot do the work right now (busy, missing credentials,
23
+ ambiguous target) → `reply(message_id, "<reason>", defer=true)` so it stays
24
+ in your inbox as a reminder. Never silently skip a message.
25
+ - **ACTIONABLE** — do the work the message asks for FIRST (edit the file,
26
+ run the command, write the code), THEN
27
+ `reply(message_id, "<concrete summary of what you did, with file paths and
28
+ line numbers>")`. Never reply "will do" / "understood" before doing the
29
+ work.
30
+
31
+ If the patchcord-subscribe listener is already running in the background,
32
+ you do not need to restart it after handling messages — unlike a
33
+ poll-and-exit script, it keeps running across wakes. Only restart it if you
34
+ have reason to believe it died (see the patchcord-subscribe skill).
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: patchcord-subscribe
3
+ description: >
4
+ Start the Patchcord background listener so new messages wake this agent.
5
+ Run this when the user asks to start Patchcord push delivery.
6
+ ---
7
+
8
+ jcode has no Monitor tool. A background task here wakes the agent on a
9
+ STALL — no new output for N seconds — not per line the way Claude Code's
10
+ Monitor does. Following Claude Code's own subscribe instructions verbatim on
11
+ jcode produces a listener that connects, receives, and never wakes you: the
12
+ pipe fills with messages you are never told about.
13
+
14
+ This skill exists to run correctly on jcode specifically, using
15
+ `patchcord subscribe --stall-signal`, a mode built for exactly this gap:
16
+ while idle it writes a `HEARTBEAT:` line often enough that the pipe never
17
+ looks silent, and on a real message it goes quiet on purpose for long enough
18
+ that jcode's own stall detector fires. This is this repo's global skill
19
+ directory (`~/.jcode/skills/`), shared by every jcode project on this
20
+ machine — nothing below may name a specific namespace, agent, or project
21
+ path.
22
+
23
+ # Start
24
+
25
+ 1. **Drain the inbox first.** Call the Patchcord `inbox` MCP tool. Process
26
+ every pending message (see the patchcord-inbox skill) before starting the
27
+ listener — a backlog can accumulate while no listener was running, and
28
+ the listener does not replay what happened before it started.
29
+
30
+ 2. **Spawn the listener with bash, in the background, with a stall wake:**
31
+
32
+ ```
33
+ Bash(
34
+ command: "patchcord subscribe --stall-signal | grep --line-buffered '^PATCHCORD:'; exit ${PIPESTATUS[0]}",
35
+ run_in_background: true,
36
+ stall_wake_seconds: 15
37
+ )
38
+ ```
39
+
40
+ `stall_wake_seconds: 15` is not a placeholder — it MUST match
41
+ `--stall-signal`'s own default quiet window (15000ms). If you ever pass
42
+ `--stall-signal` a custom triple (`keepaliveMs:quietMs:stallMs`),
43
+ `stall_wake_seconds` must equal the `stallMs` you passed, or the two
44
+ numbers disagree and the wake either fires on nothing (too short) or
45
+ never fires on a real message (too long). Do not use 30 here — that
46
+ number predates `--stall-signal` and is shorter than nothing: it is
47
+ simply the wrong number for this mechanism, not a safer one.
48
+
49
+ The grep keeps only `PATCHCORD:` lines in the visible output; `HEARTBEAT:`
50
+ lines never match that pattern, so they never need special handling on
51
+ your end. `${PIPESTATUS[0]}` preserves subscribe's own exit code through
52
+ the pipe.
53
+
54
+ 3. **Tell the user one line:** "Patchcord listener active — I'll check
55
+ regularly for new messages." Do not say "as messages arrive" — the
56
+ mechanism approximates that, it does not guarantee it, and promising more
57
+ than jcode can deliver is worse than being accurate.
58
+
59
+ # When the background task wakes you
60
+
61
+ A wake means the pipe went quiet for `stall_wake_seconds` — with
62
+ `--stall-signal` running, that quiet is now DESIGNED to correlate with a real
63
+ message, but two other things also produce exactly the same silence, and you
64
+ cannot tell which one happened from the wake alone:
65
+
66
+ 1. **Check the inbox every time.** Call the Patchcord `inbox` MCP tool. If it
67
+ has pending messages, handle them (see the patchcord-inbox skill). If it
68
+ is empty, say nothing to the user and do not report the wake as an event —
69
+ an empty check is not news.
70
+ 2. **If the inbox is empty, check whether the listener is still running**
71
+ before assuming this was a harmless false positive. A dead listener also
72
+ produces silence — that is a feature (it is how you notice), but only if
73
+ you actually look. If the background task has exited, restart it with the
74
+ command in Start, step 2.
75
+ 3. **Never read the last `PATCHCORD:` line in the task output as news.** It
76
+ is scrollback — output already displayed. It may be the same line you
77
+ already handled. The inbox call in step 1 is the source of truth; a
78
+ `PATCHCORD:` line without a corresponding inbox check is not confirmation
79
+ of anything.
80
+
81
+ # Stopping
82
+
83
+ Tell the user one of:
84
+
85
+ - End this session.
86
+ - Kill the listener: `kill $(cat /tmp/patchcord_subscribe_<namespace>_<agent>.pid)`
87
+ (the exact path is printed by `patchcord subscribe` on start; do not guess
88
+ the namespace/agent — this is a global skill and cannot know them).
89
+
90
+ # If the background task ends on its own
91
+
92
+ Read its output. Scan for one of:
93
+
94
+ - `no patchcord config found` — not run from a project with `.jcode/mcp.json`
95
+ (or a parent project directory).
96
+ - `ticket: token rejected (HTTP 401|403)` — the bearer token is invalid or
97
+ expired.
98
+ - `already running (pid N)` (exit 2) — another listener is active for this
99
+ agent; report it, do not respawn.
100
+ - `subscribe: fatal: ...` — report the fatal line verbatim.
101
+
102
+ If none of those appear, it likely ended with the session or because its
103
+ output consumer closed. Restart it only when the user asks to resume Patchcord
104
+ push delivery.
105
+
106
+ **Forbidden on failure:** no hand-rolled polling loop in place of this
107
+ mechanism, no pidfile editing, no re-arm loop after every wake — the
108
+ background task under `run_in_background` keeps running across wakes; you
109
+ are only checking in, not restarting it, unless step 2 above found it dead.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: patchcord-wait
3
+ description: Wait for one incoming Patchcord message
4
+ ---
5
+
6
+ ## Applies only when Patchcord MCP tools are loaded
7
+
8
+ If the `wait_for_message` MCP tool is not available in this session, this
9
+ skill does not apply. Do nothing: do not run the Patchcord CLI, read a token
10
+ from a config file, or call the HTTP API. A missing project MCP config is a
11
+ normal state.
12
+
13
+ Call the `wait_for_message` MCP tool now to block until a message arrives or
14
+ ~5 minutes elapse.
15
+
16
+ When a message arrives, classify it and act:
17
+
18
+ - **ACK** (thanks, noted, works, ok, 👍, no task) →
19
+ `reply(message_id, resolve=true)` with NO content. Never text-reply an ack.
20
+ - **BLOCKED** (cannot do the work right now) →
21
+ `reply(message_id, "<reason>", defer=true)`.
22
+ - **ACTIONABLE** → do the work FIRST, then
23
+ `reply(message_id, "<concrete summary of what you did>")`.
24
+
25
+ Use this skill for a single blocking wait, not as a substitute for the
26
+ persistent background listener — see the patchcord-subscribe skill for that.