@infomity/remogent 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +200 -2
- package/dist/adapters/claude.js +393 -0
- package/dist/adapters/codex.js +428 -0
- package/dist/adapters/gemini.js +229 -0
- package/dist/adapters/rpc.js +83 -0
- package/dist/adapters/util.js +90 -0
- package/dist/firewall.js +75 -0
- package/dist/identity.js +56 -0
- package/dist/index.js +416 -0
- package/dist/logfile.js +39 -0
- package/dist/port.js +19 -0
- package/dist/protocol.js +115 -0
- package/dist/server.js +161 -0
- package/dist/service.js +244 -0
- package/dist/sessions.js +389 -0
- package/dist/store.js +103 -0
- package/dist/title.js +43 -0
- package/package.json +54 -4
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { ProtocolError, } from "./protocol.js";
|
|
4
|
+
import { autoTitle, customTitle } from "./title.js";
|
|
5
|
+
class Session {
|
|
6
|
+
id;
|
|
7
|
+
engine;
|
|
8
|
+
cwd;
|
|
9
|
+
permissionMode;
|
|
10
|
+
status = "idle";
|
|
11
|
+
title;
|
|
12
|
+
createdAt;
|
|
13
|
+
updatedAt;
|
|
14
|
+
events = [];
|
|
15
|
+
viewers = new Set();
|
|
16
|
+
pending = new Map();
|
|
17
|
+
allowedForSession = new Set();
|
|
18
|
+
handle;
|
|
19
|
+
permSeq = 0;
|
|
20
|
+
/** Set by `SessionManager.delete`; a deleted session ignores any further engine events. */
|
|
21
|
+
deleted = false;
|
|
22
|
+
/** When the engine (or a viewer action) last produced an event; the stall watchdog measures silence from here. */
|
|
23
|
+
lastActivityAt = Date.now();
|
|
24
|
+
/** True while the last engine event was a tool call that has not reported completion. */
|
|
25
|
+
toolRunning = false;
|
|
26
|
+
/** When the watchdog last warned about the current silence (0 = not warned). */
|
|
27
|
+
lastStallWarnAt = 0;
|
|
28
|
+
constructor(id, engine, cwd, permissionMode, createdAt = Date.now()) {
|
|
29
|
+
this.id = id;
|
|
30
|
+
this.engine = engine;
|
|
31
|
+
this.cwd = cwd;
|
|
32
|
+
this.permissionMode = permissionMode;
|
|
33
|
+
this.createdAt = createdAt;
|
|
34
|
+
this.updatedAt = createdAt;
|
|
35
|
+
}
|
|
36
|
+
get lastSeq() {
|
|
37
|
+
return this.events.length ? this.events[this.events.length - 1].seq : 0;
|
|
38
|
+
}
|
|
39
|
+
info() {
|
|
40
|
+
return {
|
|
41
|
+
sessionId: this.id,
|
|
42
|
+
engine: this.engine,
|
|
43
|
+
cwd: this.cwd,
|
|
44
|
+
status: this.status,
|
|
45
|
+
...(this.title ? { title: this.title } : {}),
|
|
46
|
+
createdAt: this.createdAt,
|
|
47
|
+
updatedAt: this.updatedAt,
|
|
48
|
+
lastSeq: this.lastSeq,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class SessionManager {
|
|
53
|
+
store;
|
|
54
|
+
sessions = new Map();
|
|
55
|
+
adapters = new Map();
|
|
56
|
+
/** Viewers that want `session_updated` pushes (list screens). */
|
|
57
|
+
listeners = new Set();
|
|
58
|
+
saveTimer;
|
|
59
|
+
watchdog;
|
|
60
|
+
stallWarnMs;
|
|
61
|
+
stallToolMs;
|
|
62
|
+
stallRepeatMs;
|
|
63
|
+
/**
|
|
64
|
+
* With a store, sessions are written to disk (at most about once a second) and can be `restore`d after a restart.
|
|
65
|
+
* A watchdog warns viewers when an active turn goes silent (a hung engine looks exactly like a slow one otherwise).
|
|
66
|
+
*/
|
|
67
|
+
constructor(store, wd = {}) {
|
|
68
|
+
this.store = store;
|
|
69
|
+
this.stallWarnMs = wd.stallWarnMs ?? 60_000;
|
|
70
|
+
this.stallToolMs = wd.stallToolMs ?? 300_000;
|
|
71
|
+
this.stallRepeatMs = wd.stallRepeatMs ?? 300_000;
|
|
72
|
+
this.watchdog = setInterval(() => this.checkStalls(), wd.checkMs ?? 5_000);
|
|
73
|
+
this.watchdog.unref();
|
|
74
|
+
}
|
|
75
|
+
registerAdapter(a) {
|
|
76
|
+
this.adapters.set(a.engine, a);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Loads persisted sessions. Call after the adapters are registered and before serving clients.
|
|
80
|
+
* - Claude (resumable) sessions come back idle; the CLI is spawned with `--resume` on the next prompt.
|
|
81
|
+
* - Sessions that were mid-turn get an error + cancelled `turn_end`; pending approvals are gone (denied), and
|
|
82
|
+
* "allow for session" grants are deliberately NOT restored.
|
|
83
|
+
* - Sessions that cannot be resumed (engine missing/not resumable, no engine conversation id) are read-only history.
|
|
84
|
+
* - Sessions that never got a prompt are dropped.
|
|
85
|
+
*/
|
|
86
|
+
async restore() {
|
|
87
|
+
if (!this.store)
|
|
88
|
+
return 0;
|
|
89
|
+
let n = 0;
|
|
90
|
+
for (const p of this.store.load()) {
|
|
91
|
+
if (!p.events.some((e) => e.event.kind === "user_message"))
|
|
92
|
+
continue;
|
|
93
|
+
const s = new Session(p.id, p.engine, p.cwd, p.permissionMode, p.createdAt);
|
|
94
|
+
s.updatedAt = p.updatedAt;
|
|
95
|
+
if (p.title)
|
|
96
|
+
s.title = p.title;
|
|
97
|
+
s.events = p.events;
|
|
98
|
+
s.permSeq = p.permSeq;
|
|
99
|
+
s.status = p.status;
|
|
100
|
+
const seqBefore = s.lastSeq;
|
|
101
|
+
const adapter = this.adapters.get(p.engine);
|
|
102
|
+
let resumed = false;
|
|
103
|
+
if (p.status !== "completed" && adapter?.resumable && p.engineSessionId) {
|
|
104
|
+
try {
|
|
105
|
+
s.handle = await adapter.start({ cwd: p.cwd, permissionMode: p.permissionMode, resumeId: p.engineSessionId }, this.sinkFor(s));
|
|
106
|
+
resumed = true;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* falls through to read-only history */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (p.status === "active" && resumed) {
|
|
113
|
+
this.emit(s, { kind: "error", message: "The host restarted while this turn was running." });
|
|
114
|
+
this.emit(s, { kind: "turn_end", stopReason: "cancelled" });
|
|
115
|
+
}
|
|
116
|
+
const want = resumed ? "idle" : "completed";
|
|
117
|
+
if (s.status !== want || lastStatusEvent(s) !== want)
|
|
118
|
+
this.setStatus(s, want);
|
|
119
|
+
// Restoring is not activity: keep the original ordering unless we had to append events.
|
|
120
|
+
if (s.lastSeq === seqBefore)
|
|
121
|
+
s.updatedAt = p.updatedAt;
|
|
122
|
+
this.sessions.set(s.id, s);
|
|
123
|
+
n++;
|
|
124
|
+
}
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
/** Writes the store now (used on shutdown). */
|
|
128
|
+
flush() {
|
|
129
|
+
if (this.saveTimer)
|
|
130
|
+
clearTimeout(this.saveTimer);
|
|
131
|
+
this.saveTimer = undefined;
|
|
132
|
+
this.store?.save([...this.sessions.values()].map(toPersisted));
|
|
133
|
+
}
|
|
134
|
+
scheduleSave() {
|
|
135
|
+
if (!this.store || this.saveTimer)
|
|
136
|
+
return;
|
|
137
|
+
this.saveTimer = setTimeout(() => this.flush(), 1000);
|
|
138
|
+
this.saveTimer.unref();
|
|
139
|
+
}
|
|
140
|
+
engines() {
|
|
141
|
+
return [...this.adapters.keys()];
|
|
142
|
+
}
|
|
143
|
+
addListener(v) {
|
|
144
|
+
this.listeners.add(v);
|
|
145
|
+
}
|
|
146
|
+
/** Call when a client socket closes. */
|
|
147
|
+
viewerGone(v) {
|
|
148
|
+
this.listeners.delete(v);
|
|
149
|
+
for (const s of this.sessions.values())
|
|
150
|
+
s.viewers.delete(v);
|
|
151
|
+
}
|
|
152
|
+
list() {
|
|
153
|
+
return [...this.sessions.values()].map((s) => s.info()).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
154
|
+
}
|
|
155
|
+
get(sessionId, reqId) {
|
|
156
|
+
const s = this.sessions.get(sessionId);
|
|
157
|
+
if (!s)
|
|
158
|
+
throw new ProtocolError("unknown_session", `unknown session "${sessionId}"`, reqId);
|
|
159
|
+
return s;
|
|
160
|
+
}
|
|
161
|
+
async create(engine, cwd, permissionMode, reqId) {
|
|
162
|
+
const adapter = this.adapters.get(engine);
|
|
163
|
+
if (!adapter)
|
|
164
|
+
throw new ProtocolError("engine_unavailable", `engine "${engine}" is not available on this host`, reqId);
|
|
165
|
+
try {
|
|
166
|
+
if (!(await stat(cwd)).isDirectory())
|
|
167
|
+
throw new Error("not a directory");
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
throw new ProtocolError("cwd_denied", "cwd does not exist or is not a directory", reqId);
|
|
171
|
+
}
|
|
172
|
+
const s = new Session("s" + randomBytes(6).toString("hex"), engine, cwd, permissionMode);
|
|
173
|
+
this.sessions.set(s.id, s);
|
|
174
|
+
try {
|
|
175
|
+
s.handle = await adapter.start({ cwd, permissionMode }, this.sinkFor(s));
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
this.sessions.delete(s.id);
|
|
179
|
+
throw new ProtocolError("engine_failed", e instanceof Error ? e.message : String(e), reqId);
|
|
180
|
+
}
|
|
181
|
+
this.pushUpdated(s);
|
|
182
|
+
return s.info();
|
|
183
|
+
}
|
|
184
|
+
attach(sessionId, v, afterSeq, reqId) {
|
|
185
|
+
const s = this.get(sessionId, reqId);
|
|
186
|
+
const replay = s.events.filter((e) => e.seq > afterSeq);
|
|
187
|
+
const fromSeq = replay.length ? replay[0].seq : s.lastSeq + 1;
|
|
188
|
+
v.send({ type: "session_attached", reqId, sessionId, fromSeq, toSeq: s.lastSeq });
|
|
189
|
+
for (const e of replay) {
|
|
190
|
+
v.send({ type: "session_event", sessionId, seq: e.seq, ...(e.ts !== undefined ? { ts: e.ts } : {}), event: e.event, replay: true });
|
|
191
|
+
}
|
|
192
|
+
for (const p of s.pending.values())
|
|
193
|
+
v.send({ type: "permission_request", sessionId, ...stripKind(p.body) });
|
|
194
|
+
s.viewers.add(v);
|
|
195
|
+
}
|
|
196
|
+
detach(sessionId, v) {
|
|
197
|
+
this.get(sessionId).viewers.delete(v);
|
|
198
|
+
}
|
|
199
|
+
/** Starts a turn; returns once it is accepted (output arrives as events). */
|
|
200
|
+
prompt(sessionId, text, reqId) {
|
|
201
|
+
const s = this.get(sessionId, reqId);
|
|
202
|
+
if (s.status === "completed" || !s.handle)
|
|
203
|
+
throw new ProtocolError("invalid_message", "session is closed", reqId);
|
|
204
|
+
if (s.status === "active")
|
|
205
|
+
throw new ProtocolError("invalid_message", "a turn is already running", reqId);
|
|
206
|
+
if (!s.title)
|
|
207
|
+
s.title = autoTitle(text);
|
|
208
|
+
this.setStatus(s, "active");
|
|
209
|
+
this.emit(s, { kind: "user_message", text });
|
|
210
|
+
s.handle.prompt(text).then(() => {
|
|
211
|
+
if (s.status === "active")
|
|
212
|
+
this.setStatus(s, "idle");
|
|
213
|
+
}, (e) => {
|
|
214
|
+
this.emit(s, { kind: "error", message: e instanceof Error ? e.message : String(e) });
|
|
215
|
+
this.emit(s, { kind: "turn_end", stopReason: "error" });
|
|
216
|
+
if (s.status === "active")
|
|
217
|
+
this.setStatus(s, "idle");
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
cancel(sessionId) {
|
|
221
|
+
const s = this.get(sessionId);
|
|
222
|
+
this.denyAllPending(s);
|
|
223
|
+
s.handle?.cancel();
|
|
224
|
+
}
|
|
225
|
+
close(sessionId, reqId) {
|
|
226
|
+
const s = this.get(sessionId, reqId);
|
|
227
|
+
this.denyAllPending(s);
|
|
228
|
+
s.handle?.close();
|
|
229
|
+
this.setStatus(s, "completed");
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Removes a chat for good: stops the engine if it still runs, drops the transcript from memory and from the
|
|
233
|
+
* store, and tells every connected client with `session_deleted`. Unlike `close`, nothing stays readable.
|
|
234
|
+
*/
|
|
235
|
+
delete(sessionId, reqId) {
|
|
236
|
+
const s = this.get(sessionId, reqId);
|
|
237
|
+
this.denyAllPending(s);
|
|
238
|
+
s.deleted = true;
|
|
239
|
+
s.handle?.close();
|
|
240
|
+
this.sessions.delete(sessionId);
|
|
241
|
+
const msg = { type: "session_deleted", sessionId };
|
|
242
|
+
const told = new Set([...this.listeners, ...s.viewers]);
|
|
243
|
+
for (const v of told)
|
|
244
|
+
v.send(msg);
|
|
245
|
+
s.viewers.clear();
|
|
246
|
+
this.scheduleSave();
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Sets the chat title (works for finished chats too). An empty title resets to the automatic one derived from the
|
|
250
|
+
* first prompt. Pushes `session_updated` to list listeners; does not touch `updatedAt` so the drawer order stays put.
|
|
251
|
+
*/
|
|
252
|
+
rename(sessionId, title, reqId) {
|
|
253
|
+
const s = this.get(sessionId, reqId);
|
|
254
|
+
const next = customTitle(title);
|
|
255
|
+
if (next)
|
|
256
|
+
s.title = next;
|
|
257
|
+
else {
|
|
258
|
+
const first = s.events.find((e) => e.event.kind === "user_message")?.event;
|
|
259
|
+
const auto = first && first.kind === "user_message" ? autoTitle(first.text) : "";
|
|
260
|
+
if (auto)
|
|
261
|
+
s.title = auto;
|
|
262
|
+
else
|
|
263
|
+
delete s.title;
|
|
264
|
+
}
|
|
265
|
+
this.pushUpdated(s);
|
|
266
|
+
this.scheduleSave();
|
|
267
|
+
}
|
|
268
|
+
respond(sessionId, requestId, decision) {
|
|
269
|
+
const s = this.get(sessionId);
|
|
270
|
+
const p = s.pending.get(requestId);
|
|
271
|
+
if (!p)
|
|
272
|
+
throw new ProtocolError("unknown_request", `no pending request "${requestId}"`);
|
|
273
|
+
s.pending.delete(requestId);
|
|
274
|
+
if (decision === "allow_session")
|
|
275
|
+
s.allowedForSession.add(p.toolKey);
|
|
276
|
+
this.emit(s, { kind: "permission_resolved", requestId, decision });
|
|
277
|
+
p.resolve(decision);
|
|
278
|
+
}
|
|
279
|
+
closeAll() {
|
|
280
|
+
if (this.watchdog)
|
|
281
|
+
clearInterval(this.watchdog);
|
|
282
|
+
this.watchdog = undefined;
|
|
283
|
+
for (const s of this.sessions.values()) {
|
|
284
|
+
this.denyAllPending(s);
|
|
285
|
+
s.handle?.close();
|
|
286
|
+
}
|
|
287
|
+
this.flush();
|
|
288
|
+
}
|
|
289
|
+
// ---- internals ----
|
|
290
|
+
sinkFor(s) {
|
|
291
|
+
return {
|
|
292
|
+
emit: (event) => this.emit(s, event),
|
|
293
|
+
requestPermission: (req) => {
|
|
294
|
+
const toolKey = `${req.tool}:${req.risk}`;
|
|
295
|
+
if (s.allowedForSession.has(toolKey))
|
|
296
|
+
return Promise.resolve("allow_session");
|
|
297
|
+
const requestId = `p${++s.permSeq}`;
|
|
298
|
+
const body = { kind: "permission_request", requestId, ...req };
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
s.pending.set(requestId, { body, toolKey, resolve });
|
|
301
|
+
this.emit(s, body);
|
|
302
|
+
for (const v of s.viewers)
|
|
303
|
+
v.send({ type: "permission_request", sessionId: s.id, ...stripKind(body) });
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
denyAllPending(s) {
|
|
309
|
+
for (const [requestId, p] of s.pending) {
|
|
310
|
+
this.emit(s, { kind: "permission_resolved", requestId, decision: "deny" });
|
|
311
|
+
p.resolve("deny");
|
|
312
|
+
}
|
|
313
|
+
s.pending.clear();
|
|
314
|
+
}
|
|
315
|
+
emit(s, event, watchdogNotice = false) {
|
|
316
|
+
if (s.deleted)
|
|
317
|
+
return; // a late engine event (exit, error) must not resurrect a deleted chat
|
|
318
|
+
const seq = s.lastSeq + 1;
|
|
319
|
+
const ts = Date.now();
|
|
320
|
+
s.events.push({ seq, event, ts });
|
|
321
|
+
s.updatedAt = ts;
|
|
322
|
+
if (!watchdogNotice) {
|
|
323
|
+
// Any real event ends the silence (the watchdog's own warning must not reset it).
|
|
324
|
+
s.lastActivityAt = ts;
|
|
325
|
+
s.lastStallWarnAt = 0;
|
|
326
|
+
s.toolRunning = event.kind === "tool_call" || (event.kind === "tool_call_update" && event.status === "running");
|
|
327
|
+
}
|
|
328
|
+
if (event.kind === "status")
|
|
329
|
+
s.status = event.status;
|
|
330
|
+
for (const v of s.viewers)
|
|
331
|
+
v.send({ type: "session_event", sessionId: s.id, seq, ts, event });
|
|
332
|
+
this.pushUpdated(s);
|
|
333
|
+
this.scheduleSave();
|
|
334
|
+
}
|
|
335
|
+
/** Warns viewers about active turns that have produced nothing for too long. Quiet while an approval is pending. */
|
|
336
|
+
checkStalls() {
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
for (const s of this.sessions.values()) {
|
|
339
|
+
if (s.deleted || s.status !== "active" || !s.handle || s.pending.size > 0)
|
|
340
|
+
continue;
|
|
341
|
+
const silent = now - s.lastActivityAt;
|
|
342
|
+
if (silent < (s.toolRunning ? this.stallToolMs : this.stallWarnMs))
|
|
343
|
+
continue;
|
|
344
|
+
if (s.lastStallWarnAt && now - s.lastStallWarnAt < this.stallRepeatMs)
|
|
345
|
+
continue;
|
|
346
|
+
s.lastStallWarnAt = now;
|
|
347
|
+
const who = s.engine === "gemini" ? "Gemini" : s.engine === "codex" ? "Codex" : "Claude";
|
|
348
|
+
const how = silent >= 120_000 ? `${Math.round(silent / 60_000)} min` : `${Math.round(silent / 1000)} s`;
|
|
349
|
+
const what = s.toolRunning ? "a tool call is still running" : `${who} may be stuck waiting on the model API`;
|
|
350
|
+
this.emit(s, { kind: "error", message: `No output from ${who} for ${how}: ${what}. Tap Stop and resend if it does not recover.` }, true);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
setStatus(s, status) {
|
|
354
|
+
this.emit(s, { kind: "status", status });
|
|
355
|
+
}
|
|
356
|
+
pushUpdated(s) {
|
|
357
|
+
for (const v of this.listeners)
|
|
358
|
+
v.send({ type: "session_updated", session: s.info() });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function stripKind(o) {
|
|
362
|
+
const { kind: _kind, ...rest } = o;
|
|
363
|
+
return rest;
|
|
364
|
+
}
|
|
365
|
+
function lastStatusEvent(s) {
|
|
366
|
+
for (let i = s.events.length - 1; i >= 0; i--) {
|
|
367
|
+
const ev = s.events[i].event;
|
|
368
|
+
if (ev.kind === "status")
|
|
369
|
+
return ev.status;
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
function toPersisted(s) {
|
|
374
|
+
const engineSessionId = s.handle?.engineSessionId?.();
|
|
375
|
+
return {
|
|
376
|
+
id: s.id,
|
|
377
|
+
engine: s.engine,
|
|
378
|
+
cwd: s.cwd,
|
|
379
|
+
permissionMode: s.permissionMode,
|
|
380
|
+
...(s.title ? { title: s.title } : {}),
|
|
381
|
+
createdAt: s.createdAt,
|
|
382
|
+
updatedAt: s.updatedAt,
|
|
383
|
+
status: s.status,
|
|
384
|
+
...(engineSessionId ? { engineSessionId } : {}),
|
|
385
|
+
permSeq: s.permSeq,
|
|
386
|
+
events: s.events,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
//# sourceMappingURL=sessions.js.map
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { REMOGENT_DIR } from "./identity.js";
|
|
4
|
+
/** Where session transcripts live on the host (next to identity.json). Plain JSON, owner-only where the OS supports it. */
|
|
5
|
+
export const SESSIONS_FILE = join(REMOGENT_DIR, "sessions.json");
|
|
6
|
+
const FILE_VERSION = 1;
|
|
7
|
+
/** Sessions untouched for this long are dropped when the daemon loads them. */
|
|
8
|
+
const MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
9
|
+
/** At most this many sessions are kept (most recently updated first). */
|
|
10
|
+
const MAX_SESSIONS = 100;
|
|
11
|
+
/** Approximate serialized size kept per session; the oldest events are trimmed first (seq numbers stay intact). */
|
|
12
|
+
const MAX_SESSION_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
14
|
+
function valid(p) {
|
|
15
|
+
if (!isObj(p))
|
|
16
|
+
return false;
|
|
17
|
+
return (typeof p["id"] === "string" &&
|
|
18
|
+
(p["engine"] === "claude" || p["engine"] === "gemini" || p["engine"] === "codex") &&
|
|
19
|
+
typeof p["cwd"] === "string" &&
|
|
20
|
+
typeof p["permissionMode"] === "string" &&
|
|
21
|
+
typeof p["createdAt"] === "number" &&
|
|
22
|
+
typeof p["updatedAt"] === "number" &&
|
|
23
|
+
typeof p["status"] === "string" &&
|
|
24
|
+
Array.isArray(p["events"]) &&
|
|
25
|
+
p["events"].every((e) => isObj(e) && typeof e["seq"] === "number" && isObj(e["event"])));
|
|
26
|
+
}
|
|
27
|
+
/** Keeps the newest events of one session within the byte budget. */
|
|
28
|
+
function trimEvents(events) {
|
|
29
|
+
let bytes = 0;
|
|
30
|
+
let from = events.length;
|
|
31
|
+
while (from > 0) {
|
|
32
|
+
bytes += JSON.stringify(events[from - 1]).length;
|
|
33
|
+
if (bytes > MAX_SESSION_BYTES)
|
|
34
|
+
break;
|
|
35
|
+
from--;
|
|
36
|
+
}
|
|
37
|
+
return from === 0 ? events : events.slice(from);
|
|
38
|
+
}
|
|
39
|
+
export class SessionStore {
|
|
40
|
+
file;
|
|
41
|
+
log;
|
|
42
|
+
constructor(file = SESSIONS_FILE, log = () => { }) {
|
|
43
|
+
this.file = file;
|
|
44
|
+
this.log = log;
|
|
45
|
+
}
|
|
46
|
+
/** Never throws: a missing file is an empty store, a corrupt one is set aside and ignored. */
|
|
47
|
+
load(now = Date.now()) {
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = readFileSync(this.file, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const raw = JSON.parse(text);
|
|
57
|
+
if (!isObj(raw) || raw["version"] !== FILE_VERSION || !Array.isArray(raw["sessions"]))
|
|
58
|
+
throw new Error("unrecognized format");
|
|
59
|
+
return raw["sessions"]
|
|
60
|
+
.filter(valid)
|
|
61
|
+
.filter((s) => now - s.updatedAt < MAX_AGE_MS)
|
|
62
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
63
|
+
.slice(0, MAX_SESSIONS);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
const aside = `${this.file}.corrupt-${now}`;
|
|
67
|
+
try {
|
|
68
|
+
renameSync(this.file, aside);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* nothing more we can do */
|
|
72
|
+
}
|
|
73
|
+
this.log(`sessions file unreadable (${e instanceof Error ? e.message : String(e)}); set aside as ${aside}`);
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Atomic (temp file + rename) so a crash mid-write never leaves a half-written store. Never throws. */
|
|
78
|
+
save(sessions) {
|
|
79
|
+
const body = {
|
|
80
|
+
version: FILE_VERSION,
|
|
81
|
+
sessions: [...sessions]
|
|
82
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
83
|
+
.slice(0, MAX_SESSIONS)
|
|
84
|
+
.map((s) => ({ ...s, events: trimEvents(s.events) })),
|
|
85
|
+
};
|
|
86
|
+
const tmp = `${this.file}.tmp`;
|
|
87
|
+
try {
|
|
88
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
89
|
+
writeFileSync(tmp, JSON.stringify(body), { encoding: "utf8", mode: 0o600 });
|
|
90
|
+
try {
|
|
91
|
+
chmodSync(tmp, 0o600);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* not supported on this platform */
|
|
95
|
+
}
|
|
96
|
+
renameSync(tmp, this.file);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
this.log(`could not save sessions: ${e instanceof Error ? e.message : String(e)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=store.js.map
|
package/dist/title.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat titles. Neither engine sends one (probed 2026-11-14: Claude Code stream-json only emits
|
|
3
|
+
* `system:session_title_changed` after an explicit `/rename`; Gemini ACP has no title channel), so the host derives
|
|
4
|
+
* a title from the first prompt and lets the owner rename it.
|
|
5
|
+
*/
|
|
6
|
+
const MAX_AUTO = 60;
|
|
7
|
+
export const MAX_CUSTOM = 80;
|
|
8
|
+
/** Windows (`C:\a\b.ts`, `E:/a/b`), relative (`./a`, `..\a`, `~/a`) and absolute multi-segment POSIX paths. */
|
|
9
|
+
const PATH_RE = /(?<![\w./\\:-])(?:[A-Za-z]:[\\/]|\.{1,2}[\\/]|~[\\/]|\/(?=[^\s/]+\/))[^\s"'`<>|]*/g;
|
|
10
|
+
function clip(s, max) {
|
|
11
|
+
if (s.length <= max)
|
|
12
|
+
return s;
|
|
13
|
+
const cut = s.slice(0, max - 1);
|
|
14
|
+
const sp = cut.lastIndexOf(" ");
|
|
15
|
+
return (sp > max * 0.5 ? cut.slice(0, sp) : cut).trimEnd() + "\u2026";
|
|
16
|
+
}
|
|
17
|
+
function lastSegment(p) {
|
|
18
|
+
const trimmed = p.replace(/[\\/]+$/, "").replace(/[.,;:!?)\]]+$/, "");
|
|
19
|
+
const seg = trimmed.split(/[\\/]/).pop() ?? "";
|
|
20
|
+
return seg && seg !== "." && seg !== ".." && seg !== "~" ? seg : "";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* First-prompt fallback title: collapses whitespace, drops a leading slash command (`/init fix the build` becomes
|
|
24
|
+
* `fix the build`) and shortens file paths to their last segment (`fix E:\a\b\foo.ts` becomes `fix foo.ts`).
|
|
25
|
+
* Never returns an empty string for non-empty input: if cleaning removes everything the trimmed original is used.
|
|
26
|
+
*/
|
|
27
|
+
export function autoTitle(raw, max = MAX_AUTO) {
|
|
28
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
29
|
+
if (!flat)
|
|
30
|
+
return "";
|
|
31
|
+
let t = flat.replace(/^(?:\/[\w:.-]+\s*)+/, "");
|
|
32
|
+
t = t.replace(PATH_RE, (m) => lastSegment(m));
|
|
33
|
+
t = t
|
|
34
|
+
.replace(/^[#>*\-\s`"']+/, "") // markdown/quote leaders
|
|
35
|
+
.replace(/\s+/g, " ")
|
|
36
|
+
.trim();
|
|
37
|
+
return clip(t || flat, max);
|
|
38
|
+
}
|
|
39
|
+
/** Owner-chosen title: whitespace collapsed and capped. Empty input returns "" (meaning "reset to automatic"). */
|
|
40
|
+
export function customTitle(raw) {
|
|
41
|
+
return clip(raw.replace(/\s+/g, " ").trim(), MAX_CUSTOM);
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=title.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@infomity/remogent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Remogent host: drive Claude Code, Gemini CLI and Codex CLI on your PC from the Remogent Android app. No cloud relay, no account.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": ["remogent", "claude-code", "gemini-cli", "codex", "remote", "android", "ai-agent", "cli"],
|
|
8
|
+
"homepage": "https://infomity.github.io/remogent/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/infomity/Remogent.git",
|
|
12
|
+
"directory": "companion"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/infomity/Remogent/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"remogent": "dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"!dist/**/*.map",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"dev": "tsx src/index.ts",
|
|
35
|
+
"start": "node dist/index.js start",
|
|
36
|
+
"setup": "npm install && npm run build && node dist/index.js setup",
|
|
37
|
+
"doctor": "node dist/index.js doctor",
|
|
38
|
+
"pair": "node dist/index.js pair",
|
|
39
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
40
|
+
"prepack": "node scripts/prepack.mjs",
|
|
41
|
+
"prepublishOnly": "npm run build"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"bonjour-service": "^1.3.0",
|
|
45
|
+
"qrcode-terminal": "^0.12.0",
|
|
46
|
+
"selfsigned": "^2.4.1",
|
|
47
|
+
"ws": "^8.18.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"@types/qrcode-terminal": "^0.12.2",
|
|
52
|
+
"@types/ws": "^8.5.12",
|
|
53
|
+
"tsx": "^4.19.0",
|
|
54
|
+
"typescript": "^5.6.0"
|
|
55
|
+
}
|
|
56
|
+
}
|