@lelouchhe/webagent 0.9.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 +39 -14
- package/config.toml +7 -27
- package/dist/index.html +4 -4
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +5 -5
- package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
- package/dist/sw.js +6 -6
- 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 +25 -0
- package/lib/auth-middleware.js +2 -2
- package/lib/auth.js +2 -2
- package/lib/bridge.js +109 -83
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +143 -90
- package/lib/files/routes.js +1 -1
- 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 +947 -489
- package/lib/server.js +64 -16
- package/lib/share/routes.js +88 -88
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +941 -314
- 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} +64 -41
- 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 +7 -1
- package/dist/js/app.QC7IRDTP.js +0 -5
- package/dist/js/viewer.GP5VXAUY.js +0 -1
- package/lib/session-manager.js +0 -638
- 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
|
|
@@ -90,31 +90,31 @@ function hasRuntimeChanges(current, patch) {
|
|
|
90
90
|
}
|
|
91
91
|
return false;
|
|
92
92
|
}
|
|
93
|
-
export class
|
|
93
|
+
export class TaskStateManager {
|
|
94
94
|
states = new Map();
|
|
95
95
|
listeners = new Set();
|
|
96
96
|
cancelTimers = new Map();
|
|
97
97
|
/** Get current state (creates default entry on first access). */
|
|
98
|
-
getState(
|
|
99
|
-
let s = this.states.get(
|
|
98
|
+
getState(taskId) {
|
|
99
|
+
let s = this.states.get(taskId);
|
|
100
100
|
if (!s) {
|
|
101
101
|
s = defaultState();
|
|
102
|
-
this.states.set(
|
|
102
|
+
this.states.set(taskId, s);
|
|
103
103
|
}
|
|
104
104
|
return s;
|
|
105
105
|
}
|
|
106
|
-
/** Read streaming state without creating runtime state for an unseen
|
|
107
|
-
peekStreaming(
|
|
108
|
-
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;
|
|
109
109
|
return streaming ? { ...streaming } : { assistant: false, thinking: false };
|
|
110
110
|
}
|
|
111
111
|
/**
|
|
112
|
-
* Merge a patch into the
|
|
112
|
+
* Merge a patch into the task's runtime state. Bumps seq and notifies
|
|
113
113
|
* listeners only when the patch actually changes something (no-op patches
|
|
114
114
|
* are dropped silently).
|
|
115
115
|
*/
|
|
116
|
-
patch(
|
|
117
|
-
const state = this.getState(
|
|
116
|
+
patch(taskId, patch) {
|
|
117
|
+
const state = this.getState(taskId);
|
|
118
118
|
const runtimeChanged = hasRuntimeChanges(state.runtime, patch.runtime);
|
|
119
119
|
if (!runtimeChanged)
|
|
120
120
|
return;
|
|
@@ -152,7 +152,7 @@ export class SessionStateManager {
|
|
|
152
152
|
state.seq += 1;
|
|
153
153
|
const event = {
|
|
154
154
|
type: "state_patch",
|
|
155
|
-
|
|
155
|
+
taskId,
|
|
156
156
|
seq: state.seq,
|
|
157
157
|
patch,
|
|
158
158
|
};
|
|
@@ -166,37 +166,60 @@ export class SessionStateManager {
|
|
|
166
166
|
this.listeners.delete(cb);
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
|
-
/** Clear all state for a
|
|
170
|
-
delete(
|
|
171
|
-
this.states.delete(
|
|
172
|
-
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);
|
|
173
173
|
if (t) {
|
|
174
174
|
clearTimeout(t);
|
|
175
|
-
this.cancelTimers.delete(
|
|
175
|
+
this.cancelTimers.delete(taskId);
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
-
/**
|
|
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). */
|
|
179
202
|
clearPlans() {
|
|
180
|
-
for (const [
|
|
203
|
+
for (const [taskId, state] of this.states) {
|
|
181
204
|
if (state.runtime.plan !== null) {
|
|
182
|
-
this.patch(
|
|
205
|
+
this.patch(taskId, { runtime: { plan: null } });
|
|
183
206
|
}
|
|
184
207
|
}
|
|
185
208
|
}
|
|
186
|
-
/** Clear context usage for every known
|
|
209
|
+
/** Clear context usage for every known task on bridge teardown. */
|
|
187
210
|
clearContextUsage() {
|
|
188
|
-
for (const [
|
|
211
|
+
for (const [taskId, state] of this.states) {
|
|
189
212
|
if (state.runtime.contextUsage !== null) {
|
|
190
|
-
this.patch(
|
|
213
|
+
this.patch(taskId, { runtime: { contextUsage: null } });
|
|
191
214
|
}
|
|
192
215
|
}
|
|
193
216
|
}
|
|
194
|
-
/** Clear active stream markers for every known
|
|
217
|
+
/** Clear active stream markers for every known task on bridge teardown. */
|
|
195
218
|
clearStreaming() {
|
|
196
|
-
for (const [
|
|
219
|
+
for (const [taskId, state] of this.states) {
|
|
197
220
|
if (state.runtime.streaming.assistant ||
|
|
198
221
|
state.runtime.streaming.thinking) {
|
|
199
|
-
this.patch(
|
|
222
|
+
this.patch(taskId, {
|
|
200
223
|
runtime: {
|
|
201
224
|
streaming: { assistant: false, thinking: false },
|
|
202
225
|
},
|
|
@@ -207,23 +230,23 @@ export class SessionStateManager {
|
|
|
207
230
|
/**
|
|
208
231
|
* Backend acknowledgement timer for cancel: if the same agent prompt is
|
|
209
232
|
* still pending after `timeoutMs`, mark the request unconfirmed.
|
|
210
|
-
* A second arm on the same
|
|
233
|
+
* A second arm on the same task replaces the existing timer.
|
|
211
234
|
*/
|
|
212
|
-
armCancelSafety(
|
|
235
|
+
armCancelSafety(taskId, timeoutMs) {
|
|
213
236
|
if (timeoutMs <= 0)
|
|
214
237
|
return;
|
|
215
|
-
const existing = this.cancelTimers.get(
|
|
238
|
+
const existing = this.cancelTimers.get(taskId);
|
|
216
239
|
if (existing)
|
|
217
240
|
clearTimeout(existing);
|
|
218
241
|
const t = setTimeout(() => {
|
|
219
|
-
this.cancelTimers.delete(
|
|
220
|
-
const busy = this.getState(
|
|
242
|
+
this.cancelTimers.delete(taskId);
|
|
243
|
+
const busy = this.getState(taskId).runtime.busy;
|
|
221
244
|
if (busy?.kind === "agent" && busy.cancelStatus === "requested") {
|
|
222
245
|
clog.warn("agent did not acknowledge", {
|
|
223
|
-
|
|
246
|
+
taskId: taskId.slice(0, 8),
|
|
224
247
|
promptId: busy.promptId,
|
|
225
248
|
});
|
|
226
|
-
this.patch(
|
|
249
|
+
this.patch(taskId, {
|
|
227
250
|
runtime: {
|
|
228
251
|
busy: { ...busy, cancelStatus: "unconfirmed" },
|
|
229
252
|
},
|
|
@@ -232,23 +255,23 @@ export class SessionStateManager {
|
|
|
232
255
|
}, timeoutMs);
|
|
233
256
|
if (typeof t === "object" && "unref" in t)
|
|
234
257
|
t.unref();
|
|
235
|
-
this.cancelTimers.set(
|
|
258
|
+
this.cancelTimers.set(taskId, t);
|
|
236
259
|
}
|
|
237
260
|
/** Mark that a cancel notification was sent for the active agent prompt. */
|
|
238
|
-
markCancelRequested(
|
|
239
|
-
const busy = this.getState(
|
|
261
|
+
markCancelRequested(taskId) {
|
|
262
|
+
const busy = this.getState(taskId).runtime.busy;
|
|
240
263
|
if (busy?.kind !== "agent")
|
|
241
264
|
return;
|
|
242
|
-
this.patch(
|
|
265
|
+
this.patch(taskId, {
|
|
243
266
|
runtime: { busy: { ...busy, cancelStatus: "requested" } },
|
|
244
267
|
});
|
|
245
268
|
}
|
|
246
269
|
/** Cancel the safety net timer (e.g. when prompt_done arrives naturally). */
|
|
247
|
-
clearCancelSafety(
|
|
248
|
-
const t = this.cancelTimers.get(
|
|
270
|
+
clearCancelSafety(taskId) {
|
|
271
|
+
const t = this.cancelTimers.get(taskId);
|
|
249
272
|
if (t) {
|
|
250
273
|
clearTimeout(t);
|
|
251
|
-
this.cancelTimers.delete(
|
|
274
|
+
this.cancelTimers.delete(taskId);
|
|
252
275
|
}
|
|
253
276
|
}
|
|
254
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,6 +60,7 @@
|
|
|
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",
|
|
@@ -92,5 +93,10 @@
|
|
|
92
93
|
"prettier": "^3.8.3",
|
|
93
94
|
"typescript": "^5.9.3",
|
|
94
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
|
|
95
101
|
}
|
|
96
102
|
}
|