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.
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate the Agent Plugins 1.0.0 distribution at ./agent-plugin/.
4
+ *
5
+ * PURELY ADDITIVE. This does not touch .claude-plugin/, skills/, hooks/,
6
+ * commands/, or anything the current installer writes. Every existing harness
7
+ * keeps working exactly as it does today; the generated directory is a second,
8
+ * parallel packaging of the same content for clients that load the open
9
+ * standard (Codex/ChatGPT, Cursor, GitHub Copilot, Kiro, VS Code).
10
+ *
11
+ * WHY GENERATED RATHER THAN HAND-WRITTEN
12
+ *
13
+ * The skills are the same skills. Copying them by hand would create two
14
+ * SKILL.md bodies that drift, and the drift would be silent — both files stay
15
+ * valid, they just stop agreeing. Deriving them means the source of truth
16
+ * remains ./skills/ and this script is the only thing that has to be re-run.
17
+ *
18
+ * THE ONE TRANSFORM APPLIED
19
+ *
20
+ * Our skills declare `name: patchcord:inbox`. The Agent Skills specification
21
+ * that Agent Plugins defers to requires the name to be lowercase alphanumeric
22
+ * plus hyphens ONLY (a colon is invalid) and to MATCH THE PARENT DIRECTORY.
23
+ * Every real Codex plugin on this machine follows that rule.
24
+ *
25
+ * So the generated copies get `name: <directory>`. The originals are left
26
+ * alone, because that string is what Claude Code surfaces as
27
+ * `/patchcord:inbox` and renaming it in place would change a user-facing
28
+ * command name to fix a spec violation nobody is currently enforcing. Two
29
+ * packagings, two conventions, one body.
30
+ */
31
+
32
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, existsSync } from "node:fs";
33
+ import { join, dirname } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
37
+ const OUT = join(ROOT, "agent-plugin");
38
+ const SKILLS_SRC = join(ROOT, "skills");
39
+
40
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
41
+ const VERSION = pkg.version;
42
+ const BASE_URL = "https://mcp.patchcord.dev";
43
+
44
+ // Regenerate from scratch so a skill deleted upstream does not linger here.
45
+ if (existsSync(OUT)) rmSync(OUT, { recursive: true });
46
+ mkdirSync(join(OUT, ".codex-plugin"), { recursive: true });
47
+
48
+ /* ---------- skills: copied, with `name` rewritten to the directory ---------- */
49
+
50
+ const skillNames = readdirSync(SKILLS_SRC, { withFileTypes: true })
51
+ .filter((d) => d.isDirectory() && existsSync(join(SKILLS_SRC, d.name, "SKILL.md")))
52
+ .map((d) => d.name);
53
+
54
+ if (skillNames.length === 0) {
55
+ console.error("build-agent-plugin: no skills found under ./skills — refusing to emit an empty plugin");
56
+ process.exit(1);
57
+ }
58
+
59
+ for (const name of skillNames) {
60
+ const body = readFileSync(join(SKILLS_SRC, name, "SKILL.md"), "utf-8");
61
+ // Replace only the `name:` line inside the leading frontmatter block. Anchored
62
+ // to the start of the file so a later `name:` in prose cannot be hit.
63
+ const fm = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
64
+ if (!fm) {
65
+ console.error(`build-agent-plugin: ${name}/SKILL.md has no frontmatter — refusing to guess`);
66
+ process.exit(1);
67
+ }
68
+ const rewritten = body.replace(/^(---\r?\n[\s\S]*?)^name:[ \t]*.*$/m, `$1name: ${name}`);
69
+ if (rewritten === body && !new RegExp(`^name:[ \\t]*${name}\\s*$`, "m").test(fm[1])) {
70
+ console.error(`build-agent-plugin: could not rewrite name: in ${name}/SKILL.md`);
71
+ process.exit(1);
72
+ }
73
+ mkdirSync(join(OUT, "skills", name), { recursive: true });
74
+ writeFileSync(join(OUT, "skills", name, "SKILL.md"), rewritten);
75
+ }
76
+
77
+ /* ---------- Agent Plugins 1.0.0 core: plugin.json + mcp.json ---------- */
78
+
79
+ // The AP manifest schema is CLOSED (additionalProperties: false). Only the
80
+ // fields it names may appear — notably there is no `skills` pointer, because
81
+ // the spec fixes that location at ./skills/ rather than letting a manifest
82
+ // redirect it.
83
+ writeFileSync(join(OUT, "plugin.json"), JSON.stringify({
84
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
85
+ name: "patchcord",
86
+ version: VERSION,
87
+ description: pkg.description,
88
+ author: { name: "ppravdin", url: "https://patchcord.dev" },
89
+ homepage: "https://patchcord.dev",
90
+ repository: "https://github.com/ppravdin/patchcord",
91
+ license: pkg.license,
92
+ keywords: pkg.keywords,
93
+ }, null, 2) + "\n");
94
+
95
+ // NOTE THE MISSING CREDENTIAL, DELIBERATELY.
96
+ //
97
+ // Agent Plugins 1.0.0 defines placeholder expansion for ${PLUGIN_ROOT} and
98
+ // ${PLUGIN_DATA} ONLY, and only in `args`, `env`, and `cwd`. `headers` is not
99
+ // in that list, and the spec has no host-environment passthrough at all. So
100
+ // there is no conformant way to say "put THIS user's bearer in the
101
+ // Authorization header" in this file.
102
+ //
103
+ // Writing `"Authorization": "Bearer ${PATCHCORD_TOKEN}"` here would produce a
104
+ // file that looks correct and sends the literal string `${PATCHCORD_TOKEN}` to
105
+ // the server. That is worse than an obviously incomplete file, so this one is
106
+ // obviously incomplete: it names the endpoint and the transport and stops.
107
+ //
108
+ // Clients supply the missing half through their own extensions — Codex via
109
+ // `bearer_token_env_var` in .mcp.json (below), VS Code via `envFile`/`headers`.
110
+ // See agent-plugin/README.md.
111
+ writeFileSync(join(OUT, "mcp.json"), JSON.stringify({
112
+ $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
113
+ mcpServers: {
114
+ patchcord: { type: "streamable-http", url: `${BASE_URL}/mcp` },
115
+ },
116
+ }, null, 2) + "\n");
117
+
118
+ /* ---------- Codex client files ---------- */
119
+
120
+ // Codex reads .codex-plugin/plugin.json with POINTER fields (skills, mcpServers)
121
+ // rather than the spec's fixed locations, so it needs its own manifest. Both
122
+ // live in one directory without colliding: different filenames, different paths.
123
+ writeFileSync(join(OUT, ".codex-plugin", "plugin.json"), JSON.stringify({
124
+ name: "patchcord",
125
+ version: VERSION,
126
+ description: pkg.description,
127
+ author: { name: "ppravdin", url: "https://patchcord.dev" },
128
+ homepage: "https://patchcord.dev",
129
+ repository: "https://github.com/ppravdin/patchcord",
130
+ license: pkg.license,
131
+ keywords: pkg.keywords,
132
+ skills: "./skills/",
133
+ mcpServers: "./.mcp.json",
134
+ }, null, 2) + "\n");
135
+
136
+ // `bearer_token_env_var` is Codex's own field — it names an environment
137
+ // variable to read the token FROM, so the credential never enters this file.
138
+ // That is what makes the plugin publishable: it carries no secret and is
139
+ // identical for every user.
140
+ writeFileSync(join(OUT, ".mcp.json"), JSON.stringify({
141
+ mcpServers: {
142
+ patchcord: {
143
+ type: "http",
144
+ url: `${BASE_URL}/mcp`,
145
+ bearer_token_env_var: "PATCHCORD_TOKEN",
146
+ },
147
+ },
148
+ }, null, 2) + "\n");
149
+
150
+ /* ---------- README (emitted, because this directory is wiped each build) ---------- */
151
+
152
+ writeFileSync(join(OUT, "README.md"), `# patchcord — Agent Plugins 1.0.0 packaging
153
+
154
+ **Generated. Do not edit by hand** — run \`node scripts/build-agent-plugin.mjs\`.
155
+ This whole directory is deleted and rewritten on every build.
156
+
157
+ This is an EXPERIMENTAL second packaging of the same skills and the same MCP
158
+ server. It replaces nothing. \`npx patchcord\` and every per-harness config the
159
+ installer writes are untouched and keep working exactly as before.
160
+
161
+ ## What is in here
162
+
163
+ | File | Read by | Purpose |
164
+ |---|---|---|
165
+ | \`plugin.json\` | Agent Plugins clients | The open-standard manifest. Required: \`$schema\`, \`name\`. |
166
+ | \`mcp.json\` | Agent Plugins clients | Standard MCP declaration. **Carries no credential — see below.** |
167
+ | \`.codex-plugin/plugin.json\` | Codex | Codex uses pointer fields (\`skills\`, \`mcpServers\`) instead of the spec's fixed locations. |
168
+ | \`.mcp.json\` | Codex | Codex MCP config, with the token read from an env var. |
169
+ | \`skills/*/SKILL.md\` | both | Copied from \`../skills/\`, with \`name:\` rewritten to the directory name. |
170
+
171
+ ## The credential, and why \`mcp.json\` looks incomplete
172
+
173
+ Agent Plugins 1.0.0 expands \`\${PLUGIN_ROOT}\` and \`\${PLUGIN_DATA}\` only, and
174
+ only inside \`args\`, \`env\`, and \`cwd\`. \`headers\` is not in that list and there
175
+ is no host-environment passthrough anywhere in the spec.
176
+
177
+ So **the standard has no way to express "use this user's bearer token"**, and
178
+ patchcord is nothing but a per-project bearer token. Writing
179
+ \`"Authorization": "Bearer \${PATCHCORD_TOKEN}"\` into \`mcp.json\` would send that
180
+ literal string to the server. The file therefore names the endpoint and the
181
+ transport and stops, rather than looking complete and failing at runtime.
182
+
183
+ Clients close the gap with their own extensions, which is where the token
184
+ actually comes from:
185
+
186
+ - **Codex** — \`bearer_token_env_var\` in \`.mcp.json\`, pointing at
187
+ \`$PATCHCORD_TOKEN\`.
188
+ - **VS Code** — \`envFile\` / \`headers\`, neither of which is in the core schema.
189
+
190
+ This is the one finding worth taking upstream: the portable core can carry the
191
+ server's identity everywhere, but not its credential.
192
+
193
+ ## Trying it in Codex
194
+
195
+ The marketplace is already registered, so:
196
+
197
+ \`\`\`bash
198
+ export PATCHCORD_TOKEN=<an agent bearer for the namespace you want>
199
+ codex plugin add patchcord-ap@patchcord-marketplace
200
+ \`\`\`
201
+
202
+ Remove it with \`codex plugin remove patchcord-ap@patchcord-marketplace\`. The
203
+ existing \`patchcord@patchcord-marketplace\` entry is unaffected either way.
204
+
205
+ ## Known limitation: one token per environment
206
+
207
+ \`$PATCHCORD_TOKEN\` is a single value per shell, while patchcord's model is one
208
+ namespace per project. A plugin installed this way is therefore **one identity
209
+ per environment**, not one per project — the same constraint that already
210
+ applies to Hermes, and the reason the current per-project installer writes
211
+ per-directory config instead. Do not use this packaging for multi-seat work
212
+ until the standard grows a per-project secret mechanism.
213
+ `);
214
+
215
+ console.log(`build-agent-plugin: wrote agent-plugin/ (v${VERSION}, skills: ${skillNames.join(", ")})`);
@@ -0,0 +1,120 @@
1
+ // Resolve where Hermes's HOME actually is, honouring HERMES_HOME — and
2
+ // derive both config.yaml's path and the skills install destination from
3
+ // that ONE resolved home, never from separate hardcoded joins.
4
+ //
5
+ // WHY THIS EXISTS
6
+ //
7
+ // bin/patchcord.mjs hardcoded join(HOME, ".hermes", "config.yaml") in two
8
+ // places — the write site (writeWorkerConfig, provisioning a seat) and the
9
+ // read site (bearer resolution, what `patchcord whoami` verifies against).
10
+ // Neither read HERMES_HOME. mux sets HERMES_HOME=<seat workdir>/.hermes to
11
+ // isolate one Hermes identity per seat; without this, every seat installs
12
+ // into and reads from the SAME operator-global ~/.hermes/config.yaml.
13
+ //
14
+ // FIXING ONLY ONE SITE IS WORSE THAN FIXING NEITHER. `patchcord whoami` is
15
+ // mux's second independent witness that a seat holds the identity just
16
+ // minted for it. If the write moves to the seat's own HERMES_HOME but the
17
+ // read keeps resolving the operator's global config, whoami silently
18
+ // verifies the wrong file against itself — a check that always "passes"
19
+ // because it was never actually looking where the write went.
20
+ //
21
+ // SKILLS FOLLOW THE HOME TOO, same reasoning one level up: they are a
22
+ // GUARD, not documentation — carrying, among other rules, "do not run the
23
+ // patchcord CLI when MCP is loaded". A seat isolated by HERMES_HOME that
24
+ // still reads its skills from the operator's global
25
+ // ~/.hermes/skills/integrations/ is running under another context's guard,
26
+ // which is not isolation, just a config file that happens to be separate.
27
+ // bin/patchcord.mjs previously joined ~/.hermes/skills/integrations
28
+ // directly in three places, none aware of HERMES_HOME — the same defect
29
+ // class as the config.yaml sites, one directory over.
30
+ //
31
+ // RESOLUTION ORDER, identical everywhere a Hermes path is needed:
32
+ // 1. HERMES_HOME from the environment — this IS the home; nothing to
33
+ // derive.
34
+ // 2. `hermes config path`, if the binary resolves — the harness's OWN
35
+ // answer for where its config lives, not our model of it. This command
36
+ // itself honours HERMES_HOME, which is exactly why a caller that shells
37
+ // it must NOT strip the environment: execSync inherits process.env by
38
+ // default, and that default is load-bearing here — an explicit `env`
39
+ // override that omits HERMES_HOME would make this probe silently
40
+ // answer for the OPERATOR's home instead of the seat's, which looks
41
+ // identical to "the env var is being ignored" from the outside. The
42
+ // home is taken as dirname() of the printed config file path, on the
43
+ // same "config path" == ".../config.yaml" assumption documented below.
44
+ // 3. ~/.hermes — today's fallback, unchanged, so nothing existing breaks
45
+ // for an operator who never set HERMES_HOME.
46
+ //
47
+ // config.yaml's path and the skills destination are both joined onto this
48
+ // SAME resolved home (resolveHermesHome), never computed independently —
49
+ // the two must never be able to disagree about which home they mean.
50
+ //
51
+ // NOT VERIFIED END TO END: no Hermes install on this machine to observe
52
+ // `hermes config path`'s actual output shape. Assumed to print the config
53
+ // FILE path directly (its name is "config path", not "profile path" or
54
+ // "home"), so step 2 takes dirname() of it as the home rather than
55
+ // assuming a directory was printed. If that assumption is wrong, the whole
56
+ // chain (config.yaml AND skills) is wrong together under step 2 rather
57
+ // than disagreeing with each other — a shared, nameable failure mode
58
+ // instead of a silent split.
59
+ //
60
+ // PROFILES (-p/--profile) ARE DELIBERATELY NOT SUPPORTED HERE. mux measured
61
+ // that Hermes parses -p/--profile even though --help does not list it, and
62
+ // that each profile gets its own config.yaml AND its own .env — a second,
63
+ // separate isolation mechanism from HERMES_HOME. This module resolves
64
+ // HERMES_HOME only. No --hermes-profile flag exists, and none is planned
65
+ // without first reading Hermes's own source or testing against a real
66
+ // install to learn whether `hermes config path`'s output already reflects
67
+ // an ambient active profile — neither is available here, so the honest
68
+ // choice was not to guess. CONSEQUENCE A USER MUST KNOW: running Hermes
69
+ // under a non-default profile without ALSO setting HERMES_HOME to match
70
+ // means this installer's write may land in a config that profile-launched
71
+ // Hermes never reads — the exact silent-wrong-file failure the rest of this
72
+ // module exists to close, just on the axis this module does not resolve.
73
+
74
+ import { execSync } from "node:child_process";
75
+ import { join, dirname } from "node:path";
76
+ import { homedir } from "node:os";
77
+
78
+ /** Shell `hermes config path`, inheriting the parent environment (the
79
+ * execSync default — do not pass an `env` override here). Returns the
80
+ * trimmed stdout, or null if the binary is absent, exits non-zero, or
81
+ * prints nothing. Exported so a test can substitute a fake without a real
82
+ * `hermes` on PATH. */
83
+ export function shellHermesConfigPath() {
84
+ try {
85
+ const out = execSync("hermes config path", {
86
+ stdio: ["ignore", "pipe", "ignore"],
87
+ }).toString("utf-8").trim();
88
+ return out || null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** Resolve Hermes's home DIRECTORY per the order documented above.
95
+ * `home` and `shell` are injectable for tests; production callers use the
96
+ * defaults (real homedir(), real shellHermesConfigPath()). Every other
97
+ * Hermes path in this module is joined onto this one value. */
98
+ export function resolveHermesHome({ home = homedir(), shell = shellHermesConfigPath } = {}) {
99
+ const envHome = process.env.HERMES_HOME;
100
+ if (envHome) {
101
+ return envHome;
102
+ }
103
+ const shelledConfigPath = shell();
104
+ if (shelledConfigPath) {
105
+ return dirname(shelledConfigPath);
106
+ }
107
+ return join(home, ".hermes");
108
+ }
109
+
110
+ /** Resolve the Hermes config.yaml path. */
111
+ export function resolveHermesConfigPath(opts) {
112
+ return join(resolveHermesHome(opts), "config.yaml");
113
+ }
114
+
115
+ /** Resolve where Hermes skills install — same home as config.yaml, so a
116
+ * seat's skills and its identity can never point at different machines'
117
+ * worth of Hermes. */
118
+ export function resolveHermesSkillsDest(opts) {
119
+ return join(resolveHermesHome(opts), "skills", "integrations");
120
+ }
@@ -126,6 +126,43 @@ const opencodeReader = (cwd) => readJsonAt(join(cwd, "opencode.json"), ["mcp", "
126
126
  const antigravityReader = (cwd) => readJsonAt(join(cwd, ".agents", "mcp_config.json"), ["mcpServers", "patchcord"], "antigravity");
127
127
  const grokReader = (cwd) => readGrokTomlShape(join(cwd, ".grok", "config.toml"));
128
128
  const codexReader = (cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml"));
129
+ // jcode: STDIO entry, so the bearer is in `args`, not in `headers`. readJsonAt
130
+ // cannot see it -- extractBearer requires headers.Authorization and a url, and
131
+ // this entry has neither. jcode has no HTTP transport, so an entry shaped like
132
+ // every other harness's would be dropped at load time, and a reader shaped like
133
+ // every other harness's finds nothing.
134
+ //
135
+ // Keyed on `patchcord-jcode`, matching what the installer writes. The distinct
136
+ // name exists so jcode's own merge (.jcode/mcp.json, then .mcp.json, then
137
+ // .claude/mcp.json -- later overriding by NAME) can never make jcode adopt
138
+ // claude_code's credential.
139
+ function readJcodeStdioShape(path) {
140
+ if (!existsSync(path)) return null;
141
+ try {
142
+ const obj = parseJsonc(readFileSync(path, "utf-8"));
143
+ const entry = obj?.mcpServers?.["patchcord-jcode"];
144
+ const args = Array.isArray(entry?.args) ? entry.args : null;
145
+ if (!args) return null;
146
+ // `--header` `Authorization: Bearer <token>` as two adjacent argv items.
147
+ let token = null;
148
+ for (let i = 0; i < args.length; i++) {
149
+ const m = typeof args[i] === "string" && args[i].match(/^Authorization:\s*Bearer\s+(\S+)$/i);
150
+ if (m) { token = m[1]; break; }
151
+ }
152
+ const urlArg = args.find((a) => typeof a === "string" && /^https?:\/\//.test(a));
153
+ if (!token || !urlArg) return null;
154
+ return {
155
+ token,
156
+ baseUrl: urlArg.replace(/\/mcp(\/bearer)?$/, ""),
157
+ configFile: path,
158
+ tool: "jcode",
159
+ };
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ const jcodeReader = (cwd) => readJcodeStdioShape(join(cwd, ".jcode", "mcp.json"));
129
166
  const kimiReader = (cwd) => readJsonAt(join(cwd, ".kimi", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
130
167
  const kimiCodeReader = (cwd) => readJsonAt(join(cwd, ".kimi-code", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
131
168
 
@@ -163,6 +200,7 @@ export function projectReadersForContext(ctx) {
163
200
  grok: grokReader,
164
201
  codex: codexReader,
165
202
  kimi: kimiCodeReader,
203
+ jcode: jcodeReader,
166
204
  };
167
205
  const defaultReaders = [
168
206
  claudeReader,
@@ -172,6 +210,7 @@ export function projectReadersForContext(ctx) {
172
210
  antigravityReader,
173
211
  grokReader,
174
212
  codexReader,
213
+ jcodeReader,
175
214
  ];
176
215
  const kimiReaders = [kimiReader, kimiCodeReader];
177
216
 
@@ -199,6 +238,7 @@ export function projectReadersForContext(ctx) {
199
238
  antigravityReader,
200
239
  grokReader,
201
240
  codexReader,
241
+ jcodeReader,
202
242
  claudeReader,
203
243
  ...kimiReaders,
204
244
  ];
@@ -0,0 +1,104 @@
1
+ // Pure parsing + validation for `patchcord subscribe --stall-signal[=<opts>]`.
2
+ //
3
+ // WHY THIS MODE EXISTS
4
+ //
5
+ // subscribe.mjs's only stdout writer is notify() — it writes a "PATCHCORD: ..."
6
+ // line on a real message and nothing else. There is no periodic heartbeat line;
7
+ // the WebSocket-level ping (HEARTBEAT_INTERVAL_MS in subscribe.mjs) never
8
+ // touches stdout. So on a harness that wakes the agent on a STALL (no stdout
9
+ // for N seconds) instead of per-line like Claude Code's Monitor, the pipe is
10
+ // ALREADY silent while idle — a stall fires whether or not a message arrived,
11
+ // which is a wake-on-silence mechanism, not wake-on-message.
12
+ //
13
+ // --stall-signal inverts that: while idle it writes a keepalive line often
14
+ // enough that the pipe never looks silent, and on a real message it goes
15
+ // quiet on purpose for long enough that the harness's stall detector fires.
16
+ // The result approximates wake-on-message using only a wake-on-silence
17
+ // primitive.
18
+ //
19
+ // KEPT SEPARATE FROM subscribe.mjs so the arithmetic can be unit tested
20
+ // without opening a WebSocket, resolving a project config, or touching a
21
+ // pidfile — all things subscribe.mjs does as soon as it runs.
22
+ //
23
+ // THE KEEPALIVE LINE ITSELF (written by subscribe.mjs, not here) starts with
24
+ // "HEARTBEAT:", which does not match `^PATCHCORD:` — the grep filter every
25
+ // subscribe skill uses already drops it for free.
26
+
27
+ export const STALL_SIGNAL_DEFAULTS = Object.freeze({
28
+ // Written to stdout on this interval while idle. Must stay well under
29
+ // stallMs or the pipe can look idle for a full stall window even though
30
+ // nothing is wrong — see validateStallSignalOpts.
31
+ keepaliveMs: 5000,
32
+ // Keepalives are suppressed for this long after a real notification, so
33
+ // the pipe goes quiet on purpose. Must be >= stallMs or the harness's
34
+ // stall condition never actually triggers and the message is missed.
35
+ quietMs: 15000,
36
+ // What the CALLER promises the harness's own stall/wake threshold is set
37
+ // to (e.g. jcode's `stall_wake_seconds`, in ms here). subscribe.mjs cannot
38
+ // observe that value — the harness owns it — so this is supplied by
39
+ // whoever launches --stall-signal and used only to validate the other two
40
+ // against it before this process does anything live.
41
+ stallMs: 15000,
42
+ });
43
+
44
+ /** Parse `--stall-signal` out of an argv array. Returns null if the flag is
45
+ * absent (mode disabled, nothing else in this module runs) or a resolved
46
+ * options object. Throws on a malformed or nonsensical inline value —
47
+ * the caller decides how to report that (subscribe.mjs calls its own
48
+ * die()). Absence of the flag is untouched by this function: it returns
49
+ * null and every default in the rest of subscribe.mjs is unaffected. */
50
+ export function parseStallSignalArg(argv) {
51
+ if (!argv.includes("--stall-signal")) return null;
52
+ const i = argv.indexOf("--stall-signal");
53
+ const inline = i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[i + 1] : null;
54
+ return resolveStallSignalOpts(inline);
55
+ }
56
+
57
+ /** `inline` is `keepaliveMs:quietMs:stallMs`, any suffix omitted to take the
58
+ * default (so `--stall-signal` alone, `--stall-signal 8000`, and
59
+ * `--stall-signal 8000:20000:20000` are all legal). Throws on a
60
+ * non-positive/non-numeric field or a combination validateStallSignalOpts
61
+ * rejects. */
62
+ export function resolveStallSignalOpts(inline) {
63
+ const d = STALL_SIGNAL_DEFAULTS;
64
+ if (!inline) {
65
+ const opts = { ...d };
66
+ validateStallSignalOpts(opts);
67
+ return opts;
68
+ }
69
+ const parts = inline.split(":");
70
+ const pick = (raw, fallback, label) => {
71
+ if (raw === undefined || raw === "") return fallback;
72
+ const n = Number(raw);
73
+ if (!Number.isFinite(n) || n <= 0) {
74
+ throw new Error(`--stall-signal: ${label} must be a positive number of milliseconds, got "${raw}"`);
75
+ }
76
+ return n;
77
+ };
78
+ const opts = {
79
+ keepaliveMs: pick(parts[0], d.keepaliveMs, "keepaliveMs"),
80
+ quietMs: pick(parts[1], d.quietMs, "quietMs"),
81
+ stallMs: pick(parts[2], d.stallMs, "stallMs"),
82
+ };
83
+ validateStallSignalOpts(opts);
84
+ return opts;
85
+ }
86
+
87
+ /** Refuse a nonsensical pair rather than run silently wrong. Both directions
88
+ * matter: a keepalive too close to stallMs risks a false stall while idle,
89
+ * and a quiet window shorter than stallMs means the harness's stall NEVER
90
+ * fires on a real message — the exact failure this mode exists to prevent. */
91
+ export function validateStallSignalOpts(opts) {
92
+ if (opts.keepaliveMs >= opts.stallMs) {
93
+ throw new Error(
94
+ `--stall-signal: keepaliveMs (${opts.keepaliveMs}) must be less than stallMs (${opts.stallMs}) — ` +
95
+ `otherwise the pipe can go quiet for a full stall window while idle and nothing is wrong`
96
+ );
97
+ }
98
+ if (opts.quietMs < opts.stallMs) {
99
+ throw new Error(
100
+ `--stall-signal: quietMs (${opts.quietMs}) must be at least stallMs (${opts.stallMs}) — ` +
101
+ `otherwise the harness's stall window never elapses during the quiet period and a real message is missed`
102
+ );
103
+ }
104
+ }
@@ -14,6 +14,7 @@ import { URL } from "node:url";
14
14
  import { dirname } from "node:path";
15
15
  import { connect as wsConnect } from "./lib/ws.mjs";
16
16
  import { resolveProjectBearer, listProjectBearers } from "./lib/resolve-project-bearer.mjs";
17
+ import { parseStallSignalArg } from "./lib/stall-signal.mjs";
17
18
 
18
19
  // --- Hermes webhook bridge mode -------------------------------------------
19
20
  // Default mode writes "PATCHCORD: ..." lines to stdout for Claude Code's
@@ -30,6 +31,36 @@ const HERMES_WEBHOOK = (() => {
30
31
  return process.env.PATCHCORD_HERMES_WEBHOOK || inline || null;
31
32
  })();
32
33
 
34
+ // --- Stall-signal mode ------------------------------------------------------
35
+ // Default mode's only stdout writer is notify() — nothing else touches
36
+ // stdout, so a harness with no Monitor that wakes on a STALL (no output for
37
+ // N seconds) instead of per-line wakes on a timer, not on a message: the pipe
38
+ // is silent whether or not anything arrived. See scripts/lib/stall-signal.mjs
39
+ // for the inversion this mode performs (keepalive while idle, deliberate
40
+ // quiet on a real message) and why the two windows must relate the way they
41
+ // do. `--stall-signal` alone uses every default; `--stall-signal
42
+ // keepaliveMs:quietMs:stallMs` overrides any prefix of the three. A malformed
43
+ // or self-contradictory value is a startup error, not a silently-wrong run.
44
+ let STALL_SIGNAL_OPTS = null;
45
+ try {
46
+ STALL_SIGNAL_OPTS = parseStallSignalArg(process.argv);
47
+ } catch (e) {
48
+ die(e.message);
49
+ }
50
+ const STALL_SIGNAL_MODE = STALL_SIGNAL_OPTS !== null;
51
+ if (STALL_SIGNAL_MODE && HERMES_MODE) {
52
+ die("--stall-signal and --hermes are mutually exclusive — each is its own wake mechanism, pick one");
53
+ }
54
+ // The keepalive line's own prefix, chosen so it can never collide with a
55
+ // real notification: every subscribe skill's grep is `^PATCHCORD:`, and
56
+ // "HEARTBEAT:" does not start with "PATCHCORD" at all, so nothing else needs
57
+ // to change for a consumer that already filters on that pattern.
58
+ const STALL_SIGNAL_KEEPALIVE_PREFIX = "HEARTBEAT:";
59
+ // Suppress keepalives until this timestamp (ms since epoch). 0 = never
60
+ // suppressed. Set by notify() on every real message.
61
+ let stallSignalQuietUntil = 0;
62
+ let stallSignalTimer = null;
63
+
33
64
  const JWT_REFRESH_SAFETY_MARGIN_SEC = 120;
34
65
  const HEARTBEAT_INTERVAL_MS = 25_000;
35
66
  const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
@@ -171,6 +202,11 @@ function httpJson(urlStr, { method = "GET", headers = {}, body = null } = {}) {
171
202
  async function notify(line, meta = {}) {
172
203
  if (!HERMES_MODE) {
173
204
  process.stdout.write(line + "\n");
205
+ // Go quiet on purpose: suppress keepalives long enough that the
206
+ // harness's own stall detector actually elapses during this window.
207
+ if (STALL_SIGNAL_MODE) {
208
+ stallSignalQuietUntil = Date.now() + STALL_SIGNAL_OPTS.quietMs;
209
+ }
174
210
  return;
175
211
  }
176
212
  if (!HERMES_WEBHOOK) return;
@@ -330,7 +366,10 @@ async function run() {
330
366
  const pidfile = `/tmp/patchcord_subscribe_${ticket.namespace_ids[0]}_${ticket.agent_id}.pid`;
331
367
  writePidfile(pidfile);
332
368
 
333
- const cleanup = () => removePidfile(pidfile);
369
+ const cleanup = () => {
370
+ removePidfile(pidfile);
371
+ if (stallSignalTimer) clearInterval(stallSignalTimer);
372
+ };
334
373
  process.on("exit", cleanup);
335
374
  process.on("SIGINT", () => {
336
375
  cleanup();
@@ -366,6 +405,23 @@ async function run() {
366
405
 
367
406
  logErr(`subscribe: agent=${ticket.agent_id} namespaces=${ticket.namespace_ids.join(",")}`);
368
407
 
408
+ // Runs for the process's whole life, independent of WS connect/reconnect
409
+ // state — a reconnect backoff is a normal transient state, not something
410
+ // that should look like a stall to the harness. Suppressed for quietMs
411
+ // after a real notification (notify() sets stallSignalQuietUntil); resumes
412
+ // once that window elapses.
413
+ if (STALL_SIGNAL_MODE) {
414
+ logErr(
415
+ `subscribe: stall-signal keepalive=${STALL_SIGNAL_OPTS.keepaliveMs}ms ` +
416
+ `quiet=${STALL_SIGNAL_OPTS.quietMs}ms stall=${STALL_SIGNAL_OPTS.stallMs}ms`
417
+ );
418
+ stallSignalTimer = setInterval(() => {
419
+ if (Date.now() >= stallSignalQuietUntil) {
420
+ process.stdout.write(`${STALL_SIGNAL_KEEPALIVE_PREFIX} ${new Date().toISOString()}\n`);
421
+ }
422
+ }, STALL_SIGNAL_OPTS.keepaliveMs);
423
+ }
424
+
369
425
  let backoffIdx = 0;
370
426
 
371
427
  const refreshAuth = async () => {
@@ -10,15 +10,30 @@ import { readFileSync, writeFileSync } from "node:fs";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { dirname, join } from "node:path";
12
12
 
13
+ // EVERY manifest, not the one that existed when this was written. A second
14
+ // packaged plugin (agent-plugin/) arrived later and this script did not know
15
+ // about it, so a merge left it claiming 0.6.40 inside a 0.6.42 package — the
16
+ // same drift the comment above describes, reappearing the moment a new manifest
17
+ // was added. A list is what stops the next one being missed: add the path here
18
+ // and the sync is automatic, rather than a step someone must remember.
13
19
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
14
20
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
15
- const manifestPath = join(root, ".claude-plugin", "plugin.json");
16
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
21
+ const MANIFESTS = [
22
+ join(root, ".claude-plugin", "plugin.json"),
23
+ join(root, "agent-plugin", "plugin.json"),
24
+ ];
17
25
 
18
- if (manifest.version === pkg.version) {
19
- process.exit(0);
26
+ for (const manifestPath of MANIFESTS) {
27
+ let manifest;
28
+ try {
29
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
30
+ } catch {
31
+ // A manifest that is not present is not an error — agent-plugin/ is built
32
+ // in some trees and not others. A manifest that is present and STALE is.
33
+ continue;
34
+ }
35
+ if (manifest.version === pkg.version) continue;
36
+ manifest.version = pkg.version;
37
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
38
+ process.stderr.write(`synced ${manifestPath} version -> ${pkg.version}\n`);
20
39
  }
21
-
22
- manifest.version = pkg.version;
23
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
24
- process.stderr.write(`synced plugin.json version -> ${pkg.version}\n`);
@@ -114,7 +114,7 @@ To message a user outside your namespace, use `@username` as the to_agent. Examp
114
114
  ```
115
115
  patchcord upload /path/to/report.md --mime text/markdown
116
116
  ```
117
- Prints the storage path. Pass that path to `send_message`. No curl, no base64 in chat, no presigned URLs. 25MB cap.
117
+ 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.
118
118
 
119
119
  **Public URLs → `attachment(relay=true, ...)`:**
120
120
  ```