dsh-taskboard 0.5.5 → 0.6.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 (42) hide show
  1. package/README.md +22 -2
  2. package/lib/client.js +2604 -767
  3. package/lib/host/execution.js +3 -0
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/routes.js +31 -1
  6. package/lib/host/routes.js.map +1 -1
  7. package/lib/host/session-sync.js +210 -10
  8. package/lib/host/session-sync.js.map +1 -1
  9. package/lib/host/store.js +9 -2
  10. package/lib/host/store.js.map +1 -1
  11. package/lib/index.js +100 -1
  12. package/lib/index.js.map +1 -1
  13. package/lib/shared/api.js.map +1 -1
  14. package/lib/shared/protocol.js +19 -1
  15. package/lib/shared/protocol.js.map +1 -1
  16. package/package.json +75 -75
  17. package/src/client/api.ts +8 -0
  18. package/src/client/board/AlertModal.tsx +3 -1
  19. package/src/client/board/ImportModal.tsx +26 -24
  20. package/src/client/board/SettingsModal.tsx +66 -26
  21. package/src/client/board/SlashPromptInput.tsx +272 -0
  22. package/src/client/board/TaskBoard.tsx +53 -49
  23. package/src/client/board/TaskCard.tsx +33 -21
  24. package/src/client/board/TaskDetail.tsx +169 -104
  25. package/src/client/board/TaskFormModal.tsx +254 -202
  26. package/src/client/board/TemplateManager.tsx +32 -29
  27. package/src/client/board/labels.ts +36 -27
  28. package/src/client/controller.ts +62 -2
  29. package/src/client/i18n/en.ts +455 -0
  30. package/src/client/i18n/runtime.ts +155 -0
  31. package/src/client/i18n/zh.ts +460 -0
  32. package/src/client/index.ts +182 -42
  33. package/src/client/sidebar-entry.ts +13 -3
  34. package/src/client/styles.ts +131 -0
  35. package/src/host/execution.ts +13 -0
  36. package/src/host/routes.ts +49 -1
  37. package/src/host/session-sync.ts +334 -14
  38. package/src/host/store.ts +15 -1
  39. package/src/index.ts +115 -1
  40. package/src/shared/api.ts +47 -0
  41. package/src/shared/protocol.ts +41 -0
  42. package/src/shared/version.ts +1 -1
@@ -29,23 +29,169 @@ function titleFromText(text) {
29
29
  return (text.trim().replace(/^#+\s*/, "").split("\n")[0]?.trim() ?? "").slice(0, 50).trim();
30
30
  }
31
31
  /**
32
+ * Detect whether a session represents a subagent child conversation.
33
+ * Subagents are created by agent delegation (e.g. invoke_subagent / subagents service)
34
+ * and should never be automatically converted into user tasks on the taskboard.
35
+ */
36
+ function isSubagentSession(sessionId, sessionMeta, event) {
37
+ if (typeof sessionId === "string") {
38
+ if (sessionId.startsWith("subagent-") || sessionId.startsWith("child-") || sessionId.startsWith("delegate-")) return true;
39
+ }
40
+ if (typeof sessionMeta === "object" && sessionMeta !== null) {
41
+ const s = sessionMeta;
42
+ const header = s.header;
43
+ const meta = s.meta;
44
+ const options = s.options;
45
+ if (header?.origin === "subagent" || meta?.origin === "subagent") return true;
46
+ if (header?.parentSession !== void 0 || header?.parentSessionId !== void 0 || meta?.parentSession !== void 0 || meta?.parentSessionId !== void 0) return true;
47
+ if (typeof header?.delegationDepth === "number" && header.delegationDepth > 0) return true;
48
+ if (typeof meta?.delegationDepth === "number" && meta.delegationDepth > 0) return true;
49
+ if (typeof options?.subagentDepth === "number" && options.subagentDepth > 0) return true;
50
+ }
51
+ if (event !== void 0) {
52
+ if (event.type === "subagent/descriptor" || event.type === "subagent/start" || event.type === "subagent/end") return true;
53
+ if (typeof event.data === "object" && event.data !== null) {
54
+ const d = event.data;
55
+ if (d.origin === "subagent" || d.subagent === true || typeof d.subagentId === "string" || typeof d.parentSession === "string") return true;
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+ /**
61
+ * Detect whether a session represents an active working conversation.
62
+ * Inspects state, status, isWorking/isBusy methods, running flags, and active turns.
63
+ */
64
+ function isSessionActiveWorking(session) {
65
+ if (typeof session !== "object" || session === null) return false;
66
+ const s = session;
67
+ if (typeof s.isWorking === "function") try {
68
+ if (Boolean(s.isWorking())) return true;
69
+ } catch {}
70
+ else if (s.isWorking === true) return true;
71
+ if (typeof s.isBusy === "function") try {
72
+ if (Boolean(s.isBusy())) return true;
73
+ } catch {}
74
+ else if (s.busy === true || s.isBusy === true) return true;
75
+ if (s.running === true || s.active === true || s.isGenerating === true || s.generating === true) return true;
76
+ if (typeof s.state === "string") {
77
+ const st = s.state.toLowerCase();
78
+ if (st === "running" || st === "working" || st === "busy" || st === "generating" || st === "executing") return true;
79
+ }
80
+ if (typeof s.status === "string") {
81
+ const st = s.status.toLowerCase();
82
+ if (st === "running" || st === "working" || st === "busy" || st === "active" || st === "generating" || st === "executing") return true;
83
+ }
84
+ if (s.activeTurn !== void 0 && s.activeTurn !== null && s.activeTurn !== false) return true;
85
+ if (s.currentTurn !== void 0 && s.currentTurn !== null) if (typeof s.currentTurn === "object") {
86
+ const ct = s.currentTurn;
87
+ if (ct.status === "running" || ct.state === "running" || ct.outcome === "running" || ct.endedAt === void 0) return true;
88
+ } else return true;
89
+ if (Array.isArray(s.turns) && s.turns.length > 0) {
90
+ const lastTurn = s.turns[s.turns.length - 1];
91
+ if (typeof lastTurn === "object" && lastTurn !== null) {
92
+ const lt = lastTurn;
93
+ if (lt.status === "running" || lt.state === "running" || lt.outcome === "running" || lt.startedAt !== void 0 && lt.endedAt === void 0) return true;
94
+ }
95
+ }
96
+ return false;
97
+ }
98
+ /** Default scan interval: 4s. */
99
+ const DEFAULT_SCAN_INTERVAL_MS = 4e3;
100
+ /**
32
101
  * Service that synchronizes external workspace sessions into the taskboard.
33
102
  */
34
103
  var ExternalSessionSyncService = class {
35
104
  deps;
36
105
  unsubscribe;
106
+ ignoredSessions = /* @__PURE__ */ new Set();
107
+ scanTimer;
37
108
  constructor(deps) {
38
109
  this.deps = deps;
39
110
  this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
40
111
  this.handleSessionEvent(sessionId, event, sessionMeta);
41
112
  });
113
+ const interval = deps.scanIntervalMs ?? 4e3;
114
+ if (interval > 0) this.scanTimer = setInterval(() => {
115
+ this.scanActiveSessions();
116
+ }, interval);
42
117
  }
43
- /** Detach listener on teardown. */
118
+ /** Detach listener and clear scanner on teardown. */
44
119
  dispose() {
45
120
  this.unsubscribe();
121
+ if (this.scanTimer !== void 0) {
122
+ clearInterval(this.scanTimer);
123
+ this.scanTimer = void 0;
124
+ }
125
+ }
126
+ /**
127
+ * Periodic active scan: checks whether external sessions linked to board tasks
128
+ * are actively working, ensuring tasks in `in_review` / `todo` / `backlog`
129
+ * automatically pull back to `in_progress`.
130
+ */
131
+ async scanActiveSessions() {
132
+ const snapshot = this.deps.store.snapshot();
133
+ if (!defaultSyncExternalSessionsOf(snapshot.settings)) return;
134
+ if (this.deps.sessions === void 0) return;
135
+ const now = this.deps.now();
136
+ const tasks = snapshot.tasks.filter((t) => t.trashedAt === void 0);
137
+ for (const task of tasks) {
138
+ const sessionId = task.claimedBy ?? task.executions[task.executions.length - 1]?.sessionId;
139
+ if (sessionId === void 0 || typeof sessionId !== "string") continue;
140
+ if (this.ignoredSessions.has(sessionId) || sessionId.startsWith("session-taskboard-")) continue;
141
+ let session;
142
+ try {
143
+ session = this.deps.sessions.get?.(sessionId);
144
+ } catch {}
145
+ if (session === void 0 && typeof this.deps.sessions.list === "function") try {
146
+ session = this.deps.sessions.list()?.find((s) => s?.id === sessionId);
147
+ } catch {}
148
+ if (session === void 0 || session === null) continue;
149
+ if (isSubagentSession(sessionId, session)) {
150
+ this.ignoredSessions.add(sessionId);
151
+ continue;
152
+ }
153
+ if (isSessionActiveWorking(session)) {
154
+ if (task.status !== "in_progress") await this.deps.store.mutate("task-updated", (ledger) => {
155
+ const current = ledger.tasks.find((t) => t.id === task.id);
156
+ if (current === void 0 || current.trashedAt !== void 0) return void 0;
157
+ current.status = "in_progress";
158
+ current.claimedBy = sessionId;
159
+ current.claimedAt = current.claimedAt ?? now;
160
+ current.updatedAt = now;
161
+ current.updatedBy = {
162
+ kind: "agent",
163
+ sessionId
164
+ };
165
+ if (!current.executions.some((e) => e.sessionId === sessionId && e.outcome === "running")) current.executions.push({
166
+ id: newExecutionId(),
167
+ sessionId,
168
+ trigger: "manual",
169
+ startedAt: now,
170
+ outcome: "running",
171
+ isolation: "none"
172
+ });
173
+ return [current];
174
+ });
175
+ }
176
+ }
46
177
  }
47
178
  async handleSessionEvent(sessionId, event, sessionMeta) {
48
- if (sessionId.startsWith("session-taskboard-")) return;
179
+ if (this.ignoredSessions.has(sessionId)) return;
180
+ if (sessionId.startsWith("session-taskboard-")) {
181
+ this.ignoredSessions.add(sessionId);
182
+ return;
183
+ }
184
+ if (isSubagentSession(sessionId, sessionMeta, event)) {
185
+ this.ignoredSessions.add(sessionId);
186
+ await this.deps.store.mutate("task-deleted", (ledger) => {
187
+ const idx = ledger.tasks.findIndex((t) => t.claimedBy === sessionId && t.createdBy.kind === "agent" && t.createdBy.sessionId === sessionId);
188
+ if (idx >= 0) {
189
+ ledger.tasks.splice(idx, 1);
190
+ return [];
191
+ }
192
+ });
193
+ return;
194
+ }
49
195
  if (!defaultSyncExternalSessionsOf(this.deps.store.snapshot().settings)) return;
50
196
  const now = this.deps.now();
51
197
  if (event.type === "turn/start") {
@@ -56,6 +202,10 @@ var ExternalSessionSyncService = class {
56
202
  await this.handleUserMessage(sessionId, event.data, now);
57
203
  return;
58
204
  }
205
+ if (event.type === "turn/step" || event.type === "turn/progress" || event.type === "agent/step" || event.type === "agent/thought" || event.type === "agent/turn/start") {
206
+ await this.ensureSessionInProgress(sessionId, now);
207
+ return;
208
+ }
59
209
  if (event.type === "session/title") {
60
210
  await this.handleSessionTitle(sessionId, event.data, now);
61
211
  return;
@@ -65,6 +215,38 @@ var ExternalSessionSyncService = class {
65
215
  return;
66
216
  }
67
217
  }
218
+ async ensureSessionInProgress(sessionId, now) {
219
+ await this.deps.store.mutate("task-updated", (ledger) => {
220
+ const task = ledger.tasks.find((t) => t.claimedBy === sessionId || t.executions.some((e) => e.sessionId === sessionId));
221
+ if (task === void 0 || task.trashedAt !== void 0) return void 0;
222
+ let changed = false;
223
+ if (task.status !== "in_progress") {
224
+ task.status = "in_progress";
225
+ task.claimedBy = sessionId;
226
+ task.claimedAt = task.claimedAt ?? now;
227
+ changed = true;
228
+ }
229
+ if (!task.executions.some((e) => e.sessionId === sessionId && e.outcome === "running")) {
230
+ task.executions.push({
231
+ id: newExecutionId(),
232
+ sessionId,
233
+ trigger: "manual",
234
+ startedAt: now,
235
+ outcome: "running",
236
+ isolation: "none"
237
+ });
238
+ changed = true;
239
+ }
240
+ if (changed) {
241
+ task.updatedAt = now;
242
+ task.updatedBy = {
243
+ kind: "agent",
244
+ sessionId
245
+ };
246
+ return [task];
247
+ }
248
+ });
249
+ }
68
250
  async handleTurnStart(sessionId, cwd, now) {
69
251
  let wsId;
70
252
  if (cwd !== void 0 && cwd.length > 0) wsId = (await this.deps.workspaces.resolveByPath(cwd))?.id;
@@ -151,20 +333,38 @@ var ExternalSessionSyncService = class {
151
333
  }
152
334
  async handleUserMessage(sessionId, msgData, now) {
153
335
  const text = extractUserMessageText(msgData);
154
- if (text.trim().length === 0) return;
155
336
  await this.deps.store.mutate("task-updated", (ledger) => {
156
337
  const task = ledger.tasks.find((t) => t.claimedBy === sessionId || t.executions.some((e) => e.sessionId === sessionId));
157
338
  if (task === void 0 || task.trashedAt !== void 0) return void 0;
158
339
  let changed = false;
159
- if (task.title.startsWith("会话 ") && task.title.length <= 16) {
160
- const derived = titleFromText(text);
161
- if (derived.length > 0) {
162
- task.title = normalizeTitle(derived);
340
+ if (text.trim().length > 0) {
341
+ if (task.title.startsWith("会话 ") && task.title.length <= 16) {
342
+ const derived = titleFromText(text);
343
+ if (derived.length > 0) {
344
+ task.title = normalizeTitle(derived);
345
+ changed = true;
346
+ }
347
+ }
348
+ if (task.description.length === 0) {
349
+ task.description = text.slice(0, 2e3);
163
350
  changed = true;
164
351
  }
165
352
  }
166
- if (task.description.length === 0) {
167
- task.description = text.slice(0, 2e3);
353
+ if (task.status !== "in_progress") {
354
+ task.status = "in_progress";
355
+ task.claimedBy = sessionId;
356
+ task.claimedAt = now;
357
+ changed = true;
358
+ }
359
+ if (!task.executions.some((e) => e.sessionId === sessionId && e.outcome === "running")) {
360
+ task.executions.push({
361
+ id: newExecutionId(),
362
+ sessionId,
363
+ trigger: "manual",
364
+ startedAt: now,
365
+ outcome: "running",
366
+ isolation: "none"
367
+ });
168
368
  changed = true;
169
369
  }
170
370
  if (changed) {
@@ -244,6 +444,6 @@ var ExternalSessionSyncService = class {
244
444
  }
245
445
  };
246
446
  //#endregion
247
- export { ExternalSessionSyncService, extractUserMessageText, titleFromText };
447
+ export { DEFAULT_SCAN_INTERVAL_MS, ExternalSessionSyncService, extractUserMessageText, isSessionActiveWorking, isSubagentSession, titleFromText };
248
448
 
249
449
  //# sourceMappingURL=session-sync.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-sync.js","names":[],"sources":["../../src/host/session-sync.ts"],"sourcesContent":["/**\n * External workspace session synchronization service.\n *\n * When `settings.syncExternalSessions` is enabled (0.5.4):\n * - Listens to session lifecycle events from outside the taskboard.\n * - On `turn/start`: automatically captures or resumes the session on the board\n * (status: `in_progress`, claimedBy: sessionId).\n * - On `user/message` / `session/title`: enriches/updates task title & description.\n * - On `turn/end`: settles the execution (success -> `in_review` 待验收, failure -> `todo`).\n *\n * @module dsh-taskboard/host/session-sync\n */\nimport {\n defaultSyncExternalSessionsOf,\n newCommentId,\n newExecutionId,\n newTaskId,\n normalizeBody,\n normalizeTitle,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport type { EventsFace } from './execution.ts'\nimport type { TaskStore } from './store.ts'\nimport type { WorkspaceFace } from './tools.ts'\n\n/** Extract text content from a user message event payload. */\nexport function extractUserMessageText(msg: unknown): string {\n if (typeof msg !== 'object' || msg === null) return ''\n const content = (msg as { content?: unknown }).content\n if (typeof content === 'string') return content\n if (Array.isArray(content)) {\n return content\n .map(part => {\n if (typeof part === 'string') return part\n if (typeof part === 'object' && part !== null && 'text' in part && typeof (part as { text: unknown }).text === 'string') {\n return (part as { text: string }).text\n }\n return ''\n })\n .filter(Boolean)\n .join('\\n')\n }\n return ''\n}\n\n/** Extract a short one-line title from prompt text. */\nexport function titleFromText(text: string): string {\n const clean = text.trim().replace(/^#+\\s*/, '')\n const firstLine = clean.split('\\n')[0]?.trim() ?? ''\n return firstLine.slice(0, 50).trim()\n}\n\n/** Dependencies required by the external session sync service. */\nexport interface SessionSyncDeps {\n store: TaskStore\n workspaces: WorkspaceFace\n events: EventsFace\n now: () => number\n}\n\n/**\n * Service that synchronizes external workspace sessions into the taskboard.\n */\nexport class ExternalSessionSyncService {\n private readonly unsubscribe: () => void\n\n constructor(private readonly deps: SessionSyncDeps) {\n this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {\n void this.handleSessionEvent(sessionId, event, sessionMeta)\n })\n }\n\n /** Detach listener on teardown. */\n dispose(): void {\n this.unsubscribe()\n }\n\n private async handleSessionEvent(\n sessionId: string,\n event: { type: string; data?: unknown },\n sessionMeta?: { header?: { cwd?: string } },\n ): Promise<void> {\n // 1. Ignore taskboard's internal execution sessions\n if (sessionId.startsWith('session-taskboard-')) return\n\n // 2. Check if external session sync is enabled in board settings\n const snapshot = this.deps.store.snapshot()\n if (!defaultSyncExternalSessionsOf(snapshot.settings)) return\n\n const now = this.deps.now()\n\n if (event.type === 'turn/start') {\n await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now)\n return\n }\n\n if (event.type === 'user/message') {\n await this.handleUserMessage(sessionId, event.data, now)\n return\n }\n\n if (event.type === 'session/title') {\n await this.handleSessionTitle(sessionId, event.data, now)\n return\n }\n\n if (event.type === 'turn/end') {\n await this.handleTurnEnd(sessionId, event.data, now)\n return\n }\n }\n\n private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {\n // Resolve workspace\n let wsId: string | undefined\n if (cwd !== undefined && cwd.length > 0) {\n const resolved = await this.deps.workspaces.resolveByPath(cwd)\n wsId = resolved?.id\n }\n if (wsId === undefined) {\n wsId = this.deps.workspaces.list()[0]?.id ?? 'default'\n }\n\n await this.deps.store.mutate('task-created', (ledger) => {\n // Find existing task linked to this session\n const existing = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n\n if (existing !== undefined) {\n if (existing.trashedAt !== undefined) return undefined\n // If already in_progress and holding claim, ensure running execution\n if (existing.status === 'in_progress' && existing.claimedBy === sessionId) {\n const hasRunning = existing.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n existing.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n existing.updatedAt = now\n existing.updatedBy = { kind: 'agent', sessionId }\n return [existing]\n }\n return undefined\n }\n\n // Resumed or continued turn (e.g. from in_review or todo)\n existing.status = 'in_progress'\n existing.claimedBy = sessionId\n existing.claimedAt = now\n existing.updatedAt = now\n existing.updatedBy = { kind: 'agent', sessionId }\n existing.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n return [existing]\n }\n\n // Create new task for this external session\n const shortId = sessionId.replace(/^session-/, '').slice(0, 8)\n const newTask: TaskRecord = {\n id: newTaskId(),\n title: `会话 ${shortId}`,\n description: '',\n prompt: '',\n workspaceId: wsId,\n urgency: 'normal',\n status: 'in_progress',\n blocked: false,\n execution: { mode: 'claim' },\n isolation: 'none',\n claimedBy: sessionId,\n claimedAt: now,\n version: 1,\n createdAt: now,\n updatedAt: now,\n createdBy: { kind: 'agent', sessionId },\n updatedBy: { kind: 'agent', sessionId },\n comments: [],\n executions: [\n {\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n },\n ],\n }\n ledger.tasks.push(newTask)\n return [newTask]\n })\n }\n\n private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {\n const text = extractUserMessageText(msgData)\n if (text.trim().length === 0) return\n\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n let changed = false\n // If title is default placeholder \"会话 ...\", replace with prompt summary\n if (task.title.startsWith('会话 ') && task.title.length <= 16) {\n const derived = titleFromText(text)\n if (derived.length > 0) {\n task.title = normalizeTitle(derived)\n changed = true\n }\n }\n // If description is empty, record initial prompt\n if (task.description.length === 0) {\n task.description = text.slice(0, 2000)\n changed = true\n }\n if (changed) {\n task.updatedAt = now\n task.updatedBy = { kind: 'user' }\n return [task]\n }\n return undefined\n })\n }\n\n private async handleSessionTitle(sessionId: string, titleData: unknown, now: number): Promise<void> {\n const rawTitle = typeof titleData === 'object' && titleData !== null && 'title' in titleData && typeof (titleData as { title: unknown }).title === 'string'\n ? (titleData as { title: string }).title\n : typeof titleData === 'string'\n ? titleData\n : ''\n if (rawTitle.trim().length === 0) return\n\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n task.title = normalizeTitle(rawTitle)\n task.updatedAt = now\n task.updatedBy = { kind: 'user' }\n return [task]\n })\n }\n\n private async handleTurnEnd(sessionId: string, endData: unknown, now: number): Promise<void> {\n const reason = typeof endData === 'object' && endData !== null && 'reason' in endData\n ? (endData as { reason: unknown }).reason\n : endData\n\n // Check if error or failure\n let isFailure = false\n let errorMessage = ''\n if (typeof reason === 'object' && reason !== null) {\n const r = reason as Record<string, unknown>\n if (r.kind === 'error' || r.kind === 'failure') {\n isFailure = true\n errorMessage = typeof r.error === 'string' ? r.error : typeof r.message === 'string' ? r.message : 'turn error'\n } else if (r.kind === 'cancel') {\n isFailure = true\n errorMessage = 'cancelled'\n }\n } else if (typeof reason === 'string' && (reason.includes('error') || reason.includes('fail'))) {\n isFailure = true\n errorMessage = reason\n }\n\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n // Settle running execution\n for (const exec of task.executions) {\n if (exec.sessionId === sessionId && exec.outcome === 'running') {\n exec.endedAt = now\n if (isFailure) {\n exec.outcome = 'failed'\n exec.error = errorMessage.slice(0, 500)\n } else {\n exec.outcome = 'succeeded'\n }\n }\n }\n\n delete task.claimedBy\n delete task.claimedAt\n task.updatedAt = now\n task.updatedBy = { kind: 'agent', sessionId }\n\n if (isFailure) {\n // Failed session hands back to todo with comment\n if (task.status === 'in_progress') {\n task.status = 'todo'\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),\n version: 1,\n createdAt: now,\n })\n }\n } else {\n // Successful settlement automatically moves to in_review (待验收)\n if (task.status === 'in_progress') {\n task.status = 'in_review'\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),\n version: 1,\n createdAt: now,\n })\n }\n }\n return [task]\n })\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"session-sync.js","names":[],"sources":["../../src/host/session-sync.ts"],"sourcesContent":["/**\n * External workspace session synchronization service.\n *\n * When `settings.syncExternalSessions` is enabled (0.5.4):\n * - Listens to session lifecycle events from outside the taskboard.\n * - On `turn/start`: automatically captures or resumes the session on the board\n * (status: `in_progress`, claimedBy: sessionId).\n * - On `user/message` / `session/title`: enriches/updates task title & description.\n * - On `turn/end`: settles the execution (success -> `in_review` 待验收, failure -> `todo`).\n *\n * @module dsh-taskboard/host/session-sync\n */\nimport {\n defaultSyncExternalSessionsOf,\n newCommentId,\n newExecutionId,\n newTaskId,\n normalizeBody,\n normalizeTitle,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport type { EventsFace } from './execution.ts'\nimport type { TaskStore } from './store.ts'\nimport type { WorkspaceFace } from './tools.ts'\n\n/** Extract text content from a user message event payload. */\nexport function extractUserMessageText(msg: unknown): string {\n if (typeof msg !== 'object' || msg === null) return ''\n const content = (msg as { content?: unknown }).content\n if (typeof content === 'string') return content\n if (Array.isArray(content)) {\n return content\n .map(part => {\n if (typeof part === 'string') return part\n if (typeof part === 'object' && part !== null && 'text' in part && typeof (part as { text: unknown }).text === 'string') {\n return (part as { text: string }).text\n }\n return ''\n })\n .filter(Boolean)\n .join('\\n')\n }\n return ''\n}\n\n/** Extract a short one-line title from prompt text. */\nexport function titleFromText(text: string): string {\n const clean = text.trim().replace(/^#+\\s*/, '')\n const firstLine = clean.split('\\n')[0]?.trim() ?? ''\n return firstLine.slice(0, 50).trim()\n}\n\n/**\n * Detect whether a session represents a subagent child conversation.\n * Subagents are created by agent delegation (e.g. invoke_subagent / subagents service)\n * and should never be automatically converted into user tasks on the taskboard.\n */\nexport function isSubagentSession(\n sessionId: string,\n sessionMeta?: unknown,\n event?: { type: string; data?: unknown },\n): boolean {\n if (typeof sessionId === 'string') {\n if (sessionId.startsWith('subagent-') || sessionId.startsWith('child-') || sessionId.startsWith('delegate-')) {\n return true\n }\n }\n\n if (typeof sessionMeta === 'object' && sessionMeta !== null) {\n const s = sessionMeta as {\n header?: Record<string, unknown>\n meta?: Record<string, unknown>\n options?: Record<string, unknown>\n }\n\n const header = s.header\n const meta = s.meta\n const options = s.options\n\n // Check origin\n if (header?.origin === 'subagent' || meta?.origin === 'subagent') return true\n\n // Check parent session lineage\n if (\n header?.parentSession !== undefined\n || header?.parentSessionId !== undefined\n || meta?.parentSession !== undefined\n || meta?.parentSessionId !== undefined\n ) {\n return true\n }\n\n // Check delegation depth\n if (typeof header?.delegationDepth === 'number' && header.delegationDepth > 0) return true\n if (typeof meta?.delegationDepth === 'number' && meta.delegationDepth > 0) return true\n if (typeof options?.subagentDepth === 'number' && options.subagentDepth > 0) return true\n }\n\n if (event !== undefined) {\n if (event.type === 'subagent/descriptor' || event.type === 'subagent/start' || event.type === 'subagent/end') {\n return true\n }\n if (typeof event.data === 'object' && event.data !== null) {\n const d = event.data as Record<string, unknown>\n if (\n d.origin === 'subagent'\n || d.subagent === true\n || typeof d.subagentId === 'string'\n || typeof d.parentSession === 'string'\n ) {\n return true\n }\n }\n }\n\n return false\n}\n\n/**\n * Detect whether a session represents an active working conversation.\n * Inspects state, status, isWorking/isBusy methods, running flags, and active turns.\n */\nexport function isSessionActiveWorking(session: unknown): boolean {\n if (typeof session !== 'object' || session === null) return false\n const s = session as Record<string, unknown>\n\n // 1. Method checks\n if (typeof s.isWorking === 'function') {\n try { if (Boolean((s.isWorking as () => boolean)())) return true } catch { /* ignore */ }\n } else if (s.isWorking === true) {\n return true\n }\n\n if (typeof s.isBusy === 'function') {\n try { if (Boolean((s.isBusy as () => boolean)())) return true } catch { /* ignore */ }\n } else if (s.busy === true || s.isBusy === true) {\n return true\n }\n\n // 2. Boolean flags\n if (s.running === true || s.active === true || s.isGenerating === true || s.generating === true) {\n return true\n }\n\n // 3. State string\n if (typeof s.state === 'string') {\n const st = s.state.toLowerCase()\n if (st === 'running' || st === 'working' || st === 'busy' || st === 'generating' || st === 'executing') {\n return true\n }\n }\n\n // 4. Status string\n if (typeof s.status === 'string') {\n const st = s.status.toLowerCase()\n if (st === 'running' || st === 'working' || st === 'busy' || st === 'active' || st === 'generating' || st === 'executing') {\n return true\n }\n }\n\n // 5. Active turn checks\n if (s.activeTurn !== undefined && s.activeTurn !== null && s.activeTurn !== false) {\n return true\n }\n if (s.currentTurn !== undefined && s.currentTurn !== null) {\n if (typeof s.currentTurn === 'object') {\n const ct = s.currentTurn as Record<string, unknown>\n if (ct.status === 'running' || ct.state === 'running' || ct.outcome === 'running' || ct.endedAt === undefined) {\n return true\n }\n } else {\n return true\n }\n }\n\n // 6. Turns list\n if (Array.isArray(s.turns) && s.turns.length > 0) {\n const lastTurn = s.turns[s.turns.length - 1]\n if (typeof lastTurn === 'object' && lastTurn !== null) {\n const lt = lastTurn as Record<string, unknown>\n if (lt.status === 'running' || lt.state === 'running' || lt.outcome === 'running' || (lt.startedAt !== undefined && lt.endedAt === undefined)) {\n return true\n }\n }\n }\n\n return false\n}\n\n/** Dependencies required by the external session sync service. */\nexport interface SessionSyncDeps {\n store: TaskStore\n workspaces: WorkspaceFace\n events: EventsFace\n sessions?: {\n get?: (id: string) => unknown\n list?: () => unknown[]\n }\n now: () => number\n scanIntervalMs?: number\n}\n\n/** Default scan interval: 4s. */\nexport const DEFAULT_SCAN_INTERVAL_MS = 4000\n\n/**\n * Service that synchronizes external workspace sessions into the taskboard.\n */\nexport class ExternalSessionSyncService {\n private readonly unsubscribe: () => void\n private readonly ignoredSessions = new Set<string>()\n private scanTimer?: NodeJS.Timeout | number\n\n constructor(private readonly deps: SessionSyncDeps) {\n this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {\n void this.handleSessionEvent(sessionId, event, sessionMeta)\n })\n\n const interval = deps.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS\n if (interval > 0) {\n this.scanTimer = setInterval(() => {\n void this.scanActiveSessions()\n }, interval)\n }\n }\n\n /** Detach listener and clear scanner on teardown. */\n dispose(): void {\n this.unsubscribe()\n if (this.scanTimer !== undefined) {\n clearInterval(this.scanTimer as NodeJS.Timeout)\n this.scanTimer = undefined\n }\n }\n\n /**\n * Periodic active scan: checks whether external sessions linked to board tasks\n * are actively working, ensuring tasks in `in_review` / `todo` / `backlog`\n * automatically pull back to `in_progress`.\n */\n async scanActiveSessions(): Promise<void> {\n const snapshot = this.deps.store.snapshot()\n if (!defaultSyncExternalSessionsOf(snapshot.settings)) return\n if (this.deps.sessions === undefined) return\n\n const now = this.deps.now()\n const tasks = snapshot.tasks.filter(t => t.trashedAt === undefined)\n\n for (const task of tasks) {\n const sessionId = task.claimedBy ?? task.executions[task.executions.length - 1]?.sessionId\n if (sessionId === undefined || typeof sessionId !== 'string') continue\n if (this.ignoredSessions.has(sessionId) || sessionId.startsWith('session-taskboard-')) continue\n\n let session: unknown\n try {\n session = this.deps.sessions.get?.(sessionId)\n } catch { /* ignore */ }\n\n if (session === undefined && typeof this.deps.sessions.list === 'function') {\n try {\n const list = this.deps.sessions.list()\n session = list?.find(s => (s as { id?: string })?.id === sessionId)\n } catch { /* ignore */ }\n }\n\n if (session === undefined || session === null) continue\n if (isSubagentSession(sessionId, session)) {\n this.ignoredSessions.add(sessionId)\n continue\n }\n\n const isWorking = isSessionActiveWorking(session)\n if (isWorking) {\n if (task.status !== 'in_progress') {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const current = ledger.tasks.find(t => t.id === task.id)\n if (current === undefined || current.trashedAt !== undefined) return undefined\n current.status = 'in_progress'\n current.claimedBy = sessionId\n current.claimedAt = current.claimedAt ?? now\n current.updatedAt = now\n current.updatedBy = { kind: 'agent', sessionId }\n const hasRunning = current.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n current.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n }\n return [current]\n })\n }\n }\n }\n }\n\n private async handleSessionEvent(\n sessionId: string,\n event: { type: string; data?: unknown },\n sessionMeta?: {\n header?: { cwd?: string; origin?: string; parentSession?: string; parentSessionId?: string; delegationDepth?: number }\n meta?: { origin?: string; parentSession?: string; delegationDepth?: number }\n options?: { subagentDepth?: number }\n },\n ): Promise<void> {\n // 1. Check if already ignored\n if (this.ignoredSessions.has(sessionId)) return\n\n // 2. Ignore taskboard's internal execution sessions\n if (sessionId.startsWith('session-taskboard-')) {\n this.ignoredSessions.add(sessionId)\n return\n }\n\n // 3. Ignore subagent sessions (delegated children)\n if (isSubagentSession(sessionId, sessionMeta, event)) {\n this.ignoredSessions.add(sessionId)\n // If a task was previously created for this subagent before detection, clean it up\n await this.deps.store.mutate('task-deleted', (ledger) => {\n const idx = ledger.tasks.findIndex(\n t => t.claimedBy === sessionId && t.createdBy.kind === 'agent' && t.createdBy.sessionId === sessionId,\n )\n if (idx >= 0) {\n ledger.tasks.splice(idx, 1)\n return []\n }\n return undefined\n })\n return\n }\n\n // 4. Check if external session sync is enabled in board settings\n const snapshot = this.deps.store.snapshot()\n if (!defaultSyncExternalSessionsOf(snapshot.settings)) return\n\n const now = this.deps.now()\n\n if (event.type === 'turn/start') {\n await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now)\n return\n }\n\n if (event.type === 'user/message') {\n await this.handleUserMessage(sessionId, event.data, now)\n return\n }\n\n if (\n event.type === 'turn/step'\n || event.type === 'turn/progress'\n || event.type === 'agent/step'\n || event.type === 'agent/thought'\n || event.type === 'agent/turn/start'\n ) {\n await this.ensureSessionInProgress(sessionId, now)\n return\n }\n\n if (event.type === 'session/title') {\n await this.handleSessionTitle(sessionId, event.data, now)\n return\n }\n\n if (event.type === 'turn/end') {\n await this.handleTurnEnd(sessionId, event.data, now)\n return\n }\n }\n\n private async ensureSessionInProgress(sessionId: string, now: number): Promise<void> {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n let changed = false\n if (task.status !== 'in_progress') {\n task.status = 'in_progress'\n task.claimedBy = sessionId\n task.claimedAt = task.claimedAt ?? now\n changed = true\n }\n const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n task.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n changed = true\n }\n if (changed) {\n task.updatedAt = now\n task.updatedBy = { kind: 'agent', sessionId }\n return [task]\n }\n return undefined\n })\n }\n\n private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {\n // Resolve workspace\n let wsId: string | undefined\n if (cwd !== undefined && cwd.length > 0) {\n const resolved = await this.deps.workspaces.resolveByPath(cwd)\n wsId = resolved?.id\n }\n if (wsId === undefined) {\n wsId = this.deps.workspaces.list()[0]?.id ?? 'default'\n }\n\n await this.deps.store.mutate('task-created', (ledger) => {\n // Find existing task linked to this session\n const existing = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n\n if (existing !== undefined) {\n if (existing.trashedAt !== undefined) return undefined\n // If already in_progress and holding claim, ensure running execution\n if (existing.status === 'in_progress' && existing.claimedBy === sessionId) {\n const hasRunning = existing.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n existing.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n existing.updatedAt = now\n existing.updatedBy = { kind: 'agent', sessionId }\n return [existing]\n }\n return undefined\n }\n\n // Resumed or continued turn (e.g. from in_review or todo)\n existing.status = 'in_progress'\n existing.claimedBy = sessionId\n existing.claimedAt = now\n existing.updatedAt = now\n existing.updatedBy = { kind: 'agent', sessionId }\n existing.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n return [existing]\n }\n\n // Create new task for this external session\n const shortId = sessionId.replace(/^session-/, '').slice(0, 8)\n const newTask: TaskRecord = {\n id: newTaskId(),\n title: `会话 ${shortId}`,\n description: '',\n prompt: '',\n workspaceId: wsId,\n urgency: 'normal',\n status: 'in_progress',\n blocked: false,\n execution: { mode: 'claim' },\n isolation: 'none',\n claimedBy: sessionId,\n claimedAt: now,\n version: 1,\n createdAt: now,\n updatedAt: now,\n createdBy: { kind: 'agent', sessionId },\n updatedBy: { kind: 'agent', sessionId },\n comments: [],\n executions: [\n {\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n },\n ],\n }\n ledger.tasks.push(newTask)\n return [newTask]\n })\n }\n\n private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {\n const text = extractUserMessageText(msgData)\n\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n let changed = false\n if (text.trim().length > 0) {\n // If title is default placeholder \"会话 ...\", replace with prompt summary\n if (task.title.startsWith('会话 ') && task.title.length <= 16) {\n const derived = titleFromText(text)\n if (derived.length > 0) {\n task.title = normalizeTitle(derived)\n changed = true\n }\n }\n // If description is empty, record initial prompt\n if (task.description.length === 0) {\n task.description = text.slice(0, 2000)\n changed = true\n }\n }\n\n // If task was in in_review, todo, or backlog, user message resumes work -> move to in_progress\n if (task.status !== 'in_progress') {\n task.status = 'in_progress'\n task.claimedBy = sessionId\n task.claimedAt = now\n changed = true\n }\n\n // Ensure running execution exists\n const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')\n if (!hasRunning) {\n task.executions.push({\n id: newExecutionId(),\n sessionId,\n trigger: 'manual',\n startedAt: now,\n outcome: 'running',\n isolation: 'none',\n })\n changed = true\n }\n\n if (changed) {\n task.updatedAt = now\n task.updatedBy = { kind: 'user' }\n return [task]\n }\n return undefined\n })\n }\n\n private async handleSessionTitle(sessionId: string, titleData: unknown, now: number): Promise<void> {\n const rawTitle = typeof titleData === 'object' && titleData !== null && 'title' in titleData && typeof (titleData as { title: unknown }).title === 'string'\n ? (titleData as { title: string }).title\n : typeof titleData === 'string'\n ? titleData\n : ''\n if (rawTitle.trim().length === 0) return\n\n await this.deps.store.mutate('task-updated', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n task.title = normalizeTitle(rawTitle)\n task.updatedAt = now\n task.updatedBy = { kind: 'user' }\n return [task]\n })\n }\n\n private async handleTurnEnd(sessionId: string, endData: unknown, now: number): Promise<void> {\n const reason = typeof endData === 'object' && endData !== null && 'reason' in endData\n ? (endData as { reason: unknown }).reason\n : endData\n\n // Check if error or failure\n let isFailure = false\n let errorMessage = ''\n if (typeof reason === 'object' && reason !== null) {\n const r = reason as Record<string, unknown>\n if (r.kind === 'error' || r.kind === 'failure') {\n isFailure = true\n errorMessage = typeof r.error === 'string' ? r.error : typeof r.message === 'string' ? r.message : 'turn error'\n } else if (r.kind === 'cancel') {\n isFailure = true\n errorMessage = 'cancelled'\n }\n } else if (typeof reason === 'string' && (reason.includes('error') || reason.includes('fail'))) {\n isFailure = true\n errorMessage = reason\n }\n\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const task = ledger.tasks.find(\n t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),\n )\n if (task === undefined || task.trashedAt !== undefined) return undefined\n\n // Settle running execution\n for (const exec of task.executions) {\n if (exec.sessionId === sessionId && exec.outcome === 'running') {\n exec.endedAt = now\n if (isFailure) {\n exec.outcome = 'failed'\n exec.error = errorMessage.slice(0, 500)\n } else {\n exec.outcome = 'succeeded'\n }\n }\n }\n\n delete task.claimedBy\n delete task.claimedAt\n task.updatedAt = now\n task.updatedBy = { kind: 'agent', sessionId }\n\n if (isFailure) {\n // Failed session hands back to todo with comment\n if (task.status === 'in_progress') {\n task.status = 'todo'\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),\n version: 1,\n createdAt: now,\n })\n }\n } else {\n // Successful settlement automatically moves to in_review (待验收)\n if (task.status === 'in_progress') {\n task.status = 'in_review'\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),\n version: 1,\n createdAt: now,\n })\n }\n }\n return [task]\n })\n }\n}\n"],"mappings":"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"}
package/lib/host/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { emptyLedger, isPlausibleTaskRecord, pruneExecutions } from "../shared/protocol.js";
1
+ import { asBoardSettings, emptyLedger, isPlausibleTaskRecord, pruneExecutions } from "../shared/protocol.js";
2
2
  import { dirname, join } from "node:path";
3
3
  import { mkdir, open, readFile, rename } from "node:fs/promises";
4
4
  //#region src/host/store.ts
@@ -47,10 +47,17 @@ var TaskStore = class {
47
47
  task.claimedBy = task.updatedBy.sessionId;
48
48
  task.claimedAt = task.updatedAt;
49
49
  }
50
+ let settings = void 0;
51
+ if (parsed.settings !== void 0) try {
52
+ settings = asBoardSettings(parsed.settings);
53
+ } catch {
54
+ console.warn("[dsh-taskboard] dropping invalid board settings on load");
55
+ }
50
56
  this.ledger = {
51
57
  schemaVersion: 1,
52
58
  revision: parsed.revision,
53
- tasks
59
+ tasks,
60
+ ...settings !== void 0 ? { settings } : {}
54
61
  };
55
62
  }
56
63
  } catch (error) {
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","names":[],"sources":["../../src/host/store.ts"],"sourcesContent":["/**\n * Host-side task ledger: one JSON file under the DSH home, mutated through a\n * serial write queue, published as immutable snapshots with a global\n * monotonic revision. Change subscribers (P2: SSE route) observe every\n * committed mutation.\n *\n * @module dsh-taskboard/host/store\n */\nimport { mkdir, open, readFile, rename } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n emptyLedger,\n isPlausibleTaskRecord,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\n\n/** One committed ledger mutation, handed to change subscribers. */\nexport interface LedgerChange {\n /** Revision after the mutation. */\n revision: number\n /** The mutated tasks, if any (a comment purge may touch none). */\n tasks: readonly TaskRecord[]\n /** What kind of mutation this was (for SSE event naming later). */\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n}\n\n/**\n * The durable ledger. All mutations run through {@link mutate}, which:\n * validates the resulting document, bumps the global revision, persists\n * atomically (temp file + rename), and only then notifies subscribers.\n */\nexport class TaskStore {\n private readonly file: string\n private ledger: TaskLedger = emptyLedger()\n private readonly subscribers = new Set<(change: LedgerChange) => void>()\n private queue: Promise<unknown> = Promise.resolve()\n private loaded = false\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n async load(): Promise<void> {\n if (this.loaded) return\n try {\n const raw = await readFile(this.file, 'utf8')\n const parsed = JSON.parse(raw) as TaskLedger\n if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {\n // S11: trust no record wholesale — drop structurally broken entries\n // (including R4's traversal-shaped ids from a hand-edited file) with\n // a notice instead of letting them reach the path-building layers.\n const plausible: TaskRecord[] = []\n for (const entry of parsed.tasks as unknown[]) {\n if (!isPlausibleTaskRecord(entry)) {\n const rawId = (entry as { id?: unknown })?.id\n const id = typeof rawId === 'string' ? rawId.slice(0, 60) : String(rawId)\n console.warn('[dsh-taskboard] dropping implausible ledger entry on load:', id)\n continue\n }\n plausible.push(entry as TaskRecord)\n }\n const tasks = plausible\n // Migration from pre-claim-field ledgers: an agent-held in_progress\n // task carried its holder in updatedBy — backfill the explicit claim\n // fields so the hold survives user edits (updatedBy is audit-only).\n for (const task of tasks) {\n if (task.status === 'in_progress' && task.claimedBy === undefined\n && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {\n task.claimedBy = task.updatedBy.sessionId\n task.claimedAt = task.updatedAt\n }\n }\n this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks }\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code\n if (code !== 'ENOENT') {\n // Quarantine a corrupt ledger: rename it aside, start fresh. Never\n // take the host down over ledger damage.\n try {\n await rename(this.file, `${this.file}.corrupt-${Date.now()}`)\n } catch { /* best effort */ }\n }\n }\n this.loaded = true\n }\n\n /**\n * The current snapshot — a deep-frozen clone. Mutating the returned value\n * throws (strict mode) instead of silently bypassing the revision/persist\n * path; internal state is never handed out.\n */\n snapshot(): TaskLedger {\n return deepFreeze(structuredClone(this.ledger))\n }\n\n /** Find a task by id (frozen clone; internal state is never handed out). */\n get(id: string): TaskRecord | undefined {\n const task = this.ledger.tasks.find(t => t.id === id)\n return task === undefined ? undefined : deepFreeze(structuredClone(task))\n }\n\n /** Subscribe to committed changes; returns the unsubscribe. */\n subscribe(fn: (change: LedgerChange) => void): () => void {\n this.subscribers.add(fn)\n return () => this.subscribers.delete(fn)\n }\n\n /**\n * Write a timestamped backup copy of the current ledger next to the live\n * file (import-replace safety, 0.4.0). Never throws the caller's flow —\n * a backup failure fails the import itself.\n * @returns the backup file path.\n */\n async backup(): Promise<string> {\n await this.load()\n const target = `${this.file}.backup-${Date.now()}`\n await persistAtomic(target, JSON.stringify(this.ledger, null, 2))\n return target\n }\n\n /**\n * Run one mutation inside the serial queue. The mutator works on a\n * structured clone; returning `undefined` aborts with no write.\n * @param kind - change kind for subscribers.\n * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.\n */\n async mutate(\n kind: LedgerChange['kind'],\n mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,\n ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {\n const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {\n await this.load()\n const draft: TaskLedger = structuredClone(this.ledger)\n const changed = mutator(draft)\n if (changed === undefined) {\n // S9 parity: even a no-op mutation hands out a frozen clone — never\n // the live internal ledger.\n return { ledger: deepFreeze(structuredClone(this.ledger)), changed: [] }\n }\n // Retention cap: every committed mutation re-checks the touched tasks,\n // so execution history can never grow unbounded (SSE state payload).\n for (const task of changed) pruneExecutions(task)\n draft.revision += 1\n const json = JSON.stringify(draft)\n await persistAtomic(this.file, json)\n this.ledger = draft\n const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }\n for (const fn of this.subscribers) {\n try {\n fn(change)\n } catch { /* subscriber errors never abort the write */ }\n }\n // S9: hand out frozen clones — the return value used to BE the new\n // internal ledger; callers must never mutate internal state in place.\n return {\n ledger: deepFreeze(structuredClone(draft)),\n changed: changed.map(t => deepFreeze(structuredClone(t))),\n }\n }\n const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n return result\n }\n\n /**\n * Run a read INSIDE the serial queue (R3): observes exactly the ledger\n * state after all previously enqueued mutations — immune to the\n * write-then-publish window around `mutate`'s persistence. Read-only: the\n * callback receives a frozen deep clone and nothing is written.\n */\n async read<T>(fn: (ledger: TaskLedger) => T): Promise<T> {\n const run = async (): Promise<T> => {\n await this.load()\n return fn(deepFreeze(structuredClone(this.ledger)))\n }\n const result = (this.queue = this.queue.then(run, run)) as Promise<T>\n return result\n }\n}\n\n/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n if (!Object.isFrozen(value)) Object.freeze(value)\n for (const key of Object.keys(value as Record<string, unknown>)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\n/**\n * Atomic file persist: write temp, fsync, then rename over the target (S10:\n * without the sync, a power loss after rename can leave a zero-length file —\n * the next load would quarantine the ledger and start empty).\n */\nasync function persistAtomic(file: string, contents: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(contents, 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"store.js","names":[],"sources":["../../src/host/store.ts"],"sourcesContent":["/**\n * Host-side task ledger: one JSON file under the DSH home, mutated through a\n * serial write queue, published as immutable snapshots with a global\n * monotonic revision. Change subscribers (P2: SSE route) observe every\n * committed mutation.\n *\n * @module dsh-taskboard/host/store\n */\nimport { mkdir, open, readFile, rename } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n asBoardSettings,\n emptyLedger,\n isPlausibleTaskRecord,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\n\n/** One committed ledger mutation, handed to change subscribers. */\nexport interface LedgerChange {\n /** Revision after the mutation. */\n revision: number\n /** The mutated tasks, if any (a comment purge may touch none). */\n tasks: readonly TaskRecord[]\n /** What kind of mutation this was (for SSE event naming later). */\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n}\n\n/**\n * The durable ledger. All mutations run through {@link mutate}, which:\n * validates the resulting document, bumps the global revision, persists\n * atomically (temp file + rename), and only then notifies subscribers.\n */\nexport class TaskStore {\n private readonly file: string\n private ledger: TaskLedger = emptyLedger()\n private readonly subscribers = new Set<(change: LedgerChange) => void>()\n private queue: Promise<unknown> = Promise.resolve()\n private loaded = false\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n async load(): Promise<void> {\n if (this.loaded) return\n try {\n const raw = await readFile(this.file, 'utf8')\n const parsed = JSON.parse(raw) as TaskLedger\n if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {\n // S11: trust no record wholesale — drop structurally broken entries\n // (including R4's traversal-shaped ids from a hand-edited file) with\n // a notice instead of letting them reach the path-building layers.\n const plausible: TaskRecord[] = []\n for (const entry of parsed.tasks as unknown[]) {\n if (!isPlausibleTaskRecord(entry)) {\n const rawId = (entry as { id?: unknown })?.id\n const id = typeof rawId === 'string' ? rawId.slice(0, 60) : String(rawId)\n console.warn('[dsh-taskboard] dropping implausible ledger entry on load:', id)\n continue\n }\n plausible.push(entry as TaskRecord)\n }\n const tasks = plausible\n // Migration from pre-claim-field ledgers: an agent-held in_progress\n // task carried its holder in updatedBy — backfill the explicit claim\n // fields so the hold survives user edits (updatedBy is audit-only).\n for (const task of tasks) {\n if (task.status === 'in_progress' && task.claimedBy === undefined\n && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {\n task.claimedBy = task.updatedBy.sessionId\n task.claimedAt = task.updatedAt\n }\n }\n let settings = undefined\n if (parsed.settings !== undefined) {\n try {\n settings = asBoardSettings(parsed.settings)\n } catch {\n console.warn('[dsh-taskboard] dropping invalid board settings on load')\n }\n }\n this.ledger = {\n schemaVersion: LEDGER_SCHEMA_VERSION,\n revision: parsed.revision,\n tasks,\n ...(settings !== undefined ? { settings } : {}),\n }\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code\n if (code !== 'ENOENT') {\n // Quarantine a corrupt ledger: rename it aside, start fresh. Never\n // take the host down over ledger damage.\n try {\n await rename(this.file, `${this.file}.corrupt-${Date.now()}`)\n } catch { /* best effort */ }\n }\n }\n this.loaded = true\n }\n\n /**\n * The current snapshot — a deep-frozen clone. Mutating the returned value\n * throws (strict mode) instead of silently bypassing the revision/persist\n * path; internal state is never handed out.\n */\n snapshot(): TaskLedger {\n return deepFreeze(structuredClone(this.ledger))\n }\n\n /** Find a task by id (frozen clone; internal state is never handed out). */\n get(id: string): TaskRecord | undefined {\n const task = this.ledger.tasks.find(t => t.id === id)\n return task === undefined ? undefined : deepFreeze(structuredClone(task))\n }\n\n /** Subscribe to committed changes; returns the unsubscribe. */\n subscribe(fn: (change: LedgerChange) => void): () => void {\n this.subscribers.add(fn)\n return () => this.subscribers.delete(fn)\n }\n\n /**\n * Write a timestamped backup copy of the current ledger next to the live\n * file (import-replace safety, 0.4.0). Never throws the caller's flow —\n * a backup failure fails the import itself.\n * @returns the backup file path.\n */\n async backup(): Promise<string> {\n await this.load()\n const target = `${this.file}.backup-${Date.now()}`\n await persistAtomic(target, JSON.stringify(this.ledger, null, 2))\n return target\n }\n\n /**\n * Run one mutation inside the serial queue. The mutator works on a\n * structured clone; returning `undefined` aborts with no write.\n * @param kind - change kind for subscribers.\n * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.\n */\n async mutate(\n kind: LedgerChange['kind'],\n mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,\n ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {\n const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {\n await this.load()\n const draft: TaskLedger = structuredClone(this.ledger)\n const changed = mutator(draft)\n if (changed === undefined) {\n // S9 parity: even a no-op mutation hands out a frozen clone — never\n // the live internal ledger.\n return { ledger: deepFreeze(structuredClone(this.ledger)), changed: [] }\n }\n // Retention cap: every committed mutation re-checks the touched tasks,\n // so execution history can never grow unbounded (SSE state payload).\n for (const task of changed) pruneExecutions(task)\n draft.revision += 1\n const json = JSON.stringify(draft)\n await persistAtomic(this.file, json)\n this.ledger = draft\n const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }\n for (const fn of this.subscribers) {\n try {\n fn(change)\n } catch { /* subscriber errors never abort the write */ }\n }\n // S9: hand out frozen clones — the return value used to BE the new\n // internal ledger; callers must never mutate internal state in place.\n return {\n ledger: deepFreeze(structuredClone(draft)),\n changed: changed.map(t => deepFreeze(structuredClone(t))),\n }\n }\n const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n return result\n }\n\n /**\n * Run a read INSIDE the serial queue (R3): observes exactly the ledger\n * state after all previously enqueued mutations — immune to the\n * write-then-publish window around `mutate`'s persistence. Read-only: the\n * callback receives a frozen deep clone and nothing is written.\n */\n async read<T>(fn: (ledger: TaskLedger) => T): Promise<T> {\n const run = async (): Promise<T> => {\n await this.load()\n return fn(deepFreeze(structuredClone(this.ledger)))\n }\n const result = (this.queue = this.queue.then(run, run)) as Promise<T>\n return result\n }\n}\n\n/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n if (!Object.isFrozen(value)) Object.freeze(value)\n for (const key of Object.keys(value as Record<string, unknown>)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\n/**\n * Atomic file persist: write temp, fsync, then rename over the target (S10:\n * without the sync, a power loss after rename can leave a zero-length file —\n * the next load would quarantine the ledger and start empty).\n */\nasync function persistAtomic(file: string, contents: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(contents, 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAyCA,IAAa,YAAb,MAAuB;CACrB;CACA,SAA6B,YAAY;CACzC,8BAA+B,IAAI,IAAoC;CACvE,QAAkC,QAAQ,QAAQ;CAClD,SAAiB;;CAGjB,YAAY,SAA2B;EACrC,KAAK,OAAO,QAAQ;CACtB;;CAGA,MAAM,OAAsB;EAC1B,IAAI,KAAK,QAAQ;EACjB,IAAI;GACF,MAAM,MAAM,MAAM,SAAS,KAAK,MAAM,MAAM;GAC5C,MAAM,SAAS,KAAK,MAAM,GAAG;GAC7B,IAAI,OAAO,OAAO,aAAa,YAAY,MAAM,QAAQ,OAAO,KAAK,GAAG;IAItE,MAAM,YAA0B,CAAC;IACjC,KAAK,MAAM,SAAS,OAAO,OAAoB;KAC7C,IAAI,CAAC,sBAAsB,KAAK,GAAG;MACjC,MAAM,QAAS,OAA4B;MAC3C,MAAM,KAAK,OAAO,UAAU,WAAW,MAAM,MAAM,GAAG,EAAE,IAAI,OAAO,KAAK;MACxE,QAAQ,KAAK,8DAA8D,EAAE;MAC7E;KACF;KACA,UAAU,KAAK,KAAmB;IACpC;IACA,MAAM,QAAQ;IAId,KAAK,MAAM,QAAQ,OACjB,IAAI,KAAK,WAAW,iBAAiB,KAAK,cAAc,KAAA,KACnD,KAAK,WAAW,SAAS,WAAW,OAAO,KAAK,UAAU,cAAc,UAAU;KACrF,KAAK,YAAY,KAAK,UAAU;KAChC,KAAK,YAAY,KAAK;IACxB;IAEF,IAAI,WAAW,KAAA;IACf,IAAI,OAAO,aAAa,KAAA,GACtB,IAAI;KACF,WAAW,gBAAgB,OAAO,QAAQ;IAC5C,QAAQ;KACN,QAAQ,KAAK,yDAAyD;IACxE;IAEF,KAAK,SAAS;KACZ,eAAA;KACA,UAAU,OAAO;KACjB;KACA,GAAI,aAAa,KAAA,IAAY,EAAE,SAAS,IAAI,CAAC;IAC/C;GACF;EACF,SAAS,OAAO;GAEd,IADc,MAAgC,SACjC,UAGX,IAAI;IACF,MAAM,OAAO,KAAK,MAAM,GAAG,KAAK,KAAK,WAAW,KAAK,IAAI,GAAG;GAC9D,QAAQ,CAAoB;EAEhC;EACA,KAAK,SAAS;CAChB;;;;;;CAOA,WAAuB;EACrB,OAAO,WAAW,gBAAgB,KAAK,MAAM,CAAC;CAChD;;CAGA,IAAI,IAAoC;EACtC,MAAM,OAAO,KAAK,OAAO,MAAM,MAAK,MAAK,EAAE,OAAO,EAAE;EACpD,OAAO,SAAS,KAAA,IAAY,KAAA,IAAY,WAAW,gBAAgB,IAAI,CAAC;CAC1E;;CAGA,UAAU,IAAgD;EACxD,KAAK,YAAY,IAAI,EAAE;EACvB,aAAa,KAAK,YAAY,OAAO,EAAE;CACzC;;;;;;;CAQA,MAAM,SAA0B;EAC9B,MAAM,KAAK,KAAK;EAChB,MAAM,SAAS,GAAG,KAAK,KAAK,UAAU,KAAK,IAAI;EAC/C,MAAM,cAAc,QAAQ,KAAK,UAAU,KAAK,QAAQ,MAAM,CAAC,CAAC;EAChE,OAAO;CACT;;;;;;;CAQA,MAAM,OACJ,MACA,SACiE;EACjE,MAAM,MAAM,YAA6E;GACvF,MAAM,KAAK,KAAK;GAChB,MAAM,QAAoB,gBAAgB,KAAK,MAAM;GACrD,MAAM,UAAU,QAAQ,KAAK;GAC7B,IAAI,YAAY,KAAA,GAGd,OAAO;IAAE,QAAQ,WAAW,gBAAgB,KAAK,MAAM,CAAC;IAAG,SAAS,CAAC;GAAE;GAIzE,KAAK,MAAM,QAAQ,SAAS,gBAAgB,IAAI;GAChD,MAAM,YAAY;GAClB,MAAM,OAAO,KAAK,UAAU,KAAK;GACjC,MAAM,cAAc,KAAK,MAAM,IAAI;GACnC,KAAK,SAAS;GACd,MAAM,SAAuB;IAAE,UAAU,MAAM;IAAU,OAAO;IAAS;GAAK;GAC9E,KAAK,MAAM,MAAM,KAAK,aACpB,IAAI;IACF,GAAG,MAAM;GACX,QAAQ,CAAgD;GAI1D,OAAO;IACL,QAAQ,WAAW,gBAAgB,KAAK,CAAC;IACzC,SAAS,QAAQ,KAAI,MAAK,WAAW,gBAAgB,CAAC,CAAC,CAAC;GAC1D;EACF;EAEA,OAAO,KADc,QAAQ,KAAK,MAAM,KAAK,KAAK,GAAG;CAEvD;;;;;;;CAQA,MAAM,KAAQ,IAA2C;EACvD,MAAM,MAAM,YAAwB;GAClC,MAAM,KAAK,KAAK;GAChB,OAAO,GAAG,WAAW,gBAAgB,KAAK,MAAM,CAAC,CAAC;EACpD;EAEA,OAAO,KADc,QAAQ,KAAK,MAAM,KAAK,KAAK,GAAG;CAEvD;AACF;;AAGA,SAAS,WAAc,OAAa;CAClC,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU;EAC/C,IAAI,CAAC,OAAO,SAAS,KAAK,GAAG,OAAO,OAAO,KAAK;EAChD,KAAK,MAAM,OAAO,OAAO,KAAK,KAAgC,GAC5D,WAAY,MAAkC,IAAI;CAEtD;CACA,OAAO;AACT;;;;;;AAOA,eAAe,cAAc,MAAc,UAAiC;CAC1E,MAAM,MAAM,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;CAC9C,MAAM,OAAO,KAAK,QAAQ,IAAI,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK;CAC9E,MAAM,KAAK,MAAM,KAAK,MAAM,GAAG;CAC/B,IAAI;EACF,MAAM,GAAG,UAAU,UAAU,MAAM;EACnC,MAAM,GAAG,KAAK;CAChB,UAAU;EACR,MAAM,GAAG,MAAM;CACjB;CACA,MAAM,OAAO,MAAM,IAAI;AACzB"}
package/lib/index.js CHANGED
@@ -52,15 +52,33 @@ function apply(ctx) {
52
52
  const events = { onSessionEvent: (listener) => wsCtx.on("session/event", (session, event) => {
53
53
  listener(session.id, event, session);
54
54
  }) };
55
+ let agentSessions;
55
56
  const sessionSync = new ExternalSessionSyncService({
56
57
  store,
57
58
  workspaces: workspaceFace(wsCtx.workspaceRegistry),
58
59
  events,
60
+ sessions: {
61
+ get: (id) => {
62
+ try {
63
+ return (agentSessions ?? wsCtx.get("sessions") ?? wsCtx.get("sessionRegistry") ?? wsCtx.root?.get("sessions"))?.get?.(id);
64
+ } catch {
65
+ return;
66
+ }
67
+ },
68
+ list: () => {
69
+ try {
70
+ return (agentSessions ?? wsCtx.get("sessions") ?? wsCtx.get("sessionRegistry") ?? wsCtx.root?.get("sessions"))?.list?.() ?? [];
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+ },
59
76
  now
60
77
  });
61
78
  disposers.push(() => sessionSync.dispose());
62
79
  const git = createGitFace();
63
80
  wsCtx.inject(["agents"], (agentCtx) => {
81
+ agentSessions = agentCtx.get("sessions");
64
82
  const execution = new ExecutionService({
65
83
  store,
66
84
  agents: { create: (options) => agentCtx.agents.create(options) },
@@ -102,6 +120,13 @@ function apply(ctx) {
102
120
  return;
103
121
  }
104
122
  },
123
+ setPermission: (sessionId, permission) => {
124
+ try {
125
+ const permService = agentCtx.get("permissionPresets");
126
+ const session = agentCtx.get("sessions")?.get(sessionId);
127
+ if (session !== void 0 && permService !== void 0) permService.set(session, permission);
128
+ } catch {}
129
+ },
105
130
  maxConcurrent
106
131
  });
107
132
  let disposeRoutes;
@@ -114,7 +139,81 @@ function apply(ctx) {
114
139
  cancel: (taskId) => execution.cancel(taskId),
115
140
  modelProviders,
116
141
  git,
117
- templates
142
+ templates,
143
+ promptCompletions: async () => {
144
+ try {
145
+ const skillsService = agentCtx.get("skills");
146
+ const commandsService = agentCtx.get("commands");
147
+ const rawSkills = skillsService?.list ? await skillsService.list().catch(() => []) : [];
148
+ const rawCommands = commandsService?.list ? commandsService.list() : [];
149
+ return {
150
+ skills: Array.isArray(rawSkills) ? rawSkills.map((s) => ({
151
+ name: s.name,
152
+ description: s.description
153
+ })) : [],
154
+ commands: Array.isArray(rawCommands) ? rawCommands.map((c) => ({
155
+ name: c.name,
156
+ description: c.description,
157
+ hint: c.input?.hint
158
+ })) : []
159
+ };
160
+ } catch {
161
+ return {
162
+ skills: [],
163
+ commands: []
164
+ };
165
+ }
166
+ },
167
+ modelCatalog: async () => {
168
+ try {
169
+ const models = [];
170
+ const llm = agentCtx.get("llm") ?? wsCtx.get("llm");
171
+ if (llm?.listProviders !== void 0 && llm.listModels !== void 0) {
172
+ const providers = llm.listProviders();
173
+ for (const p of providers) try {
174
+ const list = await llm.listModels(p.id);
175
+ for (const m of list) {
176
+ let reasoning;
177
+ try {
178
+ const meta = llm.resolveModelInfo !== void 0 ? await llm.resolveModelInfo(p.id, m.id) : llm.resolveModel !== void 0 ? await llm.resolveModel(p.id, m.id) : void 0;
179
+ if (meta?.reasoning !== void 0) reasoning = meta.reasoning;
180
+ } catch {}
181
+ models.push({
182
+ provider: p.id,
183
+ model: m.id,
184
+ name: m.name,
185
+ ...m.description ? { description: m.description } : {},
186
+ ...reasoning !== void 0 ? { reasoning } : {}
187
+ });
188
+ }
189
+ } catch {}
190
+ }
191
+ const presetsService = agentCtx.get("agentPresets");
192
+ const presets = [];
193
+ let defaultPresetId;
194
+ if (presetsService?.list !== void 0) try {
195
+ const raw = await presetsService.list();
196
+ const list = raw.ok === true ? raw.value.presets : Array.isArray(raw) ? raw : [];
197
+ for (const p of list) {
198
+ presets.push({
199
+ id: p.id,
200
+ name: p.name
201
+ });
202
+ if (p.isDefault) defaultPresetId = p.id;
203
+ }
204
+ } catch {}
205
+ return {
206
+ models,
207
+ presets,
208
+ ...defaultPresetId !== void 0 ? { defaultPresetId } : {}
209
+ };
210
+ } catch {
211
+ return {
212
+ models: [],
213
+ presets: []
214
+ };
215
+ }
216
+ }
118
217
  });
119
218
  return () => disposeRoutes?.();
120
219
  });