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.
- package/dist/codex-hook-events.d.ts +20 -0
- package/dist/codex-hook-events.js +156 -0
- package/dist/codex-hook-events.js.map +1 -0
- package/dist/commands/codex-app-server.d.ts +1 -0
- package/dist/commands/codex-app-server.js +179 -0
- package/dist/commands/codex-app-server.js.map +1 -0
- package/dist/commands/codex-doctor.js +3 -1
- package/dist/commands/codex-doctor.js.map +1 -1
- package/dist/commands/codex.js +6 -0
- package/dist/commands/codex.js.map +1 -1
- package/dist/commands/corpus/index.d.ts +1 -0
- package/dist/commands/corpus/index.js +5 -0
- package/dist/commands/corpus/index.js.map +1 -1
- package/dist/commands/corpus/refresh.d.ts +1 -0
- package/dist/commands/corpus/refresh.js +60 -0
- package/dist/commands/corpus/refresh.js.map +1 -1
- package/dist/commands/desk-line.d.ts +36 -0
- package/dist/commands/desk-line.js +248 -0
- package/dist/commands/desk-line.js.map +1 -0
- package/dist/commands/inbox-spool.d.ts +27 -0
- package/dist/commands/inbox-spool.js +151 -0
- package/dist/commands/inbox-spool.js.map +1 -0
- package/dist/commands/inbox-watch.js +59 -8
- package/dist/commands/inbox-watch.js.map +1 -1
- package/dist/commands/init.js +75 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/opencode-relay.d.ts +136 -0
- package/dist/commands/opencode-relay.js +529 -0
- package/dist/commands/opencode-relay.js.map +1 -0
- package/dist/commands/opencode-watch.d.ts +17 -0
- package/dist/commands/opencode-watch.js +493 -0
- package/dist/commands/opencode-watch.js.map +1 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +5 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/watcher-registry.d.ts +8 -0
- package/dist/commands/watcher-registry.js +19 -0
- package/dist/commands/watcher-registry.js.map +1 -1
- package/dist/hook.js +54 -83
- package/dist/hook.js.map +1 -1
- package/dist/index.js +220 -1
- package/dist/index.js.map +1 -1
- package/dist/opencode-plugin-helpers.d.ts +200 -0
- package/dist/opencode-plugin-helpers.js +512 -0
- package/dist/opencode-plugin-helpers.js.map +1 -0
- package/dist/opencode-plugin.d.ts +37 -134
- package/dist/opencode-plugin.js +648 -364
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/session-id.d.ts +8 -6
- package/dist/session-id.js +10 -9
- package/dist/session-id.js.map +1 -1
- 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
|