@nanhara/hara 0.121.1 → 0.122.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/CHANGELOG.md +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/serve/server.js
CHANGED
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
// (no import cycle back into the CLI entry).
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
7
7
|
import { randomBytes, randomUUID, timingSafeEqual, createHash } from "node:crypto";
|
|
8
|
-
import {
|
|
8
|
+
import { closeSync, constants as fsConstants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
10
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
11
11
|
import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
|
|
12
12
|
import { runAgent } from "../agent/loop.js";
|
|
13
13
|
import { COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "../agent/compact.js";
|
|
14
14
|
import { rewindTo } from "../agent/rewind.js";
|
|
15
15
|
import { analyzeContext } from "../agent/context-report.js";
|
|
16
|
-
import { recentTouched } from "../agent/touched.js";
|
|
16
|
+
import { clearTouched, recentTouched } from "../agent/touched.js";
|
|
17
|
+
import { resetRepeatGuard } from "../agent/repeat-guard.js";
|
|
17
18
|
import { contextWindow, ctxPctFor } from "../statusbar.js";
|
|
18
19
|
import { listProjectFiles } from "../fs-walk.js";
|
|
19
20
|
import { fuzzyRank } from "../fuzzy.js";
|
|
@@ -24,9 +25,186 @@ import { listInstalled, enabledPlugins, setPluginEnabled, panelsForProject } fro
|
|
|
24
25
|
import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
|
|
25
26
|
import { loadJobs, saveJobs, addJob } from "../cron/store.js";
|
|
26
27
|
import { parseSchedule, describeSchedule } from "../cron/schedule.js";
|
|
28
|
+
import { loadTasks } from "../tools/task.js";
|
|
29
|
+
import { listPending, resolvePending } from "../gateway/flows-pending.js";
|
|
30
|
+
import { disposeTodoScope, restoreTodos, serializeTodos } from "../tools/todo.js";
|
|
31
|
+
import { disposeReminderScope } from "../agent/reminders.js";
|
|
27
32
|
import { SessionHub, realStore } from "./sessions.js";
|
|
28
33
|
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
34
|
+
import { readModelContextFileSync } from "../fs-read.js";
|
|
29
35
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
36
|
+
const COMPACT_TIMEOUT_MS = 60_000;
|
|
37
|
+
const SHUTDOWN_GRACE_MS = 2_000;
|
|
38
|
+
const SOCKET_CLOSE_GRACE_MS = 250;
|
|
39
|
+
const DISCOVERY_LOCK_WAIT_MS = 2_000;
|
|
40
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
41
|
+
const isPidAlive = (pid) => {
|
|
42
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, 0);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return error?.code === "EPERM";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const ensurePrivateDiscoveryDir = (dir) => {
|
|
53
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
|
+
let fd;
|
|
55
|
+
try {
|
|
56
|
+
fd = openSync(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
57
|
+
const st = fstatSync(fd);
|
|
58
|
+
if (!st.isDirectory())
|
|
59
|
+
throw new Error(`${dir} must be a private directory, not a symlink`);
|
|
60
|
+
// mkdir's mode does not affect a legacy directory. Operate through the verified descriptor so a path
|
|
61
|
+
// replacement cannot redirect chmod to a symlink target between validation and permission tightening.
|
|
62
|
+
fchmodSync(fd, 0o700);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (fd !== undefined)
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/** Serialize discovery replacement/removal across serve instances. The instance-stamped lock owner lets a
|
|
70
|
+
* later process reclaim a crash-stale lock without ever treating a live writer as stale. */
|
|
71
|
+
const withDiscoveryLock = async (dir, instanceId, fn, waitMs = DISCOVERY_LOCK_WAIT_MS) => {
|
|
72
|
+
const lockDir = join(dir, ".serve.json.lock");
|
|
73
|
+
const ownerPath = join(lockDir, "owner.json");
|
|
74
|
+
const deadline = Date.now() + waitMs;
|
|
75
|
+
for (;;) {
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(lockDir, { mode: 0o700 });
|
|
78
|
+
writeFileSync(ownerPath, JSON.stringify({ pid: process.pid, instanceId }), { mode: 0o600, flag: "wx" });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error?.code !== "EEXIST")
|
|
83
|
+
throw error;
|
|
84
|
+
let stale = false;
|
|
85
|
+
try {
|
|
86
|
+
const owner = JSON.parse(readFileSync(ownerPath, "utf8"));
|
|
87
|
+
stale = typeof owner.pid === "number" && !isPidAlive(owner.pid);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A writer may be between mkdir and owner creation. Only reclaim a malformed lock after a full
|
|
91
|
+
// grace interval; a normally running write holds it for just a few synchronous filesystem calls.
|
|
92
|
+
try {
|
|
93
|
+
stale = Date.now() - statSync(lockDir).mtimeMs > DISCOVERY_LOCK_WAIT_MS;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (stale) {
|
|
100
|
+
try {
|
|
101
|
+
renameSync(lockDir, join(dir, `.serve.json.lock.stale-${process.pid}-${randomUUID()}`));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
catch (renameError) {
|
|
105
|
+
if (renameError?.code === "ENOENT")
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (Date.now() >= deadline)
|
|
110
|
+
throw new Error("timed out waiting for the serve discovery lock");
|
|
111
|
+
await pause(10);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return fn();
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
// Only remove the lock directory if its owner record is still ours. This is deliberately conservative:
|
|
119
|
+
// leaving a recoverable stale lock is safer than deleting a replacement owned by another instance.
|
|
120
|
+
try {
|
|
121
|
+
const owner = JSON.parse(readFileSync(ownerPath, "utf8"));
|
|
122
|
+
if (owner.pid === process.pid && owner.instanceId === instanceId) {
|
|
123
|
+
unlinkSync(ownerPath);
|
|
124
|
+
rmdirSync(lockDir);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* stale-lock recovery handles interrupted cleanup */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const syncDirectory = (dir) => {
|
|
133
|
+
let fd;
|
|
134
|
+
try {
|
|
135
|
+
fd = openSync(dir, fsConstants.O_RDONLY);
|
|
136
|
+
fsyncSync(fd);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Some filesystems do not support fsync on directories. The atomic rename and private file mode still
|
|
140
|
+
// hold; directory fsync is an extra crash-durability barrier where the platform supports it.
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
if (fd !== undefined)
|
|
144
|
+
closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const writeDiscovery = async (dir, path, record) => {
|
|
148
|
+
ensurePrivateDiscoveryDir(dir);
|
|
149
|
+
await withDiscoveryLock(dir, record.instanceId, () => {
|
|
150
|
+
const temp = join(dir, `.serve.json.${process.pid}.${record.instanceId}.tmp`);
|
|
151
|
+
let fd;
|
|
152
|
+
try {
|
|
153
|
+
fd = openSync(temp, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, 0o600);
|
|
154
|
+
fchmodSync(fd, 0o600);
|
|
155
|
+
writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
156
|
+
fsyncSync(fd);
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
fd = undefined;
|
|
159
|
+
// rename replaces a legacy file or symlink inode; it never follows serve.json's symlink target.
|
|
160
|
+
renameSync(temp, path);
|
|
161
|
+
syncDirectory(dir);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
if (fd !== undefined)
|
|
165
|
+
closeSync(fd);
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(temp);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* renamed or never created */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
const removeOwnedDiscovery = async (dir, path, record) => {
|
|
176
|
+
await withDiscoveryLock(dir, record.instanceId, () => {
|
|
177
|
+
let fd;
|
|
178
|
+
try {
|
|
179
|
+
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
180
|
+
const opened = fstatSync(fd);
|
|
181
|
+
if (!opened.isFile() || opened.size > 64 * 1024)
|
|
182
|
+
return;
|
|
183
|
+
const current = JSON.parse(readFileSync(fd, "utf8"));
|
|
184
|
+
if (current.instanceId !== record.instanceId
|
|
185
|
+
|| current.pid !== record.pid
|
|
186
|
+
|| current.port !== record.port
|
|
187
|
+
|| typeof current.token !== "string"
|
|
188
|
+
|| !sameToken(current.token, record.token))
|
|
189
|
+
return;
|
|
190
|
+
// Re-check the directory entry against the already-open, verified inode. Cooperating writers are
|
|
191
|
+
// serialized by the lock; this check also refuses an uncooperative symlink/replacement race.
|
|
192
|
+
const linked = lstatSync(path);
|
|
193
|
+
if (!linked.isFile() || linked.isSymbolicLink() || linked.dev !== opened.dev || linked.ino !== opened.ino)
|
|
194
|
+
return;
|
|
195
|
+
unlinkSync(path);
|
|
196
|
+
syncDirectory(dir);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error?.code !== "ENOENT" && error?.code !== "ELOOP")
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
if (fd !== undefined)
|
|
204
|
+
closeSync(fd);
|
|
205
|
+
}
|
|
206
|
+
}, 250);
|
|
207
|
+
};
|
|
30
208
|
const sameToken = (a, b) => {
|
|
31
209
|
// constant-time compare over digests (inputs differ in length)
|
|
32
210
|
const ha = createHash("sha256").update(a).digest();
|
|
@@ -56,7 +234,27 @@ export function historyForClient(history) {
|
|
|
56
234
|
}
|
|
57
235
|
export async function startServe(opts, deps) {
|
|
58
236
|
const token = opts.token ?? randomBytes(16).toString("hex");
|
|
237
|
+
const instanceId = randomUUID();
|
|
59
238
|
const hub = new SessionHub(deps.store ?? realStore);
|
|
239
|
+
const runtimeInfo = (cwd) => {
|
|
240
|
+
const live = deps.runtimeInfo?.(cwd);
|
|
241
|
+
return {
|
|
242
|
+
providerId: live?.providerId ?? deps.providerId,
|
|
243
|
+
model: live?.model ?? deps.model,
|
|
244
|
+
effortLevels: live?.effortLevels ?? deps.effortLevels ?? [],
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
const refreshSessionProvider = async (session) => {
|
|
248
|
+
const fresh = deps.buildProviderFor
|
|
249
|
+
? await deps.buildProviderFor(session.meta.model, session.effort, session.meta.cwd)
|
|
250
|
+
: await deps.buildSessionProvider(session.meta.cwd);
|
|
251
|
+
if (!fresh)
|
|
252
|
+
return false;
|
|
253
|
+
session.provider = fresh;
|
|
254
|
+
session.meta.provider = fresh.id;
|
|
255
|
+
// Preserve the user's pinned model in meta; provider factories should honor it, but fail closed if they do not.
|
|
256
|
+
return fresh.model === session.meta.model;
|
|
257
|
+
};
|
|
60
258
|
const wss = new WebSocketServer({ host: opts.host, port: opts.port, maxPayload: 10 * 1024 * 1024 });
|
|
61
259
|
await new Promise((res, rej) => {
|
|
62
260
|
wss.once("listening", res);
|
|
@@ -65,6 +263,9 @@ export async function startServe(opts, deps) {
|
|
|
65
263
|
const port = wss.address().port;
|
|
66
264
|
const authed = new Set();
|
|
67
265
|
const pendingApprovals = new Map();
|
|
266
|
+
const inFlightRequests = new Set();
|
|
267
|
+
let closing = false;
|
|
268
|
+
let closePromise = null;
|
|
68
269
|
const broadcast = (method, params) => {
|
|
69
270
|
const frame = rpcNotify(method, params);
|
|
70
271
|
for (const ws of authed)
|
|
@@ -72,16 +273,40 @@ export async function startServe(opts, deps) {
|
|
|
72
273
|
ws.send(frame);
|
|
73
274
|
};
|
|
74
275
|
// Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
|
|
75
|
-
const
|
|
276
|
+
const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
|
|
277
|
+
const discoveryPath = join(discoveryDir, "serve.json");
|
|
278
|
+
const discovery = { host: opts.host, port, token, pid: process.pid, version: deps.version, instanceId };
|
|
76
279
|
if (!deps.quietDiscovery) {
|
|
77
|
-
|
|
78
|
-
|
|
280
|
+
try {
|
|
281
|
+
await writeDiscovery(discoveryDir, discoveryPath, discovery);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
// The socket is already listening so its assigned port can be advertised. If advertising fails,
|
|
285
|
+
// fail atomically as a server too: never leave an unreachable/authentication-less listener behind.
|
|
286
|
+
await removeOwnedDiscovery(discoveryDir, discoveryPath, discovery).catch(() => { });
|
|
287
|
+
for (const client of wss.clients)
|
|
288
|
+
client.terminate();
|
|
289
|
+
await Promise.race([
|
|
290
|
+
new Promise((resolve) => {
|
|
291
|
+
try {
|
|
292
|
+
wss.close(() => resolve());
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
resolve();
|
|
296
|
+
}
|
|
297
|
+
}),
|
|
298
|
+
pause(SOCKET_CLOSE_GRACE_MS),
|
|
299
|
+
]);
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
79
302
|
}
|
|
80
303
|
/** Run one turn on a session, streaming events to all authed clients. */
|
|
81
304
|
const runTurn = async (s, text, images) => {
|
|
82
305
|
const sessionId = s.meta.id;
|
|
83
306
|
s.busy = true;
|
|
84
|
-
|
|
307
|
+
const turnAbort = new AbortController();
|
|
308
|
+
s.abort = turnAbort;
|
|
309
|
+
const historyStart = s.history.length;
|
|
85
310
|
const before = { input: s.stats.input, output: s.stats.output };
|
|
86
311
|
const sink = {
|
|
87
312
|
text: (d) => broadcast("event.text", { sessionId, delta: d }),
|
|
@@ -92,18 +317,33 @@ export async function startServe(opts, deps) {
|
|
|
92
317
|
};
|
|
93
318
|
const confirm = (q) => new Promise((resolve) => {
|
|
94
319
|
const approvalId = randomUUID();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
320
|
+
let settled = false;
|
|
321
|
+
let timer;
|
|
322
|
+
const finish = (v) => {
|
|
323
|
+
if (settled)
|
|
324
|
+
return;
|
|
325
|
+
settled = true;
|
|
100
326
|
clearTimeout(timer);
|
|
101
327
|
pendingApprovals.delete(approvalId);
|
|
328
|
+
turnAbort.signal.removeEventListener("abort", onAbort);
|
|
102
329
|
resolve(v);
|
|
103
|
-
}
|
|
104
|
-
|
|
330
|
+
};
|
|
331
|
+
const onAbort = () => finish(false);
|
|
332
|
+
timer = setTimeout(() => finish(false), APPROVAL_TIMEOUT_MS); // unanswered → deny, turn continues
|
|
333
|
+
pendingApprovals.set(approvalId, finish);
|
|
334
|
+
if (turnAbort.signal.aborted)
|
|
335
|
+
finish(false);
|
|
336
|
+
else {
|
|
337
|
+
turnAbort.signal.addEventListener("abort", onAbort, { once: true });
|
|
338
|
+
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
339
|
+
}
|
|
105
340
|
});
|
|
106
341
|
try {
|
|
342
|
+
if (!(await refreshSessionProvider(s))) {
|
|
343
|
+
throw new Error(`provider not authenticated for pinned model '${s.meta.model}' at ${s.meta.cwd}`);
|
|
344
|
+
}
|
|
345
|
+
const turnGuardian = deps.buildGuardian ? await deps.buildGuardian(s.meta.cwd) : deps.guardian;
|
|
346
|
+
restoreTodos(s.meta.todos, sessionId);
|
|
107
347
|
// Slash skills, CLI parity: "/skill-id request…" expands into the skill-entering message, so a
|
|
108
348
|
// desktop composer's "/" popup triggers the exact behavior the terminal gets. Unknown ids fall
|
|
109
349
|
// through as plain text (the model sees what the user typed).
|
|
@@ -119,11 +359,12 @@ export async function startServe(opts, deps) {
|
|
|
119
359
|
// @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
|
|
120
360
|
// Pasted images ride along as NeutralMsg.images — a vision-capable model sees them inline.
|
|
121
361
|
s.history.push({ role: "user", content: expandMentions(content, s.meta.cwd), ...(images && images.length ? { images } : {}) });
|
|
122
|
-
await runAgent(s.history, {
|
|
362
|
+
const outcome = await runAgent(s.history, {
|
|
123
363
|
provider: s.provider,
|
|
124
364
|
ctx: {
|
|
125
365
|
cwd: s.meta.cwd,
|
|
126
366
|
sandbox: deps.sandbox,
|
|
367
|
+
todoScope: sessionId,
|
|
127
368
|
spawn: (t, role) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role),
|
|
128
369
|
ui: sink,
|
|
129
370
|
},
|
|
@@ -133,15 +374,36 @@ export async function startServe(opts, deps) {
|
|
|
133
374
|
projectContext: s.projectContext,
|
|
134
375
|
memory: memoryDigest(s.meta.cwd),
|
|
135
376
|
stats: s.stats,
|
|
136
|
-
signal:
|
|
137
|
-
|
|
377
|
+
signal: turnAbort.signal,
|
|
378
|
+
onProviderTurn: (turn) => {
|
|
379
|
+
s.pendingProviderTurns += 1;
|
|
380
|
+
const settled = () => {
|
|
381
|
+
s.pendingProviderTurns = Math.max(0, s.pendingProviderTurns - 1);
|
|
382
|
+
if (closing)
|
|
383
|
+
hub.releaseIdle();
|
|
384
|
+
};
|
|
385
|
+
void turn.then(settled, settled);
|
|
386
|
+
},
|
|
387
|
+
guardian: turnGuardian,
|
|
138
388
|
});
|
|
389
|
+
s.meta.todos = serializeTodos(sessionId);
|
|
139
390
|
hub.save(s);
|
|
140
391
|
const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
|
|
141
|
-
const reply = lastAssistantText(s.history);
|
|
142
392
|
// context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern) —
|
|
143
393
|
// clients render a meter without an extra round-trip.
|
|
144
394
|
const ctx = ctxOf(s);
|
|
395
|
+
if (outcome.status !== "completed") {
|
|
396
|
+
const failure = outcome.status === "error"
|
|
397
|
+
? (outcome.error ?? "agent turn failed")
|
|
398
|
+
: outcome.status === "empty"
|
|
399
|
+
? "the model returned an empty response after retrying"
|
|
400
|
+
: "agent turn halted by the safety circuit-breaker";
|
|
401
|
+
broadcast("event.turn_end", { sessionId, reply: "", error: failure, status: outcome.status, usage, ctx });
|
|
402
|
+
throw new Error(failure);
|
|
403
|
+
}
|
|
404
|
+
// A persistent session may already contain many assistant messages. Only messages appended by THIS
|
|
405
|
+
// request are eligible for its reply; a failed/empty turn must never replay a previous success.
|
|
406
|
+
const reply = lastAssistantText(s.history.slice(historyStart));
|
|
145
407
|
broadcast("event.turn_end", { sessionId, reply, usage, ctx });
|
|
146
408
|
return { reply, usage, ctx };
|
|
147
409
|
}
|
|
@@ -158,12 +420,37 @@ export async function startServe(opts, deps) {
|
|
|
158
420
|
/** Summarize + replace a session's history — the CLI's /compact, serve-side (codex thread/compact).
|
|
159
421
|
* Mirrors index.ts compactConversation; the file restore is limited to files under the session's own
|
|
160
422
|
* cwd because serve is multi-session (recentTouched is process-wide and must not leak across projects). */
|
|
161
|
-
const compactSession = async (s) => {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
423
|
+
const compactSession = async (s, controller) => {
|
|
424
|
+
const timeoutMs = Math.max(1, Math.min(deps.compactTimeoutMs ?? COMPACT_TIMEOUT_MS, COMPACT_TIMEOUT_MS));
|
|
425
|
+
const r = await new Promise((resolve, reject) => {
|
|
426
|
+
let settled = false;
|
|
427
|
+
let timedOut = false;
|
|
428
|
+
const finish = (fn) => {
|
|
429
|
+
if (settled)
|
|
430
|
+
return;
|
|
431
|
+
settled = true;
|
|
432
|
+
clearTimeout(timer);
|
|
433
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
434
|
+
fn();
|
|
435
|
+
};
|
|
436
|
+
const onAbort = () => finish(() => reject(new Error(timedOut ? "compaction timed out" : "compaction interrupted")));
|
|
437
|
+
const timer = setTimeout(() => {
|
|
438
|
+
timedOut = true;
|
|
439
|
+
controller.abort(); // cooperative providers stop their own network/body work too
|
|
440
|
+
onAbort(); // AbortController dispatch is synchronous, but keep this idempotent fallback explicit
|
|
441
|
+
}, timeoutMs);
|
|
442
|
+
timer.unref();
|
|
443
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
444
|
+
if (controller.signal.aborted)
|
|
445
|
+
return onAbort();
|
|
446
|
+
// Promise.resolve protects this boundary even if a non-conforming provider throws synchronously.
|
|
447
|
+
void Promise.resolve().then(() => s.provider.turn({
|
|
448
|
+
system: COMPACT_SYSTEM,
|
|
449
|
+
history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
450
|
+
tools: [],
|
|
451
|
+
onText: () => { },
|
|
452
|
+
signal: controller.signal,
|
|
453
|
+
})).then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
|
|
167
454
|
});
|
|
168
455
|
if (r.stop === "error")
|
|
169
456
|
return null;
|
|
@@ -173,10 +460,13 @@ export async function startServe(opts, deps) {
|
|
|
173
460
|
s.meta.workingSet = workingSetFromSummary(summary);
|
|
174
461
|
s.history.length = 0;
|
|
175
462
|
s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
176
|
-
const touched = recentTouched(20).filter((
|
|
463
|
+
const touched = recentTouched(20, s.meta.id).filter((file) => {
|
|
464
|
+
const rel = relative(s.meta.cwd, file);
|
|
465
|
+
return !!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
466
|
+
}).slice(0, 5);
|
|
177
467
|
const restore = buildFileRestore(touched, (f) => {
|
|
178
468
|
try {
|
|
179
|
-
return
|
|
469
|
+
return readModelContextFileSync(f, 32 * 1024);
|
|
180
470
|
}
|
|
181
471
|
catch {
|
|
182
472
|
return null;
|
|
@@ -191,293 +481,412 @@ export async function startServe(opts, deps) {
|
|
|
191
481
|
return summary;
|
|
192
482
|
};
|
|
193
483
|
wss.on("connection", (ws) => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// capability negotiation (codex app-server pattern): the server ADVERTISES its method set so
|
|
208
|
-
// clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
|
|
209
|
-
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
210
|
-
const methods = [
|
|
211
|
-
"session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
|
|
212
|
-
"session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
|
|
213
|
-
"approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
|
|
214
|
-
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
215
|
-
];
|
|
216
|
-
return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
|
|
484
|
+
if (closing) {
|
|
485
|
+
ws.close(1012, "server shutting down");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
ws.on("message", (raw) => {
|
|
489
|
+
const task = (async () => {
|
|
490
|
+
if (closing)
|
|
491
|
+
return;
|
|
492
|
+
const parsed = parseFrame(String(raw));
|
|
493
|
+
if ("error" in parsed) {
|
|
494
|
+
if (ws.readyState === ws.OPEN)
|
|
495
|
+
ws.send(rpcError(null, ERR.PARSE, parsed.error));
|
|
496
|
+
return;
|
|
217
497
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if ("missing" in r)
|
|
240
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
241
|
-
if ("lockedBy" in r)
|
|
242
|
-
return reply(rpcError(id, ERR.LOCKED, `session held by live pid ${r.lockedBy}`));
|
|
243
|
-
r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
|
|
244
|
-
return reply(rpcResult(id, { sessionId: r.session.meta.id, model: r.session.meta.model, history: historyForClient(r.session.history) }));
|
|
245
|
-
}
|
|
246
|
-
case "session.send": {
|
|
247
|
-
if (typeof p.sessionId !== "string" || typeof p.text !== "string" || !p.text)
|
|
248
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId + text required"));
|
|
249
|
-
const s = hub.get(p.sessionId);
|
|
250
|
-
if (!s)
|
|
251
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
|
|
252
|
-
if (s.busy)
|
|
253
|
-
return reply(rpcError(id, ERR.BUSY, "a turn is already running on this session"));
|
|
254
|
-
const images = Array.isArray(p.images)
|
|
255
|
-
? p.images.filter((im) => im && typeof im.path === "string").map((im) => ({ path: im.path, mediaType: typeof im.mediaType === "string" ? im.mediaType : "image/png" }))
|
|
256
|
-
: undefined;
|
|
257
|
-
const r = await runTurn(s, p.text, images);
|
|
258
|
-
return reply(rpcResult(id, r));
|
|
259
|
-
}
|
|
260
|
-
case "session.interrupt": {
|
|
261
|
-
const s = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
262
|
-
if (!s)
|
|
263
|
-
return reply(rpcError(id, ERR.NO_SESSION, "no such live session"));
|
|
264
|
-
s.abort?.abort();
|
|
265
|
-
return reply(rpcResult(id, {}));
|
|
266
|
-
}
|
|
267
|
-
case "approval.reply": {
|
|
268
|
-
if (typeof p.approvalId !== "string")
|
|
269
|
-
return reply(rpcError(id, ERR.PARAMS, "approvalId required"));
|
|
270
|
-
const resolve = pendingApprovals.get(p.approvalId);
|
|
271
|
-
if (resolve)
|
|
272
|
-
resolve(p.always === true ? "always" : p.allow === true);
|
|
273
|
-
return reply(rpcResult(id, {})); // idempotent — a late/duplicate reply is a no-op
|
|
274
|
-
}
|
|
275
|
-
case "plugins.list": {
|
|
276
|
-
const on = new Set(enabledPlugins().map((pl) => pl.name));
|
|
277
|
-
return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length, panels: pl.manifest.panels ?? [] })) }));
|
|
278
|
-
}
|
|
279
|
-
case "plugins.set": {
|
|
280
|
-
if (typeof p.name !== "string" || typeof p.enabled !== "boolean")
|
|
281
|
-
return reply(rpcError(id, ERR.PARAMS, "name + enabled required"));
|
|
282
|
-
if (!listInstalled().some((pl) => pl.name === p.name))
|
|
283
|
-
return reply(rpcError(id, ERR.PARAMS, `no installed plugin "${p.name}"`));
|
|
284
|
-
setPluginEnabled(p.name, p.enabled);
|
|
285
|
-
return reply(rpcResult(id, { name: p.name, enabled: p.enabled })); // takes effect on the next session/turn (loaders re-read)
|
|
286
|
-
}
|
|
287
|
-
case "session.rename": {
|
|
288
|
-
if (typeof p.sessionId !== "string" || typeof p.title !== "string")
|
|
289
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId + title required"));
|
|
290
|
-
if (!hub.rename(p.sessionId, p.title.slice(0, 120)))
|
|
291
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
292
|
-
return reply(rpcResult(id, { sessionId: p.sessionId, title: p.title.slice(0, 120) }));
|
|
293
|
-
}
|
|
294
|
-
case "session.archive": {
|
|
295
|
-
if (typeof p.sessionId !== "string" || typeof p.archived !== "boolean")
|
|
296
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId + archived required"));
|
|
297
|
-
if (!hub.setArchived(p.sessionId, p.archived))
|
|
298
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
299
|
-
return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
|
|
300
|
-
}
|
|
301
|
-
case "session.fork": {
|
|
302
|
-
// duplicate the conversation into a new session (codex thread/fork) — rewind's
|
|
303
|
-
// non-destructive sibling: explore a different direction without losing the original
|
|
304
|
-
if (typeof p.sessionId !== "string")
|
|
305
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
306
|
-
const provider = await deps.buildSessionProvider();
|
|
307
|
-
if (!provider)
|
|
308
|
-
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
|
|
309
|
-
const r = hub.fork(p.sessionId, { provider, providerId: provider.id, approval: deps.approval, projectContext: undefined });
|
|
310
|
-
if ("missing" in r)
|
|
311
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
312
|
-
r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
|
|
313
|
-
return reply(rpcResult(id, { sessionId: r.session.meta.id, title: r.session.meta.title, model: r.session.meta.model, history: historyForClient(r.session.history) }));
|
|
314
|
-
}
|
|
315
|
-
case "session.delete": {
|
|
316
|
-
// permanent removal (codex thread/delete) — archive is the soft path; this one is forever
|
|
317
|
-
if (typeof p.sessionId !== "string")
|
|
318
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
319
|
-
const r = hub.delete(p.sessionId);
|
|
320
|
-
if (r === "busy")
|
|
321
|
-
return reply(rpcError(id, ERR.BUSY, "a turn is running — delete after it finishes"));
|
|
322
|
-
if (r === "missing")
|
|
323
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId} (or held by another process)`));
|
|
324
|
-
return reply(rpcResult(id, { sessionId: p.sessionId, deleted: true }));
|
|
325
|
-
}
|
|
326
|
-
case "models.list": {
|
|
327
|
-
const models = deps.listModels ? await deps.listModels().catch(() => []) : [];
|
|
328
|
-
return reply(rpcResult(id, { models, current: deps.model, effortLevels: deps.effortLevels ?? [] }));
|
|
329
|
-
}
|
|
330
|
-
case "session.set-model": {
|
|
331
|
-
// per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
|
|
332
|
-
// provider; takes effect on the NEXT turn. Refused mid-turn.
|
|
333
|
-
if (typeof p.sessionId !== "string")
|
|
334
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
335
|
-
const s = hub.get(p.sessionId);
|
|
336
|
-
if (!s)
|
|
337
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
338
|
-
if (s.busy)
|
|
339
|
-
return reply(rpcError(id, ERR.BUSY, "a turn is running — switch after it finishes"));
|
|
340
|
-
const model = typeof p.model === "string" && p.model ? p.model : s.meta.model;
|
|
341
|
-
const effort = typeof p.effort === "string" && p.effort ? p.effort : undefined;
|
|
342
|
-
if (!deps.buildProviderFor)
|
|
343
|
-
return reply(rpcError(id, ERR.METHOD, "model switching not supported by this server"));
|
|
344
|
-
const provider = await deps.buildProviderFor(model, effort);
|
|
345
|
-
if (!provider)
|
|
346
|
-
return reply(rpcError(id, ERR.INTERNAL, `could not build provider for ${model}`));
|
|
347
|
-
s.provider = provider;
|
|
348
|
-
s.meta.model = model;
|
|
349
|
-
s.effort = effort;
|
|
350
|
-
return reply(rpcResult(id, { sessionId: s.meta.id, model, effort: effort ?? null }));
|
|
498
|
+
const { req } = parsed;
|
|
499
|
+
const id = req.id ?? null;
|
|
500
|
+
const reply = (frame) => void (id !== null && ws.readyState === ws.OPEN && ws.send(frame));
|
|
501
|
+
const p = (req.params ?? {});
|
|
502
|
+
try {
|
|
503
|
+
if (req.method === "initialize") {
|
|
504
|
+
if (typeof p.token !== "string" || !sameToken(p.token, token))
|
|
505
|
+
return reply(rpcError(id, ERR.UNAUTHORIZED, "bad token"));
|
|
506
|
+
authed.add(ws);
|
|
507
|
+
// capability negotiation (codex app-server pattern): the server ADVERTISES its method set so
|
|
508
|
+
// clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
|
|
509
|
+
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
510
|
+
const methods = [
|
|
511
|
+
"session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
|
|
512
|
+
"session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
|
|
513
|
+
"approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
|
|
514
|
+
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
515
|
+
"tasks.list", "approvals.list", "approvals.resolve",
|
|
516
|
+
];
|
|
517
|
+
const runtime = runtimeInfo();
|
|
518
|
+
return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: runtime.providerId, model: runtime.model, capabilities: { methods } }));
|
|
351
519
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
520
|
+
if (!authed.has(ws))
|
|
521
|
+
return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
|
|
522
|
+
switch (req.method) {
|
|
523
|
+
case "session.list":
|
|
524
|
+
return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).filter((m) => !m.archived || p.archived === true).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt, source: m.source ?? "interactive", sourceName: m.sourceName, archived: m.archived ?? false })) }));
|
|
525
|
+
case "session.create": {
|
|
526
|
+
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
527
|
+
const provider = await deps.buildSessionProvider(cwd);
|
|
528
|
+
if (closing)
|
|
529
|
+
return;
|
|
530
|
+
if (!provider)
|
|
531
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
|
|
532
|
+
const approval = ["suggest", "auto-edit", "full-auto"].includes(p.approval) ? p.approval : deps.approval;
|
|
533
|
+
const s = hub.create({ cwd, provider, providerId: provider.id, model: provider.model, approval, projectContext: loadAgentsMd(cwd) || undefined });
|
|
534
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, model: s.meta.model }));
|
|
363
535
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
400
|
-
return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
|
|
401
|
-
}
|
|
402
|
-
case "project.panels": {
|
|
403
|
-
// panels applicable to a project (plugin manifest `detect` markers under the cwd) — powers
|
|
404
|
-
// the desktop's chat ↔ live-preview split for design/video projects.
|
|
405
|
-
const ps = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
406
|
-
const pcwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (ps?.meta.cwd ?? opts.cwd);
|
|
407
|
-
const panels = panelsForProject(pcwd).map(({ plugin, panel }) => ({ plugin, id: panel.id, title: panel.title, command: panel.command, args: panel.args ?? [], port: panel.port }));
|
|
408
|
-
return reply(rpcResult(id, { cwd: pcwd, panels }));
|
|
409
|
-
}
|
|
410
|
-
case "files.search": {
|
|
411
|
-
// fuzzy file lookup for the composer's @-mention autocomplete (codex fuzzyFileSearch).
|
|
412
|
-
// Relative POSIX paths; empty query returns the first files as a browse list.
|
|
413
|
-
const sess = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
414
|
-
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (sess?.meta.cwd ?? opts.cwd);
|
|
415
|
-
const limit = Math.min(Math.max(Math.trunc(Number(p.limit) || 20), 1), 50);
|
|
416
|
-
const all = listProjectFiles(cwd);
|
|
417
|
-
const query = typeof p.query === "string" ? p.query : "";
|
|
418
|
-
const files = query ? fuzzyRank(query, all, (f) => f).slice(0, limit).map((r) => r.item) : all.slice(0, limit);
|
|
419
|
-
return reply(rpcResult(id, { files, cwd }));
|
|
420
|
-
}
|
|
421
|
-
case "session.context": {
|
|
422
|
-
// context-spend breakdown + watermark on demand (codex thread/tokenUsage + /context).
|
|
423
|
-
if (typeof p.sessionId !== "string")
|
|
424
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
425
|
-
const s = hub.get(p.sessionId);
|
|
426
|
-
if (!s)
|
|
427
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
428
|
-
const report = analyzeContext(s.history);
|
|
429
|
-
return reply(rpcResult(id, { sessionId: s.meta.id, ...ctxOf(s), total: report.total, rows: report.rows.slice(0, 8) }));
|
|
430
|
-
}
|
|
431
|
-
case "session.compact": {
|
|
432
|
-
// manual compaction (codex thread/compact/start): summarize + replace history, keep working
|
|
433
|
-
// notes, restore this-cwd touched files. Busy-guarded like a turn — it IS a provider call.
|
|
434
|
-
if (typeof p.sessionId !== "string")
|
|
435
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
436
|
-
const s = hub.get(p.sessionId);
|
|
437
|
-
if (!s)
|
|
438
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
439
|
-
if (s.busy)
|
|
440
|
-
return reply(rpcError(id, ERR.BUSY, "a turn is running — compact after it finishes"));
|
|
441
|
-
if (s.history.length < 2)
|
|
442
|
-
return reply(rpcError(id, ERR.PARAMS, "nothing to compact yet"));
|
|
443
|
-
s.busy = true;
|
|
444
|
-
try {
|
|
445
|
-
broadcast("event.notice", { sessionId: s.meta.id, text: "✻ Compacting conversation…" });
|
|
446
|
-
const summary = await compactSession(s);
|
|
447
|
-
if (!summary)
|
|
448
|
-
return reply(rpcError(id, ERR.INTERNAL, "compaction failed — try again or /clear"));
|
|
449
|
-
broadcast("event.notice", { sessionId: s.meta.id, text: `(compacted — history replaced with a summary; ${s.meta.workingSet?.length ?? 0} notes kept)` });
|
|
450
|
-
return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
|
|
536
|
+
case "session.resume": {
|
|
537
|
+
if (typeof p.sessionId !== "string")
|
|
538
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
539
|
+
const live = hub.get(p.sessionId);
|
|
540
|
+
if (live?.busy || live?.configuring)
|
|
541
|
+
return reply(rpcError(id, ERR.BUSY, "session is running or changing configuration — retry resume shortly"));
|
|
542
|
+
const priorMeta = hub.peekMeta(p.sessionId);
|
|
543
|
+
const provider = priorMeta && deps.buildProviderFor
|
|
544
|
+
? await deps.buildProviderFor(priorMeta.model, undefined, priorMeta.cwd)
|
|
545
|
+
: await deps.buildSessionProvider(priorMeta?.cwd);
|
|
546
|
+
if (closing)
|
|
547
|
+
return;
|
|
548
|
+
if (!provider)
|
|
549
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
|
|
550
|
+
const r = hub.resume(p.sessionId, { provider, approval: deps.approval, projectContext: undefined });
|
|
551
|
+
if ("missing" in r)
|
|
552
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
553
|
+
if ("lockedBy" in r)
|
|
554
|
+
return reply(rpcError(id, ERR.LOCKED, `session held by live pid ${r.lockedBy}`));
|
|
555
|
+
if ("busy" in r)
|
|
556
|
+
return reply(rpcError(id, ERR.BUSY, "session is running or changing configuration — retry resume shortly"));
|
|
557
|
+
r.session.configuring = true;
|
|
558
|
+
let refreshed = false;
|
|
559
|
+
try {
|
|
560
|
+
refreshed = await refreshSessionProvider(r.session);
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
r.session.configuring = false;
|
|
564
|
+
}
|
|
565
|
+
if (!refreshed) {
|
|
566
|
+
hub.detach(r.session.meta.id);
|
|
567
|
+
return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
|
|
568
|
+
}
|
|
569
|
+
r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
|
|
570
|
+
return reply(rpcResult(id, { sessionId: r.session.meta.id, model: r.session.meta.model, history: historyForClient(r.session.history) }));
|
|
451
571
|
}
|
|
452
|
-
|
|
453
|
-
|
|
572
|
+
case "session.send": {
|
|
573
|
+
if (typeof p.sessionId !== "string" || typeof p.text !== "string" || !p.text)
|
|
574
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + text required"));
|
|
575
|
+
const s = hub.get(p.sessionId);
|
|
576
|
+
if (!s)
|
|
577
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
|
|
578
|
+
if (s.busy || s.configuring)
|
|
579
|
+
return reply(rpcError(id, ERR.BUSY, "this session is busy or changing configuration"));
|
|
580
|
+
const images = Array.isArray(p.images)
|
|
581
|
+
? p.images.filter((im) => im && typeof im.path === "string").map((im) => ({ path: im.path, mediaType: typeof im.mediaType === "string" ? im.mediaType : "image/png" }))
|
|
582
|
+
: undefined;
|
|
583
|
+
const r = await runTurn(s, p.text, images);
|
|
584
|
+
return reply(rpcResult(id, r));
|
|
454
585
|
}
|
|
586
|
+
case "session.interrupt": {
|
|
587
|
+
const s = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
588
|
+
if (!s)
|
|
589
|
+
return reply(rpcError(id, ERR.NO_SESSION, "no such live session"));
|
|
590
|
+
s.abort?.abort();
|
|
591
|
+
return reply(rpcResult(id, {}));
|
|
592
|
+
}
|
|
593
|
+
case "approval.reply": {
|
|
594
|
+
if (typeof p.approvalId !== "string")
|
|
595
|
+
return reply(rpcError(id, ERR.PARAMS, "approvalId required"));
|
|
596
|
+
const resolve = pendingApprovals.get(p.approvalId);
|
|
597
|
+
if (resolve)
|
|
598
|
+
resolve(p.always === true ? "always" : p.allow === true);
|
|
599
|
+
return reply(rpcResult(id, {})); // idempotent — a late/duplicate reply is a no-op
|
|
600
|
+
}
|
|
601
|
+
case "plugins.list": {
|
|
602
|
+
const on = new Set(enabledPlugins().map((pl) => pl.name));
|
|
603
|
+
return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length, panels: pl.manifest.panels ?? [] })) }));
|
|
604
|
+
}
|
|
605
|
+
case "plugins.set": {
|
|
606
|
+
if (typeof p.name !== "string" || typeof p.enabled !== "boolean")
|
|
607
|
+
return reply(rpcError(id, ERR.PARAMS, "name + enabled required"));
|
|
608
|
+
if (!listInstalled().some((pl) => pl.name === p.name))
|
|
609
|
+
return reply(rpcError(id, ERR.PARAMS, `no installed plugin "${p.name}"`));
|
|
610
|
+
setPluginEnabled(p.name, p.enabled);
|
|
611
|
+
return reply(rpcResult(id, { name: p.name, enabled: p.enabled })); // takes effect on the next session/turn (loaders re-read)
|
|
612
|
+
}
|
|
613
|
+
case "session.rename": {
|
|
614
|
+
if (typeof p.sessionId !== "string" || typeof p.title !== "string")
|
|
615
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + title required"));
|
|
616
|
+
const live = hub.get(p.sessionId);
|
|
617
|
+
if (live?.busy || live?.configuring)
|
|
618
|
+
return reply(rpcError(id, ERR.BUSY, "a turn/configuration change is running — rename after it finishes"));
|
|
619
|
+
if (!hub.rename(p.sessionId, p.title.slice(0, 120)))
|
|
620
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
621
|
+
return reply(rpcResult(id, { sessionId: p.sessionId, title: p.title.slice(0, 120) }));
|
|
622
|
+
}
|
|
623
|
+
case "session.archive": {
|
|
624
|
+
if (typeof p.sessionId !== "string" || typeof p.archived !== "boolean")
|
|
625
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + archived required"));
|
|
626
|
+
const live = hub.get(p.sessionId);
|
|
627
|
+
if (live?.busy || live?.configuring)
|
|
628
|
+
return reply(rpcError(id, ERR.BUSY, "a turn/configuration change is running — archive after it finishes"));
|
|
629
|
+
if (!hub.setArchived(p.sessionId, p.archived))
|
|
630
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
631
|
+
return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
|
|
632
|
+
}
|
|
633
|
+
case "session.fork": {
|
|
634
|
+
// duplicate the conversation into a new session (codex thread/fork) — rewind's
|
|
635
|
+
// non-destructive sibling: explore a different direction without losing the original
|
|
636
|
+
if (typeof p.sessionId !== "string")
|
|
637
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
638
|
+
const sourceMeta = hub.peekMeta(p.sessionId);
|
|
639
|
+
const provider = sourceMeta && deps.buildProviderFor
|
|
640
|
+
? await deps.buildProviderFor(sourceMeta.model, undefined, sourceMeta.cwd)
|
|
641
|
+
: await deps.buildSessionProvider(sourceMeta?.cwd);
|
|
642
|
+
if (closing)
|
|
643
|
+
return;
|
|
644
|
+
if (!provider)
|
|
645
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
|
|
646
|
+
const r = hub.fork(p.sessionId, { provider, providerId: provider.id, approval: deps.approval, projectContext: undefined });
|
|
647
|
+
if ("missing" in r)
|
|
648
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
649
|
+
if ("busy" in r)
|
|
650
|
+
return reply(rpcError(id, ERR.BUSY, "source session is mid-turn — fork after it completes"));
|
|
651
|
+
r.session.configuring = true;
|
|
652
|
+
let refreshed = false;
|
|
653
|
+
try {
|
|
654
|
+
refreshed = await refreshSessionProvider(r.session);
|
|
655
|
+
}
|
|
656
|
+
finally {
|
|
657
|
+
r.session.configuring = false;
|
|
658
|
+
}
|
|
659
|
+
if (!refreshed) {
|
|
660
|
+
hub.delete(r.session.meta.id);
|
|
661
|
+
return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
|
|
662
|
+
}
|
|
663
|
+
r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
|
|
664
|
+
return reply(rpcResult(id, { sessionId: r.session.meta.id, title: r.session.meta.title, model: r.session.meta.model, history: historyForClient(r.session.history) }));
|
|
665
|
+
}
|
|
666
|
+
case "session.delete": {
|
|
667
|
+
// permanent removal (codex thread/delete) — archive is the soft path; this one is forever
|
|
668
|
+
if (typeof p.sessionId !== "string")
|
|
669
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
670
|
+
const r = hub.delete(p.sessionId);
|
|
671
|
+
if (r === "busy")
|
|
672
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is running — delete after it finishes"));
|
|
673
|
+
if (r === "missing")
|
|
674
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId} (or held by another process)`));
|
|
675
|
+
disposeTodoScope(p.sessionId);
|
|
676
|
+
disposeReminderScope(p.sessionId);
|
|
677
|
+
resetRepeatGuard(p.sessionId);
|
|
678
|
+
clearTouched(p.sessionId);
|
|
679
|
+
return reply(rpcResult(id, { sessionId: p.sessionId, deleted: true }));
|
|
680
|
+
}
|
|
681
|
+
case "models.list": {
|
|
682
|
+
const session = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
683
|
+
const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (session?.meta.cwd ?? opts.cwd);
|
|
684
|
+
const models = deps.listModels ? await deps.listModels(targetCwd).catch(() => []) : [];
|
|
685
|
+
const runtime = runtimeInfo(targetCwd);
|
|
686
|
+
return reply(rpcResult(id, { models, current: session?.meta.model ?? runtime.model, effort: session?.effort ?? null, effortLevels: runtime.effortLevels }));
|
|
687
|
+
}
|
|
688
|
+
case "session.set-model": {
|
|
689
|
+
// per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
|
|
690
|
+
// provider; takes effect on the NEXT turn. Refused mid-turn.
|
|
691
|
+
if (typeof p.sessionId !== "string")
|
|
692
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
693
|
+
const s = hub.get(p.sessionId);
|
|
694
|
+
if (!s)
|
|
695
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
696
|
+
if (s.busy || s.configuring)
|
|
697
|
+
return reply(rpcError(id, ERR.BUSY, "a turn/configuration change is running — switch after it finishes"));
|
|
698
|
+
const model = typeof p.model === "string" && p.model ? p.model : s.meta.model;
|
|
699
|
+
const effort = typeof p.effort === "string" && p.effort ? p.effort : undefined;
|
|
700
|
+
if (!deps.buildProviderFor)
|
|
701
|
+
return reply(rpcError(id, ERR.METHOD, "model switching not supported by this server"));
|
|
702
|
+
s.configuring = true;
|
|
703
|
+
try {
|
|
704
|
+
const provider = await deps.buildProviderFor(model, effort, s.meta.cwd);
|
|
705
|
+
if (closing)
|
|
706
|
+
return;
|
|
707
|
+
if (!provider)
|
|
708
|
+
return reply(rpcError(id, ERR.INTERNAL, `could not build provider for ${model}`));
|
|
709
|
+
if (provider.model !== model)
|
|
710
|
+
return reply(rpcError(id, ERR.INTERNAL, `provider did not honor requested model ${model}`));
|
|
711
|
+
s.provider = provider;
|
|
712
|
+
s.meta.provider = provider.id;
|
|
713
|
+
s.meta.model = model;
|
|
714
|
+
s.meta.effort = effort;
|
|
715
|
+
s.effort = effort;
|
|
716
|
+
hub.save(s); // persist the picker immediately, even if no next turn is sent
|
|
717
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, model, effort: effort ?? null }));
|
|
718
|
+
}
|
|
719
|
+
finally {
|
|
720
|
+
s.configuring = false;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
case "automation.list": {
|
|
724
|
+
// The automation timeline's data: cron jobs with their last outcome, plus this machine's
|
|
725
|
+
// automated sessions (source=cron/gateway) so the desktop can render results and "continue
|
|
726
|
+
// as conversation". Read-only.
|
|
727
|
+
const jobs = loadJobs().map((j) => ({ id: j.id, name: j.name, mode: j.mode, cwd: j.cwd, enabled: j.enabled, deliver: j.deliver, lastRunAt: j.lastRunAt, lastStatus: j.lastStatus, lastError: j.lastError, schedule: describeSchedule(j.schedule) }));
|
|
728
|
+
const automated = hub.list().filter((m) => m.source === "cron" || m.source === "gateway").map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, source: m.source, sourceName: m.sourceName, updatedAt: m.updatedAt }));
|
|
729
|
+
return reply(rpcResult(id, { jobs, sessions: automated }));
|
|
730
|
+
}
|
|
731
|
+
case "tasks.list": {
|
|
732
|
+
// The project's persistent task pool (the `task` tool's file store) — desktop's tasks panel.
|
|
733
|
+
// File-backed, so it reflects tasks created by ANY hara process in that cwd. Read-only.
|
|
734
|
+
const taskCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
735
|
+
return reply(rpcResult(id, { tasks: loadTasks(taskCwd) }));
|
|
736
|
+
}
|
|
737
|
+
case "approvals.list": {
|
|
738
|
+
// Unified approvals inbox: gateway-flow drafts awaiting the owner's verdict. (Per-turn tool
|
|
739
|
+
// approvals stay on the live approval.request/reply channel — they are transient by nature.)
|
|
740
|
+
return reply(rpcResult(id, { flowDrafts: listPending() }));
|
|
741
|
+
}
|
|
742
|
+
case "approvals.resolve": {
|
|
743
|
+
if (typeof p.id !== "string" || !["approve", "edit", "reject"].includes(p.verdict)) {
|
|
744
|
+
return reply(rpcError(id, ERR.PARAMS, "id + verdict(approve|edit|reject) required"));
|
|
745
|
+
}
|
|
746
|
+
if (p.verdict === "edit" && (typeof p.draft !== "string" || !p.draft.trim())) {
|
|
747
|
+
return reply(rpcError(id, ERR.PARAMS, "a non-empty draft is required for edit"));
|
|
748
|
+
}
|
|
749
|
+
const outcome = await resolvePending(p.id, p.verdict, typeof p.draft === "string" ? p.draft : undefined);
|
|
750
|
+
return reply(rpcResult(id, { outcome }));
|
|
751
|
+
}
|
|
752
|
+
case "automation.add": {
|
|
753
|
+
if (typeof p.name !== "string" || !p.name || typeof p.schedule !== "string" || typeof p.task !== "string" || !p.task) {
|
|
754
|
+
return reply(rpcError(id, ERR.PARAMS, "name + schedule + task required"));
|
|
755
|
+
}
|
|
756
|
+
const sched = parseSchedule(p.schedule, Date.now());
|
|
757
|
+
if ("error" in sched)
|
|
758
|
+
return reply(rpcError(id, ERR.PARAMS, `bad schedule: ${sched.error}`));
|
|
759
|
+
const job = addJob({
|
|
760
|
+
name: p.name.slice(0, 60),
|
|
761
|
+
schedule: sched,
|
|
762
|
+
task: p.task,
|
|
763
|
+
mode: ["print", "org", "command"].includes(p.mode) ? p.mode : "print",
|
|
764
|
+
cwd: typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd,
|
|
765
|
+
...(typeof p.tz === "string" && p.tz ? { tz: p.tz } : {}),
|
|
766
|
+
createdAt: Date.now(),
|
|
767
|
+
});
|
|
768
|
+
return reply(rpcResult(id, { id: job.id, name: job.name, schedule: describeSchedule(job.schedule) }));
|
|
769
|
+
}
|
|
770
|
+
case "automation.toggle": {
|
|
771
|
+
if (typeof p.id !== "string" || typeof p.enabled !== "boolean")
|
|
772
|
+
return reply(rpcError(id, ERR.PARAMS, "id + enabled required"));
|
|
773
|
+
const jobs = loadJobs();
|
|
774
|
+
const job = jobs.find((j) => j.id === p.id);
|
|
775
|
+
if (!job)
|
|
776
|
+
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
777
|
+
job.enabled = p.enabled;
|
|
778
|
+
saveJobs(jobs);
|
|
779
|
+
return reply(rpcResult(id, { id: job.id, enabled: job.enabled }));
|
|
780
|
+
}
|
|
781
|
+
case "automation.delete": {
|
|
782
|
+
if (typeof p.id !== "string")
|
|
783
|
+
return reply(rpcError(id, ERR.PARAMS, "id required"));
|
|
784
|
+
const jobs = loadJobs();
|
|
785
|
+
if (!jobs.some((j) => j.id === p.id))
|
|
786
|
+
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
787
|
+
saveJobs(jobs.filter((j) => j.id !== p.id));
|
|
788
|
+
return reply(rpcResult(id, { id: p.id, deleted: true }));
|
|
789
|
+
}
|
|
790
|
+
case "skills.list": {
|
|
791
|
+
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
792
|
+
return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
|
|
793
|
+
}
|
|
794
|
+
case "project.panels": {
|
|
795
|
+
// panels applicable to a project (plugin manifest `detect` markers under the cwd) — powers
|
|
796
|
+
// the desktop's chat ↔ live-preview split for design/video projects.
|
|
797
|
+
const ps = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
798
|
+
const pcwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (ps?.meta.cwd ?? opts.cwd);
|
|
799
|
+
const panels = panelsForProject(pcwd).map(({ plugin, panel }) => ({ plugin, id: panel.id, title: panel.title, command: panel.command, args: panel.args ?? [], port: panel.port }));
|
|
800
|
+
return reply(rpcResult(id, { cwd: pcwd, panels }));
|
|
801
|
+
}
|
|
802
|
+
case "files.search": {
|
|
803
|
+
// fuzzy file lookup for the composer's @-mention autocomplete (codex fuzzyFileSearch).
|
|
804
|
+
// Relative POSIX paths; empty query returns the first files as a browse list.
|
|
805
|
+
const sess = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
806
|
+
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (sess?.meta.cwd ?? opts.cwd);
|
|
807
|
+
const limit = Math.min(Math.max(Math.trunc(Number(p.limit) || 20), 1), 50);
|
|
808
|
+
const all = listProjectFiles(cwd);
|
|
809
|
+
const query = typeof p.query === "string" ? p.query : "";
|
|
810
|
+
const files = query ? fuzzyRank(query, all, (f) => f).slice(0, limit).map((r) => r.item) : all.slice(0, limit);
|
|
811
|
+
return reply(rpcResult(id, { files, cwd }));
|
|
812
|
+
}
|
|
813
|
+
case "session.context": {
|
|
814
|
+
// context-spend breakdown + watermark on demand (codex thread/tokenUsage + /context).
|
|
815
|
+
if (typeof p.sessionId !== "string")
|
|
816
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
817
|
+
const s = hub.get(p.sessionId);
|
|
818
|
+
if (!s)
|
|
819
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
820
|
+
const report = analyzeContext(s.history);
|
|
821
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, ...ctxOf(s), total: report.total, rows: report.rows.slice(0, 8) }));
|
|
822
|
+
}
|
|
823
|
+
case "session.compact": {
|
|
824
|
+
// manual compaction (codex thread/compact/start): summarize + replace history, keep working
|
|
825
|
+
// notes, restore this-cwd touched files. Busy-guarded like a turn — it IS a provider call.
|
|
826
|
+
if (typeof p.sessionId !== "string")
|
|
827
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
828
|
+
const s = hub.get(p.sessionId);
|
|
829
|
+
if (!s)
|
|
830
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
831
|
+
if (s.busy || s.configuring)
|
|
832
|
+
return reply(rpcError(id, ERR.BUSY, "a turn/configuration change is running — compact after it finishes"));
|
|
833
|
+
if (s.history.length < 2)
|
|
834
|
+
return reply(rpcError(id, ERR.PARAMS, "nothing to compact yet"));
|
|
835
|
+
s.busy = true;
|
|
836
|
+
const compactAbort = new AbortController();
|
|
837
|
+
s.abort = compactAbort;
|
|
838
|
+
try {
|
|
839
|
+
if (!(await refreshSessionProvider(s))) {
|
|
840
|
+
return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${s.meta.model}'`));
|
|
841
|
+
}
|
|
842
|
+
broadcast("event.notice", { sessionId: s.meta.id, text: "✻ Compacting conversation…" });
|
|
843
|
+
const summary = await compactSession(s, compactAbort);
|
|
844
|
+
if (!summary)
|
|
845
|
+
return reply(rpcError(id, ERR.INTERNAL, "compaction failed — try again or /clear"));
|
|
846
|
+
broadcast("event.notice", { sessionId: s.meta.id, text: `(compacted — history replaced with a summary; ${s.meta.workingSet?.length ?? 0} notes kept)` });
|
|
847
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
|
|
848
|
+
}
|
|
849
|
+
finally {
|
|
850
|
+
s.busy = false;
|
|
851
|
+
if (s.abort === compactAbort)
|
|
852
|
+
s.abort = null;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
case "session.rewind": {
|
|
856
|
+
// fork the thread back to before the n-th-most-recent user turn (codex thread/rollback;
|
|
857
|
+
// n=1 drops the last exchange). History-only — file edits are not reverted.
|
|
858
|
+
if (typeof p.sessionId !== "string" || !Number.isInteger(p.n))
|
|
859
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + n required"));
|
|
860
|
+
const s = hub.get(p.sessionId);
|
|
861
|
+
if (!s)
|
|
862
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
863
|
+
if (s.busy || s.configuring)
|
|
864
|
+
return reply(rpcError(id, ERR.BUSY, "a turn/configuration change is running — rewind after it finishes"));
|
|
865
|
+
const next = rewindTo(s.history, p.n);
|
|
866
|
+
if (!next)
|
|
867
|
+
return reply(rpcError(id, ERR.PARAMS, `n out of range (1..${s.history.filter((m) => m.role === "user").length})`));
|
|
868
|
+
s.history.length = 0;
|
|
869
|
+
s.history.push(...next);
|
|
870
|
+
hub.save(s);
|
|
871
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
|
|
872
|
+
}
|
|
873
|
+
default:
|
|
874
|
+
return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
|
|
455
875
|
}
|
|
456
|
-
case "session.rewind": {
|
|
457
|
-
// fork the thread back to before the n-th-most-recent user turn (codex thread/rollback;
|
|
458
|
-
// n=1 drops the last exchange). History-only — file edits are not reverted.
|
|
459
|
-
if (typeof p.sessionId !== "string" || !Number.isInteger(p.n))
|
|
460
|
-
return reply(rpcError(id, ERR.PARAMS, "sessionId + n required"));
|
|
461
|
-
const s = hub.get(p.sessionId);
|
|
462
|
-
if (!s)
|
|
463
|
-
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
464
|
-
if (s.busy)
|
|
465
|
-
return reply(rpcError(id, ERR.BUSY, "a turn is running — rewind after it finishes"));
|
|
466
|
-
const next = rewindTo(s.history, p.n);
|
|
467
|
-
if (!next)
|
|
468
|
-
return reply(rpcError(id, ERR.PARAMS, `n out of range (1..${s.history.filter((m) => m.role === "user").length})`));
|
|
469
|
-
s.history.length = 0;
|
|
470
|
-
s.history.push(...next);
|
|
471
|
-
hub.save(s);
|
|
472
|
-
return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
|
|
473
|
-
}
|
|
474
|
-
default:
|
|
475
|
-
return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
|
|
476
876
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
877
|
+
catch (e) {
|
|
878
|
+
return reply(rpcError(id, ERR.INTERNAL, String(e?.message ?? e)));
|
|
879
|
+
}
|
|
880
|
+
})();
|
|
881
|
+
inFlightRequests.add(task);
|
|
882
|
+
const settled = () => {
|
|
883
|
+
inFlightRequests.delete(task);
|
|
884
|
+
// close() may already have returned after its bounded grace period. Once a late turn/provider
|
|
885
|
+
// handshake clears busy/configuring, releaseIdle finishes the lock cleanup without waiting for exit.
|
|
886
|
+
if (closing)
|
|
887
|
+
hub.releaseIdle();
|
|
888
|
+
};
|
|
889
|
+
void task.then(settled, settled);
|
|
481
890
|
});
|
|
482
891
|
ws.on("close", () => {
|
|
483
892
|
authed.delete(ws);
|
|
@@ -489,20 +898,67 @@ export async function startServe(opts, deps) {
|
|
|
489
898
|
}
|
|
490
899
|
});
|
|
491
900
|
});
|
|
492
|
-
const close =
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
901
|
+
const close = () => {
|
|
902
|
+
if (closePromise)
|
|
903
|
+
return closePromise;
|
|
904
|
+
closing = true; // message handlers check this before parsing, so no new work enters the hub
|
|
905
|
+
closePromise = (async () => {
|
|
906
|
+
const deadline = Date.now() + SHUTDOWN_GRACE_MS;
|
|
907
|
+
const serverClosed = new Promise((resolve) => {
|
|
908
|
+
try {
|
|
909
|
+
wss.close(() => resolve()); // stop accepting sockets immediately
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
resolve(); // already closed/not running
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
for (const resolve of pendingApprovals.values())
|
|
916
|
+
resolve(false);
|
|
917
|
+
pendingApprovals.clear();
|
|
918
|
+
for (const session of hub.active())
|
|
919
|
+
session.abort?.abort();
|
|
920
|
+
for (const client of wss.clients) {
|
|
921
|
+
try {
|
|
922
|
+
client.close(1001, "server shutting down");
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
client.terminate();
|
|
926
|
+
}
|
|
500
927
|
}
|
|
501
|
-
|
|
502
|
-
|
|
928
|
+
const terminateTimer = setTimeout(() => {
|
|
929
|
+
for (const client of wss.clients)
|
|
930
|
+
client.terminate();
|
|
931
|
+
}, SOCKET_CLOSE_GRACE_MS);
|
|
932
|
+
terminateTimer.unref();
|
|
933
|
+
if (!deps.quietDiscovery)
|
|
934
|
+
await removeOwnedDiscovery(discoveryDir, discoveryPath, discovery).catch(() => { });
|
|
935
|
+
let quiet = false;
|
|
936
|
+
while (Date.now() < deadline) {
|
|
937
|
+
if (inFlightRequests.size === 0 && hub.active().every((session) => !session.busy && !session.configuring && session.pendingProviderTurns === 0)) {
|
|
938
|
+
quiet = true;
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
|
|
503
942
|
}
|
|
504
|
-
|
|
505
|
-
|
|
943
|
+
// Never release a lock while its turn/configuration may still persist. Idle sessions are safe to
|
|
944
|
+
// release; an uncooperative in-flight operation retains ownership until it settles/process exit.
|
|
945
|
+
if (quiet)
|
|
946
|
+
hub.releaseAll();
|
|
947
|
+
else
|
|
948
|
+
hub.releaseIdle();
|
|
949
|
+
for (const client of wss.clients)
|
|
950
|
+
client.terminate();
|
|
951
|
+
const remaining = deadline - Date.now();
|
|
952
|
+
if (remaining > 0) {
|
|
953
|
+
await Promise.race([
|
|
954
|
+
serverClosed,
|
|
955
|
+
new Promise((resolve) => setTimeout(resolve, remaining)),
|
|
956
|
+
]);
|
|
957
|
+
}
|
|
958
|
+
clearTimeout(terminateTimer);
|
|
959
|
+
authed.clear();
|
|
960
|
+
})();
|
|
961
|
+
return closePromise;
|
|
506
962
|
};
|
|
507
963
|
return { port, token, close };
|
|
508
964
|
}
|