patchcord 0.5.14 → 0.5.16

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
@@ -97,6 +97,43 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
97
97
  } catch { return null; }
98
98
  }
99
99
 
100
+ // Read+merge+write a JSON config file. If the file exists but its contents
101
+ // can't be parsed, REFUSE to write — silently overwriting would erase
102
+ // unrelated MCP servers, settings, or hand-edits the user has in there.
103
+ // Returns true on success, false when skipped due to a parse failure (so
104
+ // the caller can suppress its "configured" message).
105
+ function updateJsonConfig(filePath, mutate) {
106
+ let parsed = {};
107
+ if (existsSync(filePath)) {
108
+ const raw = readFileSync(filePath, "utf-8");
109
+ try {
110
+ // JSONC-tolerant: Zed/Gemini settings allow //, /* */, trailing commas.
111
+ const cleaned = raw
112
+ .replace(/\/\/.*$/gm, "")
113
+ .replace(/\/\*[\s\S]*?\*\//g, "")
114
+ .replace(/,\s*([}\]])/g, "$1");
115
+ const result = cleaned.trim() ? JSON.parse(cleaned) : {};
116
+ // null / arrays / primitives aren't config objects we can merge into;
117
+ // refuse rather than blow them away.
118
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
119
+ console.log(`\n ${yellow}⚠${r} Skipped ${filePath} — existing file is not a JSON object.`);
120
+ console.log(` Replace it with a valid object ({...}) or remove it, then re-run.`);
121
+ return false;
122
+ }
123
+ parsed = result;
124
+ } catch {
125
+ console.log(`\n ${yellow}⚠${r} Skipped ${filePath} — could not parse existing JSON.`);
126
+ console.log(` Fix the file by hand and re-run the installer. We will not`);
127
+ console.log(` overwrite it: that would erase your other servers/settings.`);
128
+ return false;
129
+ }
130
+ }
131
+ mutate(parsed);
132
+ mkdirSync(dirname(filePath), { recursive: true });
133
+ writeFileSync(filePath, JSON.stringify(parsed, null, 2) + "\n");
134
+ return true;
135
+ }
136
+
100
137
  console.log(`
101
138
  ___ ____ ___ ____ _ _ ____ ____ ____ ___
102
139
  |__] |__| | | |__| | | | |__/ | \\
@@ -661,20 +698,14 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
661
698
  },
662
699
  };
663
700
 
664
- if (existsSync(cursorPath)) {
665
- try {
666
- const existing = JSON.parse(readFileSync(cursorPath, "utf-8"));
667
- existing.mcpServers = existing.mcpServers || {};
668
- existing.mcpServers.patchcord = cursorConfig.mcpServers.patchcord;
669
- writeFileSync(cursorPath, JSON.stringify(existing, null, 2) + "\n");
670
- } catch {
671
- writeFileSync(cursorPath, JSON.stringify(cursorConfig, null, 2) + "\n");
672
- }
673
- } else {
674
- writeFileSync(cursorPath, JSON.stringify(cursorConfig, null, 2) + "\n");
701
+ const cursorOk = updateJsonConfig(cursorPath, (obj) => {
702
+ obj.mcpServers = obj.mcpServers || {};
703
+ obj.mcpServers.patchcord = cursorConfig.mcpServers.patchcord;
704
+ });
705
+ if (cursorOk) {
706
+ console.log(`\n ${green}✓${r} Cursor configured: ${dim}${cursorPath}${r}`);
707
+ console.log(` ${dim}Per-project only — other projects won't see this agent.${r}`);
675
708
  }
676
- console.log(`\n ${green}✓${r} Cursor configured: ${dim}${cursorPath}${r}`);
677
- console.log(` ${dim}Per-project only — other projects won't see this agent.${r}`);
678
709
  } else if (isWindsurf) {
679
710
  // Windsurf: global only (~/.codeium/windsurf/mcp_config.json)
680
711
  const wsPath = join(HOME, ".codeium", "windsurf", "mcp_config.json");
@@ -690,84 +721,72 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
690
721
  },
691
722
  };
692
723
 
693
- if (existsSync(wsPath)) {
694
- try {
695
- const content = readFileSync(wsPath, "utf-8").trim();
696
- const existing = content ? JSON.parse(content) : {};
697
- existing.mcpServers = existing.mcpServers || {};
698
- existing.mcpServers.patchcord = wsConfig.mcpServers.patchcord;
699
- writeFileSync(wsPath, JSON.stringify(existing, null, 2) + "\n");
700
- } catch {
701
- writeFileSync(wsPath, JSON.stringify(wsConfig, null, 2) + "\n");
702
- }
703
- } else {
704
- mkdirSync(join(HOME, ".codeium", "windsurf"), { recursive: true });
705
- writeFileSync(wsPath, JSON.stringify(wsConfig, null, 2) + "\n");
724
+ const wsOk = updateJsonConfig(wsPath, (obj) => {
725
+ obj.mcpServers = obj.mcpServers || {};
726
+ obj.mcpServers.patchcord = wsConfig.mcpServers.patchcord;
727
+ });
728
+ if (wsOk) {
729
+ console.log(`\n ${green}✓${r} Windsurf configured: ${dim}${wsPath}${r}`);
730
+ console.log(` ${yellow}Global config all Windsurf projects share this agent.${r}`);
706
731
  }
707
- console.log(`\n ${green}✓${r} Windsurf configured: ${dim}${wsPath}${r}`);
708
- console.log(` ${yellow}Global config — all Windsurf projects share this agent.${r}`);
709
732
  } else if (isGemini) {
710
733
  // Gemini CLI: global only (~/.gemini/settings.json)
711
734
  const geminiPath = join(HOME, ".gemini", "settings.json");
712
- let geminiSettings = (existsSync(geminiPath) && safeReadJson(geminiPath)) || {};
713
- if (!geminiSettings.mcpServers) geminiSettings.mcpServers = {};
714
- geminiSettings.mcpServers.patchcord = {
715
- httpUrl: `${serverUrl}/mcp`,
716
- headers: {
717
- Authorization: `Bearer ${token}`,
718
- "X-Patchcord-Machine": hostname,
719
- },
720
- };
721
- // Clean up deprecated tools.allowed if present (removed in Gemini CLI 1.0)
722
- if (geminiSettings.tools?.allowed) {
723
- geminiSettings.tools.allowed = geminiSettings.tools.allowed.filter(t => !t.startsWith("mcp_patchcord_"));
724
- if (geminiSettings.tools.allowed.length === 0) delete geminiSettings.tools;
735
+ const geminiOk = updateJsonConfig(geminiPath, (obj) => {
736
+ obj.mcpServers = obj.mcpServers || {};
737
+ obj.mcpServers.patchcord = {
738
+ httpUrl: `${serverUrl}/mcp`,
739
+ headers: {
740
+ Authorization: `Bearer ${token}`,
741
+ "X-Patchcord-Machine": hostname,
742
+ },
743
+ };
744
+ // Clean up deprecated tools.allowed if present (removed in Gemini CLI 1.0)
745
+ if (obj.tools?.allowed) {
746
+ obj.tools.allowed = obj.tools.allowed.filter(t => !t.startsWith("mcp_patchcord_"));
747
+ if (obj.tools.allowed.length === 0) delete obj.tools;
748
+ }
749
+ });
750
+ if (geminiOk) {
751
+ console.log(`\n ${green}✓${r} Gemini CLI configured: ${dim}${geminiPath}${r}`);
752
+ console.log(` ${yellow}Global config — all Gemini CLI projects share this agent.${r}`);
725
753
  }
726
- mkdirSync(join(HOME, ".gemini"), { recursive: true });
727
- writeFileSync(geminiPath, JSON.stringify(geminiSettings, null, 2) + "\n");
728
- console.log(`\n ${green}✓${r} Gemini CLI configured: ${dim}${geminiPath}${r}`);
729
- console.log(` ${yellow}Global config — all Gemini CLI projects share this agent.${r}`);
730
754
  } else if (isZed) {
731
755
  // Zed: global settings.json → context_servers
732
756
  const zedPath = process.platform === "darwin"
733
757
  ? join(HOME, "Library", "Application Support", "Zed", "settings.json")
734
758
  : join(HOME, ".config", "zed", "settings.json");
735
- let zedSettings = (existsSync(zedPath) && safeReadJson(zedPath)) || {};
736
- if (!zedSettings.context_servers) zedSettings.context_servers = {};
737
- zedSettings.context_servers.patchcord = {
738
- url: `${serverUrl}/mcp`,
739
- headers: {
740
- Authorization: `Bearer ${token}`,
741
- "X-Patchcord-Machine": hostname,
742
- },
743
- };
744
- const zedDir = process.platform === "darwin"
745
- ? join(HOME, "Library", "Application Support", "Zed")
746
- : join(HOME, ".config", "zed");
747
- mkdirSync(zedDir, { recursive: true });
748
- writeFileSync(zedPath, JSON.stringify(zedSettings, null, 2) + "\n");
749
- console.log(`\n ${green}✓${r} Zed configured: ${dim}${zedPath}${r}`);
750
- console.log(` ${yellow}Global config — all Zed projects share this agent.${r}`);
759
+ const zedOk = updateJsonConfig(zedPath, (obj) => {
760
+ obj.context_servers = obj.context_servers || {};
761
+ obj.context_servers.patchcord = {
762
+ url: `${serverUrl}/mcp`,
763
+ headers: {
764
+ Authorization: `Bearer ${token}`,
765
+ "X-Patchcord-Machine": hostname,
766
+ },
767
+ };
768
+ });
769
+ if (zedOk) {
770
+ console.log(`\n ${green}✓${r} Zed configured: ${dim}${zedPath}${r}`);
771
+ console.log(` ${yellow}Global config all Zed projects share this agent.${r}`);
772
+ }
751
773
  } else if (isOpenCode) {
752
774
  // OpenCode: per-project opencode.json → mcp
753
775
  const ocPath = join(cwd, "opencode.json");
754
- let ocConfig = {};
755
- if (existsSync(ocPath)) {
756
- try {
757
- ocConfig = JSON.parse(readFileSync(ocPath, "utf-8"));
758
- } catch {}
776
+ const ocOk = updateJsonConfig(ocPath, (obj) => {
777
+ obj.mcp = obj.mcp || {};
778
+ obj.mcp.patchcord = {
779
+ type: "remote",
780
+ url: `${serverUrl}/mcp`,
781
+ headers: {
782
+ Authorization: `Bearer ${token}`,
783
+ "X-Patchcord-Machine": hostname,
784
+ },
785
+ };
786
+ });
787
+ if (ocOk) {
788
+ console.log(`\n ${green}✓${r} OpenCode configured: ${dim}${ocPath}${r}`);
759
789
  }
760
- if (!ocConfig.mcp) ocConfig.mcp = {};
761
- ocConfig.mcp.patchcord = {
762
- type: "remote",
763
- url: `${serverUrl}/mcp`,
764
- headers: {
765
- Authorization: `Bearer ${token}`,
766
- "X-Patchcord-Machine": hostname,
767
- },
768
- };
769
- writeFileSync(ocPath, JSON.stringify(ocConfig, null, 2) + "\n");
770
- console.log(`\n ${green}✓${r} OpenCode configured: ${dim}${ocPath}${r}`);
771
790
  } else if (isOpenClaw) {
772
791
  // OpenClaw: global ~/.openclaw/openclaw.json → mcp.servers
773
792
  // Try CLI first, fall back to direct file write
@@ -785,28 +804,23 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
785
804
  console.log(`\n ${green}✓${r} OpenClaw configured via CLI: ${dim}openclaw mcp set${r}`);
786
805
  } else {
787
806
  // CLI not available — write config directly
788
- const openclawDir = join(HOME, ".openclaw");
789
- const openclawPath = join(openclawDir, "openclaw.json");
790
- let openclawConfig = {};
791
- if (existsSync(openclawPath)) {
792
- try {
793
- openclawConfig = JSON.parse(readFileSync(openclawPath, "utf-8"));
794
- } catch {}
807
+ const openclawPath = join(HOME, ".openclaw", "openclaw.json");
808
+ const openclawOk = updateJsonConfig(openclawPath, (obj) => {
809
+ obj.mcp = obj.mcp || {};
810
+ obj.mcp.servers = obj.mcp.servers || {};
811
+ obj.mcp.servers.patchcord = {
812
+ url: `${serverUrl}/mcp`,
813
+ transport: "streamable-http",
814
+ headers: {
815
+ Authorization: `Bearer ${token}`,
816
+ "X-Patchcord-Machine": hostname,
817
+ },
818
+ connectionTimeoutMs: 300000,
819
+ };
820
+ });
821
+ if (openclawOk) {
822
+ console.log(`\n ${green}✓${r} OpenClaw configured: ${dim}${openclawPath}${r}`);
795
823
  }
796
- if (!openclawConfig.mcp) openclawConfig.mcp = {};
797
- if (!openclawConfig.mcp.servers) openclawConfig.mcp.servers = {};
798
- openclawConfig.mcp.servers.patchcord = {
799
- url: `${serverUrl}/mcp`,
800
- transport: "streamable-http",
801
- headers: {
802
- Authorization: `Bearer ${token}`,
803
- "X-Patchcord-Machine": hostname,
804
- },
805
- connectionTimeoutMs: 300000,
806
- };
807
- mkdirSync(openclawDir, { recursive: true });
808
- writeFileSync(openclawPath, JSON.stringify(openclawConfig, null, 2) + "\n");
809
- console.log(`\n ${green}✓${r} OpenClaw configured: ${dim}${openclawPath}${r}`);
810
824
  }
811
825
  console.log(` ${yellow}Global config — all OpenClaw channels share this agent.${r}`);
812
826
  console.log(` ${dim}Run: openclaw gateway restart${r}`);
@@ -818,32 +832,28 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
818
832
  // Antigravity: global ~/.gemini/antigravity/mcp_config.json → mcpServers
819
833
  const agDir = join(HOME, ".gemini", "antigravity");
820
834
  const agPath = join(agDir, "mcp_config.json");
821
- let agConfig = {};
822
- if (existsSync(agPath)) {
823
- try {
824
- agConfig = JSON.parse(readFileSync(agPath, "utf-8"));
825
- } catch {}
835
+ const agOk = updateJsonConfig(agPath, (obj) => {
836
+ obj.mcpServers = obj.mcpServers || {};
837
+ obj.mcpServers.patchcord = {
838
+ serverUrl: `${serverUrl}/mcp`,
839
+ headers: {
840
+ Authorization: `Bearer ${token}`,
841
+ "X-Patchcord-Machine": hostname,
842
+ },
843
+ };
844
+ });
845
+ if (agOk) {
846
+ console.log(`\n ${green}✓${r} Antigravity configured: ${dim}${agPath}${r}`);
847
+ // Install global skills
848
+ const agSkillDir = join(agDir, "skills", "patchcord");
849
+ const agWaitDir = join(agDir, "skills", "patchcord-wait");
850
+ mkdirSync(agSkillDir, { recursive: true });
851
+ mkdirSync(agWaitDir, { recursive: true });
852
+ cpSync(join(pluginRoot, "skills", "inbox", "SKILL.md"), join(agSkillDir, "SKILL.md"));
853
+ cpSync(join(pluginRoot, "skills", "wait", "SKILL.md"), join(agWaitDir, "SKILL.md"));
854
+ console.log(` ${green}✓${r} Skills installed: ${dim}patchcord${r}, ${dim}patchcord-wait${r}`);
855
+ console.log(` ${yellow}Global config — all Antigravity projects share this agent.${r}`);
826
856
  }
827
- if (!agConfig.mcpServers) agConfig.mcpServers = {};
828
- agConfig.mcpServers.patchcord = {
829
- serverUrl: `${serverUrl}/mcp`,
830
- headers: {
831
- Authorization: `Bearer ${token}`,
832
- "X-Patchcord-Machine": hostname,
833
- },
834
- };
835
- mkdirSync(agDir, { recursive: true });
836
- writeFileSync(agPath, JSON.stringify(agConfig, null, 2) + "\n");
837
- console.log(`\n ${green}✓${r} Antigravity configured: ${dim}${agPath}${r}`);
838
- // Install global skills
839
- const agSkillDir = join(agDir, "skills", "patchcord");
840
- const agWaitDir = join(agDir, "skills", "patchcord-wait");
841
- mkdirSync(agSkillDir, { recursive: true });
842
- mkdirSync(agWaitDir, { recursive: true });
843
- cpSync(join(pluginRoot, "skills", "inbox", "SKILL.md"), join(agSkillDir, "SKILL.md"));
844
- cpSync(join(pluginRoot, "skills", "wait", "SKILL.md"), join(agWaitDir, "SKILL.md"));
845
- console.log(` ${green}✓${r} Skills installed: ${dim}patchcord${r}, ${dim}patchcord-wait${r}`);
846
- console.log(` ${yellow}Global config — all Antigravity projects share this agent.${r}`);
847
857
  } else if (isCline) {
848
858
  // Cline VS Code extension: global cline_mcp_settings.json
849
859
  // Config lives in VS Code's globalStorage for saoudrizwan.claude-dev
@@ -884,24 +894,22 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
884
894
  }
885
895
 
886
896
  const clinePath = join(clineSettingsDir, "cline_mcp_settings.json");
887
- let clineConfig = {};
888
- if (existsSync(clinePath)) {
889
- try { clineConfig = JSON.parse(readFileSync(clinePath, "utf-8")); } catch {}
897
+ const clineOk = updateJsonConfig(clinePath, (obj) => {
898
+ obj.mcpServers = obj.mcpServers || {};
899
+ obj.mcpServers.patchcord = {
900
+ url: `${serverUrl}/mcp`,
901
+ headers: {
902
+ Authorization: `Bearer ${token}`,
903
+ "X-Patchcord-Machine": hostname,
904
+ },
905
+ disabled: false,
906
+ alwaysAllow: [],
907
+ };
908
+ });
909
+ if (clineOk) {
910
+ console.log(`\n ${green}✓${r} Cline configured: ${dim}${clinePath}${r}`);
911
+ console.log(` ${yellow}Global config — all Cline projects share this agent.${r}`);
890
912
  }
891
- if (!clineConfig.mcpServers) clineConfig.mcpServers = {};
892
- clineConfig.mcpServers.patchcord = {
893
- url: `${serverUrl}/mcp`,
894
- headers: {
895
- Authorization: `Bearer ${token}`,
896
- "X-Patchcord-Machine": hostname,
897
- },
898
- disabled: false,
899
- alwaysAllow: [],
900
- };
901
- mkdirSync(clineSettingsDir, { recursive: true });
902
- writeFileSync(clinePath, JSON.stringify(clineConfig, null, 2) + "\n");
903
- console.log(`\n ${green}✓${r} Cline configured: ${dim}${clinePath}${r}`);
904
- console.log(` ${yellow}Global config — all Cline projects share this agent.${r}`);
905
913
  } else if (isVSCode) {
906
914
  // VS Code: write .vscode/mcp.json (per-project)
907
915
  const vscodeDir = join(cwd, ".vscode");
@@ -920,20 +928,14 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
920
928
  },
921
929
  };
922
930
 
923
- if (existsSync(vscodePath)) {
924
- try {
925
- const existing = JSON.parse(readFileSync(vscodePath, "utf-8"));
926
- existing.servers = existing.servers || {};
927
- existing.servers.patchcord = vscodeConfig.servers.patchcord;
928
- writeFileSync(vscodePath, JSON.stringify(existing, null, 2) + "\n");
929
- } catch {
930
- writeFileSync(vscodePath, JSON.stringify(vscodeConfig, null, 2) + "\n");
931
- }
932
- } else {
933
- writeFileSync(vscodePath, JSON.stringify(vscodeConfig, null, 2) + "\n");
931
+ const vscodeOk = updateJsonConfig(vscodePath, (obj) => {
932
+ obj.servers = obj.servers || {};
933
+ obj.servers.patchcord = vscodeConfig.servers.patchcord;
934
+ });
935
+ if (vscodeOk) {
936
+ console.log(`\n ${green}✓${r} VS Code configured: ${dim}${vscodePath}${r}`);
937
+ console.log(` ${dim}Requires GitHub Copilot extension with agent mode enabled.${r}`);
934
938
  }
935
- console.log(`\n ${green}✓${r} VS Code configured: ${dim}${vscodePath}${r}`);
936
- console.log(` ${dim}Requires GitHub Copilot extension with agent mode enabled.${r}`);
937
939
  } else if (isCodex) {
938
940
  // Codex: write MCP config + per-project skills + global plugin
939
941
  // Per-project skills (working @patchcord in Codex v0.117)
@@ -1010,19 +1012,17 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1010
1012
 
1011
1013
  // Personal marketplace entry (relative path from marketplace root)
1012
1014
  const marketplacePath = join(marketplaceDir, "marketplace.json");
1013
- let marketplace = { name: "patchcord", interface: { displayName: "Patchcord" }, plugins: [] };
1014
- if (existsSync(marketplacePath)) {
1015
- try { marketplace = JSON.parse(readFileSync(marketplacePath, "utf-8")); } catch {}
1016
- }
1017
- if (!marketplace.plugins) marketplace.plugins = [];
1018
- marketplace.plugins = marketplace.plugins.filter(p => p.name !== "patchcord");
1019
- marketplace.plugins.push({
1020
- name: "patchcord",
1021
- source: { source: "local", path: "./patchcord" },
1022
- policy: { installation: "INSTALLED_BY_DEFAULT", authentication: "ON_INSTALL" },
1023
- category: "Productivity",
1015
+ updateJsonConfig(marketplacePath, (obj) => {
1016
+ if (!obj.name) obj.name = "patchcord";
1017
+ if (!obj.interface) obj.interface = { displayName: "Patchcord" };
1018
+ obj.plugins = (obj.plugins || []).filter(p => p.name !== "patchcord");
1019
+ obj.plugins.push({
1020
+ name: "patchcord",
1021
+ source: { source: "local", path: "./patchcord" },
1022
+ policy: { installation: "INSTALLED_BY_DEFAULT", authentication: "ON_INSTALL" },
1023
+ category: "Productivity",
1024
+ });
1024
1025
  });
1025
- writeFileSync(marketplacePath, JSON.stringify(marketplace, null, 2) + "\n");
1026
1026
 
1027
1027
  // Also install global skills (working @patchcord — plugin/read broken in Codex v0.117)
1028
1028
  const globalSkillDir = join(homedir(), ".agents", "skills", "patchcord");
@@ -1052,17 +1052,15 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1052
1052
  },
1053
1053
  };
1054
1054
 
1055
- if (existsSync(mcpPath)) {
1056
- try {
1057
- const existing = JSON.parse(readFileSync(mcpPath, "utf-8"));
1058
- existing.mcpServers = existing.mcpServers || {};
1059
- existing.mcpServers.patchcord = mcpConfig.mcpServers.patchcord;
1060
- writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + "\n");
1061
- } catch {
1062
- writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
1063
- }
1064
- } else {
1065
- writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
1055
+ const mcpOk = updateJsonConfig(mcpPath, (obj) => {
1056
+ obj.mcpServers = obj.mcpServers || {};
1057
+ obj.mcpServers.patchcord = mcpConfig.mcpServers.patchcord;
1058
+ });
1059
+ if (!mcpOk) {
1060
+ // Existing .mcp.json was malformed helper already explained.
1061
+ // Bail with non-zero so wrapper scripts notice; the user fixes
1062
+ // their JSON and re-runs.
1063
+ process.exit(1);
1066
1064
  }
1067
1065
  console.log(`\n ${green}✓${r} Claude Code configured: ${dim}${mcpPath}${r}`);
1068
1066
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -16,6 +16,12 @@ import { connect as wsConnect } from "./lib/ws.mjs";
16
16
  const JWT_REFRESH_SAFETY_MARGIN_SEC = 120;
17
17
  const HEARTBEAT_INTERVAL_MS = 25_000;
18
18
  const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
19
+ // Freshness watchdog: if we haven't received ANY frame from the server
20
+ // (heartbeat replies, postgres_changes, system events) for this long, the
21
+ // WS is silently dead — phx replies should arrive every ~25s, so a 90s
22
+ // gap means three missed heartbeats. Force a reconnect via the outer loop.
23
+ const FRESHNESS_CHECK_INTERVAL_MS = 30_000;
24
+ const FRESHNESS_STALE_MS = 90_000;
19
25
 
20
26
  // Guarantee a terminal stderr line on any unhandled failure so the agent
21
27
  // reading Monitor's output file always sees WHY the process died.
@@ -230,6 +236,8 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
230
236
  let ref = 1;
231
237
  let heartbeatTimer = null;
232
238
  let refreshTimer = null;
239
+ let freshnessTimer = null;
240
+ let lastEventAt = Date.now();
233
241
  let currentJwt = ticket.jwt;
234
242
  let settled = false;
235
243
 
@@ -238,6 +246,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
238
246
  settled = true;
239
247
  if (heartbeatTimer) clearInterval(heartbeatTimer);
240
248
  if (refreshTimer) clearTimeout(refreshTimer);
249
+ if (freshnessTimer) clearInterval(freshnessTimer);
241
250
  try {
242
251
  ws.close();
243
252
  } catch (_) {}
@@ -283,6 +292,21 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
283
292
  } catch (_) {}
284
293
  }, HEARTBEAT_INTERVAL_MS);
285
294
 
295
+ // Freshness watchdog. Phoenix replies to every heartbeat with a
296
+ // phx_reply frame; if those stop arriving (silent network drop,
297
+ // backgrounded socket after laptop sleep, server-side eviction
298
+ // without close frame), the WS appears open but is dead. Force a
299
+ // reconnect via the outer loop — done(err) increments backoffIdx.
300
+ freshnessTimer = setInterval(() => {
301
+ const stale = Date.now() - lastEventAt;
302
+ if (stale > FRESHNESS_STALE_MS) {
303
+ process.stderr.write(
304
+ `subscribe: ws stale ${Math.round(stale / 1000)}s, forcing reconnect\n`
305
+ );
306
+ done(new Error(`ws stale ${Math.round(stale / 1000)}s`));
307
+ }
308
+ }, FRESHNESS_CHECK_INTERVAL_MS);
309
+
286
310
  const scheduleRefresh = (ttlSec) => {
287
311
  const refreshIn = Math.max((ttlSec - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000, 30_000);
288
312
  refreshTimer = setTimeout(doRefresh, refreshIn);
@@ -331,6 +355,10 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
331
355
  });
332
356
 
333
357
  ws.on("message", (raw) => {
358
+ // Any inbound frame counts as proof of life — phx_reply, system,
359
+ // presence_state, postgres_changes, etc. Reset BEFORE the parse
360
+ // attempt so even malformed frames count.
361
+ lastEventAt = Date.now();
334
362
  let frame;
335
363
  try {
336
364
  frame = JSON.parse(raw);
@@ -32,19 +32,30 @@ The user can undo any change in 3 seconds with git. A wrong action costs nothing
32
32
 
33
33
  ## On session start or when prompted by a hook
34
34
 
35
- Call inbox(). It returns pending messages and recently active agents.
35
+ Call inbox(). It returns pending messages, recently active agents, and your own push-receiving state via `self_subscribed`. Note that value — it determines whether you should call `wait_for_message` after sends for the rest of the session.
36
+
37
+ If `subscribe_appears_down: true` is in the response, your subscribe.mjs was running but appears dead. Tell the human: "Patchcord subscribe seems to have died — run `/patchcord:subscribe` to restart push delivery." Do not try to restart it yourself.
36
38
 
37
39
  If there are pending messages, reply to all of them immediately. Do not ask the human first. Do not explain what you plan to reply. Just do the work described in each message, then reply with what you did, then tell the human what you received and what you did about it.
38
40
 
39
41
  ## Sending
40
42
 
41
- 1. inbox() - clear any pending messages that block outbound sends. Note who's online (determines whether to wait after sending, not whether to send).
43
+ 1. inbox() - clear any pending messages that block outbound sends. From the response, note `self_subscribed` (your own push-receiving state).
42
44
  2. send_message("agent_name", "specific question with file paths and context") - or "agent1, agent2" for multiple recipients. Use `@username` for cross-user Gate messaging. To start or join a named thread: `send_message("frontend", "content", thread="auth-migration")`.
43
- 3. If recipient is online: wait_for_message() - block until response arrives. Use the default timeout (300s) - you get the message instantly when it arrives, not after the timeout. The other agent needs time to do the work and reply. Never shorten the timeout. If offline: skip the wait, tell the human the message is queued.
45
+ 3. Decide whether to wait based on **two signals** in the send response:
46
+ - `self_subscribed` (from the most recent inbox call) — are YOU push-receiving?
47
+ - `recipient_subscribed` (in the send response) — is the recipient push-receiving?
48
+
49
+ | self_subscribed | recipient_subscribed | What to do |
50
+ | --- | --- | --- |
51
+ | true | true | **Do NOT call wait_for_message.** Continue working. Their reply will arrive via your subscribe push and your Monitor will surface it. Tell the human: "Sent — [agent] will see it within seconds." |
52
+ | true | false | **Do NOT call wait_for_message.** Continue working. Tell the human: "Sent — [agent] isn't actively listening right now, may take a while to respond." |
53
+ | false | true | **Call wait_for_message** with default timeout. Recipient is live, expect a reply soon. |
54
+ | false | false | **Skip wait_for_message.** Tell the human: "Sent — [agent] isn't currently active. Ask them to check inbox in their session." |
44
55
 
45
- Always send regardless of whether the recipient appears online or offline. Messages are stored and delivered when the recipient checks inbox. "Offline" means not recently active - not that they can't receive messages.
56
+ Always send regardless of recipient state. Messages are stored and delivered when the recipient checks inbox.
46
57
 
47
- After sending to an offline agent, tell the human: "Message sent. [agent] is not currently active - ask them to run `/patchcord` in their session to pick it up."
58
+ If `recipient_subscribed` is missing from the response (older server, registry disabled), fall back to the legacy `recipient_online` field for the same decision.
48
59
 
49
60
  If send_message fails with a send gate error: call inbox(), reply to or resolve all pending messages, then retry the send.
50
61
 
@@ -57,7 +68,7 @@ If send_message fails with a send gate error: call inbox(), reply to or resolve
57
68
  - `reply(message_id, "done: [details]", resolve=true)` — work done, thread closed. Stamps `thread_resolved_at` and notifies sender.
58
69
  - `reply(message_id, resolve=true)` — silently close a thread without sending anything (e.g. clearing misfired messages)
59
70
  - `reply(message_id, "ack, prioritizing [other task] first", defer=true)` — you acknowledged but haven't done the work yet. The message stays in your inbox as a reminder.
60
- 4. wait_for_message() if the sender is online - stay responsive for follow-ups
71
+ 4. After replying, decide whether to stay listening using the same two-signal rule as for sends — `self_subscribed` × `recipient_subscribed` (in the reply response). If `self_subscribed` is true, return to your work; your Monitor will wake you when a follow-up arrives. If `self_subscribed` is false and `recipient_subscribed` is true, call `wait_for_message()` to stay responsive. Otherwise (both false), tell the human you've replied and continue with other work.
61
72
  5. If you can't do the work, say specifically what's blocking you. Don't guess about another agent's code.
62
73
 
63
74
  When you have multiple pending messages, prioritize by urgency. Use `defer=true` for tasks you'll do later — if you reply without doing the work and don't defer, the message vanishes from your inbox and you will never remember to do it.