greprag 5.32.0 → 5.35.0

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.
Files changed (52) hide show
  1. package/dist/codex-hook-events.d.ts +20 -0
  2. package/dist/codex-hook-events.js +156 -0
  3. package/dist/codex-hook-events.js.map +1 -0
  4. package/dist/commands/codex-app-server.d.ts +1 -0
  5. package/dist/commands/codex-app-server.js +179 -0
  6. package/dist/commands/codex-app-server.js.map +1 -0
  7. package/dist/commands/codex-doctor.js +3 -1
  8. package/dist/commands/codex-doctor.js.map +1 -1
  9. package/dist/commands/codex.js +6 -0
  10. package/dist/commands/codex.js.map +1 -1
  11. package/dist/commands/corpus/index.d.ts +1 -0
  12. package/dist/commands/corpus/index.js +5 -0
  13. package/dist/commands/corpus/index.js.map +1 -1
  14. package/dist/commands/corpus/refresh.d.ts +1 -0
  15. package/dist/commands/corpus/refresh.js +60 -0
  16. package/dist/commands/corpus/refresh.js.map +1 -1
  17. package/dist/commands/desk-line.d.ts +36 -0
  18. package/dist/commands/desk-line.js +248 -0
  19. package/dist/commands/desk-line.js.map +1 -0
  20. package/dist/commands/inbox-spool.d.ts +27 -0
  21. package/dist/commands/inbox-spool.js +151 -0
  22. package/dist/commands/inbox-spool.js.map +1 -0
  23. package/dist/commands/inbox-watch.js +59 -8
  24. package/dist/commands/inbox-watch.js.map +1 -1
  25. package/dist/commands/init.js +75 -7
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/commands/opencode-relay.d.ts +136 -0
  28. package/dist/commands/opencode-relay.js +529 -0
  29. package/dist/commands/opencode-relay.js.map +1 -0
  30. package/dist/commands/opencode-watch.d.ts +17 -0
  31. package/dist/commands/opencode-watch.js +493 -0
  32. package/dist/commands/opencode-watch.js.map +1 -0
  33. package/dist/commands/status.d.ts +2 -0
  34. package/dist/commands/status.js +5 -1
  35. package/dist/commands/status.js.map +1 -1
  36. package/dist/commands/watcher-registry.d.ts +8 -0
  37. package/dist/commands/watcher-registry.js +19 -0
  38. package/dist/commands/watcher-registry.js.map +1 -1
  39. package/dist/hook.js +54 -83
  40. package/dist/hook.js.map +1 -1
  41. package/dist/index.js +220 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/opencode-plugin-helpers.d.ts +200 -0
  44. package/dist/opencode-plugin-helpers.js +512 -0
  45. package/dist/opencode-plugin-helpers.js.map +1 -0
  46. package/dist/opencode-plugin.d.ts +37 -134
  47. package/dist/opencode-plugin.js +648 -364
  48. package/dist/opencode-plugin.js.map +1 -1
  49. package/dist/session-id.d.ts +8 -6
  50. package/dist/session-id.js +10 -9
  51. package/dist/session-id.js.map +1 -1
  52. package/package.json +8 -4
@@ -0,0 +1,136 @@
1
+ /** greprag opencode relay — bridge greprag inbox SSE into a live opencode
2
+ * desktop session. Desktop-only by design.
3
+ *
4
+ * The Monitor-style "fire a turn into a live session from an external event"
5
+ * primitive that Claude Code ships natively. opencode is turn-based (no
6
+ * built-in Monitor tool), but its HTTP server exposes a session-direct
7
+ * injection endpoint: POST /session/{id}/prompt_async enqueues a fresh
8
+ * turn on the named session and returns 204 No Content — exactly the
9
+ * fire-and-forget shape SSE delivery wants. This daemon is the glue:
10
+ * subscribe to greprag's inbox stream, format each message as a system
11
+ * notification, POST it at the running opencode server. The receiving
12
+ * session renders the injected prompt into its conversation feed via the
13
+ * same SSE channel the engine uses — no separate toast surface needed.
14
+ *
15
+ * The earlier 3-POST TUI sequence (show-toast + append-prompt + submit-prompt)
16
+ * targeted opencode's TUI client control plane, which the desktop client
17
+ * does not subscribe to. As of 2026-Q2 the TUI client is deprecated; the
18
+ * desktop app is the only supported surface. prompt_async is the
19
+ * client-agnostic primitive — same code path serves desktop, web, and any
20
+ * future client built on the same server.
21
+ *
22
+ * The default posture is session-targeted: the relay auto-derives the
23
+ * greprag 8-hex from the opencode session UUID (via `truncateSessionId`)
24
+ * and subscribes to only messages addressed to that session. Other
25
+ * sessions' traffic and broadcasts are excluded by default — use
26
+ * `--tenant-wide` to opt into the noisy debug mode.
27
+ *
28
+ * Resilience mirrors inbox-watch: same exponential backoff, same idle
29
+ * timeout, same cursor-preserving reconnect. The bash `while true` wrapper
30
+ * from armMonitorCommand is the right invocation in production — this
31
+ * process handles network-layer failures only. adr: adr/opencode-monitor-relay.md
32
+ */
33
+ export interface RelayOptions {
34
+ /** opencode session id (UUID). The target session the message gets
35
+ * injected into. Required. */
36
+ session: string;
37
+ /** opencode server base URL. Default http://127.0.0.1:4096. */
38
+ opencodeUrl?: string;
39
+ /** Filter inbox stream to one project. Mirrors `inbox watch --project`. */
40
+ project?: string;
41
+ /** Filter inbox stream to one greprag session (8-hex). Mirrors
42
+ * `inbox watch --session`. Distinct from `session` (the opencode
43
+ * target). If unset, the relay auto-derives the 8-hex from `session`
44
+ * via `truncateSessionId()` so it filters to messages addressed to
45
+ * THIS opencode session only. Pass explicitly to override the
46
+ * derivation (e.g. for multi-opencode-session relays that share one
47
+ * greprag session). */
48
+ inboxSession?: string;
49
+ /** Resume cursor. */
50
+ since?: string;
51
+ /** Also pretty-print each message to stdout. */
52
+ print?: boolean;
53
+ /** Tenant-wide subscription — see every message in the tenant's inbox
54
+ * stream, including messages addressed to other sessions. Debug flag.
55
+ * Without this, the relay filters to one greprag session (auto-derived
56
+ * from `session` or set via `inboxSession`). */
57
+ tenantWide?: boolean;
58
+ /** Resolve SSE + format + log, but do not POST. */
59
+ dryRun?: boolean;
60
+ /** External abort (tests / SDK use). */
61
+ signal?: AbortSignal;
62
+ /** Test overrides. */
63
+ backoffInitialMs?: number;
64
+ idleTimeoutMs?: number;
65
+ /** Hook for tests — called AFTER the POST resolves (or in dry-run, after
66
+ * the formatted body is built). Lets tests inspect the wire payload
67
+ * without a real opencode server. */
68
+ onRelayed?: (payload: RelayPayload) => void | Promise<void>;
69
+ }
70
+ interface InboxReferences {
71
+ memory_ids?: string[];
72
+ artifacts?: Array<{
73
+ type: string;
74
+ id: string;
75
+ url?: string | null;
76
+ }>;
77
+ files?: Array<{
78
+ path: string;
79
+ lines?: string;
80
+ }>;
81
+ discord?: {
82
+ snowflake?: string | null;
83
+ [k: string]: unknown;
84
+ };
85
+ }
86
+ interface StreamMessage {
87
+ id: string;
88
+ from: {
89
+ tenant: string;
90
+ email: string | null;
91
+ project_id: string | null;
92
+ session_id?: string | null;
93
+ };
94
+ to_session_id?: string | null;
95
+ body: string;
96
+ references: InboxReferences | null;
97
+ message_type: string;
98
+ created_at: string;
99
+ }
100
+ /** What the relay POSTs at opencode — exposed for tests and for the dry-run
101
+ * log line. The wire shape is `{ parts: [{ type: 'text', text }] }` per the
102
+ * opencode server's `SessionPromptAsyncData` body. */
103
+ export interface RelayPayload {
104
+ prompt: string;
105
+ meta: {
106
+ messageId: string;
107
+ from: string;
108
+ toSession: string | null;
109
+ /** Receiving session's own greprag 8-hex (from truncateSessionId(session)).
110
+ * Surfaces in the framing as `from_session:` and the reply directive's
111
+ * `--from-session` so the model can self-address. Null when the opencode
112
+ * session UUID isn't hex-truncatable. */
113
+ ownSession8: string | null;
114
+ receivedAt: string;
115
+ };
116
+ }
117
+ /** Build the prompt body the receiving opencode session will read as a fresh
118
+ * user turn. Framed as a system notification so the session knows this is
119
+ * inbox traffic, not a fresh operator prompt — same intent as Claude Code's
120
+ * "[SYSTEM NOTIFICATION - NOT USER INPUT]" wrapper, just without the
121
+ * harness-specific surface. adr: adr/opencode-monitor-relay.md
122
+ *
123
+ * `ownSession8` is the receiving session's own greprag 8-hex (derived from
124
+ * the opencode session UUID). The framing surfaces it as `from_session:`
125
+ * so the model can self-address on reply via
126
+ * `greprag send --to <handle>/<8hex> ... --from-session <ownSession8>`. */
127
+ export declare function formatPrompt(msg: StreamMessage, ownSession8: string | null): string;
128
+ /** Run the relay loop. Returns when opts.signal aborts or a non-recoverable
129
+ * 4xx is hit. Production invocation wraps this in a `while true` shell loop
130
+ * to survive process-level death (host crash, OS kill, fork failure). */
131
+ export declare function runOpencodeRelay(opts: RelayOptions): Promise<void>;
132
+ /** Build the production wrapper command — the same `while true` shape the
133
+ * Claude Code arm directive uses, so a relay restart-on-death can be
134
+ * copy-pasted into a chip's spawn payload or a CLAUDE.md. adr: adr/monitor-resilience.md */
135
+ export declare function armOpencodeRelayCommand(session: string, opencodeUrl: string): string;
136
+ export {};
@@ -0,0 +1,529 @@
1
+ "use strict";
2
+ /** greprag opencode relay — bridge greprag inbox SSE into a live opencode
3
+ * desktop session. Desktop-only by design.
4
+ *
5
+ * The Monitor-style "fire a turn into a live session from an external event"
6
+ * primitive that Claude Code ships natively. opencode is turn-based (no
7
+ * built-in Monitor tool), but its HTTP server exposes a session-direct
8
+ * injection endpoint: POST /session/{id}/prompt_async enqueues a fresh
9
+ * turn on the named session and returns 204 No Content — exactly the
10
+ * fire-and-forget shape SSE delivery wants. This daemon is the glue:
11
+ * subscribe to greprag's inbox stream, format each message as a system
12
+ * notification, POST it at the running opencode server. The receiving
13
+ * session renders the injected prompt into its conversation feed via the
14
+ * same SSE channel the engine uses — no separate toast surface needed.
15
+ *
16
+ * The earlier 3-POST TUI sequence (show-toast + append-prompt + submit-prompt)
17
+ * targeted opencode's TUI client control plane, which the desktop client
18
+ * does not subscribe to. As of 2026-Q2 the TUI client is deprecated; the
19
+ * desktop app is the only supported surface. prompt_async is the
20
+ * client-agnostic primitive — same code path serves desktop, web, and any
21
+ * future client built on the same server.
22
+ *
23
+ * The default posture is session-targeted: the relay auto-derives the
24
+ * greprag 8-hex from the opencode session UUID (via `truncateSessionId`)
25
+ * and subscribes to only messages addressed to that session. Other
26
+ * sessions' traffic and broadcasts are excluded by default — use
27
+ * `--tenant-wide` to opt into the noisy debug mode.
28
+ *
29
+ * Resilience mirrors inbox-watch: same exponential backoff, same idle
30
+ * timeout, same cursor-preserving reconnect. The bash `while true` wrapper
31
+ * from armMonitorCommand is the right invocation in production — this
32
+ * process handles network-layer failures only. adr: adr/opencode-monitor-relay.md
33
+ */
34
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
35
+ if (k2 === undefined) k2 = k;
36
+ var desc = Object.getOwnPropertyDescriptor(m, k);
37
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
38
+ desc = { enumerable: true, get: function() { return m[k]; } };
39
+ }
40
+ Object.defineProperty(o, k2, desc);
41
+ }) : (function(o, m, k, k2) {
42
+ if (k2 === undefined) k2 = k;
43
+ o[k2] = m[k];
44
+ }));
45
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
46
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
47
+ }) : function(o, v) {
48
+ o["default"] = v;
49
+ });
50
+ var __importStar = (this && this.__importStar) || (function () {
51
+ var ownKeys = function(o) {
52
+ ownKeys = Object.getOwnPropertyNames || function (o) {
53
+ var ar = [];
54
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
55
+ return ar;
56
+ };
57
+ return ownKeys(o);
58
+ };
59
+ return function (mod) {
60
+ if (mod && mod.__esModule) return mod;
61
+ var result = {};
62
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
63
+ __setModuleDefault(result, mod);
64
+ return result;
65
+ };
66
+ })();
67
+ Object.defineProperty(exports, "__esModule", { value: true });
68
+ exports.formatPrompt = formatPrompt;
69
+ exports.runOpencodeRelay = runOpencodeRelay;
70
+ exports.armOpencodeRelayCommand = armOpencodeRelayCommand;
71
+ const fs = __importStar(require("fs"));
72
+ const path = __importStar(require("path"));
73
+ const inbox_watch_1 = require("./inbox-watch");
74
+ const session_id_1 = require("../session-id");
75
+ const API_URL_DEFAULT = 'https://api.greprag.com';
76
+ const OPENCODE_URL_DEFAULT = 'http://127.0.0.1:4096';
77
+ const BACKOFF_INITIAL_MS = 1_000;
78
+ const BACKOFF_MAX_MS = 30_000;
79
+ const BACKOFF_FACTOR = 2;
80
+ const IDLE_TIMEOUT_MS = 60_000;
81
+ const IDLE_CHECK_INTERVAL_MS = 5_000;
82
+ const LOG_PREFIX = '[greprag opencode relay]';
83
+ // -- Config (mirrors the loader in index.ts/inbox-watch.ts) -----------------
84
+ function loadEnvFile(filePath) {
85
+ try {
86
+ const content = fs.readFileSync(filePath, 'utf-8');
87
+ for (const line of content.split('\n')) {
88
+ const trimmed = line.trim();
89
+ if (!trimmed || trimmed.startsWith('#'))
90
+ continue;
91
+ const eqIdx = trimmed.indexOf('=');
92
+ if (eqIdx < 1)
93
+ continue;
94
+ const key = trimmed.slice(0, eqIdx).trim();
95
+ let value = trimmed.slice(eqIdx + 1).trim();
96
+ if ((value.startsWith('"') && value.endsWith('"')) ||
97
+ (value.startsWith("'") && value.endsWith("'"))) {
98
+ value = value.slice(1, -1);
99
+ }
100
+ if (!process.env[key])
101
+ process.env[key] = value;
102
+ }
103
+ }
104
+ catch { /* missing file — fine */ }
105
+ }
106
+ function getConfig() {
107
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
108
+ if (homeDir)
109
+ loadEnvFile(path.join(homeDir, '.greprag', '.env'));
110
+ if (!process.env.GREPRAG_API_KEY)
111
+ loadEnvFile(path.join(process.cwd(), '.env'));
112
+ return {
113
+ apiUrl: process.env.GREPRAG_API_URL || API_URL_DEFAULT,
114
+ apiKey: process.env.GREPRAG_API_KEY || '',
115
+ };
116
+ }
117
+ function fmtDuration(ms) {
118
+ if (ms < 1000)
119
+ return `${ms}ms`;
120
+ return `${Math.round(ms / 1000)}s`;
121
+ }
122
+ // -- Payload formatting -----------------------------------------------------
123
+ /** Build the prompt body the receiving opencode session will read as a fresh
124
+ * user turn. Framed as a system notification so the session knows this is
125
+ * inbox traffic, not a fresh operator prompt — same intent as Claude Code's
126
+ * "[SYSTEM NOTIFICATION - NOT USER INPUT]" wrapper, just without the
127
+ * harness-specific surface. adr: adr/opencode-monitor-relay.md
128
+ *
129
+ * `ownSession8` is the receiving session's own greprag 8-hex (derived from
130
+ * the opencode session UUID). The framing surfaces it as `from_session:`
131
+ * so the model can self-address on reply via
132
+ * `greprag send --to <handle>/<8hex> ... --from-session <ownSession8>`. */
133
+ function formatPrompt(msg, ownSession8) {
134
+ const sender = msg.from.email || msg.from.tenant + '@greprag.com';
135
+ const fromShort = (0, session_id_1.truncateSessionId)(msg.from.session_id ?? null);
136
+ const toShort = (0, session_id_1.truncateSessionId)(msg.to_session_id ?? null);
137
+ const ts = new Date(msg.created_at).toISOString();
138
+ const lines = [];
139
+ lines.push('[greprag inbox notification — not a user prompt; treat as an async inbound message]');
140
+ lines.push(`from: ${sender}${fromShort ? ` (session ${fromShort})` : ''}`);
141
+ if (toShort)
142
+ lines.push(`to_session: ${toShort}`);
143
+ if (ownSession8)
144
+ lines.push(`from_session: ${ownSession8}`);
145
+ lines.push(`at: ${ts}`);
146
+ lines.push(`message_id: ${msg.id}`);
147
+ lines.push(`type: ${msg.message_type}`);
148
+ lines.push('');
149
+ lines.push(msg.body);
150
+ const refs = msg.references;
151
+ if (refs && (refs.memory_ids?.length || refs.artifacts?.length || refs.files?.length)) {
152
+ lines.push('');
153
+ lines.push('references:');
154
+ if (refs.memory_ids?.length) {
155
+ lines.push(` memory: ${refs.memory_ids.map(id => id.slice(0, 8)).join(', ')}`);
156
+ }
157
+ for (const a of refs.artifacts || []) {
158
+ const url = a.url ? ` ${a.url}` : '';
159
+ lines.push(` ${a.type}: ${a.id}${url}`);
160
+ }
161
+ for (const f of refs.files || []) {
162
+ const linesSpec = f.lines ? `:${f.lines}` : '';
163
+ lines.push(` file: ${f.path}${linesSpec}`);
164
+ }
165
+ }
166
+ const selfId = ownSession8 ?? '<this-session-8hex>';
167
+ if (msg.message_type === 'discord_dm') {
168
+ const snowflake = msg.references?.discord?.snowflake;
169
+ if (snowflake) {
170
+ lines.push('');
171
+ lines.push(`reply: greprag send --to discord:${snowflake} "your reply" --from-session ${selfId}`);
172
+ }
173
+ }
174
+ else {
175
+ const replyTarget = msg.from.session_id
176
+ ? `${sender}/${msg.from.session_id}`
177
+ : sender;
178
+ lines.push('');
179
+ lines.push(`reply: greprag send --to ${replyTarget} "your reply" --from-session ${selfId}`);
180
+ }
181
+ return lines.join('\n');
182
+ }
183
+ /** Build the wire payload for one inbox message. The receiving session
184
+ * reads `prompt` as a fresh user turn. Meta is for tests + the dry-run log.
185
+ * `ownSession8` is the receiving session's 8-hex (the relay's filter id,
186
+ * computed once at startup from `session`) — surfaces in the framing as
187
+ * `from_session:` so the receiving model can self-address on reply. */
188
+ function buildPayload(msg, ownSession8) {
189
+ return {
190
+ prompt: formatPrompt(msg, ownSession8),
191
+ meta: {
192
+ messageId: msg.id,
193
+ from: msg.from.email || msg.from.tenant + '@greprag.com',
194
+ toSession: (0, session_id_1.truncateSessionId)(msg.to_session_id ?? null),
195
+ ownSession8,
196
+ receivedAt: new Date().toISOString(),
197
+ },
198
+ };
199
+ }
200
+ // -- HTTP to opencode -------------------------------------------------------
201
+ /** Build the headers for an outbound POST to the opencode server. Honors
202
+ * OPENCODE_SERVER_USERNAME / OPENCODE_SERVER_PASSWORD (the same env vars
203
+ * the opencode desktop child processes set; matching them keeps the relay
204
+ * from being 401'd when running in the same environment as the desktop
205
+ * app). */
206
+ function opencodeHeaders() {
207
+ const headers = { 'Content-Type': 'application/json' };
208
+ const user = process.env.OPENCODE_SERVER_USERNAME;
209
+ const pass = process.env.OPENCODE_SERVER_PASSWORD;
210
+ if (user && pass) {
211
+ headers['Authorization'] = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
212
+ }
213
+ return headers;
214
+ }
215
+ /** POST {baseUrl}{pathSegment} with a JSON body. Returns the parsed JSON or
216
+ * throws. The opencode server's prompt_async endpoint returns 204 No
217
+ * Content on success — we treat 2xx as success and don't trust the body
218
+ * shape strictly (opencode is pre-1.x, schema drifts). */
219
+ async function postToOpencode(baseUrl, pathSegment, body) {
220
+ const url = baseUrl.replace(/\/+$/, '') + pathSegment;
221
+ const res = await fetch(url, {
222
+ method: 'POST',
223
+ headers: opencodeHeaders(),
224
+ body: JSON.stringify(body),
225
+ });
226
+ if (!res.ok) {
227
+ const text = await res.text();
228
+ throw new Error(`opencode ${res.status} on ${pathSegment}: ${text.slice(0, 200)}`);
229
+ }
230
+ }
231
+ /** Deliver one inbox message to the opencode desktop session. Single POST to
232
+ * `/session/{id}/prompt_async` enqueues a fresh turn; the server returns
233
+ * 204 No Content and the model run continues asynchronously. That's exactly
234
+ * what we want: a "kick and forget" that doesn't block the SSE loop on
235
+ * model latency. */
236
+ async function relayOne(baseUrl, sessionId, payload) {
237
+ await postToOpencode(baseUrl, `/session/${sessionId}/prompt_async`, { parts: [{ type: 'text', text: payload.prompt }] });
238
+ }
239
+ // -- SSE read loop (mirrors inbox-watch) -----------------------------------
240
+ class StreamInterrupted extends Error {
241
+ constructor(message, lastId) {
242
+ super(message);
243
+ this.name = 'StreamInterrupted';
244
+ this.lastId = lastId;
245
+ }
246
+ }
247
+ async function readStream(deps) {
248
+ const inner = new AbortController();
249
+ const onOuter = () => inner.abort();
250
+ if (deps.signal.aborted)
251
+ inner.abort();
252
+ else
253
+ deps.signal.addEventListener('abort', onOuter, { once: true });
254
+ let idleAborted = false;
255
+ let lastByteAt = Date.now();
256
+ const idleTimer = setInterval(() => {
257
+ if (Date.now() - lastByteAt > deps.idleTimeoutMs) {
258
+ idleAborted = true;
259
+ inner.abort();
260
+ }
261
+ }, IDLE_CHECK_INTERVAL_MS);
262
+ try {
263
+ const url = buildStreamUrl(deps.apiUrl, deps.project, deps.inboxSession, deps.initialId);
264
+ const res = await fetch(url, {
265
+ headers: { 'Authorization': `Bearer ${deps.apiKey}`, 'Accept': 'text/event-stream' },
266
+ signal: inner.signal,
267
+ });
268
+ if (!res.ok) {
269
+ const text = await res.text();
270
+ throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
271
+ }
272
+ if (!res.body)
273
+ throw new Error('No response body — server did not stream');
274
+ let lastId = deps.initialId;
275
+ const reader = res.body.getReader();
276
+ const decoder = new TextDecoder();
277
+ let buffer = '';
278
+ try {
279
+ while (true) {
280
+ let chunk;
281
+ try {
282
+ chunk = await reader.read();
283
+ }
284
+ catch (err) {
285
+ if (idleAborted) {
286
+ throw new StreamInterrupted(`idle ${fmtDuration(deps.idleTimeoutMs)}, no heartbeat`, lastId);
287
+ }
288
+ throw new StreamInterrupted(err.message || String(err), lastId);
289
+ }
290
+ const { done, value } = chunk;
291
+ if (done)
292
+ return lastId;
293
+ lastByteAt = Date.now();
294
+ buffer += decoder.decode(value, { stream: true });
295
+ buffer = buffer.replace(/\r\n/g, '\n');
296
+ let sep;
297
+ while ((sep = buffer.indexOf('\n\n')) >= 0) {
298
+ const block = buffer.slice(0, sep);
299
+ buffer = buffer.slice(sep + 2);
300
+ const ev = (0, inbox_watch_1.parseEventBlock)(block);
301
+ if (!ev)
302
+ continue;
303
+ if (ev.id)
304
+ lastId = ev.id;
305
+ if (ev.event === 'message') {
306
+ let msg;
307
+ try {
308
+ msg = JSON.parse(ev.data);
309
+ }
310
+ catch {
311
+ continue;
312
+ }
313
+ deps.print(msg);
314
+ await deps.relay(msg);
315
+ }
316
+ else if (ev.event === 'replay-complete') {
317
+ try {
318
+ const data = JSON.parse(ev.data || '{}');
319
+ if (typeof data.count === 'number' && data.count > 0) {
320
+ const plural = data.count === 1 ? '' : 's';
321
+ console.error(`${LOG_PREFIX} replayed ${data.count} missed row${plural}, resuming live tail`);
322
+ }
323
+ }
324
+ catch { /* malformed control payload — ignore */ }
325
+ }
326
+ else if (ev.event === 'error') {
327
+ console.error(`${LOG_PREFIX} server error: ${ev.data}`);
328
+ return lastId;
329
+ }
330
+ }
331
+ }
332
+ }
333
+ finally {
334
+ try {
335
+ reader.releaseLock();
336
+ }
337
+ catch { /* already released */ }
338
+ }
339
+ }
340
+ finally {
341
+ clearInterval(idleTimer);
342
+ deps.signal.removeEventListener('abort', onOuter);
343
+ }
344
+ }
345
+ function buildStreamUrl(apiUrl, project, session, since) {
346
+ const u = new URL(apiUrl.replace(/\/+$/, '') + '/v1/inbox/stream');
347
+ if (project)
348
+ u.searchParams.set('project', project);
349
+ if (session)
350
+ u.searchParams.set('session', session);
351
+ if (since)
352
+ u.searchParams.set('since', since);
353
+ return u.toString();
354
+ }
355
+ function sleep(ms, signal) {
356
+ return new Promise((resolve) => {
357
+ const t = setTimeout(resolve, ms);
358
+ const onAbort = () => { clearTimeout(t); resolve(); };
359
+ if (signal.aborted) {
360
+ onAbort();
361
+ return;
362
+ }
363
+ signal.addEventListener('abort', onAbort, { once: true });
364
+ });
365
+ }
366
+ // -- Public entry -----------------------------------------------------------
367
+ /** Compute the effective greprag 8-hex inbox-session filter for the relay.
368
+ * Tenant-wide overrides everything. Explicit `inboxSession` wins over the
369
+ * auto-derived value (which is `truncateSessionId(session)`). The auto-derive
370
+ * is the whole point of the per-session posture: the opencode session UUID
371
+ * truncates to a stable 8-hex that the greprag API auto-registers on every
372
+ * memory turn (via session_titles + InboxDO session-activity heartbeat),
373
+ * so the relay can subscribe to "messages for me" without the user having
374
+ * to know their 8-hex.
375
+ *
376
+ * Returns `null` when no filter should be applied (i.e. tenant-wide). */
377
+ function resolveInboxSession(opts) {
378
+ if (opts.tenantWide)
379
+ return null;
380
+ if (opts.inboxSession)
381
+ return opts.inboxSession;
382
+ return (0, session_id_1.truncateSessionId)(opts.session);
383
+ }
384
+ /** Run the relay loop. Returns when opts.signal aborts or a non-recoverable
385
+ * 4xx is hit. Production invocation wraps this in a `while true` shell loop
386
+ * to survive process-level death (host crash, OS kill, fork failure). */
387
+ async function runOpencodeRelay(opts) {
388
+ if (!opts.session) {
389
+ console.error(`${LOG_PREFIX} --session <opencode-session-id> is required.`);
390
+ console.error(` Get it from \`opencode session list\` or the opencode UI session header.`);
391
+ process.exit(1);
392
+ }
393
+ const cfg = getConfig();
394
+ if (!cfg.apiKey) {
395
+ console.error(`${LOG_PREFIX} Not configured. Run: greprag init`);
396
+ process.exit(1);
397
+ }
398
+ const opencodeUrl = opts.opencodeUrl || OPENCODE_URL_DEFAULT;
399
+ const ownSession8 = (0, session_id_1.truncateSessionId)(opts.session);
400
+ // Refuse to start tenant-wide by accident. The whole point of the
401
+ // per-session posture is that one opencode session only sees its own
402
+ // messages; running unfiltered would leak cross-session traffic into
403
+ // the wrong conversations. User must either pass --inbox-session
404
+ // explicitly, --tenant-wide to acknowledge the noisy mode, or use an
405
+ // opencode session id that's hex-truncatable.
406
+ if (!opts.tenantWide && !opts.inboxSession && !ownSession8) {
407
+ console.error(`${LOG_PREFIX} Cannot derive greprag 8-hex from opencode session id "${opts.session}".`);
408
+ console.error(` The default posture is session-targeted — pass --inbox-session <8hex> explicitly,`);
409
+ console.error(` or --tenant-wide to see every message in the tenant.`);
410
+ process.exit(1);
411
+ }
412
+ const effectiveInboxSession = resolveInboxSession(opts);
413
+ console.error(`${LOG_PREFIX} relaying inbox → opencode desktop`);
414
+ console.error(` opencode_url: ${opencodeUrl}`);
415
+ console.error(` session: ${opts.session}`);
416
+ console.error(` inbox_filter: ${opts.tenantWide ? '(tenant-wide)' : (effectiveInboxSession ?? '(unable to derive — pass --inbox-session)')}`);
417
+ console.error(` project: ${opts.project || '(tenant-wide)'}`);
418
+ console.error(` dry_run: ${opts.dryRun ? 'yes' : 'no'}`);
419
+ console.error(` print: ${opts.print ? 'yes' : 'no'}`);
420
+ let cursor = opts.since ?? null;
421
+ const initialBackoff = opts.backoffInitialMs ?? BACKOFF_INITIAL_MS;
422
+ const idleTimeoutMs = opts.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
423
+ let backoff = initialBackoff;
424
+ let isFirstAttempt = true;
425
+ const controller = new AbortController();
426
+ const onSignal = () => { controller.abort(); };
427
+ process.on('SIGINT', onSignal);
428
+ process.on('SIGTERM', onSignal);
429
+ if (opts.signal) {
430
+ if (opts.signal.aborted)
431
+ controller.abort();
432
+ else
433
+ opts.signal.addEventListener('abort', onSignal, { once: true });
434
+ }
435
+ const relay = async (msg) => {
436
+ const payload = buildPayload(msg, ownSession8);
437
+ if (opts.onRelayed)
438
+ await opts.onRelayed(payload);
439
+ if (opts.dryRun) {
440
+ console.error(`${LOG_PREFIX} [dry-run] would POST prompt_async for message ${payload.meta.messageId.slice(0, 8)}`);
441
+ return;
442
+ }
443
+ try {
444
+ await relayOne(opencodeUrl, opts.session, payload);
445
+ console.error(`${LOG_PREFIX} delivered message ${payload.meta.messageId.slice(0, 8)} → session ${opts.session}`);
446
+ }
447
+ catch (err) {
448
+ // Don't drop the message — re-throw so the outer loop's reconnect logic
449
+ // takes over (with cursor preserved). The bash wrapper respawns on
450
+ // process death, so a transient opencode outage recovers cleanly.
451
+ throw err;
452
+ }
453
+ };
454
+ // Zombie guard: if the opencode server has been gone for too long, exit
455
+ // non-zero so the bash wrapper / opencode-plugin respawner can give up
456
+ // instead of looping forever against a dead endpoint. The counter resets
457
+ // on every successful delivery, so a healthy run never trips it; only a
458
+ // sustained outage (5 deliveries in a row that all 5xx) terminates us.
459
+ const MAX_CONSECUTIVE_DELIVERY_FAILURES = 5;
460
+ let consecutiveDeliveryFailures = 0;
461
+ const relayWithFailureGate = async (msg) => {
462
+ try {
463
+ await relay(msg);
464
+ consecutiveDeliveryFailures = 0;
465
+ }
466
+ catch (err) {
467
+ consecutiveDeliveryFailures++;
468
+ if (consecutiveDeliveryFailures >= MAX_CONSECUTIVE_DELIVERY_FAILURES) {
469
+ console.error(`${LOG_PREFIX} ${consecutiveDeliveryFailures} consecutive delivery failures — ` +
470
+ `opencode server appears down, exiting for respawn.`);
471
+ process.exit(1);
472
+ }
473
+ throw err;
474
+ }
475
+ };
476
+ const print = (msg) => {
477
+ if (!opts.print)
478
+ return;
479
+ const sender = msg.from.email || msg.from.tenant + '@greprag.com';
480
+ console.log(`[${sender}] ${msg.body}`);
481
+ };
482
+ while (!controller.signal.aborted) {
483
+ if (!isFirstAttempt) {
484
+ const lastSeen = cursor ? cursor.slice(0, 8) : 'none';
485
+ console.error(`${LOG_PREFIX} reconnecting (last_seen_id=${lastSeen})`);
486
+ }
487
+ isFirstAttempt = false;
488
+ try {
489
+ const lastId = await readStream({
490
+ apiUrl: cfg.apiUrl, apiKey: cfg.apiKey, signal: controller.signal,
491
+ initialId: cursor, idleTimeoutMs,
492
+ project: opts.project, inboxSession: effectiveInboxSession ?? undefined,
493
+ relay: relayWithFailureGate, print,
494
+ });
495
+ if (lastId)
496
+ cursor = lastId;
497
+ backoff = initialBackoff;
498
+ }
499
+ catch (err) {
500
+ if (controller.signal.aborted)
501
+ break;
502
+ if (err instanceof StreamInterrupted && err.lastId)
503
+ cursor = err.lastId;
504
+ const msg = err.message || String(err);
505
+ const m = /^HTTP (4\d\d)/.exec(msg);
506
+ if (m) {
507
+ console.error(`${LOG_PREFIX} ${msg}`);
508
+ process.exit(1);
509
+ }
510
+ const lastSeen = cursor ? cursor.slice(0, 8) : 'none';
511
+ console.error(`${LOG_PREFIX} disconnect: ${msg} — retrying in ${fmtDuration(backoff)} (last_seen_id=${lastSeen})`);
512
+ }
513
+ if (controller.signal.aborted)
514
+ break;
515
+ await sleep(backoff, controller.signal);
516
+ backoff = Math.min(backoff * BACKOFF_FACTOR, BACKOFF_MAX_MS);
517
+ }
518
+ }
519
+ /** Build the production wrapper command — the same `while true` shape the
520
+ * Claude Code arm directive uses, so a relay restart-on-death can be
521
+ * copy-pasted into a chip's spawn payload or a CLAUDE.md. adr: adr/monitor-resilience.md */
522
+ function armOpencodeRelayCommand(session, opencodeUrl) {
523
+ const urlFlag = opencodeUrl && opencodeUrl !== OPENCODE_URL_DEFAULT
524
+ ? ` --opencode-url "${opencodeUrl}"`
525
+ : '';
526
+ return `while true; do greprag opencode relay --session ${session}${urlFlag}; ` +
527
+ `echo "[restart]" >&2; sleep 1; done`;
528
+ }
529
+ //# sourceMappingURL=opencode-relay.js.map