@trygocode/notify 0.1.1 → 0.1.3
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/README.md +176 -102
- package/dist/src/claude.js +29 -12
- package/dist/src/cli.js +52 -0
- package/dist/src/cursor.js +25 -8
- package/dist/src/mcp.js +1 -38
- package/dist/src/on_stop.js +132 -0
- package/dist/src/opencode.js +292 -0
- package/dist/src/push.js +69 -1
- package/dist/src/setup.js +12 -7
- package/dist/src/uninstall.js +2 -0
- package/dist/src/version.js +1 -1
- package/package.json +3 -21
- package/snippets/ralph-homer.sh +1 -1
- package/CHANGELOG.md +0 -43
- package/LICENSE +0 -21
- package/assets/README.md +0 -13
- package/assets/banner.png +0 -0
- package/assets/icon-128.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon.svg +0 -8
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// `gocode-notify on-stop` — the single end-of-turn dispatcher (PRD §2.2).
|
|
2
|
+
//
|
|
3
|
+
// This is the ONE command the runtime stop hooks call (Cursor `stop`, Claude
|
|
4
|
+
// `Stop`). It exists to guarantee EXACTLY ONE notification per turn while
|
|
5
|
+
// keeping auto-push and the plain "finished" ping from ever firing together:
|
|
6
|
+
//
|
|
7
|
+
// 1. Derive this repo's identity (§2.5) — for settings resolution + the
|
|
8
|
+
// notification's `project` label.
|
|
9
|
+
// 2. Resolve the merged Notify settings for the repo (§2.1 step 1 / §3.6) via
|
|
10
|
+
// the 60s-TTL cache in `config.ts` (fail-safe: auto-push OFF when nothing
|
|
11
|
+
// is known).
|
|
12
|
+
// 3. Branch:
|
|
13
|
+
// • `auto_push.enabled === true` → run the `push-on-stop` git flow
|
|
14
|
+
// (`push.ts`), which sends its OWN finished/error notification carrying
|
|
15
|
+
// the commit summary. The plain `send` is SUPPRESSED for this turn.
|
|
16
|
+
// • otherwise → run the existing `send --kind finished` flow exactly as
|
|
17
|
+
// the hook did before this dispatcher existed.
|
|
18
|
+
//
|
|
19
|
+
// Backward-compatible: `send`, `test`, `push-on-stop` stay independently
|
|
20
|
+
// callable for power users + tests. Existing installs upgrade their hook to call
|
|
21
|
+
// `on-stop` on the next `setup`/`--force` (PRD §2.2).
|
|
22
|
+
//
|
|
23
|
+
// Best-effort + total: like every hook entry, it NEVER throws and ALWAYS maps to
|
|
24
|
+
// process exit 0 (PRD §0.5 "Never block the agent"). Every side-effecting dep is
|
|
25
|
+
// injectable so the dispatcher is unit-testable with zero network / git / fs.
|
|
26
|
+
//
|
|
27
|
+
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
28
|
+
import { resolveNotifySettings } from "./config.js";
|
|
29
|
+
import { deriveRepoIdentity } from "./repo_key.js";
|
|
30
|
+
import { pushOnStop, } from "./push.js";
|
|
31
|
+
import { appendLog, send } from "./send.js";
|
|
32
|
+
/** Slice the merged settings down to what the push flow consumes. */
|
|
33
|
+
function toPushSettings(settings) {
|
|
34
|
+
return {
|
|
35
|
+
auto_push: settings.auto_push,
|
|
36
|
+
commit_message: settings.commit_message,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The end-of-turn dispatcher (PRD §2.2). Resolves settings, then either delegates
|
|
41
|
+
* to the auto-push flow (which sends its own notification) OR fires the plain
|
|
42
|
+
* `finished` ping — NEVER both, so a turn produces exactly one notification.
|
|
43
|
+
*
|
|
44
|
+
* Best-effort + total: it NEVER throws and NEVER rejects — every branch returns
|
|
45
|
+
* an {@link OnStopResult}, and the caller maps any result to process exit 0 so a
|
|
46
|
+
* slow/failed push or send can never block the agent's turn (PRD §0.5).
|
|
47
|
+
*/
|
|
48
|
+
export async function onStop(opts = {}) {
|
|
49
|
+
const source = opts.source ?? "unknown";
|
|
50
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
51
|
+
const deriveRepo = opts.deriveRepo ?? deriveRepoIdentity;
|
|
52
|
+
const resolveSettings = opts.resolveSettings ?? resolveNotifySettings;
|
|
53
|
+
const logLine = async (line) => {
|
|
54
|
+
try {
|
|
55
|
+
if (opts.log)
|
|
56
|
+
await opts.log(line);
|
|
57
|
+
else
|
|
58
|
+
await appendLog(`ON-STOP: ${line}`, { home: opts.home, timestamp: opts.timestamp });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// logging must never be the thing that blocks the flow
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const sendImpl = opts.sendImpl ??
|
|
65
|
+
((payload) => send(payload, {
|
|
66
|
+
home: opts.home,
|
|
67
|
+
server: opts.server,
|
|
68
|
+
fetchImpl: opts.fetchImpl,
|
|
69
|
+
timeoutMs: opts.timeoutMs,
|
|
70
|
+
timestamp: opts.timestamp,
|
|
71
|
+
}));
|
|
72
|
+
try {
|
|
73
|
+
// ── Step 1: derive repo identity (never throws — local fallback on failure). ──
|
|
74
|
+
let repo;
|
|
75
|
+
try {
|
|
76
|
+
repo = await deriveRepo(cwd);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
repo = undefined; // resolution falls back to the global blob
|
|
80
|
+
}
|
|
81
|
+
// ── Step 2: resolve merged settings for this repo (fail-safe: auto-push OFF). ──
|
|
82
|
+
const resolved = await resolveSettings({
|
|
83
|
+
home: opts.home,
|
|
84
|
+
repo,
|
|
85
|
+
server: opts.server,
|
|
86
|
+
fetchImpl: opts.fetchImpl,
|
|
87
|
+
timeoutMs: opts.timeoutMs,
|
|
88
|
+
ttlMs: opts.ttlMs,
|
|
89
|
+
now: opts.now,
|
|
90
|
+
});
|
|
91
|
+
const settings = resolved.settings;
|
|
92
|
+
const autoPushOn = settings.auto_push?.enabled === true;
|
|
93
|
+
// ── Step 3a: auto-push owns the notification for this turn. ──
|
|
94
|
+
if (autoPushOn) {
|
|
95
|
+
const pushImpl = opts.pushImpl ?? pushOnStop;
|
|
96
|
+
const push = await pushImpl({
|
|
97
|
+
settings: toPushSettings(settings),
|
|
98
|
+
source,
|
|
99
|
+
cwd,
|
|
100
|
+
project: repo?.repo_label,
|
|
101
|
+
dedupeKey: opts.dedupeKey,
|
|
102
|
+
dryRun: opts.dryRun,
|
|
103
|
+
server: opts.server,
|
|
104
|
+
fetchImpl: opts.fetchImpl,
|
|
105
|
+
timeoutMs: opts.timeoutMs,
|
|
106
|
+
timestamp: opts.timestamp,
|
|
107
|
+
home: opts.home,
|
|
108
|
+
});
|
|
109
|
+
await logLine(`auto-push path → ${push.outcome} (settings: ${resolved.source})`);
|
|
110
|
+
return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
|
|
111
|
+
}
|
|
112
|
+
// ── Step 3b: auto-push off → the plain `finished` notification (legacy flow). ──
|
|
113
|
+
if (opts.dryRun) {
|
|
114
|
+
await logLine(`dry-run: would send finished (auto-push off, settings: ${resolved.source})`);
|
|
115
|
+
return { mode: "dry-run-send", settingsSource: resolved.source, repo };
|
|
116
|
+
}
|
|
117
|
+
const payload = { kind: "finished", source };
|
|
118
|
+
if (repo?.repo_label)
|
|
119
|
+
payload.project = repo.repo_label;
|
|
120
|
+
if (opts.dedupeKey)
|
|
121
|
+
payload.dedupe_key = opts.dedupeKey;
|
|
122
|
+
const sent = await sendImpl(payload);
|
|
123
|
+
await logLine(`send path → finished ${sent.ok ? "delivered" : "failed"} (settings: ${resolved.source})`);
|
|
124
|
+
return { mode: "send", settingsSource: resolved.source, send: sent, repo };
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
// Defence in depth: the dispatcher must never throw. Degrade to a logged
|
|
128
|
+
// no-op so the hook's turn is never blocked (PRD §0.5).
|
|
129
|
+
await logLine(`unexpected error — treated as no-op: ${err instanceof Error ? err.message : String(err)}`);
|
|
130
|
+
return { mode: "send", settingsSource: "default", detail: "unexpected error" };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// OpenCode config writer (PRD §0 Phase 0, T-OC1) — the installer's per-runtime
|
|
2
|
+
// writer for OpenCode, at parity with `claude.ts` / `cursor.ts`. It does two
|
|
3
|
+
// things, all idempotently and without clobbering the user's existing config:
|
|
4
|
+
//
|
|
5
|
+
// 1. MERGE our MCP server entry into `opencode.json`'s `mcp` map, using
|
|
6
|
+
// OpenCode's shape (DIFFERENT from Claude/Cursor):
|
|
7
|
+
// "gocode-notify": { "type": "local",
|
|
8
|
+
// "command": ["npx","-y","@trygocode/notify","mcp"],
|
|
9
|
+
// "enabled": true }
|
|
10
|
+
// `command` is a single ARRAY (binary + args), plus `type` + `enabled`.
|
|
11
|
+
// 2. WRITE a `session.idle` plugin to `<config-dir>/plugin/gocode-notify.js`.
|
|
12
|
+
// OpenCode has NO stop-hook command array; instead a plugin subscribes to
|
|
13
|
+
// the `session.idle` event (the OpenCode equivalent of Cursor `stop` /
|
|
14
|
+
// Claude `Stop`) and fires the SHARED `on-stop` dispatcher:
|
|
15
|
+
// gocode-notify on-stop --source opencode --dedupe-key opencode-idle || true
|
|
16
|
+
// The child is detached + unref'd and errors are swallowed (`|| true`), so a
|
|
17
|
+
// notification/push failure can never block the session — exactly one ping
|
|
18
|
+
// per idle, in lockstep with Claude/Cursor (same `on-stop` dispatcher).
|
|
19
|
+
//
|
|
20
|
+
// The on-demand rule/skill (Claude SKILL.md / Cursor rule) is SKIPPED for
|
|
21
|
+
// OpenCode: OpenCode has no auto-loaded standalone per-file rule mechanism (the
|
|
22
|
+
// equivalent is the shared `AGENTS.md`, which we must NOT clobber), so per the
|
|
23
|
+
// T-OC1 spec ("write the on-demand rule/skill if OpenCode supports one, else skip
|
|
24
|
+
// gracefully") we skip it. The anti-double-ping invariant is still upheld because
|
|
25
|
+
// the `session.idle` plugin owns the single automatic ping.
|
|
26
|
+
//
|
|
27
|
+
// MERGE, never clobber: the user's own `mcp` servers + top-level `opencode.json`
|
|
28
|
+
// keys are preserved. Re-running converges (idempotent) — our `mcp` entry is
|
|
29
|
+
// replaced in place (not duplicated) and the plugin file is rewritten
|
|
30
|
+
// byte-identically. `uninstallOpenCodeConfig` removes EXACTLY our `mcp` entry +
|
|
31
|
+
// our plugin file and nothing else (PRD §A8, the uninstall test).
|
|
32
|
+
//
|
|
33
|
+
// Our entries are identified by stable markers (the plugin contains both
|
|
34
|
+
// `gocode-notify` and `--source opencode`) so idempotency and surgical uninstall
|
|
35
|
+
// work even across version bumps to the exact command string.
|
|
36
|
+
//
|
|
37
|
+
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
38
|
+
import { promises as fs } from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { resolveHome } from "./creds.js";
|
|
41
|
+
/** Display name of the runtime this writer handles (matches the detector). */
|
|
42
|
+
export const OPENCODE_RUNTIME_NAME = "OpenCode";
|
|
43
|
+
/** MCP server key written into `opencode.json` `mcp`. */
|
|
44
|
+
export const MCP_SERVER_NAME = "gocode-notify";
|
|
45
|
+
/**
|
|
46
|
+
* Home-relative OpenCode config dirs, in the detector's priority order. The
|
|
47
|
+
* writer resolves to whichever the detector found (or the primary one when
|
|
48
|
+
* creating fresh). Kept in lockstep with `detect.ts`'s OpenCode `detectDirs`.
|
|
49
|
+
*/
|
|
50
|
+
export const OPENCODE_DETECT_DIRS = [".config/opencode", ".opencode"];
|
|
51
|
+
/** Basename of the OpenCode config file we merge into (NOT `mcp.json`). */
|
|
52
|
+
export const OPENCODE_CONFIG_BASENAME = "opencode.json";
|
|
53
|
+
/**
|
|
54
|
+
* The MCP server entry we register (PRD §0). OpenCode's shape differs from
|
|
55
|
+
* Claude/Cursor: `command` is a single ARRAY (binary + args) and there are
|
|
56
|
+
* `type` + `enabled` fields.
|
|
57
|
+
*/
|
|
58
|
+
export const OPENCODE_MCP_ENTRY = {
|
|
59
|
+
type: "local",
|
|
60
|
+
command: ["npx", "-y", "@trygocode/notify", "mcp"],
|
|
61
|
+
enabled: true,
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The shell command the `session.idle` plugin shells out to (PRD §0). Calls the
|
|
65
|
+
* shared `on-stop` DISPATCHER (NOT `send` directly): on-stop resolves settings
|
|
66
|
+
* and either runs the auto-push flow (which sends its own notification) or fires
|
|
67
|
+
* the plain `finished` ping — exactly one notification per idle, in lockstep with
|
|
68
|
+
* Claude/Cursor. Ends in `|| true` so a failure can never block the session, and
|
|
69
|
+
* carries a `--dedupe-key` so overlapping triggers coalesce server-side.
|
|
70
|
+
*/
|
|
71
|
+
export const OPENCODE_STOP_COMMAND = "npx -y @trygocode/notify on-stop --source opencode --dedupe-key opencode-idle || true";
|
|
72
|
+
/**
|
|
73
|
+
* Substrings that together identify our plugin file as OURS. Used to keep
|
|
74
|
+
* uninstall surgical: we only delete the plugin file when BOTH markers are
|
|
75
|
+
* present, so a user-authored file that happens to share the name is never
|
|
76
|
+
* removed. Present in {@link OPENCODE_PLUGIN_CONTENT}.
|
|
77
|
+
*/
|
|
78
|
+
const PLUGIN_MARKERS = ["gocode-notify", "--source opencode"];
|
|
79
|
+
/**
|
|
80
|
+
* The `session.idle` plugin written to `<config-dir>/plugin/gocode-notify.js`.
|
|
81
|
+
* An OpenCode plugin exports an async factory returning `{ event }`; we fire the
|
|
82
|
+
* shared `on-stop` dispatcher fire-and-forget on `session.idle`. The child is
|
|
83
|
+
* detached + unref'd with stdio ignored so it NEVER blocks the session, and any
|
|
84
|
+
* spawn error is swallowed (belt-and-braces with the command's own `|| true`).
|
|
85
|
+
*/
|
|
86
|
+
export const OPENCODE_PLUGIN_CONTENT = `// gocode-notify — OpenCode session.idle plugin (auto-generated by @trygocode/notify).
|
|
87
|
+
// Fires exactly one fire-and-forget phone notification when an OpenCode session
|
|
88
|
+
// goes idle (the OpenCode equivalent of Claude \`Stop\` / Cursor \`stop\`) by
|
|
89
|
+
// shelling out to the shared gocode-notify \`on-stop\` dispatcher. It NEVER blocks
|
|
90
|
+
// the session: the child is detached + unref'd, stdio ignored, errors swallowed.
|
|
91
|
+
//
|
|
92
|
+
// Stable markers (do not edit): gocode-notify --source opencode
|
|
93
|
+
// Managed by @trygocode/notify — \`npx @trygocode/notify uninstall\` removes this file.
|
|
94
|
+
import { spawn } from "node:child_process";
|
|
95
|
+
|
|
96
|
+
export const GocodeNotify = async () => ({
|
|
97
|
+
event: async ({ event }) => {
|
|
98
|
+
if (!event || event.type !== "session.idle") return;
|
|
99
|
+
try {
|
|
100
|
+
const child = spawn(
|
|
101
|
+
${JSON.stringify(OPENCODE_STOP_COMMAND)},
|
|
102
|
+
{ shell: true, detached: true, stdio: "ignore" },
|
|
103
|
+
);
|
|
104
|
+
child.unref();
|
|
105
|
+
} catch {
|
|
106
|
+
// never block the session on a notification failure
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
`;
|
|
111
|
+
function isRecord(value) {
|
|
112
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
+
}
|
|
114
|
+
function errMessage(err) {
|
|
115
|
+
return err instanceof Error ? err.message : String(err);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the OpenCode config directory. Prefers the dir the detector already
|
|
119
|
+
* settled on (`runtime.configPath`'s parent) so the writer and detector agree;
|
|
120
|
+
* otherwise probes {@link OPENCODE_DETECT_DIRS} under HOME, falling back to the
|
|
121
|
+
* primary dir when neither exists (fresh install). Never throws.
|
|
122
|
+
*/
|
|
123
|
+
export async function resolveOpenCodeDir(runtime, opts) {
|
|
124
|
+
if (runtime?.configPath)
|
|
125
|
+
return path.dirname(runtime.configPath);
|
|
126
|
+
const home = resolveHome(opts);
|
|
127
|
+
for (const rel of OPENCODE_DETECT_DIRS) {
|
|
128
|
+
const dir = path.join(home, rel);
|
|
129
|
+
try {
|
|
130
|
+
await fs.stat(dir);
|
|
131
|
+
return dir;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// keep probing
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return path.join(home, OPENCODE_DETECT_DIRS[0]);
|
|
138
|
+
}
|
|
139
|
+
/** Absolute path to OpenCode's `opencode.json` for the resolved config dir. */
|
|
140
|
+
export async function opencodeConfigPath(runtime, opts) {
|
|
141
|
+
return path.join(await resolveOpenCodeDir(runtime, opts), OPENCODE_CONFIG_BASENAME);
|
|
142
|
+
}
|
|
143
|
+
/** Absolute path to the OpenCode plugin directory (canonical singular `plugin/`). */
|
|
144
|
+
export async function opencodePluginDir(runtime, opts) {
|
|
145
|
+
return path.join(await resolveOpenCodeDir(runtime, opts), "plugin");
|
|
146
|
+
}
|
|
147
|
+
/** Absolute path to our `session.idle` plugin file. */
|
|
148
|
+
export async function opencodePluginPath(runtime, opts) {
|
|
149
|
+
return path.join(await opencodePluginDir(runtime, opts), "gocode-notify.js");
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read a JSON object from `file`. Returns null when the file does not exist.
|
|
153
|
+
* Throws when it exists but is not a JSON object — so we never silently clobber
|
|
154
|
+
* a file we failed to parse (the caller surfaces it as a write failure).
|
|
155
|
+
*/
|
|
156
|
+
async function readJsonObject(file) {
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = await fs.readFile(file, "utf8");
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
if (err.code === "ENOENT")
|
|
163
|
+
return null;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(raw);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new Error(`gocode-notify: ${file} contains invalid JSON`);
|
|
172
|
+
}
|
|
173
|
+
if (!isRecord(parsed)) {
|
|
174
|
+
throw new Error(`gocode-notify: ${file} is not a JSON object`);
|
|
175
|
+
}
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
/** Write a JSON object with 2-space indent + trailing newline (matches creds). */
|
|
179
|
+
async function writeJsonFile(file, value) {
|
|
180
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
181
|
+
await fs.writeFile(file, JSON.stringify(value, null, 2) + "\n");
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Merge our MCP server entry into `config.mcp`, preserving the user's other
|
|
185
|
+
* `mcp` servers and top-level keys. Replaces OUR entry in place (idempotent).
|
|
186
|
+
* Mutates `config` in place. A fresh copy of the entry is written each time so
|
|
187
|
+
* the constant is never aliased into the user's config.
|
|
188
|
+
*/
|
|
189
|
+
function mergeMcp(config) {
|
|
190
|
+
const mcp = isRecord(config.mcp) ? config.mcp : {};
|
|
191
|
+
mcp[MCP_SERVER_NAME] = {
|
|
192
|
+
type: OPENCODE_MCP_ENTRY.type,
|
|
193
|
+
command: [...OPENCODE_MCP_ENTRY.command],
|
|
194
|
+
enabled: OPENCODE_MCP_ENTRY.enabled,
|
|
195
|
+
};
|
|
196
|
+
config.mcp = mcp;
|
|
197
|
+
}
|
|
198
|
+
/** True when a plugin file's content is one we wrote (BOTH markers present). */
|
|
199
|
+
function isOurPlugin(content) {
|
|
200
|
+
return PLUGIN_MARKERS.every((m) => content.includes(m));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Install OpenCode config (PRD §0): merge the MCP entry into `opencode.json` and
|
|
204
|
+
* write the `session.idle` plugin. A {@link RuntimeConfigWriter}-shaped function —
|
|
205
|
+
* never throws; returns a {@link ConfigWriteResult}. Idempotent: re-running
|
|
206
|
+
* converges without duplicating our entries.
|
|
207
|
+
*/
|
|
208
|
+
export async function writeOpenCodeConfig(runtime, opts) {
|
|
209
|
+
const name = runtime?.name ?? OPENCODE_RUNTIME_NAME;
|
|
210
|
+
// Track paths as they land so a mid-way failure reports what WAS actually
|
|
211
|
+
// written rather than claiming nothing changed.
|
|
212
|
+
const written = [];
|
|
213
|
+
try {
|
|
214
|
+
const dir = await resolveOpenCodeDir(runtime, opts);
|
|
215
|
+
await fs.mkdir(dir, { recursive: true });
|
|
216
|
+
const configFile = path.join(dir, OPENCODE_CONFIG_BASENAME);
|
|
217
|
+
const config = (await readJsonObject(configFile)) ?? {};
|
|
218
|
+
mergeMcp(config);
|
|
219
|
+
await writeJsonFile(configFile, config);
|
|
220
|
+
written.push(configFile);
|
|
221
|
+
const pluginDir = path.join(dir, "plugin");
|
|
222
|
+
await fs.mkdir(pluginDir, { recursive: true });
|
|
223
|
+
const pluginFile = path.join(pluginDir, "gocode-notify.js");
|
|
224
|
+
await fs.writeFile(pluginFile, OPENCODE_PLUGIN_CONTENT);
|
|
225
|
+
written.push(pluginFile);
|
|
226
|
+
return {
|
|
227
|
+
runtime: name,
|
|
228
|
+
written,
|
|
229
|
+
skipped: false,
|
|
230
|
+
detail: "merged mcp.gocode-notify entry; wrote session.idle plugin (rule skipped — OpenCode has no standalone rule file)",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
return {
|
|
235
|
+
runtime: name,
|
|
236
|
+
written,
|
|
237
|
+
skipped: false,
|
|
238
|
+
failed: true,
|
|
239
|
+
detail: `OpenCode config write failed: ${errMessage(err)}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Remove EXACTLY the entries this writer added (PRD §A8): our `mcp` entry and our
|
|
245
|
+
* plugin file. The user's own `mcp` servers, top-level `opencode.json` keys, and
|
|
246
|
+
* other plugins are preserved untouched. The plugin file is only deleted when it
|
|
247
|
+
* carries OUR markers, so a user file sharing the name is never removed.
|
|
248
|
+
* Idempotent — a second run (or a run when nothing was installed) is a clean
|
|
249
|
+
* no-op. Never throws.
|
|
250
|
+
*/
|
|
251
|
+
export async function uninstallOpenCodeConfig(opts) {
|
|
252
|
+
const removed = [];
|
|
253
|
+
try {
|
|
254
|
+
const dir = await resolveOpenCodeDir(undefined, opts);
|
|
255
|
+
// opencode.json — remove our mcp entry only.
|
|
256
|
+
const configFile = path.join(dir, OPENCODE_CONFIG_BASENAME);
|
|
257
|
+
const config = await readJsonObject(configFile);
|
|
258
|
+
if (config && isRecord(config.mcp) && MCP_SERVER_NAME in config.mcp) {
|
|
259
|
+
delete config.mcp[MCP_SERVER_NAME];
|
|
260
|
+
if (Object.keys(config.mcp).length === 0)
|
|
261
|
+
delete config.mcp;
|
|
262
|
+
await writeJsonFile(configFile, config);
|
|
263
|
+
removed.push(configFile);
|
|
264
|
+
}
|
|
265
|
+
// plugin/gocode-notify.js — remove only OUR plugin file (marker-checked).
|
|
266
|
+
const pluginFile = path.join(dir, "plugin", "gocode-notify.js");
|
|
267
|
+
let pluginContent = null;
|
|
268
|
+
try {
|
|
269
|
+
pluginContent = await fs.readFile(pluginFile, "utf8");
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
// Only ENOENT means "not installed". A permission/IO error must surface as
|
|
273
|
+
// a failure, not be silently reported as a clean uninstall.
|
|
274
|
+
if (err.code !== "ENOENT")
|
|
275
|
+
throw err;
|
|
276
|
+
pluginContent = null;
|
|
277
|
+
}
|
|
278
|
+
if (pluginContent !== null && isOurPlugin(pluginContent)) {
|
|
279
|
+
await fs.rm(pluginFile, { force: true });
|
|
280
|
+
removed.push(pluginFile);
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
removed,
|
|
284
|
+
detail: removed.length > 0
|
|
285
|
+
? `removed gocode-notify entries (${removed.length} path${removed.length === 1 ? "" : "s"})`
|
|
286
|
+
: "no gocode-notify entries found",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
return { removed, failed: true, detail: `OpenCode uninstall failed: ${errMessage(err)}` };
|
|
291
|
+
}
|
|
292
|
+
}
|
package/dist/src/push.js
CHANGED
|
@@ -213,7 +213,54 @@ export async function pushOnStop(opts = {}) {
|
|
|
213
213
|
finally {
|
|
214
214
|
await removeCommitFile(file).catch(() => { });
|
|
215
215
|
}
|
|
216
|
-
|
|
216
|
+
let sha = (await git(["rev-parse", "--short", "HEAD"])).stdout.trim() || undefined;
|
|
217
|
+
// ── Step 7.5 (PRD §3): pull-before-push. Default ON; opt out with
|
|
218
|
+
// `auto_push.pull_before_push: false` (FF-only). Runs AFTER the commit so
|
|
219
|
+
// any local work is already a commit being rebased atop the remote. ──
|
|
220
|
+
if (autoPush.pull_before_push !== false) {
|
|
221
|
+
// Learn the remote state. A fetch failure (offline / brand-new branch)
|
|
222
|
+
// falls through to the plain push, which still handles non-FF safely.
|
|
223
|
+
const fetched = await git(["fetch", remote, branch]);
|
|
224
|
+
if (fetched.code === 0 && !(await isFastForwardPossible(git, remote, branch))) {
|
|
225
|
+
// Remote moved — a plain push would be non-FF. Rebase our commit atop it.
|
|
226
|
+
const pulled = await git(["pull", "--rebase", "--autostash", remote, branch]);
|
|
227
|
+
if (pulled.code !== 0) {
|
|
228
|
+
// A REAL conflict (or pull failure): RESTORE the tree byte-for-byte and
|
|
229
|
+
// do NOT push. `--autostash` restores any stray changes on abort. We
|
|
230
|
+
// NEVER auto-resolve (-X theirs/ours) — that loses work; reasoned
|
|
231
|
+
// resolution is AI-Solve's job (PRD §5), user-initiated only.
|
|
232
|
+
const conflicting = parseConflictFiles((await git(["diff", "--name-only", "--diff-filter=U"])).stdout);
|
|
233
|
+
await git(["rebase", "--abort"]);
|
|
234
|
+
const fileList = conflicting.length ? conflicting.join(", ") : "remote changes";
|
|
235
|
+
await logLine(`pull --rebase hit a conflict on ${remote} ${branch} — aborted (tree restored), not pushing; conflicts: ${fileList}`);
|
|
236
|
+
// PRD §4: git_conflict is an error-class signal. The dedicated
|
|
237
|
+
// `git_conflict` push kind + deep-link payload arrive in a later phase;
|
|
238
|
+
// here we alert via an error notification (never block, exit 0).
|
|
239
|
+
const conflictRes = await sendImpl({
|
|
240
|
+
kind: "error",
|
|
241
|
+
title: "Push needs a merge",
|
|
242
|
+
body: `${branch} — remote changed; rebase conflicts in ${conflicting.length || "remote"} file(s). Pull + resolve (or use AI-Solve).`,
|
|
243
|
+
source,
|
|
244
|
+
project: opts.project,
|
|
245
|
+
dedupe_key: opts.dedupeKey,
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
outcome: "conflict-aborted",
|
|
249
|
+
branch,
|
|
250
|
+
sha,
|
|
251
|
+
message: composed.message,
|
|
252
|
+
generator: composed.generator,
|
|
253
|
+
notified: conflictRes.ok,
|
|
254
|
+
conflictingFiles: conflicting,
|
|
255
|
+
detail: `rebase conflict aborted; not pushed (${fileList})`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// Clean rebase: our commit now sits atop the remote → an FF push works.
|
|
259
|
+
// The rebase rewrote HEAD, so refresh the short SHA for the notification.
|
|
260
|
+
sha = (await git(["rev-parse", "--short", "HEAD"])).stdout.trim() || sha;
|
|
261
|
+
await logLine(`pulled remote changes (rebase clean) on ${remote} ${branch} — pushing`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
217
264
|
// ── Step 8: fast-forward push. NEVER --force. ──
|
|
218
265
|
const pushed = await git(["push", remote, branch]);
|
|
219
266
|
if (pushed.code !== 0) {
|
|
@@ -269,6 +316,27 @@ export async function pushOnStop(opts = {}) {
|
|
|
269
316
|
return { outcome: "disabled", detail: "unexpected error" };
|
|
270
317
|
}
|
|
271
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* After a `git fetch`, decide whether a plain (fast-forward) push is possible:
|
|
321
|
+
* true when the remote-tracking ref `<remote>/<branch>` is an ancestor of (or
|
|
322
|
+
* equal to) HEAD — i.e. we already have everything the remote has. False ONLY
|
|
323
|
+
* when the remote has commits we don't (a non-FF push). A missing remote ref
|
|
324
|
+
* (brand-new branch that fetch didn't create) counts as FF-possible.
|
|
325
|
+
*/
|
|
326
|
+
export async function isFastForwardPossible(git, remote, branch) {
|
|
327
|
+
const ancestor = await git(["merge-base", "--is-ancestor", `${remote}/${branch}`, "HEAD"]);
|
|
328
|
+
// 0 → remote is an ancestor of HEAD (or equal) → FF push OK.
|
|
329
|
+
// 1 → remote has diverged → non-FF → must `pull --rebase` first.
|
|
330
|
+
// 128 → unknown ref (new branch) → nothing to rebase onto → treat as FF.
|
|
331
|
+
return ancestor.code !== 1;
|
|
332
|
+
}
|
|
333
|
+
/** Parse the unmerged-file list from `git diff --name-only --diff-filter=U`. */
|
|
334
|
+
export function parseConflictFiles(stdout) {
|
|
335
|
+
return stdout
|
|
336
|
+
.split("\n")
|
|
337
|
+
.map((l) => l.trim())
|
|
338
|
+
.filter((l) => l !== "");
|
|
339
|
+
}
|
|
272
340
|
/** True when git's push stderr signals a non-fast-forward / rejected push. */
|
|
273
341
|
export function isNonFastForward(stderr) {
|
|
274
342
|
return /\b(non-fast-forward|rejected|fetch first|tip of your current branch is behind)\b/i.test(stderr);
|
package/dist/src/setup.js
CHANGED
|
@@ -25,10 +25,13 @@ import { readCredentials } from "./creds.js";
|
|
|
25
25
|
import { detectRuntimes } from "./status.js";
|
|
26
26
|
import { writeClaudeConfig, CLAUDE_RUNTIME_NAME } from "./claude.js";
|
|
27
27
|
import { writeCursorConfig, CURSOR_RUNTIME_NAME } from "./cursor.js";
|
|
28
|
+
import { writeOpenCodeConfig, OPENCODE_RUNTIME_NAME } from "./opencode.js";
|
|
28
29
|
/**
|
|
29
|
-
* Default config writer: reports the detected runtime as pending.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* Default config writer: reports the detected runtime as pending. Used as the
|
|
31
|
+
* fall-through for any future runtime whose dedicated writer has not landed yet.
|
|
32
|
+
* All currently-supported runtimes (Claude Code / Cursor / OpenCode) have real
|
|
33
|
+
* writers wired in {@link defaultConfigWriter}. Writing nothing is fully
|
|
34
|
+
* idempotent.
|
|
32
35
|
*/
|
|
33
36
|
export const pendingConfigWriter = async (runtime) => ({
|
|
34
37
|
runtime: runtime.name,
|
|
@@ -38,16 +41,18 @@ export const pendingConfigWriter = async (runtime) => ({
|
|
|
38
41
|
});
|
|
39
42
|
/**
|
|
40
43
|
* The default config writer used in production: routes each detected runtime to
|
|
41
|
-
* its dedicated writer. Claude Code
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* idempotent).
|
|
44
|
+
* its dedicated writer. Claude Code → {@link writeClaudeConfig}, Cursor →
|
|
45
|
+
* {@link writeCursorConfig}, OpenCode → {@link writeOpenCodeConfig}. Any future
|
|
46
|
+
* runtime without a writer falls through to {@link pendingConfigWriter} (a
|
|
47
|
+
* no-op, fully idempotent).
|
|
45
48
|
*/
|
|
46
49
|
export const defaultConfigWriter = async (runtime, opts) => {
|
|
47
50
|
if (runtime.name === CLAUDE_RUNTIME_NAME)
|
|
48
51
|
return writeClaudeConfig(runtime, opts);
|
|
49
52
|
if (runtime.name === CURSOR_RUNTIME_NAME)
|
|
50
53
|
return writeCursorConfig(runtime, opts);
|
|
54
|
+
if (runtime.name === OPENCODE_RUNTIME_NAME)
|
|
55
|
+
return writeOpenCodeConfig(runtime, opts);
|
|
51
56
|
return pendingConfigWriter(runtime, opts);
|
|
52
57
|
};
|
|
53
58
|
/**
|
package/dist/src/uninstall.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { uninstallClaudeConfig, CLAUDE_RUNTIME_NAME, } from "./claude.js";
|
|
2
2
|
import { uninstallCursorConfig, CURSOR_RUNTIME_NAME } from "./cursor.js";
|
|
3
|
+
import { uninstallOpenCodeConfig, OPENCODE_RUNTIME_NAME } from "./opencode.js";
|
|
3
4
|
/** The production runtime uninstallers, in run order. */
|
|
4
5
|
export const defaultUninstallers = [
|
|
5
6
|
{ runtime: CLAUDE_RUNTIME_NAME, run: uninstallClaudeConfig },
|
|
6
7
|
{ runtime: CURSOR_RUNTIME_NAME, run: uninstallCursorConfig },
|
|
8
|
+
{ runtime: OPENCODE_RUNTIME_NAME, run: uninstallOpenCodeConfig },
|
|
7
9
|
];
|
|
8
10
|
/**
|
|
9
11
|
* Run the uninstall orchestration (PRD §4.1, §11). Resolves (never rejects) to a
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Single source of truth for the CLI version. Keep in sync with package.json.
|
|
2
|
-
export const VERSION = "0.1.
|
|
2
|
+
export const VERSION = "0.1.3";
|
package/package.json
CHANGED
|
@@ -1,28 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trygocode/notify",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Free phone notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Free phone notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"homepage": "https://github.com/joseph-lewis/gocode-notify#readme",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/joseph-lewis/gocode-notify.git"
|
|
11
|
-
},
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/joseph-lewis/gocode-notify/issues"
|
|
14
|
-
},
|
|
15
|
-
"author": "GoCode",
|
|
16
7
|
"bin": {
|
|
17
8
|
"gocode-notify": "dist/src/cli.js"
|
|
18
9
|
},
|
|
19
10
|
"files": [
|
|
20
11
|
"dist/src",
|
|
21
12
|
"snippets",
|
|
22
|
-
"
|
|
23
|
-
"README.md",
|
|
24
|
-
"LICENSE",
|
|
25
|
-
"CHANGELOG.md"
|
|
13
|
+
"README.md"
|
|
26
14
|
],
|
|
27
15
|
"engines": {
|
|
28
16
|
"node": ">=18"
|
|
@@ -37,17 +25,11 @@
|
|
|
37
25
|
"keywords": [
|
|
38
26
|
"notifications",
|
|
39
27
|
"push",
|
|
40
|
-
"push-notifications",
|
|
41
28
|
"fcm",
|
|
42
29
|
"mcp",
|
|
43
|
-
"model-context-protocol",
|
|
44
30
|
"cli",
|
|
45
31
|
"claude-code",
|
|
46
32
|
"cursor",
|
|
47
|
-
"cursor-ide",
|
|
48
|
-
"hooks",
|
|
49
|
-
"ai-coding",
|
|
50
|
-
"opencode",
|
|
51
33
|
"ralph",
|
|
52
34
|
"gocode"
|
|
53
35
|
],
|
package/snippets/ralph-homer.sh
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# gocode-notify — Ralph
|
|
1
|
+
# gocode-notify — Ralph/Homer loop opt-in snippet (PRD §5.6, trigger C)
|
|
2
2
|
#
|
|
3
3
|
# OPT-IN. This snippet is NOT auto-injected by `gocode-notify setup`; the
|
|
4
4
|
# installer never edits your loop scripts without consent. Paste these two
|
package/CHANGELOG.md
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `@trygocode/notify` are documented here. This project
|
|
4
|
-
follows [Semantic Versioning](https://semver.org).
|
|
5
|
-
|
|
6
|
-
## [0.1.1] — 2026-06-03
|
|
7
|
-
|
|
8
|
-
### Changed
|
|
9
|
-
- **Real GoCode artwork** — replaced placeholder mark with the official GoCode
|
|
10
|
-
Notify icon (`icon-512.png` / `icon-128.png`) and a README hero banner. MCP
|
|
11
|
-
server `icons` metadata now points at the real PNGs.
|
|
12
|
-
- **Clearer docs** — the free GoCode phone app is now called out up front as a
|
|
13
|
-
required component, the "free" status is stated explicitly, and a new
|
|
14
|
-
**GoCode platform settings** section documents auto-push, safe pull-before-push,
|
|
15
|
-
AI-Solve conflict resolution, and review-&-merge.
|
|
16
|
-
- Renamed "Ralph / Homer loops" to "Ralph Wiggum / Autopilot script loops"
|
|
17
|
-
throughout for readers unfamiliar with the internal codenames.
|
|
18
|
-
|
|
19
|
-
## [0.1.0] — 2026-06-03
|
|
20
|
-
|
|
21
|
-
Initial public release.
|
|
22
|
-
|
|
23
|
-
### Added
|
|
24
|
-
- **One-command install** — `npx @trygocode/notify@latest setup` pairs the machine,
|
|
25
|
-
auto-detects your agent runtimes, and merges hooks + the MCP server + an
|
|
26
|
-
anti-double-ping rule into each (Cursor, Claude Code, OpenCode). Idempotent;
|
|
27
|
-
safe to re-run; `--force` re-pairs.
|
|
28
|
-
- **Three notification triggers** — (A) runtime hooks (Cursor `stop`; Claude Code
|
|
29
|
-
`Stop` / `Notification` / `SubagentStop`), (B) the `gocode_notify` MCP tool for
|
|
30
|
-
explicit "ping me when X is done" requests, (C) an opt-in Ralph/Homer loop
|
|
31
|
-
completion/halt snippet.
|
|
32
|
-
- **Secure pairing** — a short-lived 6-digit code is exchanged for a scoped,
|
|
33
|
-
push-only API key stored locally (`~/.gocode/credentials`, chmod 600). The key
|
|
34
|
-
can only send pushes to your own phone; revoke any time from the app.
|
|
35
|
-
- **Offline outbox** — sends made while the server is unreachable are queued and
|
|
36
|
-
flushed best-effort later, so a blocked agent is never caused by a slow push.
|
|
37
|
-
- **`status` self-diagnosis**, `test` round-trip push, and a clean `uninstall`
|
|
38
|
-
that removes exactly what this tool added.
|
|
39
|
-
- **MCP server metadata** — `icons` + `websiteUrl` (SEP-973) so compatible clients
|
|
40
|
-
can show the GoCode mark next to the server.
|
|
41
|
-
|
|
42
|
-
[0.1.1]: https://github.com/joseph-lewis/gocode-notify/releases/tag/v0.1.1
|
|
43
|
-
[0.1.0]: https://github.com/joseph-lewis/gocode-notify/releases/tag/v0.1.0
|