@lelouchhe/webagent 0.8.0 → 0.10.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 +43 -15
- package/config.toml +7 -27
- package/dist/index.html +21 -5
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +6 -6
- package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
- package/dist/sw.js +6 -6
- package/lib/agent-key.js +6 -0
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +69 -7
- package/lib/auth-middleware.js +11 -4
- package/lib/auth.js +2 -2
- package/lib/bridge.js +209 -90
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +166 -85
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +1022 -475
- package/lib/server.js +84 -34
- package/lib/share/routes.js +97 -85
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +992 -284
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +90 -38
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +8 -1
- package/dist/js/app.XBFXH37R.js +0 -2
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.CVWXSKJM.js +0 -1
- package/lib/session-manager.js +0 -613
- package/lib/title-service.js +0 -95
package/lib/task-path.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Server-side syntax primitives for task-target commands. */
|
|
2
|
+
/** An invalid command head is rejected rather than being guessed as ordinary input. */
|
|
3
|
+
export class TaskPathParseError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "TaskPathParseError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function isWhitespace(char) {
|
|
10
|
+
return /\s/u.test(char);
|
|
11
|
+
}
|
|
12
|
+
function skipWhitespace(source, offset) {
|
|
13
|
+
let cursor = offset;
|
|
14
|
+
while (cursor < source.length && isWhitespace(source[cursor]))
|
|
15
|
+
cursor++;
|
|
16
|
+
return cursor;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse one deliberately small shell-style word.
|
|
20
|
+
*
|
|
21
|
+
* Quotes and backslash escapes exist only as input syntax. This is not a shell:
|
|
22
|
+
* variables, command substitution, globbing, comments, and operators are all
|
|
23
|
+
* ordinary characters.
|
|
24
|
+
*/
|
|
25
|
+
function parseShellWord(source, start) {
|
|
26
|
+
let cursor = start;
|
|
27
|
+
let value = "";
|
|
28
|
+
let consumed = false;
|
|
29
|
+
while (cursor < source.length && !isWhitespace(source[cursor])) {
|
|
30
|
+
const char = source[cursor];
|
|
31
|
+
if (char === "\\") {
|
|
32
|
+
if (cursor + 1 >= source.length) {
|
|
33
|
+
throw new TaskPathParseError("Target ends with an incomplete escape");
|
|
34
|
+
}
|
|
35
|
+
value += source[cursor + 1];
|
|
36
|
+
cursor += 2;
|
|
37
|
+
consumed = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (char === "'" || char === '"') {
|
|
41
|
+
const quote = char;
|
|
42
|
+
cursor++;
|
|
43
|
+
consumed = true;
|
|
44
|
+
let closed = false;
|
|
45
|
+
while (cursor < source.length) {
|
|
46
|
+
const quoted = source[cursor];
|
|
47
|
+
if (quoted === quote) {
|
|
48
|
+
cursor++;
|
|
49
|
+
closed = true;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (quote === '"' && quoted === "\\") {
|
|
53
|
+
if (cursor + 1 >= source.length) {
|
|
54
|
+
throw new TaskPathParseError("Target ends with an incomplete escape inside double quotes");
|
|
55
|
+
}
|
|
56
|
+
value += source[cursor + 1];
|
|
57
|
+
cursor += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
value += quoted;
|
|
61
|
+
cursor++;
|
|
62
|
+
}
|
|
63
|
+
if (!closed) {
|
|
64
|
+
throw new TaskPathParseError("Target contains an unterminated quote");
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
value += char;
|
|
69
|
+
cursor++;
|
|
70
|
+
consumed = true;
|
|
71
|
+
}
|
|
72
|
+
if (!consumed || value.length === 0) {
|
|
73
|
+
throw new TaskPathParseError("Task command requires a non-empty target");
|
|
74
|
+
}
|
|
75
|
+
return { value, end: cursor };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Decode a slash-separated path without resolving it against a task tree or
|
|
79
|
+
* filesystem. Consecutive separators are normalized; `.` and `..` remain so
|
|
80
|
+
* the receiving resolver can enforce its own root and visibility policy.
|
|
81
|
+
*/
|
|
82
|
+
export function parseTaskPath(target) {
|
|
83
|
+
// An empty target is a bare `+`/`@`: the caller lists its default scope
|
|
84
|
+
// instead of resolving a path.
|
|
85
|
+
if (!target)
|
|
86
|
+
return { absolute: false, segments: [] };
|
|
87
|
+
const absolute = target.startsWith("/");
|
|
88
|
+
const segments = target.split("/").filter(Boolean);
|
|
89
|
+
return {
|
|
90
|
+
absolute,
|
|
91
|
+
segments,
|
|
92
|
+
...(target.endsWith("/") ? { trailingSlash: true } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse the command marker and its target word. Policy and path resolution are
|
|
97
|
+
* intentionally outside this module; callers retain the raw remainder as the
|
|
98
|
+
* eventual message body or creation brief.
|
|
99
|
+
*/
|
|
100
|
+
export function parseTaskCommand(source) {
|
|
101
|
+
const markerStart = skipWhitespace(source, 0);
|
|
102
|
+
let marker;
|
|
103
|
+
let targetStart;
|
|
104
|
+
if (source.startsWith("@!", markerStart)) {
|
|
105
|
+
marker = "@!";
|
|
106
|
+
targetStart = markerStart + 2;
|
|
107
|
+
}
|
|
108
|
+
else if (source[markerStart] === "+" || source[markerStart] === "@") {
|
|
109
|
+
marker = source[markerStart];
|
|
110
|
+
targetStart = markerStart + 1;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
throw new TaskPathParseError("Task command must start with +, @, or @!");
|
|
114
|
+
}
|
|
115
|
+
const start = skipWhitespace(source, targetStart);
|
|
116
|
+
if (start >= source.length) {
|
|
117
|
+
return {
|
|
118
|
+
marker,
|
|
119
|
+
target: "",
|
|
120
|
+
path: { absolute: false, segments: [] },
|
|
121
|
+
remainder: source.slice(targetStart),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const word = parseShellWord(source, start);
|
|
125
|
+
return {
|
|
126
|
+
marker,
|
|
127
|
+
target: word.value,
|
|
128
|
+
path: parseTaskPath(word.value),
|
|
129
|
+
remainder: source.slice(word.end),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-
|
|
3
|
-
*
|
|
2
|
+
* Per-task runtime state: single source of truth for "what state is this
|
|
3
|
+
* task in right now" (busy / streaming / pending permissions / plan).
|
|
4
4
|
*
|
|
5
5
|
* The frontend fetches a full snapshot on connect / reconnect / after long
|
|
6
6
|
* backgrounding, then applies incremental `state_patch` SSE events. This
|
|
@@ -17,6 +17,7 @@ function defaultState() {
|
|
|
17
17
|
pendingPermissions: [],
|
|
18
18
|
streaming: { assistant: false, thinking: false },
|
|
19
19
|
plan: null,
|
|
20
|
+
contextUsage: null,
|
|
20
21
|
},
|
|
21
22
|
};
|
|
22
23
|
}
|
|
@@ -56,6 +57,14 @@ function plansEqual(a, b) {
|
|
|
56
57
|
return false;
|
|
57
58
|
return a.every((entry, index) => entry.status === b[index].status && entry.content === b[index].content);
|
|
58
59
|
}
|
|
60
|
+
function contextUsageEqual(a, b) {
|
|
61
|
+
if (a === null || b === null)
|
|
62
|
+
return a === b;
|
|
63
|
+
return (a.used === b.used &&
|
|
64
|
+
a.size === b.size &&
|
|
65
|
+
(a.cost?.amount ?? null) === (b.cost?.amount ?? null) &&
|
|
66
|
+
(a.cost?.currency ?? null) === (b.cost?.currency ?? null));
|
|
67
|
+
}
|
|
59
68
|
/** True when the patch would change the current runtime state. */
|
|
60
69
|
function hasRuntimeChanges(current, patch) {
|
|
61
70
|
if (!patch)
|
|
@@ -68,6 +77,9 @@ function hasRuntimeChanges(current, patch) {
|
|
|
68
77
|
return true;
|
|
69
78
|
if ("plan" in patch && !plansEqual(current.plan, patch.plan ?? null))
|
|
70
79
|
return true;
|
|
80
|
+
if ("contextUsage" in patch &&
|
|
81
|
+
!contextUsageEqual(current.contextUsage, patch.contextUsage ?? null))
|
|
82
|
+
return true;
|
|
71
83
|
if ("streaming" in patch && patch.streaming) {
|
|
72
84
|
const s = patch.streaming;
|
|
73
85
|
if (s.assistant !== undefined &&
|
|
@@ -78,31 +90,31 @@ function hasRuntimeChanges(current, patch) {
|
|
|
78
90
|
}
|
|
79
91
|
return false;
|
|
80
92
|
}
|
|
81
|
-
export class
|
|
93
|
+
export class TaskStateManager {
|
|
82
94
|
states = new Map();
|
|
83
95
|
listeners = new Set();
|
|
84
96
|
cancelTimers = new Map();
|
|
85
97
|
/** Get current state (creates default entry on first access). */
|
|
86
|
-
getState(
|
|
87
|
-
let s = this.states.get(
|
|
98
|
+
getState(taskId) {
|
|
99
|
+
let s = this.states.get(taskId);
|
|
88
100
|
if (!s) {
|
|
89
101
|
s = defaultState();
|
|
90
|
-
this.states.set(
|
|
102
|
+
this.states.set(taskId, s);
|
|
91
103
|
}
|
|
92
104
|
return s;
|
|
93
105
|
}
|
|
94
|
-
/** Read streaming state without creating runtime state for an unseen
|
|
95
|
-
peekStreaming(
|
|
96
|
-
const streaming = this.states.get(
|
|
106
|
+
/** Read streaming state without creating runtime state for an unseen task. */
|
|
107
|
+
peekStreaming(taskId) {
|
|
108
|
+
const streaming = this.states.get(taskId)?.runtime.streaming;
|
|
97
109
|
return streaming ? { ...streaming } : { assistant: false, thinking: false };
|
|
98
110
|
}
|
|
99
111
|
/**
|
|
100
|
-
* Merge a patch into the
|
|
112
|
+
* Merge a patch into the task's runtime state. Bumps seq and notifies
|
|
101
113
|
* listeners only when the patch actually changes something (no-op patches
|
|
102
114
|
* are dropped silently).
|
|
103
115
|
*/
|
|
104
|
-
patch(
|
|
105
|
-
const state = this.getState(
|
|
116
|
+
patch(taskId, patch) {
|
|
117
|
+
const state = this.getState(taskId);
|
|
106
118
|
const runtimeChanged = hasRuntimeChanges(state.runtime, patch.runtime);
|
|
107
119
|
if (!runtimeChanged)
|
|
108
120
|
return;
|
|
@@ -119,6 +131,15 @@ export class SessionStateManager {
|
|
|
119
131
|
state.runtime.plan =
|
|
120
132
|
patch.runtime.plan?.map((entry) => ({ ...entry })) ?? null;
|
|
121
133
|
}
|
|
134
|
+
if ("contextUsage" in patch.runtime) {
|
|
135
|
+
const usage = patch.runtime.contextUsage;
|
|
136
|
+
state.runtime.contextUsage = usage
|
|
137
|
+
? {
|
|
138
|
+
...usage,
|
|
139
|
+
...(usage.cost ? { cost: { ...usage.cost } } : {}),
|
|
140
|
+
}
|
|
141
|
+
: null;
|
|
142
|
+
}
|
|
122
143
|
if ("streaming" in patch.runtime && patch.runtime.streaming) {
|
|
123
144
|
if (patch.runtime.streaming.assistant !== undefined) {
|
|
124
145
|
state.runtime.streaming.assistant = patch.runtime.streaming.assistant;
|
|
@@ -131,7 +152,7 @@ export class SessionStateManager {
|
|
|
131
152
|
state.seq += 1;
|
|
132
153
|
const event = {
|
|
133
154
|
type: "state_patch",
|
|
134
|
-
|
|
155
|
+
taskId,
|
|
135
156
|
seq: state.seq,
|
|
136
157
|
patch,
|
|
137
158
|
};
|
|
@@ -145,29 +166,60 @@ export class SessionStateManager {
|
|
|
145
166
|
this.listeners.delete(cb);
|
|
146
167
|
};
|
|
147
168
|
}
|
|
148
|
-
/** Clear all state for a
|
|
149
|
-
delete(
|
|
150
|
-
this.states.delete(
|
|
151
|
-
const t = this.cancelTimers.get(
|
|
169
|
+
/** Clear all state for a task (call from TaskManager.deleteTask). */
|
|
170
|
+
delete(taskId) {
|
|
171
|
+
this.states.delete(taskId);
|
|
172
|
+
const t = this.cancelTimers.get(taskId);
|
|
152
173
|
if (t) {
|
|
153
174
|
clearTimeout(t);
|
|
154
|
-
this.cancelTimers.delete(
|
|
175
|
+
this.cancelTimers.delete(taskId);
|
|
155
176
|
}
|
|
156
177
|
}
|
|
157
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Reset a live task's runtime state to defaults without restarting the
|
|
180
|
+
* seq ledger. Used when rotating a task's ACP execution in place (clear):
|
|
181
|
+
* the WebAgent task identity survives, so clients that validate
|
|
182
|
+
* incremental `state_patch` events against their own lastStateSeq must keep
|
|
183
|
+
* seeing a monotonic seq. Hard-deleting the entry here would restart the
|
|
184
|
+
* server seq at 0; every post-rotation snapshot would then look
|
|
185
|
+
* "superseded" to the client and be dropped, leaving it permanently
|
|
186
|
+
* desynced (stuck busy). Broadcasts one patch when anything actually
|
|
187
|
+
* changed, mirroring the explicit reset patch used by the compaction path.
|
|
188
|
+
*/
|
|
189
|
+
reset(taskId) {
|
|
190
|
+
this.clearCancelSafety(taskId);
|
|
191
|
+
this.patch(taskId, {
|
|
192
|
+
runtime: {
|
|
193
|
+
busy: null,
|
|
194
|
+
pendingPermissions: [],
|
|
195
|
+
streaming: { assistant: false, thinking: false },
|
|
196
|
+
plan: null,
|
|
197
|
+
contextUsage: null,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/** Clear current plans for every known task (used on bridge reload). */
|
|
158
202
|
clearPlans() {
|
|
159
|
-
for (const [
|
|
203
|
+
for (const [taskId, state] of this.states) {
|
|
160
204
|
if (state.runtime.plan !== null) {
|
|
161
|
-
this.patch(
|
|
205
|
+
this.patch(taskId, { runtime: { plan: null } });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Clear context usage for every known task on bridge teardown. */
|
|
210
|
+
clearContextUsage() {
|
|
211
|
+
for (const [taskId, state] of this.states) {
|
|
212
|
+
if (state.runtime.contextUsage !== null) {
|
|
213
|
+
this.patch(taskId, { runtime: { contextUsage: null } });
|
|
162
214
|
}
|
|
163
215
|
}
|
|
164
216
|
}
|
|
165
|
-
/** Clear active stream markers for every known
|
|
217
|
+
/** Clear active stream markers for every known task on bridge teardown. */
|
|
166
218
|
clearStreaming() {
|
|
167
|
-
for (const [
|
|
219
|
+
for (const [taskId, state] of this.states) {
|
|
168
220
|
if (state.runtime.streaming.assistant ||
|
|
169
221
|
state.runtime.streaming.thinking) {
|
|
170
|
-
this.patch(
|
|
222
|
+
this.patch(taskId, {
|
|
171
223
|
runtime: {
|
|
172
224
|
streaming: { assistant: false, thinking: false },
|
|
173
225
|
},
|
|
@@ -178,23 +230,23 @@ export class SessionStateManager {
|
|
|
178
230
|
/**
|
|
179
231
|
* Backend acknowledgement timer for cancel: if the same agent prompt is
|
|
180
232
|
* still pending after `timeoutMs`, mark the request unconfirmed.
|
|
181
|
-
* A second arm on the same
|
|
233
|
+
* A second arm on the same task replaces the existing timer.
|
|
182
234
|
*/
|
|
183
|
-
armCancelSafety(
|
|
235
|
+
armCancelSafety(taskId, timeoutMs) {
|
|
184
236
|
if (timeoutMs <= 0)
|
|
185
237
|
return;
|
|
186
|
-
const existing = this.cancelTimers.get(
|
|
238
|
+
const existing = this.cancelTimers.get(taskId);
|
|
187
239
|
if (existing)
|
|
188
240
|
clearTimeout(existing);
|
|
189
241
|
const t = setTimeout(() => {
|
|
190
|
-
this.cancelTimers.delete(
|
|
191
|
-
const busy = this.getState(
|
|
242
|
+
this.cancelTimers.delete(taskId);
|
|
243
|
+
const busy = this.getState(taskId).runtime.busy;
|
|
192
244
|
if (busy?.kind === "agent" && busy.cancelStatus === "requested") {
|
|
193
245
|
clog.warn("agent did not acknowledge", {
|
|
194
|
-
|
|
246
|
+
taskId: taskId.slice(0, 8),
|
|
195
247
|
promptId: busy.promptId,
|
|
196
248
|
});
|
|
197
|
-
this.patch(
|
|
249
|
+
this.patch(taskId, {
|
|
198
250
|
runtime: {
|
|
199
251
|
busy: { ...busy, cancelStatus: "unconfirmed" },
|
|
200
252
|
},
|
|
@@ -203,23 +255,23 @@ export class SessionStateManager {
|
|
|
203
255
|
}, timeoutMs);
|
|
204
256
|
if (typeof t === "object" && "unref" in t)
|
|
205
257
|
t.unref();
|
|
206
|
-
this.cancelTimers.set(
|
|
258
|
+
this.cancelTimers.set(taskId, t);
|
|
207
259
|
}
|
|
208
260
|
/** Mark that a cancel notification was sent for the active agent prompt. */
|
|
209
|
-
markCancelRequested(
|
|
210
|
-
const busy = this.getState(
|
|
261
|
+
markCancelRequested(taskId) {
|
|
262
|
+
const busy = this.getState(taskId).runtime.busy;
|
|
211
263
|
if (busy?.kind !== "agent")
|
|
212
264
|
return;
|
|
213
|
-
this.patch(
|
|
265
|
+
this.patch(taskId, {
|
|
214
266
|
runtime: { busy: { ...busy, cancelStatus: "requested" } },
|
|
215
267
|
});
|
|
216
268
|
}
|
|
217
269
|
/** Cancel the safety net timer (e.g. when prompt_done arrives naturally). */
|
|
218
|
-
clearCancelSafety(
|
|
219
|
-
const t = this.cancelTimers.get(
|
|
270
|
+
clearCancelSafety(taskId) {
|
|
271
|
+
const t = this.cancelTimers.get(taskId);
|
|
220
272
|
if (t) {
|
|
221
273
|
clearTimeout(t);
|
|
222
|
-
this.cancelTimers.delete(
|
|
274
|
+
this.cancelTimers.delete(taskId);
|
|
223
275
|
}
|
|
224
276
|
}
|
|
225
277
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
function normalize(spec) {
|
|
2
|
+
const exclusive = new Set(spec.exclusive ?? []);
|
|
3
|
+
const shared = new Set((spec.shared ?? []).filter((key) => !exclusive.has(key)));
|
|
4
|
+
return { shared, exclusive };
|
|
5
|
+
}
|
|
6
|
+
function conflicts(a, b) {
|
|
7
|
+
for (const key of a.exclusive) {
|
|
8
|
+
if (b.exclusive.has(key) || b.shared.has(key))
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
for (const key of a.shared) {
|
|
12
|
+
if (b.exclusive.has(key))
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Small in-process hierarchical lock scheduler for one task tree.
|
|
19
|
+
*
|
|
20
|
+
* Requests are granted atomically, so callers never hold part of a lineage
|
|
21
|
+
* while waiting for another node. Compatible sibling requests can be active
|
|
22
|
+
* together; an earlier conflicting request keeps later requests from
|
|
23
|
+
* overtaking it. Releases are idempotent so failure paths can clean up safely.
|
|
24
|
+
*/
|
|
25
|
+
export class TaskTreeLock {
|
|
26
|
+
active = new Set();
|
|
27
|
+
pending = [];
|
|
28
|
+
acquire(spec) {
|
|
29
|
+
const request = normalize(spec);
|
|
30
|
+
if (request.shared.size === 0 && request.exclusive.size === 0) {
|
|
31
|
+
return Promise.resolve(() => { });
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
this.pending.push({ ...request, resolve });
|
|
35
|
+
this.pump();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Acquire only when the scheduler is completely idle; otherwise skip. */
|
|
39
|
+
tryAcquire(spec) {
|
|
40
|
+
if (this.active.size > 0 || this.pending.length > 0)
|
|
41
|
+
return null;
|
|
42
|
+
const request = normalize(spec);
|
|
43
|
+
if (request.shared.size === 0 && request.exclusive.size === 0)
|
|
44
|
+
return () => { };
|
|
45
|
+
this.active.add(request);
|
|
46
|
+
return this.releaseFor(request);
|
|
47
|
+
}
|
|
48
|
+
pump() {
|
|
49
|
+
for (let i = 0; i < this.pending.length;) {
|
|
50
|
+
const candidate = this.pending[i];
|
|
51
|
+
const blockedByActive = [...this.active].some((active) => conflicts(candidate, active));
|
|
52
|
+
const blockedByEarlier = this.pending
|
|
53
|
+
.slice(0, i)
|
|
54
|
+
.some((earlier) => conflicts(candidate, earlier));
|
|
55
|
+
if (blockedByActive || blockedByEarlier) {
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
this.pending.splice(i, 1);
|
|
60
|
+
this.active.add(candidate);
|
|
61
|
+
candidate.resolve(this.releaseFor(candidate));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
releaseFor(request) {
|
|
65
|
+
let released = false;
|
|
66
|
+
return () => {
|
|
67
|
+
if (released)
|
|
68
|
+
return;
|
|
69
|
+
released = true;
|
|
70
|
+
this.active.delete(request);
|
|
71
|
+
this.pump();
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { mkdirSync, realpathSync } from "node:fs";
|
|
2
2
|
import { join, sep } from "node:path";
|
|
3
3
|
/**
|
|
4
|
-
* Resolved absolute path to `<dataDir>/
|
|
4
|
+
* Resolved absolute path to `<dataDir>/tasks/`. Pinned at server boot so
|
|
5
5
|
* later `file://` URI construction and startsWith assertions all compare
|
|
6
|
-
* against the same realpath (defends against macOS `/var
|
|
6
|
+
* against the same realpath (defends against macOS `/var to /private/var`
|
|
7
7
|
* symlink + any future symlink swaps under `data_dir`).
|
|
8
8
|
*
|
|
9
9
|
* Throws if the directory cannot be created or resolved — fail fast at boot
|
|
10
10
|
* rather than later when an attachment dispatch tries to use it.
|
|
11
11
|
*/
|
|
12
|
-
export function
|
|
13
|
-
const dir = join(dataDir, "
|
|
12
|
+
export function resolveTasksAnchor(dataDir) {
|
|
13
|
+
const dir = join(dataDir, "tasks");
|
|
14
14
|
mkdirSync(dir, { recursive: true });
|
|
15
15
|
const real = realpathSync(dir);
|
|
16
16
|
// Normalize trailing separator so `startsWith(anchor + sep)` is the
|
|
@@ -19,10 +19,11 @@ export function resolveSessionsAnchor(dataDir) {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Returns true iff `realpath` is a strict descendant of
|
|
22
|
-
* `<
|
|
22
|
+
* `<tasksAnchor>/<taskId>/attachments/` (on-disk directory is still
|
|
23
|
+
* `sessions`). Both args must already be
|
|
23
24
|
* realpath-resolved (no `..`, no symlinks left).
|
|
24
25
|
*/
|
|
25
|
-
export function
|
|
26
|
-
const expected =
|
|
26
|
+
export function isInsideTaskAttachments(tasksAnchor, taskId, realpath) {
|
|
27
|
+
const expected = tasksAnchor + sep + taskId + sep + "attachments" + sep;
|
|
27
28
|
return realpath.startsWith(expected);
|
|
28
29
|
}
|
package/lib/tokens.js
CHANGED
|
@@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto";
|
|
|
11
11
|
* - Internal IDs that aren't auth-bearing (client IDs, request IDs,
|
|
12
12
|
* error correlation IDs) — see sse-manager.ts, routes.ts, etc.
|
|
13
13
|
* - HMAC signing keys (image URL secret) — see server.ts.
|
|
14
|
-
* - UUIDs used as opaque labels (
|
|
14
|
+
* - UUIDs used as opaque labels (task IDs, msg IDs) — these
|
|
15
15
|
* are protocol identifiers, not credentials.
|
|
16
16
|
*
|
|
17
17
|
* Repo convention: `node:crypto` only. No `nanoid` / `uuid` deps.
|
package/lib/types.js
CHANGED
|
@@ -3,10 +3,10 @@ import { z } from "zod";
|
|
|
3
3
|
const FROM_REF_ALLOWED = /^(cron|external):[A-Za-z0-9._\-+/]{1,120}$/;
|
|
4
4
|
export const MessageIngressSchema = z.object({
|
|
5
5
|
from_ref: z.string().min(1).max(128).regex(FROM_REF_ALLOWED, {
|
|
6
|
-
message: "from_ref must start with 'cron:' or 'external:' (reserved values and '
|
|
6
|
+
message: "from_ref must start with 'cron:' or 'external:' (reserved values and 'task:<id>' rejected in MVP)",
|
|
7
7
|
}),
|
|
8
8
|
from_label: z.string().max(64).optional(),
|
|
9
|
-
to: z.union([z.literal("user"), z.string().regex(/^
|
|
9
|
+
to: z.union([z.literal("user"), z.string().regex(/^task:[^\s]+$/)]),
|
|
10
10
|
deliver: z.enum(["silent", "inapp", "push"]).default("push"),
|
|
11
11
|
dedup_key: z.string().max(128).optional(),
|
|
12
12
|
title: z.string().min(1).max(256),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lelouchhe/webagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A terminal-style web UI for ACP-compatible agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -60,10 +60,12 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@agentclientprotocol/sdk": "^0.25.0",
|
|
63
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
63
64
|
"@types/proper-lockfile": "^4.1.4",
|
|
64
65
|
"@types/web-push": "^3.6.4",
|
|
65
66
|
"better-sqlite3": "^12.6.2",
|
|
66
67
|
"busboy": "^1.6.0",
|
|
68
|
+
"diff": "^9.0.0",
|
|
67
69
|
"dompurify": "^3.4.1",
|
|
68
70
|
"file-type": "^22.0.1",
|
|
69
71
|
"highlight.js": "^11.11.1",
|
|
@@ -91,5 +93,10 @@
|
|
|
91
93
|
"prettier": "^3.8.3",
|
|
92
94
|
"typescript": "^5.9.3",
|
|
93
95
|
"typescript-eslint": "^8.59.0"
|
|
96
|
+
},
|
|
97
|
+
"allowScripts": {
|
|
98
|
+
"better-sqlite3@12.6.2": true,
|
|
99
|
+
"esbuild@0.27.3": true,
|
|
100
|
+
"fsevents@2.3.2": true
|
|
94
101
|
}
|
|
95
102
|
}
|