@sksoftofficial/ocduet 0.2.1
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 +1 -0
- package/bin/ocduet.js +61 -0
- package/package.json +33 -0
- package/src/commands/install.js +222 -0
- package/src/commands/relay.js +151 -0
- package/src/commands/service.js +164 -0
- package/src/commands/token.js +49 -0
- package/src/commands/uninstall.js +86 -0
- package/src/daemon.js +747 -0
- package/src/e2ee.js +141 -0
- package/src/paths.js +137 -0
- package/src/plugin/ocduet-server.js +609 -0
- package/src/plugin/ocduet-sidebar.jsx +309 -0
- package/src/plugin/web/app.css +290 -0
- package/src/plugin/web/app.js +1716 -0
- package/src/plugin/web/index.html +70 -0
- package/src/plugin/web/pair.html +131 -0
- package/src/qrcodegen.js +741 -0
- package/src/qrterm.js +5 -0
- package/src/relay-client.js +172 -0
- package/src/relayer.js +337 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
// ocduet attach-stub (opencode server plugin).
|
|
2
|
+
// Runs inside each opencode core process. Its only job: make sure the ocduet
|
|
3
|
+
// daemon is running (spawn it if not), attach to it over an internal socket,
|
|
4
|
+
// forward every bus event, and execute RPCs from phones via the SDK client.
|
|
5
|
+
// All the churn-heavy code (web UI, WS server, token QR) lives in the daemon,
|
|
6
|
+
// so `ocduet restart` updates the bridge WITHOUT restarting opencode.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
|
|
12
|
+
const XDG_DATA = process.env.XDG_DATA_HOME && path.isAbsolute(process.env.XDG_DATA_HOME)
|
|
13
|
+
? process.env.XDG_DATA_HOME
|
|
14
|
+
: path.join(os.homedir(), ".local", "share");
|
|
15
|
+
// Filled by the installer so different opencode XDG profiles share one bridge.
|
|
16
|
+
const INSTALLED_DATA_DIR = null;
|
|
17
|
+
const DATA_DIR = INSTALLED_DATA_DIR || process.env.OCDUET_DATA_DIR || path.join(XDG_DATA, "ocduet");
|
|
18
|
+
const TOKEN_PATH = path.join(DATA_DIR, "token");
|
|
19
|
+
const STATE_PATH = path.join(DATA_DIR, "state.json");
|
|
20
|
+
const DAEMON_POINTER = path.join(DATA_DIR, "daemon.json");
|
|
21
|
+
const SPAWN_COOLDOWN_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
function slug(directory) {
|
|
24
|
+
const base = path.basename(String(directory || "").replace(/\/+$/, "")) || "project";
|
|
25
|
+
const safe = base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "project";
|
|
26
|
+
const hash = crypto.createHash("sha256").update(String(directory || "")).digest("hex").slice(0, 8);
|
|
27
|
+
return `${safe}-${hash}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readToken() {
|
|
31
|
+
try {
|
|
32
|
+
return fs.readFileSync(TOKEN_PATH, "utf8").trim() || null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function daemonPort() {
|
|
39
|
+
try {
|
|
40
|
+
const s = JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
|
|
41
|
+
// localPort = loopback http server (stubs/CLI); port = the https phone port
|
|
42
|
+
if (s && typeof s.localPort === "number" && s.localPort > 0) return s.localPort;
|
|
43
|
+
if (s && typeof s.port === "number" && s.port > 0) return s.port;
|
|
44
|
+
} catch {}
|
|
45
|
+
return parseInt(process.env.OCDUET_PORT || "4098", 10);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function daemonHealthy(port) {
|
|
49
|
+
return fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(1500) })
|
|
50
|
+
.then(async (r) => {
|
|
51
|
+
if (!r.ok) return false;
|
|
52
|
+
// only trust an answer that identifies as the daemon — old in-process
|
|
53
|
+
// bridges also answer /status with 200 on these ports
|
|
54
|
+
try {
|
|
55
|
+
const data = await r.json();
|
|
56
|
+
return data?.daemon === true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
.catch(() => false);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function spawnDaemon() {
|
|
65
|
+
let daemonPath = null;
|
|
66
|
+
try {
|
|
67
|
+
daemonPath = JSON.parse(fs.readFileSync(DAEMON_POINTER, "utf8")).daemonPath;
|
|
68
|
+
} catch {}
|
|
69
|
+
if (!daemonPath || !fs.existsSync(daemonPath)) return false;
|
|
70
|
+
try {
|
|
71
|
+
const logFd = fs.openSync(path.join(DATA_DIR, "daemon.log"), "a", 0o600);
|
|
72
|
+
const child = Bun.spawn({
|
|
73
|
+
cmd: ["node", daemonPath],
|
|
74
|
+
env: { ...process.env, OCDUET_DATA_DIR: DATA_DIR },
|
|
75
|
+
detached: true,
|
|
76
|
+
stdin: "ignore",
|
|
77
|
+
stdout: logFd,
|
|
78
|
+
stderr: logFd,
|
|
79
|
+
});
|
|
80
|
+
child.unref();
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function unwrap(result) {
|
|
88
|
+
if (result && typeof result === "object" && ("data" in result || "error" in result)) {
|
|
89
|
+
if (result.error) {
|
|
90
|
+
const e = result.error;
|
|
91
|
+
throw new Error(e.message || e.statusText || JSON.stringify(e));
|
|
92
|
+
}
|
|
93
|
+
return result.data;
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function userSessions(list) {
|
|
99
|
+
return (Array.isArray(list) ? list : []).filter((s) => s && !s.parentID);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function listAllSessions(client, ownDir) {
|
|
103
|
+
// the host client injects its own directory into every request (empty strings
|
|
104
|
+
// get replaced too), so there is no "all directories" query available.
|
|
105
|
+
// Instead: enumerate projects, then one EXPLICIT per-directory query each —
|
|
106
|
+
// explicit directories survive the injection and each per-directory list is
|
|
107
|
+
// more complete than the capped global one anyway.
|
|
108
|
+
const dirs = new Set();
|
|
109
|
+
if (ownDir) dirs.add(ownDir);
|
|
110
|
+
try {
|
|
111
|
+
const pr = await client._client.get({ url: "/project" });
|
|
112
|
+
for (const p of pr?.data || []) if (p?.worktree) dirs.add(p.worktree);
|
|
113
|
+
} catch {}
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
const merged = [];
|
|
116
|
+
const results = await Promise.allSettled(
|
|
117
|
+
[...dirs].map((d) => client._client.get({ url: "/session", query: { directory: d } })));
|
|
118
|
+
for (const r of results) {
|
|
119
|
+
for (const s of r?.value?.data || []) {
|
|
120
|
+
if (s?.id && !seen.has(s.id)) { seen.add(s.id); merged.push(s); }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return merged;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let sessionDirCache = { at: 0, map: new Map() };
|
|
127
|
+
async function sessionDirOf(client, id, fallback) {
|
|
128
|
+
if (Date.now() - sessionDirCache.at > 5000) {
|
|
129
|
+
try {
|
|
130
|
+
const list = await listAllSessions(client, fallback);
|
|
131
|
+
sessionDirCache = { at: Date.now(), map: new Map(list.filter((s) => s.directory).map((s) => [s.id, s.directory])) };
|
|
132
|
+
} catch {
|
|
133
|
+
sessionDirCache = { at: Date.now(), map: new Map() };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return sessionDirCache.map.get(String(id)) || fallback;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pendingPermissions = new Map();
|
|
140
|
+
const pendingQuestions = new Map();
|
|
141
|
+
let permissionLevel = "ask";
|
|
142
|
+
|
|
143
|
+
// ---- usage quota (ported from the TUI quota-sidebar plugin) ----
|
|
144
|
+
const QUOTA_ORDER = ["5h", "hourly", "weekly", "monthly", "mcp", "code review"];
|
|
145
|
+
const clampPct = (v) => Math.max(0, Math.min(100, v));
|
|
146
|
+
const quotaRank = (label) => { const i = QUOTA_ORDER.indexOf(label); return i < 0 ? 99 : i; };
|
|
147
|
+
|
|
148
|
+
async function quotaFetchJson(url, headers) {
|
|
149
|
+
const ctrl = new AbortController();
|
|
150
|
+
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
151
|
+
try {
|
|
152
|
+
const resp = await fetch(url, { headers, signal: ctrl.signal });
|
|
153
|
+
const text = await resp.text();
|
|
154
|
+
let json = null;
|
|
155
|
+
try { json = JSON.parse(text); } catch {}
|
|
156
|
+
return { ok: resp.ok, status: resp.status, json };
|
|
157
|
+
} finally { clearTimeout(timer); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseJwt(token) {
|
|
161
|
+
const parts = token.split(".");
|
|
162
|
+
if (parts.length !== 3) return null;
|
|
163
|
+
try {
|
|
164
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
165
|
+
const pad = (4 - (b64.length % 4)) % 4;
|
|
166
|
+
return JSON.parse(Buffer.from(b64 + "=".repeat(pad), "base64").toString("utf8"));
|
|
167
|
+
} catch { return null; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function quotaZai(entry) {
|
|
171
|
+
const key = typeof entry?.key === "string" ? entry.key.trim() : "";
|
|
172
|
+
if (!key) return { name: "Z.ai", ok: false, error: "no api key" };
|
|
173
|
+
try {
|
|
174
|
+
const r = await quotaFetchJson("https://api.z.ai/api/monitor/usage/quota/limit", {
|
|
175
|
+
Authorization: key, "User-Agent": "ocduet/1.0", "Content-Type": "application/json",
|
|
176
|
+
});
|
|
177
|
+
if (!r.ok) return { name: "Z.ai", ok: false, error: `HTTP ${r.status}` };
|
|
178
|
+
const limits = r.json?.data?.limits ?? r.json?.limits;
|
|
179
|
+
if (!Array.isArray(limits)) return { name: "Z.ai", ok: false, error: "no limits" };
|
|
180
|
+
const windows = [];
|
|
181
|
+
for (const limit of limits) {
|
|
182
|
+
const label = limit.type === "TIME_LIMIT" ? "mcp" : ({ 3: "5h", 6: "weekly" })[limit.unit];
|
|
183
|
+
if (!label) continue;
|
|
184
|
+
const resetMs = Number.isFinite(limit.nextResetTime) ? Math.round(limit.nextResetTime) : undefined;
|
|
185
|
+
windows.push({ label, remainingPct: Math.round(clampPct(100 - (limit.percentage ?? 0))), resetMs });
|
|
186
|
+
}
|
|
187
|
+
windows.sort((a, b) => quotaRank(a.label) - quotaRank(b.label));
|
|
188
|
+
return { name: "Z.ai", ok: true, windows };
|
|
189
|
+
} catch (err) { return { name: "Z.ai", ok: false, error: String(err?.message ?? err) }; }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function quotaOpenAI(entry) {
|
|
193
|
+
const access = typeof entry?.access === "string" ? entry.access.trim() : "";
|
|
194
|
+
if (!access) return { name: "OpenAI", ok: false, error: "no access token" };
|
|
195
|
+
const accountId = parseJwt(access)?.["https://api.openai.com/auth"]?.chatgpt_account_id ?? entry?.accountId;
|
|
196
|
+
const headers = { Authorization: `Bearer ${access}`, "User-Agent": "ocduet/1.0" };
|
|
197
|
+
if (accountId) headers["ChatGPT-Account-Id"] = String(accountId);
|
|
198
|
+
try {
|
|
199
|
+
const r = await quotaFetchJson("https://chatgpt.com/backend-api/wham/usage", headers);
|
|
200
|
+
if (!r.ok) return { name: "OpenAI", ok: false, error: `HTTP ${r.status}` };
|
|
201
|
+
const rl = r.json?.rate_limit ?? {};
|
|
202
|
+
const windows = [];
|
|
203
|
+
const add = (w, fallback) => {
|
|
204
|
+
if (!w || typeof w.used_percent !== "number") return;
|
|
205
|
+
let label = fallback;
|
|
206
|
+
if (w.limit_window_seconds === 5 * 60 * 60) label = "5h";
|
|
207
|
+
else if (w.limit_window_seconds === 7 * 24 * 60 * 60) label = "weekly";
|
|
208
|
+
else if (w.limit_window_seconds === 60 * 60) label = "hourly";
|
|
209
|
+
let resetMs;
|
|
210
|
+
if (typeof w.reset_at === "number" && w.reset_at > 0) resetMs = Math.round(w.reset_at * 1000);
|
|
211
|
+
else if (typeof w.reset_after_seconds === "number" && w.reset_after_seconds > 0) resetMs = Date.now() + Math.round(w.reset_after_seconds * 1000);
|
|
212
|
+
windows.push({ label, remainingPct: Math.round(clampPct(100 - w.used_percent)), resetMs });
|
|
213
|
+
};
|
|
214
|
+
add(rl.primary_window, "5h");
|
|
215
|
+
add(rl.secondary_window, "weekly");
|
|
216
|
+
add(r.json?.code_review_rate_limit?.primary_window, "code review");
|
|
217
|
+
windows.sort((a, b) => quotaRank(a.label) - quotaRank(b.label));
|
|
218
|
+
const resets = r.json?.rate_limit_reset_credits?.available_count;
|
|
219
|
+
return { name: "OpenAI", ok: true, windows, resetsAvailable: typeof resets === "number" ? resets : undefined, resetWindow: "weekly" };
|
|
220
|
+
} catch (err) { return { name: "OpenAI", ok: false, error: String(err?.message ?? err) }; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function quotaOpenCodeGo(entry) {
|
|
224
|
+
const key = typeof entry?.key === "string" ? entry.key.trim() : typeof entry?.apiKey === "string" ? entry.apiKey.trim() : "";
|
|
225
|
+
if (!key) return { name: "OpenCode Go", ok: false, error: "no api key" };
|
|
226
|
+
try {
|
|
227
|
+
const r = await quotaFetchJson("https://opencode.ai/zen/go/v1/usage", {
|
|
228
|
+
Authorization: `Bearer ${key}`, "User-Agent": "ocduet/1.0",
|
|
229
|
+
});
|
|
230
|
+
if (!r.ok) return { name: "OpenCode Go", ok: false, error: `HTTP ${r.status}` };
|
|
231
|
+
const usage = r.json?.usage ?? {};
|
|
232
|
+
const windows = [];
|
|
233
|
+
const add = (label, w) => {
|
|
234
|
+
if (!w || typeof w.percent !== "number") return;
|
|
235
|
+
let resetMs;
|
|
236
|
+
const at = w.resetsAt ?? w.resets_at;
|
|
237
|
+
if (typeof at === "string" && at.trim() && Number.isFinite(Date.parse(at))) resetMs = Date.parse(at);
|
|
238
|
+
else if (typeof at === "number" && at > 0) resetMs = Math.round(at > 1e12 ? at : at * 1000);
|
|
239
|
+
else if (typeof w.reset_after_seconds === "number" && w.reset_after_seconds > 0) resetMs = Date.now() + Math.round(w.reset_after_seconds * 1000);
|
|
240
|
+
windows.push({ label, remainingPct: Math.round(clampPct(100 - w.percent)), resetMs });
|
|
241
|
+
};
|
|
242
|
+
add("5h", usage.rolling);
|
|
243
|
+
add("weekly", usage.weekly);
|
|
244
|
+
add("monthly", usage.monthly);
|
|
245
|
+
if (!windows.length) return { name: "OpenCode Go", ok: false, error: "no usage windows" };
|
|
246
|
+
windows.sort((a, b) => quotaRank(a.label) - quotaRank(b.label));
|
|
247
|
+
return { name: "OpenCode Go", ok: true, windows };
|
|
248
|
+
} catch (err) { return { name: "OpenCode Go", ok: false, error: String(err?.message ?? err) }; }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function quotaLoad() {
|
|
252
|
+
let auth = null;
|
|
253
|
+
try { auth = JSON.parse(fs.readFileSync(path.join(XDG_DATA, "opencode", "auth.json"), "utf8")); } catch {}
|
|
254
|
+
if (!auth) return { ok: false, error: "auth.json not found" };
|
|
255
|
+
const tasks = [];
|
|
256
|
+
if (auth["zai-coding-plan"]) tasks.push(quotaZai(auth["zai-coding-plan"]));
|
|
257
|
+
if (auth.openai) tasks.push(quotaOpenAI(auth.openai));
|
|
258
|
+
if (auth["opencode-go"]) tasks.push(quotaOpenCodeGo(auth["opencode-go"]));
|
|
259
|
+
const providers = await Promise.all(tasks);
|
|
260
|
+
return { ok: true, providers, updatedAt: Date.now() };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// question.asked / permission.asked are NOT delivered to the plugin event
|
|
264
|
+
// hook for cross-directory runs (opencode 1.18.x), so the in-memory Maps
|
|
265
|
+
// miss them entirely and the phone would never see the card. The server
|
|
266
|
+
// exposes authoritative pending registries at GET /question and
|
|
267
|
+
// GET /permission — directory-scoped — so always merge a live query for
|
|
268
|
+
// the caller-supplied directory (the session's own) plus this instance's.
|
|
269
|
+
async function livePending(ctx, url, dirs) {
|
|
270
|
+
const out = [];
|
|
271
|
+
for (const d of dirs) {
|
|
272
|
+
try {
|
|
273
|
+
const res = await ctx.client._client.get({ url, query: { directory: d } });
|
|
274
|
+
const list = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
|
|
275
|
+
if (Array.isArray(list)) out.push(...list);
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const RPC_METHODS = {
|
|
282
|
+
"session.create": (ctx, args) =>
|
|
283
|
+
ctx.client.session.create({
|
|
284
|
+
query: { directory: ctx.directory },
|
|
285
|
+
body: { ...(args.title ? { title: String(args.title) } : {}) },
|
|
286
|
+
}),
|
|
287
|
+
"session.list": async (ctx, _args) => userSessions(await listAllSessions(ctx.client, ctx.directory)),
|
|
288
|
+
"session.messages": (ctx, args) =>
|
|
289
|
+
ctx.client.session.messages({
|
|
290
|
+
path: { id: String(args.id) },
|
|
291
|
+
query: { directory: ctx.directory, ...(args.limit ? { limit: args.limit } : {}) },
|
|
292
|
+
}),
|
|
293
|
+
"session.message": (ctx, args) =>
|
|
294
|
+
ctx.client.session.message({ path: { id: String(args.id), messageID: String(args.messageID) }, query: { directory: ctx.directory } }),
|
|
295
|
+
"session.prompt": (ctx, args) =>
|
|
296
|
+
ctx.client.session.prompt({
|
|
297
|
+
path: { id: String(args.id) },
|
|
298
|
+
query: { directory: ctx.directory },
|
|
299
|
+
body: {
|
|
300
|
+
...(args.agent ? { agent: String(args.agent) } : {}),
|
|
301
|
+
...(args.model?.providerID && args.model?.modelID
|
|
302
|
+
? { model: { providerID: String(args.model.providerID), modelID: String(args.model.modelID) } }
|
|
303
|
+
: {}),
|
|
304
|
+
...(args.variant && args.variant !== "default" ? { variant: String(args.variant) } : {}),
|
|
305
|
+
parts: [{ type: "text", text: String(args.text ?? "") }],
|
|
306
|
+
},
|
|
307
|
+
}),
|
|
308
|
+
"session.abort": (ctx, args) =>
|
|
309
|
+
ctx.client.session.abort({ path: { id: String(args.id) }, query: { directory: ctx.directory } }),
|
|
310
|
+
"session.delete": (ctx, args) =>
|
|
311
|
+
ctx.client.session.delete
|
|
312
|
+
? ctx.client.session.delete({ path: { id: String(args.id) }, query: { directory: ctx.directory } })
|
|
313
|
+
: ctx.client.session.remove({ path: { id: String(args.id) }, query: { directory: ctx.directory } }),
|
|
314
|
+
"session.rename": (ctx, args) =>
|
|
315
|
+
ctx.client.session.rename({ path: { id: String(args.id) }, query: { directory: ctx.directory }, body: { title: String(args.title ?? "") } }),
|
|
316
|
+
"quota.get": () => quotaLoad(),
|
|
317
|
+
"session.share": (ctx, args) => ctx.client.session.share({ path: { id: String(args.id) }, query: { directory: ctx.directory } }),
|
|
318
|
+
"session.compact": async (ctx, args) => {
|
|
319
|
+
const id = String(args.id);
|
|
320
|
+
let body;
|
|
321
|
+
try {
|
|
322
|
+
const list = unwrap(await ctx.client.session.list()) ?? [];
|
|
323
|
+
const m = list.find((s) => s.id === id)?.model;
|
|
324
|
+
if (m?.providerID && m?.id) body = { providerID: m.providerID, modelID: m.id };
|
|
325
|
+
} catch {}
|
|
326
|
+
if (!body) throw new Error("cannot resolve session model for compaction");
|
|
327
|
+
return ctx.client.session.summarize({ path: { id }, query: { directory: ctx.directory }, body });
|
|
328
|
+
},
|
|
329
|
+
"permission.list": async (ctx, args) => {
|
|
330
|
+
const sid = args && args.sessionID ? String(args.sessionID) : null;
|
|
331
|
+
const byId = new Map();
|
|
332
|
+
const seed = [];
|
|
333
|
+
if (sid) seed.push(...(pendingPermissions.get(sid)?.values() ?? []));
|
|
334
|
+
else for (const m of pendingPermissions.values()) seed.push(...m.values());
|
|
335
|
+
for (const p of seed) byId.set(String(p.permissionID ?? p.id), p);
|
|
336
|
+
const dirs = new Set([ctx.directory]);
|
|
337
|
+
if (args && args.directory) dirs.add(String(args.directory));
|
|
338
|
+
for (const p of await livePending(ctx, "/permission", dirs)) {
|
|
339
|
+
const id = p?.id ?? p?.permissionID ?? p?.requestID;
|
|
340
|
+
if (!id || !p?.sessionID) continue;
|
|
341
|
+
if (sid && String(p.sessionID) !== sid) continue;
|
|
342
|
+
byId.set(String(id), p);
|
|
343
|
+
}
|
|
344
|
+
return [...byId.values()];
|
|
345
|
+
},
|
|
346
|
+
"permission.respond": (ctx, args) =>
|
|
347
|
+
ctx.client.postSessionIdPermissionsPermissionId({
|
|
348
|
+
path: { id: String(args.sessionID), permissionID: String(args.permissionID) },
|
|
349
|
+
query: { directory: ctx.directory },
|
|
350
|
+
body: { response: ["once", "always", "reject"].includes(args.response) ? args.response : "once" },
|
|
351
|
+
}),
|
|
352
|
+
"permission.level": (_ctx, args) => {
|
|
353
|
+
if (args && ["ask", "auto"].includes(args.level)) permissionLevel = args.level;
|
|
354
|
+
return permissionLevel;
|
|
355
|
+
},
|
|
356
|
+
"question.list": async (ctx, args) => {
|
|
357
|
+
const sid = args && args.sessionID ? String(args.sessionID) : null;
|
|
358
|
+
const byId = new Map();
|
|
359
|
+
const seed = sid
|
|
360
|
+
? [...(pendingQuestions.values() ?? [])].filter((q) => q.sessionID === sid)
|
|
361
|
+
: [...(pendingQuestions.values() ?? [])];
|
|
362
|
+
for (const q of seed) byId.set(String(q.id), q);
|
|
363
|
+
const dirs = new Set([ctx.directory]);
|
|
364
|
+
if (args && args.directory) dirs.add(String(args.directory));
|
|
365
|
+
for (const q of await livePending(ctx, "/question", dirs)) {
|
|
366
|
+
const id = q?.id ?? q?.requestID;
|
|
367
|
+
if (!id || !q?.sessionID) continue;
|
|
368
|
+
if (sid && String(q.sessionID) !== sid) continue;
|
|
369
|
+
if (!Array.isArray(q.questions)) continue;
|
|
370
|
+
byId.set(String(id), {
|
|
371
|
+
id: String(id),
|
|
372
|
+
sessionID: String(q.sessionID),
|
|
373
|
+
questions: q.questions,
|
|
374
|
+
tool: q.tool ?? null,
|
|
375
|
+
time: q.time ?? Date.now(),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return [...byId.values()];
|
|
379
|
+
},
|
|
380
|
+
"question.respond": async (ctx, args) => {
|
|
381
|
+
const answers = (Array.isArray(args.answers) ? args.answers : []).map((a) => (Array.isArray(a) ? a.map(String) : [String(a)]));
|
|
382
|
+
return unwrap(await ctx.client._client.post({
|
|
383
|
+
url: `/question/${encodeURIComponent(String(args.requestID))}/reply`,
|
|
384
|
+
query: { directory: ctx.directory },
|
|
385
|
+
body: { answers },
|
|
386
|
+
headers: { "Content-Type": "application/json" },
|
|
387
|
+
}));
|
|
388
|
+
},
|
|
389
|
+
"question.reject": async (ctx, args) =>
|
|
390
|
+
unwrap(await ctx.client._client.post({
|
|
391
|
+
url: `/question/${encodeURIComponent(String(args.requestID))}/reject`,
|
|
392
|
+
query: { directory: ctx.directory },
|
|
393
|
+
})),
|
|
394
|
+
"provider.list": async (ctx, _args) => {
|
|
395
|
+
const result = unwrap(await ctx.client.provider.list({ query: { directory: ctx.directory } }));
|
|
396
|
+
const connected = new Set(result?.connected || []);
|
|
397
|
+
const out = [];
|
|
398
|
+
for (const p of result?.all || []) {
|
|
399
|
+
if (!connected.has(p.id)) continue;
|
|
400
|
+
const models = (Array.isArray(p.models) ? p.models : Object.values(p.models || {}))
|
|
401
|
+
.filter((m) => m && m.id)
|
|
402
|
+
.map((m) => ({
|
|
403
|
+
id: m.id,
|
|
404
|
+
name: m.name || m.id,
|
|
405
|
+
reasoning: !!m.capabilities?.reasoning,
|
|
406
|
+
variants: Object.keys(m.variants || {}),
|
|
407
|
+
context: m.limit?.context,
|
|
408
|
+
}));
|
|
409
|
+
out.push({ id: p.id, name: p.name || p.id, models });
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
function trackPermission(event) {
|
|
416
|
+
if (!event || !event.type) return;
|
|
417
|
+
const p = event.properties ?? {};
|
|
418
|
+
if (event.type === "permission.updated" || event.type === "permission.asked" || event.type === "permission.v2.asked") {
|
|
419
|
+
const sid = p.sessionID;
|
|
420
|
+
const pid = p.id ?? p.requestID;
|
|
421
|
+
if (!sid || !pid) return;
|
|
422
|
+
if (!pendingPermissions.has(sid)) pendingPermissions.set(sid, new Map());
|
|
423
|
+
pendingPermissions.get(sid).set(pid, {
|
|
424
|
+
id: pid,
|
|
425
|
+
sessionID: sid,
|
|
426
|
+
type: p.type ?? p.permission ?? p.action ?? "tool",
|
|
427
|
+
pattern: p.pattern ?? p.patterns ?? p.resources,
|
|
428
|
+
title: p.title,
|
|
429
|
+
time: p.time?.created,
|
|
430
|
+
});
|
|
431
|
+
} else if (event.type === "permission.replied" || event.type === "permission.v2.replied") {
|
|
432
|
+
const m = pendingPermissions.get(p.sessionID);
|
|
433
|
+
if (m) {
|
|
434
|
+
m.delete(p.permissionID ?? p.requestID);
|
|
435
|
+
if (!m.size) pendingPermissions.delete(p.sessionID);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function trackQuestion(event) {
|
|
441
|
+
if (!event || !event.type) return;
|
|
442
|
+
const p = event.properties ?? {};
|
|
443
|
+
if (event.type === "question.asked" || event.type === "question.v2.asked") {
|
|
444
|
+
const id = p.id ?? p.requestID;
|
|
445
|
+
const sid = p.sessionID;
|
|
446
|
+
if (!id || !sid) return;
|
|
447
|
+
pendingQuestions.set(String(id), {
|
|
448
|
+
id: String(id),
|
|
449
|
+
sessionID: String(sid),
|
|
450
|
+
questions: Array.isArray(p.questions) ? p.questions : [],
|
|
451
|
+
time: Date.now(),
|
|
452
|
+
});
|
|
453
|
+
} else if (event.type === "question.replied" || event.type === "question.rejected"
|
|
454
|
+
|| event.type === "question.v2.replied" || event.type === "question.v2.rejected") {
|
|
455
|
+
pendingQuestions.delete(String(p.requestID ?? p.id));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export default async function ocduetAttach({ client, directory, worktree, project }) {
|
|
460
|
+
if (typeof Bun === "undefined") return {};
|
|
461
|
+
if (globalThis.__ocduetAttach) return {};
|
|
462
|
+
globalThis.__ocduetAttach = true;
|
|
463
|
+
|
|
464
|
+
const ctx = { client, directory, worktree };
|
|
465
|
+
const instanceId = `${slug(directory)}-${process.pid}`;
|
|
466
|
+
const info = {
|
|
467
|
+
id: instanceId,
|
|
468
|
+
directory,
|
|
469
|
+
worktree,
|
|
470
|
+
project: project ? { id: project.id, name: project.name ?? null } : null,
|
|
471
|
+
pid: process.pid,
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
let ws = null;
|
|
475
|
+
let backoff = 1000;
|
|
476
|
+
let lastSpawnAttempt = 0;
|
|
477
|
+
let closed = false;
|
|
478
|
+
|
|
479
|
+
const register = () => {
|
|
480
|
+
(async () => {
|
|
481
|
+
let sessions = [];
|
|
482
|
+
try {
|
|
483
|
+
sessions = userSessions(unwrap(await Promise.race([
|
|
484
|
+
listAllSessions(client, directory),
|
|
485
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 3000)),
|
|
486
|
+
]))) ?? [];
|
|
487
|
+
} catch {}
|
|
488
|
+
try {
|
|
489
|
+
ws.send(JSON.stringify({ t: "register", instance: info, sessions }));
|
|
490
|
+
} catch {}
|
|
491
|
+
})();
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const connect = async () => {
|
|
495
|
+
while (!closed) {
|
|
496
|
+
const port = daemonPort();
|
|
497
|
+
if (!(await daemonHealthy(port))) {
|
|
498
|
+
if (Date.now() - lastSpawnAttempt > SPAWN_COOLDOWN_MS) {
|
|
499
|
+
lastSpawnAttempt = Date.now();
|
|
500
|
+
spawnDaemon();
|
|
501
|
+
// give it a moment to come up
|
|
502
|
+
for (let i = 0; i < 15 && !(await daemonHealthy(daemonPort())); i++) {
|
|
503
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const token = readToken();
|
|
510
|
+
if (!token) {
|
|
511
|
+
await new Promise((r) => setTimeout(r, 10000));
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
await new Promise((resolve) => {
|
|
515
|
+
let settled = false;
|
|
516
|
+
try {
|
|
517
|
+
ws = new WebSocket(`ws://127.0.0.1:${daemonPort()}/internal?t=${encodeURIComponent(token)}`);
|
|
518
|
+
} catch {
|
|
519
|
+
resolve();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const done = () => { if (!settled) { settled = true; resolve(); } };
|
|
523
|
+
ws.onopen = () => {
|
|
524
|
+
backoff = 1000;
|
|
525
|
+
register();
|
|
526
|
+
done();
|
|
527
|
+
};
|
|
528
|
+
ws.onmessage = (ev) => {
|
|
529
|
+
let msg;
|
|
530
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
531
|
+
if (msg.t === "rpc" && msg.id) {
|
|
532
|
+
const handler = RPC_METHODS[msg.method];
|
|
533
|
+
if (!handler) {
|
|
534
|
+
ws.send(JSON.stringify({ t: "rpcResult", id: msg.id, ok: false, error: `unknown method: ${msg.method}` }));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
Promise.resolve()
|
|
538
|
+
.then(() => handler({ ...ctx, directory: (msg.args && msg.args.directory) || ctx.directory }, msg.args || {}))
|
|
539
|
+
.then((result) => ws.send(JSON.stringify({ t: "rpcResult", id: msg.id, ok: true, result: unwrap(result) })))
|
|
540
|
+
.catch((err) => ws.send(JSON.stringify({ t: "rpcResult", id: msg.id, ok: false, error: String(err?.message || err) })));
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
ws.onclose = () => { ws = null; done(); };
|
|
544
|
+
ws.onerror = () => { try { ws && ws.close(); } catch {} };
|
|
545
|
+
setTimeout(done, 8000);
|
|
546
|
+
});
|
|
547
|
+
if (!closed && !ws) {
|
|
548
|
+
await new Promise((r) => setTimeout(r, Math.min(backoff, 15000)));
|
|
549
|
+
backoff = Math.min(backoff * 1.7, 15000);
|
|
550
|
+
} else if (ws) {
|
|
551
|
+
// wait for close, then loop again
|
|
552
|
+
await new Promise((resolve) => {
|
|
553
|
+
const check = setInterval(() => {
|
|
554
|
+
if (!ws || ws.readyState > 1) { clearInterval(check); resolve(); }
|
|
555
|
+
}, 1000);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
connect();
|
|
562
|
+
|
|
563
|
+
let sessionsTimer = null;
|
|
564
|
+
const pushSessions = () => {
|
|
565
|
+
clearTimeout(sessionsTimer);
|
|
566
|
+
sessionsTimer = setTimeout(async () => {
|
|
567
|
+
if (closed) return;
|
|
568
|
+
try {
|
|
569
|
+
const sessions = userSessions(unwrap(await Promise.race([
|
|
570
|
+
listAllSessions(client, directory),
|
|
571
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 3000)),
|
|
572
|
+
]))) ?? [];
|
|
573
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ t: "sessions", sessions }));
|
|
574
|
+
} catch {}
|
|
575
|
+
}, 500);
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
579
|
+
process.on(sig, () => {
|
|
580
|
+
closed = true;
|
|
581
|
+
try { ws && ws.close(); } catch {}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
event: async ({ event }) => {
|
|
587
|
+
trackPermission(event);
|
|
588
|
+
trackQuestion(event);
|
|
589
|
+
if (event?.type === "session.created" || event?.type === "session.updated" || event?.type === "session.deleted"
|
|
590
|
+
|| event?.type === "session.idle" || event?.type === "message.updated") pushSessions();
|
|
591
|
+
if (permissionLevel === "auto" && (event?.type === "permission.updated" || event?.type === "permission.asked" || event?.type === "permission.v2.asked")) {
|
|
592
|
+
const p = event.properties ?? {};
|
|
593
|
+
const pid = p.id ?? p.requestID;
|
|
594
|
+
if (p.sessionID && pid) {
|
|
595
|
+
try {
|
|
596
|
+
await unwrap(await ctx.client.postSessionIdPermissionsPermissionId({
|
|
597
|
+
path: { id: String(p.sessionID), permissionID: String(pid) },
|
|
598
|
+
query: { directory: await sessionDirOf(client, String(p.sessionID), ctx.directory) },
|
|
599
|
+
body: { response: "once" },
|
|
600
|
+
}));
|
|
601
|
+
} catch {}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (ws && ws.readyState === 1) {
|
|
605
|
+
try { ws.send(JSON.stringify({ t: "event", event })); } catch {}
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}
|