@trygocode/notify 0.1.3 → 0.1.4
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/dist/src/on_stop.js +3 -3
- package/dist/src/opencode.js +117 -15
- package/package.json +1 -1
package/dist/src/on_stop.js
CHANGED
|
@@ -106,12 +106,12 @@ export async function onStop(opts = {}) {
|
|
|
106
106
|
timestamp: opts.timestamp,
|
|
107
107
|
home: opts.home,
|
|
108
108
|
});
|
|
109
|
-
await logLine(`auto-push path → ${push.outcome} (settings: ${resolved.source})`);
|
|
109
|
+
await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
|
|
110
110
|
return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
|
|
111
111
|
}
|
|
112
112
|
// ── Step 3b: auto-push off → the plain `finished` notification (legacy flow). ──
|
|
113
113
|
if (opts.dryRun) {
|
|
114
|
-
await logLine(`dry-run: would send finished (auto-push off, settings: ${resolved.source})`);
|
|
114
|
+
await logLine(`dry-run: would send finished (auto-push off, source: ${source}, settings: ${resolved.source})`);
|
|
115
115
|
return { mode: "dry-run-send", settingsSource: resolved.source, repo };
|
|
116
116
|
}
|
|
117
117
|
const payload = { kind: "finished", source };
|
|
@@ -120,7 +120,7 @@ export async function onStop(opts = {}) {
|
|
|
120
120
|
if (opts.dedupeKey)
|
|
121
121
|
payload.dedupe_key = opts.dedupeKey;
|
|
122
122
|
const sent = await sendImpl(payload);
|
|
123
|
-
await logLine(`send path → finished ${sent.ok ? "delivered" : "failed"} (settings: ${resolved.source})`);
|
|
123
|
+
await logLine(`send path → finished ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
|
|
124
124
|
return { mode: "send", settingsSource: resolved.source, send: sent, repo };
|
|
125
125
|
}
|
|
126
126
|
catch (err) {
|
package/dist/src/opencode.js
CHANGED
|
@@ -77,33 +77,135 @@ export const OPENCODE_STOP_COMMAND = "npx -y @trygocode/notify on-stop --source
|
|
|
77
77
|
*/
|
|
78
78
|
const PLUGIN_MARKERS = ["gocode-notify", "--source opencode"];
|
|
79
79
|
/**
|
|
80
|
-
*
|
|
80
|
+
* Best-effort predicate: does an OpenCode `session.status` payload's `status`
|
|
81
|
+
* value mean "the agent finished this turn"?
|
|
82
|
+
*
|
|
83
|
+
* The `status` value has shifted shape across OpenCode versions — a bare string
|
|
84
|
+
* (`"idle"`) and an object (`{ type | state | status: "idle" }`). We normalise
|
|
85
|
+
* to a lowercase string and treat any idle/done/complete/finish word as
|
|
86
|
+
* end-of-turn, while explicitly REJECTING busy/working/running/active/stream/
|
|
87
|
+
* pending states so we never ping mid-turn.
|
|
88
|
+
*
|
|
89
|
+
* Exported so the generated plugin's behaviour is unit-testable in isolation
|
|
90
|
+
* (the plugin body inlines the identical logic — keep the two in lockstep; the
|
|
91
|
+
* `opencode.test.ts` "statusIsIdle parity" test guards against drift).
|
|
92
|
+
*/
|
|
93
|
+
export function opencodeStatusIsIdle(status) {
|
|
94
|
+
if (status == null)
|
|
95
|
+
return false;
|
|
96
|
+
const raw = typeof status === "string"
|
|
97
|
+
? status
|
|
98
|
+
: isRecord(status)
|
|
99
|
+
? (status.type ?? status.state ?? status.status ?? "")
|
|
100
|
+
: "";
|
|
101
|
+
const s = String(raw).toLowerCase();
|
|
102
|
+
if (!s)
|
|
103
|
+
return false;
|
|
104
|
+
if (s.includes("busy") ||
|
|
105
|
+
s.includes("work") ||
|
|
106
|
+
s.includes("run") ||
|
|
107
|
+
s.includes("active") ||
|
|
108
|
+
s.includes("stream") ||
|
|
109
|
+
s.includes("pending")) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return (s.includes("idle") ||
|
|
113
|
+
s.includes("done") ||
|
|
114
|
+
s.includes("complete") ||
|
|
115
|
+
s.includes("finish"));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The end-of-turn plugin written to `<config-dir>/plugin/gocode-notify.js`.
|
|
119
|
+
*
|
|
81
120
|
* An OpenCode plugin exports an async factory returning `{ event }`; we fire the
|
|
82
|
-
* shared `on-stop` dispatcher fire-and-forget
|
|
83
|
-
*
|
|
84
|
-
*
|
|
121
|
+
* shared `on-stop` dispatcher fire-and-forget when a session finishes a turn.
|
|
122
|
+
*
|
|
123
|
+
* IMPORTANT — why we listen to TWO events:
|
|
124
|
+
* - `session.idle` is the original "turn finished" signal, but as of recent
|
|
125
|
+
* OpenCode builds (≥ ~1.14) it is **deprecated** and no longer reliably
|
|
126
|
+
* emitted in the GUI (upstream moved to `session.status`). Riding it alone
|
|
127
|
+
* meant "Cursor pings me, OpenCode doesn't".
|
|
128
|
+
* - `session.status` (payload `{ sessionID, status }`) is the modern signal.
|
|
129
|
+
* It fires on EVERY status change, so we only treat it as end-of-turn when
|
|
130
|
+
* the status is the idle/finished state (best-effort shape detection — the
|
|
131
|
+
* `status` value has been a string and an object across versions).
|
|
132
|
+
*
|
|
133
|
+
* We subscribe to BOTH so the plugin works on old AND new OpenCode. A small
|
|
134
|
+
* per-session debounce (DEDUPE_MS) coalesces the idle+status pair for the same
|
|
135
|
+
* turn so we never spawn `on-stop` twice; the server's `--dedupe-key` is the
|
|
136
|
+
* second line of defence. The child is detached + unref'd with stdio ignored so
|
|
137
|
+
* it NEVER blocks the session, and any spawn error is swallowed (belt-and-braces
|
|
138
|
+
* with the command's own `|| true`).
|
|
85
139
|
*/
|
|
86
|
-
export const OPENCODE_PLUGIN_CONTENT = `// gocode-notify — OpenCode
|
|
140
|
+
export const OPENCODE_PLUGIN_CONTENT = `// gocode-notify — OpenCode end-of-turn plugin (auto-generated by @trygocode/notify).
|
|
87
141
|
// Fires exactly one fire-and-forget phone notification when an OpenCode session
|
|
88
|
-
//
|
|
142
|
+
// finishes a turn (the OpenCode equivalent of Claude \`Stop\` / Cursor \`stop\`) by
|
|
89
143
|
// shelling out to the shared gocode-notify \`on-stop\` dispatcher. It NEVER blocks
|
|
90
144
|
// the session: the child is detached + unref'd, stdio ignored, errors swallowed.
|
|
91
145
|
//
|
|
146
|
+
// Listens to BOTH \`session.idle\` (legacy, deprecated in newer OpenCode) and
|
|
147
|
+
// \`session.status\` (modern end-of-turn signal). A per-session debounce stops the
|
|
148
|
+
// two from double-firing for the same turn. See tools/gocode-notify/src/opencode.ts.
|
|
149
|
+
//
|
|
92
150
|
// Stable markers (do not edit): gocode-notify --source opencode
|
|
93
151
|
// Managed by @trygocode/notify — \`npx @trygocode/notify uninstall\` removes this file.
|
|
94
152
|
import { spawn } from "node:child_process";
|
|
95
153
|
|
|
154
|
+
// Per-session last-fire timestamps so idle+status for the SAME turn coalesce.
|
|
155
|
+
const lastFiredAt = new Map();
|
|
156
|
+
const DEDUPE_MS = 4000;
|
|
157
|
+
|
|
158
|
+
function fire() {
|
|
159
|
+
try {
|
|
160
|
+
const child = spawn(
|
|
161
|
+
${JSON.stringify(OPENCODE_STOP_COMMAND)},
|
|
162
|
+
{ shell: true, detached: true, stdio: "ignore" },
|
|
163
|
+
);
|
|
164
|
+
child.unref();
|
|
165
|
+
} catch {
|
|
166
|
+
// never block the session on a notification failure
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Best-effort: does this \`session.status\` payload mean "the agent finished"?
|
|
171
|
+
// The status value has been a bare string ("idle") and an object ({type|state|
|
|
172
|
+
// status: "idle"}) across OpenCode versions; treat any of those idle-ish shapes
|
|
173
|
+
// as end-of-turn, and explicitly IGNORE busy/working/running states.
|
|
174
|
+
function statusIsIdle(status) {
|
|
175
|
+
if (status == null) return false;
|
|
176
|
+
const s = (typeof status === "string"
|
|
177
|
+
? status
|
|
178
|
+
: (status.type ?? status.state ?? status.status ?? "")
|
|
179
|
+
).toString().toLowerCase();
|
|
180
|
+
if (!s) return false;
|
|
181
|
+
if (s.includes("busy") || s.includes("work") || s.includes("run") ||
|
|
182
|
+
s.includes("active") || s.includes("stream") || s.includes("pending")) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return s.includes("idle") || s.includes("done") ||
|
|
186
|
+
s.includes("complete") || s.includes("finish");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function maybeFire(sessionID) {
|
|
190
|
+
const key = sessionID || "_";
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const prev = lastFiredAt.get(key) ?? 0;
|
|
193
|
+
if (now - prev < DEDUPE_MS) return; // coalesce idle+status for one turn
|
|
194
|
+
lastFiredAt.set(key, now);
|
|
195
|
+
fire();
|
|
196
|
+
}
|
|
197
|
+
|
|
96
198
|
export const GocodeNotify = async () => ({
|
|
97
199
|
event: async ({ event }) => {
|
|
98
|
-
if (!event
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
200
|
+
if (!event) return;
|
|
201
|
+
const props = event.properties ?? {};
|
|
202
|
+
if (event.type === "session.idle") {
|
|
203
|
+
maybeFire(props.sessionID);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (event.type === "session.status") {
|
|
207
|
+
if (statusIsIdle(props.status)) maybeFire(props.sessionID);
|
|
208
|
+
return;
|
|
107
209
|
}
|
|
108
210
|
},
|
|
109
211
|
});
|
package/package.json
CHANGED