@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.
Files changed (47) hide show
  1. package/README.md +39 -14
  2. package/config.toml +7 -27
  3. package/dist/index.html +4 -4
  4. package/dist/js/app.INIQQEGD.js +5 -0
  5. package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
  6. package/dist/js/viewer.RHZMFYWJ.js +1 -0
  7. package/dist/login.html +1 -1
  8. package/dist/share-viewer.html +5 -5
  9. package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
  10. package/dist/sw.js +6 -6
  11. package/lib/attachment-dispatch.js +60 -31
  12. package/lib/attachment-interceptor.js +7 -7
  13. package/lib/attachment-labels.js +1 -1
  14. package/lib/attachments.js +25 -0
  15. package/lib/auth-middleware.js +2 -2
  16. package/lib/auth.js +2 -2
  17. package/lib/bridge.js +109 -83
  18. package/lib/client-registry.js +12 -12
  19. package/lib/config.js +2 -31
  20. package/lib/event-handler.js +143 -90
  21. package/lib/files/routes.js +1 -1
  22. package/lib/mcp/capability.js +74 -0
  23. package/lib/mcp/server.js +148 -0
  24. package/lib/mcp/task-history.js +245 -0
  25. package/lib/mcp/task-host.js +253 -0
  26. package/lib/mcp/tools.js +168 -0
  27. package/lib/mode-bucket.js +1 -1
  28. package/lib/push-service.js +33 -35
  29. package/lib/routes.js +947 -489
  30. package/lib/server.js +64 -16
  31. package/lib/share/routes.js +88 -88
  32. package/lib/shared/task-reference.js +20 -0
  33. package/lib/sse-manager.js +8 -8
  34. package/lib/store.js +941 -314
  35. package/lib/task-collaboration.js +15 -0
  36. package/lib/task-manager.js +1409 -0
  37. package/lib/task-path.js +131 -0
  38. package/lib/{session-state.js → task-state.js} +64 -41
  39. package/lib/task-tree-lock.js +74 -0
  40. package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
  41. package/lib/tokens.js +1 -1
  42. package/lib/types.js +2 -2
  43. package/package.json +7 -1
  44. package/dist/js/app.QC7IRDTP.js +0 -5
  45. package/dist/js/viewer.GP5VXAUY.js +0 -1
  46. package/lib/session-manager.js +0 -638
  47. package/lib/title-service.js +0 -95
@@ -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-session runtime state: single source of truth for "what state is this
3
- * session in right now" (busy / streaming / pending permissions / plan).
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 SessionStateManager {
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(sessionId) {
99
- let s = this.states.get(sessionId);
98
+ getState(taskId) {
99
+ let s = this.states.get(taskId);
100
100
  if (!s) {
101
101
  s = defaultState();
102
- this.states.set(sessionId, s);
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 session. */
107
- peekStreaming(sessionId) {
108
- const streaming = this.states.get(sessionId)?.runtime.streaming;
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 session's runtime state. Bumps seq and notifies
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(sessionId, patch) {
117
- const state = this.getState(sessionId);
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
- sessionId,
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 session (call from SessionManager.deleteSession). */
170
- delete(sessionId) {
171
- this.states.delete(sessionId);
172
- const t = this.cancelTimers.get(sessionId);
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(sessionId);
175
+ this.cancelTimers.delete(taskId);
176
176
  }
177
177
  }
178
- /** Clear current plans for every known session (used on bridge reload). */
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 [sessionId, state] of this.states) {
203
+ for (const [taskId, state] of this.states) {
181
204
  if (state.runtime.plan !== null) {
182
- this.patch(sessionId, { runtime: { plan: null } });
205
+ this.patch(taskId, { runtime: { plan: null } });
183
206
  }
184
207
  }
185
208
  }
186
- /** Clear context usage for every known session on bridge teardown. */
209
+ /** Clear context usage for every known task on bridge teardown. */
187
210
  clearContextUsage() {
188
- for (const [sessionId, state] of this.states) {
211
+ for (const [taskId, state] of this.states) {
189
212
  if (state.runtime.contextUsage !== null) {
190
- this.patch(sessionId, { runtime: { contextUsage: null } });
213
+ this.patch(taskId, { runtime: { contextUsage: null } });
191
214
  }
192
215
  }
193
216
  }
194
- /** Clear active stream markers for every known session on bridge teardown. */
217
+ /** Clear active stream markers for every known task on bridge teardown. */
195
218
  clearStreaming() {
196
- for (const [sessionId, state] of this.states) {
219
+ for (const [taskId, state] of this.states) {
197
220
  if (state.runtime.streaming.assistant ||
198
221
  state.runtime.streaming.thinking) {
199
- this.patch(sessionId, {
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 session replaces the existing timer.
233
+ * A second arm on the same task replaces the existing timer.
211
234
  */
212
- armCancelSafety(sessionId, timeoutMs) {
235
+ armCancelSafety(taskId, timeoutMs) {
213
236
  if (timeoutMs <= 0)
214
237
  return;
215
- const existing = this.cancelTimers.get(sessionId);
238
+ const existing = this.cancelTimers.get(taskId);
216
239
  if (existing)
217
240
  clearTimeout(existing);
218
241
  const t = setTimeout(() => {
219
- this.cancelTimers.delete(sessionId);
220
- const busy = this.getState(sessionId).runtime.busy;
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
- sessionId: sessionId.slice(0, 8),
246
+ taskId: taskId.slice(0, 8),
224
247
  promptId: busy.promptId,
225
248
  });
226
- this.patch(sessionId, {
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(sessionId, t);
258
+ this.cancelTimers.set(taskId, t);
236
259
  }
237
260
  /** Mark that a cancel notification was sent for the active agent prompt. */
238
- markCancelRequested(sessionId) {
239
- const busy = this.getState(sessionId).runtime.busy;
261
+ markCancelRequested(taskId) {
262
+ const busy = this.getState(taskId).runtime.busy;
240
263
  if (busy?.kind !== "agent")
241
264
  return;
242
- this.patch(sessionId, {
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(sessionId) {
248
- const t = this.cancelTimers.get(sessionId);
270
+ clearCancelSafety(taskId) {
271
+ const t = this.cancelTimers.get(taskId);
249
272
  if (t) {
250
273
  clearTimeout(t);
251
- this.cancelTimers.delete(sessionId);
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>/sessions/`. Pinned at server boot so
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 /private/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 resolveSessionsAnchor(dataDir) {
13
- const dir = join(dataDir, "sessions");
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
- * `<sessionsAnchor>/<sessionId>/attachments/`. Both args must already be
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 isInsideSessionAttachments(sessionsAnchor, sessionId, realpath) {
26
- const expected = sessionsAnchor + sep + sessionId + sep + "attachments" + sep;
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 (session IDs, msg IDs) — these
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 'session:<id>' rejected in MVP)",
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(/^session:[^\s]+$/)]),
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.9.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
  }