@lelouchhe/webagent 0.2.6 → 0.4.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/README.md +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +102 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +127 -9
- package/lib/daemon.js +185 -40
- package/lib/event-handler.js +209 -90
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1218 -144
- package/lib/server.js +159 -32
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +94 -0
- package/lib/store.js +654 -24
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.4FZ67UW4.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session runtime state: single source of truth for "what state is this
|
|
3
|
+
* session in right now" (busy / streaming / pending permissions).
|
|
4
|
+
*
|
|
5
|
+
* The frontend fetches a full snapshot on connect / reconnect / after long
|
|
6
|
+
* backgrounding, then applies incremental `state_patch` SSE events. This
|
|
7
|
+
* replaces the old "replay history + reconcile" approach which repeatedly
|
|
8
|
+
* grew one-off sync paths per state field.
|
|
9
|
+
*/
|
|
10
|
+
function defaultState() {
|
|
11
|
+
return {
|
|
12
|
+
seq: 0,
|
|
13
|
+
runtime: {
|
|
14
|
+
busy: null,
|
|
15
|
+
pendingPermissions: [],
|
|
16
|
+
streaming: { assistant: false, thinking: false },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function busyEqual(a, b) {
|
|
21
|
+
if (a === null && b === null)
|
|
22
|
+
return true;
|
|
23
|
+
if (a === null || b === null)
|
|
24
|
+
return false;
|
|
25
|
+
return a.kind === b.kind && a.since === b.since && a.promptId === b.promptId;
|
|
26
|
+
}
|
|
27
|
+
function permsEqual(a, b) {
|
|
28
|
+
if (a.length !== b.length)
|
|
29
|
+
return false;
|
|
30
|
+
for (let i = 0; i < a.length; i++) {
|
|
31
|
+
const x = a[i], y = b[i];
|
|
32
|
+
if (x.requestId !== y.requestId ||
|
|
33
|
+
x.toolName !== y.toolName ||
|
|
34
|
+
x.title !== y.title)
|
|
35
|
+
return false;
|
|
36
|
+
if (x.options.length !== y.options.length)
|
|
37
|
+
return false;
|
|
38
|
+
for (let j = 0; j < x.options.length; j++) {
|
|
39
|
+
if (x.options[j].optionId !== y.options[j].optionId ||
|
|
40
|
+
x.options[j].label !== y.options[j].label)
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/** True when the patch would change the current runtime state. */
|
|
47
|
+
function hasRuntimeChanges(current, patch) {
|
|
48
|
+
if (!patch)
|
|
49
|
+
return false;
|
|
50
|
+
if ("busy" in patch && !busyEqual(current.busy, patch.busy ?? null))
|
|
51
|
+
return true;
|
|
52
|
+
if ("pendingPermissions" in patch &&
|
|
53
|
+
patch.pendingPermissions &&
|
|
54
|
+
!permsEqual(current.pendingPermissions, patch.pendingPermissions))
|
|
55
|
+
return true;
|
|
56
|
+
if ("streaming" in patch && patch.streaming) {
|
|
57
|
+
const s = patch.streaming;
|
|
58
|
+
if (s.assistant !== undefined &&
|
|
59
|
+
s.assistant !== current.streaming.assistant)
|
|
60
|
+
return true;
|
|
61
|
+
if (s.thinking !== undefined && s.thinking !== current.streaming.thinking)
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
export class SessionStateManager {
|
|
67
|
+
states = new Map();
|
|
68
|
+
listeners = new Set();
|
|
69
|
+
cancelTimers = new Map();
|
|
70
|
+
/** Get current state (creates default entry on first access). */
|
|
71
|
+
getState(sessionId) {
|
|
72
|
+
let s = this.states.get(sessionId);
|
|
73
|
+
if (!s) {
|
|
74
|
+
s = defaultState();
|
|
75
|
+
this.states.set(sessionId, s);
|
|
76
|
+
}
|
|
77
|
+
return s;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Merge a patch into the session's runtime state. Bumps seq and notifies
|
|
81
|
+
* listeners only when the patch actually changes something (no-op patches
|
|
82
|
+
* are dropped silently).
|
|
83
|
+
*/
|
|
84
|
+
patch(sessionId, patch) {
|
|
85
|
+
const state = this.getState(sessionId);
|
|
86
|
+
const runtimeChanged = hasRuntimeChanges(state.runtime, patch.runtime);
|
|
87
|
+
if (!runtimeChanged)
|
|
88
|
+
return;
|
|
89
|
+
if (patch.runtime) {
|
|
90
|
+
if ("busy" in patch.runtime) {
|
|
91
|
+
state.runtime.busy = patch.runtime.busy ?? null;
|
|
92
|
+
}
|
|
93
|
+
if ("pendingPermissions" in patch.runtime &&
|
|
94
|
+
patch.runtime.pendingPermissions) {
|
|
95
|
+
state.runtime.pendingPermissions =
|
|
96
|
+
patch.runtime.pendingPermissions.slice();
|
|
97
|
+
}
|
|
98
|
+
if ("streaming" in patch.runtime && patch.runtime.streaming) {
|
|
99
|
+
if (patch.runtime.streaming.assistant !== undefined) {
|
|
100
|
+
state.runtime.streaming.assistant = patch.runtime.streaming.assistant;
|
|
101
|
+
}
|
|
102
|
+
if (patch.runtime.streaming.thinking !== undefined) {
|
|
103
|
+
state.runtime.streaming.thinking = patch.runtime.streaming.thinking;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
state.seq += 1;
|
|
108
|
+
const event = {
|
|
109
|
+
type: "state_patch",
|
|
110
|
+
sessionId,
|
|
111
|
+
seq: state.seq,
|
|
112
|
+
patch,
|
|
113
|
+
};
|
|
114
|
+
for (const l of this.listeners)
|
|
115
|
+
l(event);
|
|
116
|
+
}
|
|
117
|
+
/** Subscribe to patch events. Returns an unsubscribe function. */
|
|
118
|
+
onPatch(cb) {
|
|
119
|
+
this.listeners.add(cb);
|
|
120
|
+
return () => {
|
|
121
|
+
this.listeners.delete(cb);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Clear all state for a session (call from SessionManager.deleteSession). */
|
|
125
|
+
delete(sessionId) {
|
|
126
|
+
this.states.delete(sessionId);
|
|
127
|
+
const t = this.cancelTimers.get(sessionId);
|
|
128
|
+
if (t) {
|
|
129
|
+
clearTimeout(t);
|
|
130
|
+
this.cancelTimers.delete(sessionId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Backend safety net for cancel: if busy is still set after `timeoutMs`,
|
|
135
|
+
* force-clear it. Replaces the old frontend cancel timer.
|
|
136
|
+
* A second arm on the same session replaces the existing timer.
|
|
137
|
+
*/
|
|
138
|
+
armCancelSafety(sessionId, timeoutMs) {
|
|
139
|
+
if (timeoutMs <= 0)
|
|
140
|
+
return;
|
|
141
|
+
const existing = this.cancelTimers.get(sessionId);
|
|
142
|
+
if (existing)
|
|
143
|
+
clearTimeout(existing);
|
|
144
|
+
const t = setTimeout(() => {
|
|
145
|
+
this.cancelTimers.delete(sessionId);
|
|
146
|
+
this.patch(sessionId, { runtime: { busy: null } });
|
|
147
|
+
}, timeoutMs);
|
|
148
|
+
if (typeof t === "object" && "unref" in t)
|
|
149
|
+
t.unref();
|
|
150
|
+
this.cancelTimers.set(sessionId, t);
|
|
151
|
+
}
|
|
152
|
+
/** Cancel the safety net timer (e.g. when prompt_done arrives naturally). */
|
|
153
|
+
clearCancelSafety(sessionId) {
|
|
154
|
+
const t = this.cancelTimers.get(sessionId);
|
|
155
|
+
if (t) {
|
|
156
|
+
clearTimeout(t);
|
|
157
|
+
this.cancelTimers.delete(sessionId);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Resolved absolute path to `<dataDir>/sessions/`. Pinned at server boot so
|
|
5
|
+
* later `file://` URI construction and startsWith assertions all compare
|
|
6
|
+
* against the same realpath (defends against macOS `/var → /private/var`
|
|
7
|
+
* symlink + any future symlink swaps under `data_dir`).
|
|
8
|
+
*
|
|
9
|
+
* Throws if the directory cannot be created or resolved — fail fast at boot
|
|
10
|
+
* rather than later when an attachment dispatch tries to use it.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveSessionsAnchor(dataDir) {
|
|
13
|
+
const dir = join(dataDir, "sessions");
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
const real = realpathSync(dir);
|
|
16
|
+
// Normalize trailing separator so `startsWith(anchor + sep)` is the
|
|
17
|
+
// canonical "is path a strict descendant" check everywhere.
|
|
18
|
+
return real.endsWith(sep) ? real.slice(0, -sep.length) : real;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Returns true iff `realpath` is a strict descendant of
|
|
22
|
+
* `<sessionsAnchor>/<sessionId>/attachments/`. Both args must already be
|
|
23
|
+
* realpath-resolved (no `..`, no symlinks left).
|
|
24
|
+
*/
|
|
25
|
+
export function isInsideSessionAttachments(sessionsAnchor, sessionId, realpath) {
|
|
26
|
+
const expected = sessionsAnchor + sep + sessionId + sep + "attachments" + sep;
|
|
27
|
+
return realpath.startsWith(expected);
|
|
28
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { log } from "../log.js";
|
|
2
|
+
const slog = log.scope("share");
|
|
3
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Sweep one batch of stale previews (older than 24h, never activated,
|
|
6
|
+
* never revoked). Returns rows removed. `now` injectable for tests.
|
|
7
|
+
*/
|
|
8
|
+
export function sweepStaleSharePreviewsOnce(store, now = Date.now()) {
|
|
9
|
+
const removed = store.pruneStalePreviews(now);
|
|
10
|
+
if (removed > 0) {
|
|
11
|
+
slog.info("preview gc", { removed });
|
|
12
|
+
}
|
|
13
|
+
return removed;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Start the share-preview GC: sweep once synchronously on boot, then
|
|
17
|
+
* every 24h. Handle is unref'd so this interval alone doesn't keep the
|
|
18
|
+
* event loop alive — server.ts owns lifecycle.
|
|
19
|
+
*
|
|
20
|
+
* Only call when `config.share.enabled === true`; otherwise skip entirely.
|
|
21
|
+
*/
|
|
22
|
+
export function startSharePreviewCleanup(store) {
|
|
23
|
+
try {
|
|
24
|
+
sweepStaleSharePreviewsOnce(store);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
slog.error("preview gc initial sweep failed", { error: err });
|
|
28
|
+
}
|
|
29
|
+
const timer = setInterval(() => {
|
|
30
|
+
try {
|
|
31
|
+
sweepStaleSharePreviewsOnce(store);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
slog.error("preview gc sweep failed", { error: err });
|
|
35
|
+
}
|
|
36
|
+
}, DAY_MS);
|
|
37
|
+
if (typeof timer.unref === "function")
|
|
38
|
+
timer.unref();
|
|
39
|
+
return {
|
|
40
|
+
armed: true,
|
|
41
|
+
stop: () => {
|
|
42
|
+
clearInterval(timer);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|