@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.
@@ -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) {
@@ -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
- * The `session.idle` plugin written to `<config-dir>/plugin/gocode-notify.js`.
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 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`).
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 session.idle plugin (auto-generated by @trygocode/notify).
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
- // goes idle (the OpenCode equivalent of Claude \`Stop\` / Cursor \`stop\`) by
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 || 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
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
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",