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/.claude-plugin/marketplace.json +5 -0
- package/.claude-plugin/plugin.json +1 -1
- package/agent-plugin/.codex-plugin/plugin.json +22 -0
- package/agent-plugin/.mcp.json +9 -0
- package/agent-plugin/README.md +61 -0
- package/agent-plugin/mcp.json +9 -0
- package/agent-plugin/plugin.json +21 -0
- package/agent-plugin/skills/inbox/SKILL.md +202 -0
- package/agent-plugin/skills/subscribe/SKILL.md +154 -0
- package/agent-plugin/skills/wait/SKILL.md +31 -0
- package/bin/patchcord.mjs +199 -25
- package/harnesses.json +21 -4
- package/package.json +3 -2
- package/per-project-skills/codex/SKILL.md +1 -1
- package/per-project-skills/cursor/inbox/SKILL.md +1 -1
- package/per-project-skills/jcode/inbox/SKILL.md +34 -0
- package/per-project-skills/jcode/subscribe/SKILL.md +109 -0
- package/per-project-skills/jcode/wait/SKILL.md +26 -0
- package/scripts/build-agent-plugin.mjs +215 -0
- package/scripts/lib/hermes-home.mjs +120 -0
- package/scripts/lib/resolve-project-bearer.mjs +40 -0
- package/scripts/lib/stall-signal.mjs +104 -0
- package/scripts/subscribe.mjs +57 -1
- package/scripts/sync-plugin-version.mjs +23 -8
- package/skills/inbox/SKILL.md +1 -1
- package/skills/subscribe/SKILL.md +42 -0
|
@@ -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
|
+
}
|
package/scripts/subscribe.mjs
CHANGED
|
@@ -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 = () =>
|
|
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
|
|
16
|
-
|
|
21
|
+
const MANIFESTS = [
|
|
22
|
+
join(root, ".claude-plugin", "plugin.json"),
|
|
23
|
+
join(root, "agent-plugin", "plugin.json"),
|
|
24
|
+
];
|
|
17
25
|
|
|
18
|
-
|
|
19
|
-
|
|
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`);
|
package/skills/inbox/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
```
|