dsh-taskboard 0.5.4 → 0.5.5

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.
@@ -0,0 +1,249 @@
1
+ import { defaultSyncExternalSessionsOf, newCommentId, newExecutionId, newTaskId, normalizeBody, normalizeTitle } from "../shared/protocol.js";
2
+ //#region src/host/session-sync.ts
3
+ /**
4
+ * External workspace session synchronization service.
5
+ *
6
+ * When `settings.syncExternalSessions` is enabled (0.5.4):
7
+ * - Listens to session lifecycle events from outside the taskboard.
8
+ * - On `turn/start`: automatically captures or resumes the session on the board
9
+ * (status: `in_progress`, claimedBy: sessionId).
10
+ * - On `user/message` / `session/title`: enriches/updates task title & description.
11
+ * - On `turn/end`: settles the execution (success -> `in_review` 待验收, failure -> `todo`).
12
+ *
13
+ * @module dsh-taskboard/host/session-sync
14
+ */
15
+ /** Extract text content from a user message event payload. */
16
+ function extractUserMessageText(msg) {
17
+ if (typeof msg !== "object" || msg === null) return "";
18
+ const content = msg.content;
19
+ if (typeof content === "string") return content;
20
+ if (Array.isArray(content)) return content.map((part) => {
21
+ if (typeof part === "string") return part;
22
+ if (typeof part === "object" && part !== null && "text" in part && typeof part.text === "string") return part.text;
23
+ return "";
24
+ }).filter(Boolean).join("\n");
25
+ return "";
26
+ }
27
+ /** Extract a short one-line title from prompt text. */
28
+ function titleFromText(text) {
29
+ return (text.trim().replace(/^#+\s*/, "").split("\n")[0]?.trim() ?? "").slice(0, 50).trim();
30
+ }
31
+ /**
32
+ * Service that synchronizes external workspace sessions into the taskboard.
33
+ */
34
+ var ExternalSessionSyncService = class {
35
+ deps;
36
+ unsubscribe;
37
+ constructor(deps) {
38
+ this.deps = deps;
39
+ this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
40
+ this.handleSessionEvent(sessionId, event, sessionMeta);
41
+ });
42
+ }
43
+ /** Detach listener on teardown. */
44
+ dispose() {
45
+ this.unsubscribe();
46
+ }
47
+ async handleSessionEvent(sessionId, event, sessionMeta) {
48
+ if (sessionId.startsWith("session-taskboard-")) return;
49
+ if (!defaultSyncExternalSessionsOf(this.deps.store.snapshot().settings)) return;
50
+ const now = this.deps.now();
51
+ if (event.type === "turn/start") {
52
+ await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now);
53
+ return;
54
+ }
55
+ if (event.type === "user/message") {
56
+ await this.handleUserMessage(sessionId, event.data, now);
57
+ return;
58
+ }
59
+ if (event.type === "session/title") {
60
+ await this.handleSessionTitle(sessionId, event.data, now);
61
+ return;
62
+ }
63
+ if (event.type === "turn/end") {
64
+ await this.handleTurnEnd(sessionId, event.data, now);
65
+ return;
66
+ }
67
+ }
68
+ async handleTurnStart(sessionId, cwd, now) {
69
+ let wsId;
70
+ if (cwd !== void 0 && cwd.length > 0) wsId = (await this.deps.workspaces.resolveByPath(cwd))?.id;
71
+ if (wsId === void 0) wsId = this.deps.workspaces.list()[0]?.id ?? "default";
72
+ await this.deps.store.mutate("task-created", (ledger) => {
73
+ const existing = ledger.tasks.find((t) => t.claimedBy === sessionId || t.executions.some((e) => e.sessionId === sessionId));
74
+ if (existing !== void 0) {
75
+ if (existing.trashedAt !== void 0) return void 0;
76
+ if (existing.status === "in_progress" && existing.claimedBy === sessionId) {
77
+ if (!existing.executions.some((e) => e.sessionId === sessionId && e.outcome === "running")) {
78
+ existing.executions.push({
79
+ id: newExecutionId(),
80
+ sessionId,
81
+ trigger: "manual",
82
+ startedAt: now,
83
+ outcome: "running",
84
+ isolation: "none"
85
+ });
86
+ existing.updatedAt = now;
87
+ existing.updatedBy = {
88
+ kind: "agent",
89
+ sessionId
90
+ };
91
+ return [existing];
92
+ }
93
+ return;
94
+ }
95
+ existing.status = "in_progress";
96
+ existing.claimedBy = sessionId;
97
+ existing.claimedAt = now;
98
+ existing.updatedAt = now;
99
+ existing.updatedBy = {
100
+ kind: "agent",
101
+ sessionId
102
+ };
103
+ existing.executions.push({
104
+ id: newExecutionId(),
105
+ sessionId,
106
+ trigger: "manual",
107
+ startedAt: now,
108
+ outcome: "running",
109
+ isolation: "none"
110
+ });
111
+ return [existing];
112
+ }
113
+ const shortId = sessionId.replace(/^session-/, "").slice(0, 8);
114
+ const newTask = {
115
+ id: newTaskId(),
116
+ title: `会话 ${shortId}`,
117
+ description: "",
118
+ prompt: "",
119
+ workspaceId: wsId,
120
+ urgency: "normal",
121
+ status: "in_progress",
122
+ blocked: false,
123
+ execution: { mode: "claim" },
124
+ isolation: "none",
125
+ claimedBy: sessionId,
126
+ claimedAt: now,
127
+ version: 1,
128
+ createdAt: now,
129
+ updatedAt: now,
130
+ createdBy: {
131
+ kind: "agent",
132
+ sessionId
133
+ },
134
+ updatedBy: {
135
+ kind: "agent",
136
+ sessionId
137
+ },
138
+ comments: [],
139
+ executions: [{
140
+ id: newExecutionId(),
141
+ sessionId,
142
+ trigger: "manual",
143
+ startedAt: now,
144
+ outcome: "running",
145
+ isolation: "none"
146
+ }]
147
+ };
148
+ ledger.tasks.push(newTask);
149
+ return [newTask];
150
+ });
151
+ }
152
+ async handleUserMessage(sessionId, msgData, now) {
153
+ const text = extractUserMessageText(msgData);
154
+ if (text.trim().length === 0) return;
155
+ await this.deps.store.mutate("task-updated", (ledger) => {
156
+ const task = ledger.tasks.find((t) => t.claimedBy === sessionId || t.executions.some((e) => e.sessionId === sessionId));
157
+ if (task === void 0 || task.trashedAt !== void 0) return void 0;
158
+ 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);
163
+ changed = true;
164
+ }
165
+ }
166
+ if (task.description.length === 0) {
167
+ task.description = text.slice(0, 2e3);
168
+ changed = true;
169
+ }
170
+ if (changed) {
171
+ task.updatedAt = now;
172
+ task.updatedBy = { kind: "user" };
173
+ return [task];
174
+ }
175
+ });
176
+ }
177
+ async handleSessionTitle(sessionId, titleData, now) {
178
+ const rawTitle = typeof titleData === "object" && titleData !== null && "title" in titleData && typeof titleData.title === "string" ? titleData.title : typeof titleData === "string" ? titleData : "";
179
+ if (rawTitle.trim().length === 0) return;
180
+ await this.deps.store.mutate("task-updated", (ledger) => {
181
+ const task = ledger.tasks.find((t) => t.claimedBy === sessionId || t.executions.some((e) => e.sessionId === sessionId));
182
+ if (task === void 0 || task.trashedAt !== void 0) return void 0;
183
+ task.title = normalizeTitle(rawTitle);
184
+ task.updatedAt = now;
185
+ task.updatedBy = { kind: "user" };
186
+ return [task];
187
+ });
188
+ }
189
+ async handleTurnEnd(sessionId, endData, now) {
190
+ const reason = typeof endData === "object" && endData !== null && "reason" in endData ? endData.reason : endData;
191
+ let isFailure = false;
192
+ let errorMessage = "";
193
+ if (typeof reason === "object" && reason !== null) {
194
+ const r = reason;
195
+ if (r.kind === "error" || r.kind === "failure") {
196
+ isFailure = true;
197
+ errorMessage = typeof r.error === "string" ? r.error : typeof r.message === "string" ? r.message : "turn error";
198
+ } else if (r.kind === "cancel") {
199
+ isFailure = true;
200
+ errorMessage = "cancelled";
201
+ }
202
+ } else if (typeof reason === "string" && (reason.includes("error") || reason.includes("fail"))) {
203
+ isFailure = true;
204
+ errorMessage = reason;
205
+ }
206
+ await this.deps.store.mutate("execution-recorded", (ledger) => {
207
+ const task = ledger.tasks.find((t) => t.claimedBy === sessionId || t.executions.some((e) => e.sessionId === sessionId));
208
+ if (task === void 0 || task.trashedAt !== void 0) return void 0;
209
+ for (const exec of task.executions) if (exec.sessionId === sessionId && exec.outcome === "running") {
210
+ exec.endedAt = now;
211
+ if (isFailure) {
212
+ exec.outcome = "failed";
213
+ exec.error = errorMessage.slice(0, 500);
214
+ } else exec.outcome = "succeeded";
215
+ }
216
+ delete task.claimedBy;
217
+ delete task.claimedAt;
218
+ task.updatedAt = now;
219
+ task.updatedBy = {
220
+ kind: "agent",
221
+ sessionId
222
+ };
223
+ if (isFailure) {
224
+ if (task.status === "in_progress") {
225
+ task.status = "todo";
226
+ task.comments.push({
227
+ id: newCommentId(),
228
+ body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),
229
+ version: 1,
230
+ createdAt: now
231
+ });
232
+ }
233
+ } else if (task.status === "in_progress") {
234
+ task.status = "in_review";
235
+ task.comments.push({
236
+ id: newCommentId(),
237
+ body: normalizeBody("[系统] 会话执行完毕,已自动进入待验收。"),
238
+ version: 1,
239
+ createdAt: now
240
+ });
241
+ }
242
+ return [task];
243
+ });
244
+ }
245
+ };
246
+ //#endregion
247
+ export { ExternalSessionSyncService, extractUserMessageText, titleFromText };
248
+
249
+ //# sourceMappingURL=session-sync.js.map
@@ -0,0 +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":";;;;;;;;;;;;;;;AA0BA,SAAgB,uBAAuB,KAAsB;CAC3D,IAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM,OAAO;CACpD,MAAM,UAAW,IAA8B;CAC/C,IAAI,OAAO,YAAY,UAAU,OAAO;CACxC,IAAI,MAAM,QAAQ,OAAO,GACvB,OAAO,QACJ,KAAI,SAAQ;EACX,IAAI,OAAO,SAAS,UAAU,OAAO;EACrC,IAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,UAAU,QAAQ,OAAQ,KAA2B,SAAS,UAC7G,OAAQ,KAA0B;EAEpC,OAAO;CACT,CAAC,CAAC,CACD,OAAO,OAAO,CAAC,CACf,KAAK,IAAI;CAEd,OAAO;AACT;;AAGA,SAAgB,cAAc,MAAsB;CAGlD,QAFc,KAAK,KAAK,CAAC,CAAC,QAAQ,UAAU,EACtB,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,EAAE,EAAE,KAAK,KAAK,GAAA,CACjC,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK;AACrC;;;;AAaA,IAAa,6BAAb,MAAwC;CAGT;CAF7B;CAEA,YAAY,MAAwC;EAAvB,KAAA,OAAA;EAC3B,KAAK,cAAc,KAAK,OAAO,gBAAgB,WAAW,OAAO,gBAAgB;GAC/E,KAAU,mBAAmB,WAAW,OAAO,WAAW;EAC5D,CAAC;CACH;;CAGA,UAAgB;EACd,KAAK,YAAY;CACnB;CAEA,MAAc,mBACZ,WACA,OACA,aACe;EAEf,IAAI,UAAU,WAAW,oBAAoB,GAAG;EAIhD,IAAI,CAAC,8BADY,KAAK,KAAK,MAAM,SACS,CAAC,CAAC,QAAQ,GAAG;EAEvD,MAAM,MAAM,KAAK,KAAK,IAAI;EAE1B,IAAI,MAAM,SAAS,cAAc;GAC/B,MAAM,KAAK,gBAAgB,WAAW,aAAa,QAAQ,KAAK,GAAG;GACnE;EACF;EAEA,IAAI,MAAM,SAAS,gBAAgB;GACjC,MAAM,KAAK,kBAAkB,WAAW,MAAM,MAAM,GAAG;GACvD;EACF;EAEA,IAAI,MAAM,SAAS,iBAAiB;GAClC,MAAM,KAAK,mBAAmB,WAAW,MAAM,MAAM,GAAG;GACxD;EACF;EAEA,IAAI,MAAM,SAAS,YAAY;GAC7B,MAAM,KAAK,cAAc,WAAW,MAAM,MAAM,GAAG;GACnD;EACF;CACF;CAEA,MAAc,gBAAgB,WAAmB,KAAyB,KAA4B;EAEpG,IAAI;EACJ,IAAI,QAAQ,KAAA,KAAa,IAAI,SAAS,GAEpC,QAAO,MADgB,KAAK,KAAK,WAAW,cAAc,GAAG,EAAA,EAC5C;EAEnB,IAAI,SAAS,KAAA,GACX,OAAO,KAAK,KAAK,WAAW,KAAK,CAAC,CAAC,EAAE,EAAE,MAAM;EAG/C,MAAM,KAAK,KAAK,MAAM,OAAO,iBAAiB,WAAW;GAEvD,MAAM,WAAW,OAAO,MAAM,MAC5B,MAAK,EAAE,cAAc,aAAa,EAAE,WAAW,MAAK,MAAK,EAAE,cAAc,SAAS,CACpF;GAEA,IAAI,aAAa,KAAA,GAAW;IAC1B,IAAI,SAAS,cAAc,KAAA,GAAW,OAAO,KAAA;IAE7C,IAAI,SAAS,WAAW,iBAAiB,SAAS,cAAc,WAAW;KAEzE,IAAI,CADe,SAAS,WAAW,MAAK,MAAK,EAAE,cAAc,aAAa,EAAE,YAAY,SAC9E,GAAG;MACf,SAAS,WAAW,KAAK;OACvB,IAAI,eAAe;OACnB;OACA,SAAS;OACT,WAAW;OACX,SAAS;OACT,WAAW;MACb,CAAC;MACD,SAAS,YAAY;MACrB,SAAS,YAAY;OAAE,MAAM;OAAS;MAAU;MAChD,OAAO,CAAC,QAAQ;KAClB;KACA;IACF;IAGA,SAAS,SAAS;IAClB,SAAS,YAAY;IACrB,SAAS,YAAY;IACrB,SAAS,YAAY;IACrB,SAAS,YAAY;KAAE,MAAM;KAAS;IAAU;IAChD,SAAS,WAAW,KAAK;KACvB,IAAI,eAAe;KACnB;KACA,SAAS;KACT,WAAW;KACX,SAAS;KACT,WAAW;IACb,CAAC;IACD,OAAO,CAAC,QAAQ;GAClB;GAGA,MAAM,UAAU,UAAU,QAAQ,aAAa,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC;GAC7D,MAAM,UAAsB;IAC1B,IAAI,UAAU;IACd,OAAO,MAAM;IACb,aAAa;IACb,QAAQ;IACR,aAAa;IACb,SAAS;IACT,QAAQ;IACR,SAAS;IACT,WAAW,EAAE,MAAM,QAAQ;IAC3B,WAAW;IACX,WAAW;IACX,WAAW;IACX,SAAS;IACT,WAAW;IACX,WAAW;IACX,WAAW;KAAE,MAAM;KAAS;IAAU;IACtC,WAAW;KAAE,MAAM;KAAS;IAAU;IACtC,UAAU,CAAC;IACX,YAAY,CACV;KACE,IAAI,eAAe;KACnB;KACA,SAAS;KACT,WAAW;KACX,SAAS;KACT,WAAW;IACb,CACF;GACF;GACA,OAAO,MAAM,KAAK,OAAO;GACzB,OAAO,CAAC,OAAO;EACjB,CAAC;CACH;CAEA,MAAc,kBAAkB,WAAmB,SAAkB,KAA4B;EAC/F,MAAM,OAAO,uBAAuB,OAAO;EAC3C,IAAI,KAAK,KAAK,CAAC,CAAC,WAAW,GAAG;EAE9B,MAAM,KAAK,KAAK,MAAM,OAAO,iBAAiB,WAAW;GACvD,MAAM,OAAO,OAAO,MAAM,MACxB,MAAK,EAAE,cAAc,aAAa,EAAE,WAAW,MAAK,MAAK,EAAE,cAAc,SAAS,CACpF;GACA,IAAI,SAAS,KAAA,KAAa,KAAK,cAAc,KAAA,GAAW,OAAO,KAAA;GAE/D,IAAI,UAAU;GAEd,IAAI,KAAK,MAAM,WAAW,KAAK,KAAK,KAAK,MAAM,UAAU,IAAI;IAC3D,MAAM,UAAU,cAAc,IAAI;IAClC,IAAI,QAAQ,SAAS,GAAG;KACtB,KAAK,QAAQ,eAAe,OAAO;KACnC,UAAU;IACZ;GACF;GAEA,IAAI,KAAK,YAAY,WAAW,GAAG;IACjC,KAAK,cAAc,KAAK,MAAM,GAAG,GAAI;IACrC,UAAU;GACZ;GACA,IAAI,SAAS;IACX,KAAK,YAAY;IACjB,KAAK,YAAY,EAAE,MAAM,OAAO;IAChC,OAAO,CAAC,IAAI;GACd;EAEF,CAAC;CACH;CAEA,MAAc,mBAAmB,WAAmB,WAAoB,KAA4B;EAClG,MAAM,WAAW,OAAO,cAAc,YAAY,cAAc,QAAQ,WAAW,aAAa,OAAQ,UAAiC,UAAU,WAC9I,UAAgC,QACjC,OAAO,cAAc,WACnB,YACA;EACN,IAAI,SAAS,KAAK,CAAC,CAAC,WAAW,GAAG;EAElC,MAAM,KAAK,KAAK,MAAM,OAAO,iBAAiB,WAAW;GACvD,MAAM,OAAO,OAAO,MAAM,MACxB,MAAK,EAAE,cAAc,aAAa,EAAE,WAAW,MAAK,MAAK,EAAE,cAAc,SAAS,CACpF;GACA,IAAI,SAAS,KAAA,KAAa,KAAK,cAAc,KAAA,GAAW,OAAO,KAAA;GAC/D,KAAK,QAAQ,eAAe,QAAQ;GACpC,KAAK,YAAY;GACjB,KAAK,YAAY,EAAE,MAAM,OAAO;GAChC,OAAO,CAAC,IAAI;EACd,CAAC;CACH;CAEA,MAAc,cAAc,WAAmB,SAAkB,KAA4B;EAC3F,MAAM,SAAS,OAAO,YAAY,YAAY,YAAY,QAAQ,YAAY,UACzE,QAAgC,SACjC;EAGJ,IAAI,YAAY;EAChB,IAAI,eAAe;EACnB,IAAI,OAAO,WAAW,YAAY,WAAW,MAAM;GACjD,MAAM,IAAI;GACV,IAAI,EAAE,SAAS,WAAW,EAAE,SAAS,WAAW;IAC9C,YAAY;IACZ,eAAe,OAAO,EAAE,UAAU,WAAW,EAAE,QAAQ,OAAO,EAAE,YAAY,WAAW,EAAE,UAAU;GACrG,OAAO,IAAI,EAAE,SAAS,UAAU;IAC9B,YAAY;IACZ,eAAe;GACjB;EACF,OAAO,IAAI,OAAO,WAAW,aAAa,OAAO,SAAS,OAAO,KAAK,OAAO,SAAS,MAAM,IAAI;GAC9F,YAAY;GACZ,eAAe;EACjB;EAEA,MAAM,KAAK,KAAK,MAAM,OAAO,uBAAuB,WAAW;GAC7D,MAAM,OAAO,OAAO,MAAM,MACxB,MAAK,EAAE,cAAc,aAAa,EAAE,WAAW,MAAK,MAAK,EAAE,cAAc,SAAS,CACpF;GACA,IAAI,SAAS,KAAA,KAAa,KAAK,cAAc,KAAA,GAAW,OAAO,KAAA;GAG/D,KAAK,MAAM,QAAQ,KAAK,YACtB,IAAI,KAAK,cAAc,aAAa,KAAK,YAAY,WAAW;IAC9D,KAAK,UAAU;IACf,IAAI,WAAW;KACb,KAAK,UAAU;KACf,KAAK,QAAQ,aAAa,MAAM,GAAG,GAAG;IACxC,OACE,KAAK,UAAU;GAEnB;GAGF,OAAO,KAAK;GACZ,OAAO,KAAK;GACZ,KAAK,YAAY;GACjB,KAAK,YAAY;IAAE,MAAM;IAAS;GAAU;GAE5C,IAAI;QAEE,KAAK,WAAW,eAAe;KACjC,KAAK,SAAS;KACd,KAAK,SAAS,KAAK;MACjB,IAAI,aAAa;MACjB,MAAM,cAAc,eAAe,aAAa,MAAM,GAAG,GAAG,EAAE,UAAU;MACxE,SAAS;MACT,WAAW;KACb,CAAC;IACH;UAGA,IAAI,KAAK,WAAW,eAAe;IACjC,KAAK,SAAS;IACd,KAAK,SAAS,KAAK;KACjB,IAAI,aAAa;KACjB,MAAM,cAAc,uBAAuB;KAC3C,SAAS;KACT,WAAW;IACb,CAAC;GACH;GAEF,OAAO,CAAC,IAAI;EACd,CAAC;CACH;AACF"}
package/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import { registerTaskboardRoutes } from "./host/routes.js";
7
7
  import { SchedulerService } from "./host/scheduler.js";
8
8
  import { TaskStore } from "./host/store.js";
9
9
  import { TemplateStore } from "./host/templates.js";
10
+ import { ExternalSessionSyncService } from "./host/session-sync.js";
10
11
  //#region src/index.ts
11
12
  /** Ledger file name under the DSH home. */
12
13
  const LEDGER_FILE = "dsh-taskboard.json";
@@ -49,8 +50,15 @@ function apply(ctx) {
49
50
  modelProviders
50
51
  }));
51
52
  const events = { onSessionEvent: (listener) => wsCtx.on("session/event", (session, event) => {
52
- listener(session.id, event);
53
+ listener(session.id, event, session);
53
54
  }) };
55
+ const sessionSync = new ExternalSessionSyncService({
56
+ store,
57
+ workspaces: workspaceFace(wsCtx.workspaceRegistry),
58
+ events,
59
+ now
60
+ });
61
+ disposers.push(() => sessionSync.dispose());
54
62
  const git = createGitFace();
55
63
  wsCtx.inject(["agents"], (agentCtx) => {
56
64
  const execution = new ExecutionService({
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * Host loader entry for dsh-taskboard.\n *\n * Wiring: the ledger store (one JSON file under the DSH home), the ten\n * `taskboard_*` agent tools, the agent workflow-protocol system-prompt\n * section, the /taskboard JSON+SSE routes (when a webServer is served),\n * the host execution service (fresh in-project sessions, pinned models), and\n * the host-side cron scheduler for scheduled tasks.\n *\n * Export shape follows the dsh-tool-todo lesson: a function/namespace plugin —\n * `name` / `inject` / `apply`, NO default export.\n *\n * @module dsh-taskboard\n */\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only module imports: they load the cordis Context augmentations\n// (ctx.tools / ctx.systemPrompt / ctx.agents) and vanish at compile time —\n// the built host half keeps ZERO runtime @deepseek-ai imports.\nimport type {} from '@deepseek-ai/dsh-tools'\nimport type {} from '@deepseek-ai/dsh-system-prompt'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'\nimport { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'\nimport { createGitFace } from './host/git.ts'\nimport { registerTaskboardRoutes } from './host/routes.ts'\nimport { SchedulerService } from './host/scheduler.ts'\nimport { dshHomePath } from './host/sdk.ts'\nimport { TaskStore } from './host/store.ts'\nimport { TemplateStore } from './host/templates.ts'\nimport { registerTaskboardTools, workspaceFace } from './host/tools.ts'\n\n/** Ledger file name under the DSH home. */\nexport const LEDGER_FILE = 'dsh-taskboard.json'\n\n/** Task-template side file name under the DSH home (0.4.0). */\nexport const TEMPLATES_FILE = 'dsh-taskboard-templates.json'\n\n/** Cordis plugin name. */\nexport const name = 'dsh-taskboard'\n\n/** Required host services (tool registry + prompt assembly). */\nexport const inject = ['tools', 'systemPrompt']\n\n/**\n * Mount the host half.\n * @param ctx - the plugin context (tools + systemPrompt injected).\n */\nexport function apply(ctx: Context): void {\n const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })\n const templates = new TemplateStore(dshHomePath(TEMPLATES_FILE))\n // Eager first load: the tools and most routes read snapshot()/get() without\n // triggering the lazy load, so a fresh boot used to serve an EMPTY board to\n // taskboard_list/get until the scheduler catchup tick or the first\n // GET /state happened to load the file (review P0). load() never throws —\n // a corrupt ledger is quarantined instead.\n void store.load()\n const now = () => Date.now()\n // Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).\n const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)\n\n // Agent workflow protocol (claim discipline, retry rules, done-gate).\n const disposeSection = ctx.systemPrompt.section({\n name: PROTOCOL_SECTION_NAME,\n order: PROTOCOL_SECTION_ORDER,\n text: TASKBOARD_PROTOCOL,\n })\n ctx.effect(() => disposeSection, 'dsh-taskboard: protocol section')\n\n // Tools, routes, execution, and the scheduler all come up with the\n // workspace registry (claim boundary + project execution need it).\n ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {\n const disposers: Array<() => void> = []\n\n // Registered model provider routes (from the host llm runtime), read\n // lazily at call time so late availability still applies; undefined when\n // the runtime is absent → only structural model validation runs.\n const modelProviders = (): string[] | undefined => {\n try {\n const llm = wsCtx.get('llm') as { listProviders?: () => Array<{ id: string }> } | undefined\n return llm === undefined || typeof llm.listProviders !== 'function'\n ? undefined\n : llm.listProviders().map(p => p.id)\n } catch { return undefined }\n }\n\n disposers.push(...registerTaskboardTools(wsCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n modelProviders,\n }))\n\n // Settlement listener over the session event bus.\n const events: EventsFace = {\n onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {\n listener(session.id, event as { type: string; data?: unknown })\n }),\n }\n\n // The narrow git face shared by execution (worktree isolation) and the\n // routes (merge / remove / workspace detection).\n const git = createGitFace()\n\n wsCtx.inject(['agents'], (agentCtx: Context) => {\n const execution = new ExecutionService({\n store,\n agents: {\n create: (options): Promise<never> => agentCtx.agents.create(options as never) as Promise<never>,\n },\n workspaces: {\n get: id => workspaceFace(wsCtx.workspaceRegistry).get(id),\n attach: async (workspaceId, sessionId) => {\n const ws = wsCtx.workspaceRegistry.get(workspaceId as never)\n if (ws !== undefined) await ws.attachSession(sessionId as never)\n },\n },\n events,\n now,\n git,\n // Preset composition (0.3.3): mirror apiproxy's composeAgent — resolve\n // the id BEFORE creation (the session header snapshots meta), mount\n // inside the factory's setup callback. No roster service → undefined\n // (bare host composition, the pre-preset behavior).\n composeAgent: async (presetId) => {\n const presets = agentCtx.get('agentPresets') as {\n resolve(id?: string): Promise<{ id: string }>\n mount(agentCtx: unknown, id?: string): Promise<unknown>\n } | undefined\n if (presets === undefined) return undefined\n const resolved = await presets.resolve(presetId)\n return {\n agentPreset: resolved.id,\n setup: async (ctx: unknown) => { await presets.mount(ctx, resolved.id) },\n }\n },\n renameSession: (sessionId, title) => {\n // Best-effort: pin the execution session's title to the task title\n // through the log-backed session-title service (user-sourced rename).\n try {\n const sessions = agentCtx.get('sessions') as { get(id: string): unknown } | undefined\n const sessionTitle = agentCtx.get('sessionTitle') as { rename(session: unknown, title: string): unknown } | undefined\n const session = sessions?.get(sessionId)\n if (session !== undefined && sessionTitle !== undefined) sessionTitle.rename(session, title)\n } catch { /* cosmetic */ }\n },\n defaultModel: () => {\n try {\n const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined\n const read = selection?.currentSelection\n return read === undefined ? undefined : read.call(selection)\n } catch { return undefined }\n },\n maxConcurrent,\n })\n\n // /dsh-taskboard routes (the run action reaches the execution service).\n let disposeRoutes: (() => void) | undefined\n agentCtx.inject(['webServer'], (webCtx: Context) => {\n disposeRoutes = registerTaskboardRoutes(webCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n run: (taskId: string, runOptions?: { reuseWorktree?: boolean }) => execution.run(taskId, 'manual', runOptions),\n cancel: (taskId: string) => execution.cancel(taskId),\n modelProviders,\n git,\n templates,\n })\n return () => disposeRoutes?.()\n })\n\n // Startup reconciliation: executions left 'running' by a previous host\n // process are marked failed and their tasks handed back to todo (their\n // settlement watchers died with that process).\n void execution.reconcile()\n\n // Host-side cron scheduler: due scheduled tasks execute even with no\n // browser open. Shares the execution concurrency cap.\n const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })\n scheduler.start()\n disposers.push(() => scheduler.dispose())\n // Detach the settlement listener with the plugin — a hot reload must\n // not leave stale services reacting to turn/end errors (review P1).\n disposers.push(() => execution.dispose())\n\n return () => {\n disposeRoutes?.()\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n\n return () => {\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * Host loader entry for dsh-taskboard.\n *\n * Wiring: the ledger store (one JSON file under the DSH home), the ten\n * `taskboard_*` agent tools, the agent workflow-protocol system-prompt\n * section, the /taskboard JSON+SSE routes (when a webServer is served),\n * the host execution service (fresh in-project sessions, pinned models), and\n * the host-side cron scheduler for scheduled tasks.\n *\n * Export shape follows the dsh-tool-todo lesson: a function/namespace plugin —\n * `name` / `inject` / `apply`, NO default export.\n *\n * @module dsh-taskboard\n */\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only module imports: they load the cordis Context augmentations\n// (ctx.tools / ctx.systemPrompt / ctx.agents) and vanish at compile time —\n// the built host half keeps ZERO runtime @deepseek-ai imports.\nimport type {} from '@deepseek-ai/dsh-tools'\nimport type {} from '@deepseek-ai/dsh-system-prompt'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'\nimport { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'\nimport { createGitFace } from './host/git.ts'\nimport { registerTaskboardRoutes } from './host/routes.ts'\nimport { SchedulerService } from './host/scheduler.ts'\nimport { dshHomePath } from './host/sdk.ts'\nimport { TaskStore } from './host/store.ts'\nimport { TemplateStore } from './host/templates.ts'\nimport { ExternalSessionSyncService } from './host/session-sync.ts'\nimport { registerTaskboardTools, workspaceFace } from './host/tools.ts'\n\n/** Ledger file name under the DSH home. */\nexport const LEDGER_FILE = 'dsh-taskboard.json'\n\n/** Task-template side file name under the DSH home (0.4.0). */\nexport const TEMPLATES_FILE = 'dsh-taskboard-templates.json'\n\n/** Cordis plugin name. */\nexport const name = 'dsh-taskboard'\n\n/** Required host services (tool registry + prompt assembly). */\nexport const inject = ['tools', 'systemPrompt']\n\n/**\n * Mount the host half.\n * @param ctx - the plugin context (tools + systemPrompt injected).\n */\nexport function apply(ctx: Context): void {\n const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })\n const templates = new TemplateStore(dshHomePath(TEMPLATES_FILE))\n // Eager first load: the tools and most routes read snapshot()/get() without\n // triggering the lazy load, so a fresh boot used to serve an EMPTY board to\n // taskboard_list/get until the scheduler catchup tick or the first\n // GET /state happened to load the file (review P0). load() never throws —\n // a corrupt ledger is quarantined instead.\n void store.load()\n const now = () => Date.now()\n // Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).\n const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)\n\n // Agent workflow protocol (claim discipline, retry rules, done-gate).\n const disposeSection = ctx.systemPrompt.section({\n name: PROTOCOL_SECTION_NAME,\n order: PROTOCOL_SECTION_ORDER,\n text: TASKBOARD_PROTOCOL,\n })\n ctx.effect(() => disposeSection, 'dsh-taskboard: protocol section')\n\n // Tools, routes, execution, and the scheduler all come up with the\n // workspace registry (claim boundary + project execution need it).\n ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {\n const disposers: Array<() => void> = []\n\n // Registered model provider routes (from the host llm runtime), read\n // lazily at call time so late availability still applies; undefined when\n // the runtime is absent → only structural model validation runs.\n const modelProviders = (): string[] | undefined => {\n try {\n const llm = wsCtx.get('llm') as { listProviders?: () => Array<{ id: string }> } | undefined\n return llm === undefined || typeof llm.listProviders !== 'function'\n ? undefined\n : llm.listProviders().map(p => p.id)\n } catch { return undefined }\n }\n\n disposers.push(...registerTaskboardTools(wsCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n modelProviders,\n }))\n\n // Settlement listener over the session event bus.\n const events: EventsFace = {\n onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {\n listener(session.id, event as { type: string; data?: unknown }, session as { header?: { cwd?: string } })\n }),\n }\n\n // External workspace sessions sync service (0.5.4).\n const sessionSync = new ExternalSessionSyncService({\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n events,\n now,\n })\n disposers.push(() => sessionSync.dispose())\n\n // The narrow git face shared by execution (worktree isolation) and the\n // routes (merge / remove / workspace detection).\n const git = createGitFace()\n\n wsCtx.inject(['agents'], (agentCtx: Context) => {\n const execution = new ExecutionService({\n store,\n agents: {\n create: (options): Promise<never> => agentCtx.agents.create(options as never) as Promise<never>,\n },\n workspaces: {\n get: id => workspaceFace(wsCtx.workspaceRegistry).get(id),\n attach: async (workspaceId, sessionId) => {\n const ws = wsCtx.workspaceRegistry.get(workspaceId as never)\n if (ws !== undefined) await ws.attachSession(sessionId as never)\n },\n },\n events,\n now,\n git,\n // Preset composition (0.3.3): mirror apiproxy's composeAgent — resolve\n // the id BEFORE creation (the session header snapshots meta), mount\n // inside the factory's setup callback. No roster service → undefined\n // (bare host composition, the pre-preset behavior).\n composeAgent: async (presetId) => {\n const presets = agentCtx.get('agentPresets') as {\n resolve(id?: string): Promise<{ id: string }>\n mount(agentCtx: unknown, id?: string): Promise<unknown>\n } | undefined\n if (presets === undefined) return undefined\n const resolved = await presets.resolve(presetId)\n return {\n agentPreset: resolved.id,\n setup: async (ctx: unknown) => { await presets.mount(ctx, resolved.id) },\n }\n },\n renameSession: (sessionId, title) => {\n // Best-effort: pin the execution session's title to the task title\n // through the log-backed session-title service (user-sourced rename).\n try {\n const sessions = agentCtx.get('sessions') as { get(id: string): unknown } | undefined\n const sessionTitle = agentCtx.get('sessionTitle') as { rename(session: unknown, title: string): unknown } | undefined\n const session = sessions?.get(sessionId)\n if (session !== undefined && sessionTitle !== undefined) sessionTitle.rename(session, title)\n } catch { /* cosmetic */ }\n },\n defaultModel: () => {\n try {\n const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined\n const read = selection?.currentSelection\n return read === undefined ? undefined : read.call(selection)\n } catch { return undefined }\n },\n maxConcurrent,\n })\n\n // /dsh-taskboard routes (the run action reaches the execution service).\n let disposeRoutes: (() => void) | undefined\n agentCtx.inject(['webServer'], (webCtx: Context) => {\n disposeRoutes = registerTaskboardRoutes(webCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n run: (taskId: string, runOptions?: { reuseWorktree?: boolean }) => execution.run(taskId, 'manual', runOptions),\n cancel: (taskId: string) => execution.cancel(taskId),\n modelProviders,\n git,\n templates,\n })\n return () => disposeRoutes?.()\n })\n\n // Startup reconciliation: executions left 'running' by a previous host\n // process are marked failed and their tasks handed back to todo (their\n // settlement watchers died with that process).\n void execution.reconcile()\n\n // Host-side cron scheduler: due scheduled tasks execute even with no\n // browser open. Shares the execution concurrency cap.\n const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })\n scheduler.start()\n disposers.push(() => scheduler.dispose())\n // Detach the settlement listener with the plugin — a hot reload must\n // not leave stale services reacting to turn/end errors (review P1).\n disposers.push(() => execution.dispose())\n\n return () => {\n disposeRoutes?.()\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n\n return () => {\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","names":[],"sources":["../../src/shared/api.ts"],"sourcesContent":["/**\n * Wire contract for the /taskboard host routes: the JSON envelope,\n * request/response shapes, and SSE event payloads shared by the host routes\n * and the browser client.\n *\n * @module dsh-taskboard/shared/api\n */\nimport type { BoardSettings, TaskLedger, TaskModel, TaskRecord, TaskSummary } from './protocol.ts'\n\nexport type { TaskModel, TaskRecord }\n\n/** Route prefix on the shared DSH webserver (same origin as the GUI). */\nexport const ROUTE_PREFIX = '/dsh-taskboard'\n\n/** SSE stream path (exact route; longest-prefix wins keep it disjoint). */\nexport const SSE_PATH = '/dsh-taskboard/events'\n\n/** Stable error codes (mirror the tool-level codes plus HTTP mapping). */\nexport type ApiErrorCode =\n | 'invalid_input'\n | 'not_found'\n | 'version_conflict'\n | 'invalid_transition'\n | 'forbidden'\n | 'internal'\n\n/** Success envelope. */\nexport type ApiOk<T> = { ok: true; value: T }\n\n/** Failure envelope. */\nexport type ApiFail = { ok: false; error: { code: ApiErrorCode; message: string } }\n\n/** The envelope either way. */\nexport type ApiResult<T> = ApiOk<T> | ApiFail\n\n// ---------------------------------------------------------------------------\n// payloads\n// ---------------------------------------------------------------------------\n\n/** Full-state response (the reconnect baseline after an SSE gap). */\nexport type StateResponse = TaskLedger\n\n/** Workspace listing for the UI pickers. */\nexport type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean }\n\n/** Create-task request body (actor is always the GUI user). */\nexport type CreateTaskBody = {\n title: string\n workspaceId: string\n urgency: string\n description?: string\n prompt?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n /** Code isolation for executions ('worktree' | 'none'); omitted = default. */\n isolation?: string\n /** Agent preset for execution sessions; omitted = deployment default. */\n presetId?: string\n /** Acceptance checklist item texts (host mints ids, all unchecked). */\n checklist?: string[]\n}\n\n/** Update-task request body (ifVersion mandatory). */\nexport type UpdateTaskBody = {\n ifVersion: number\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n blocked?: boolean\n /** Rebind the task to another project (GUI owner surface only). */\n workspaceId?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel | null\n /** Change isolation; locked once the task has execution history. */\n isolation?: string\n /** Change the execution preset (takes effect on the next run). */\n presetId?: string | null\n /** Replace the whole checklist (GUI owner surface); null clears it. */\n checklist?: unknown\n}\n\n/** Move-task request body (ifVersion mandatory; the user MAY move to done). */\nexport type MoveTaskBody = { ifVersion: number; status: string }\n\n/**\n * Quick-reject request body (card ✗ button): move back to todo plus an\n * optional user comment, committed as ONE ledger mutation so a failed move\n * can never strand an orphan comment.\n */\nexport type RejectTaskBody = { ifVersion: number; body?: string }\n\n/** Comment request body. */\nexport type CommentBody = { body: string }\n\n/** Delete request body (purge=true physically removes a trashed task). */\nexport type DeleteTaskBody = { ifVersion?: number; purge?: boolean }\n\n/** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */\nexport type RunTaskBody = { reuse?: boolean }\n\n/** Merge outcome: `noop: true` = the branch had no commits over HEAD (nothing merged). */\nexport type MergeBranchResponse = { merged: boolean; noop?: boolean; branch: string }\n\n/** Remove a task's worktree; optionally delete its branch too. */\nexport type WorktreeRemoveBody = { deleteBranch?: boolean }\n\n/** One orphan worktree directory (exists on disk, owned by no live task). */\nexport type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }\n\n/** A git-enabled workspace whose .gitignore does not cover the worktree dir. */\nexport type GitignoreSuggestion = { workspaceId: string; workspacePath: string }\n\n/** Health-diagnostics response (⚙ panel). */\nexport type DiagnosticsResponse = {\n revision: number\n tasks: number\n /** Executions currently marked `running`. */\n staleRunning: number\n /** Worktree directories whose task no longer exists in the ledger. */\n orphanWorktrees: OrphanWorktree[]\n /** Git workspaces whose .gitignore does not ignore the worktree dir. */\n gitIgnoreSuggestions: GitignoreSuggestion[]\n}\n\n/** Fields a task template may prefill (0.4.0). */\nexport type TaskTemplateSpec = {\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n isolation?: string\n presetId?: string\n /** Checklist item texts (host mints ids at create time). */\n checklist?: string[]\n}\n\n/** One reusable task template (0.4.0). */\nexport type TaskTemplate = {\n id: string\n name: string\n task: TaskTemplateSpec\n /** Seeded built-in templates (kept on load, deletable like any other). */\n builtin?: boolean\n createdAt: number\n updatedAt: number\n}\n\n/** Templates listing response. */\nexport type TemplatesResponse = { templates: TaskTemplate[] }\n\n/** Board-settings response (0.5.0; absent fields follow factory defaults). */\nexport type SettingsResponse = BoardSettings\n\n/** Update-board-settings request body (0.5.0; whole-object replace semantics). */\nexport type UpdateSettingsBody = {\n /** Default code isolation for NEW tasks ('worktree' | 'none'). */\n defaultIsolation?: string\n}\n\n/** Import dry-run response (0.4.0): every task classified, nothing written. */\nexport type ImportPreviewResponse = {\n plan: {\n create: Array<{ id: string; title: string; status: string }>\n overwrite: Array<{ id: string; title: string; status: string }>\n invalid: Array<{ id?: string; reason: string }>\n }\n}\n\n/** Import commit response. */\nexport type ImportCommitResponse = {\n mode: 'merge' | 'replace'\n created: number\n overwritten: number\n replacedTotal?: number\n /** The backup file written BEFORE a replace wiped the live ledger. */\n backupFile?: string\n}\n\n/** Diff-viewer response (0.4.0). */\nexport type DiffResponse = { diff: string; truncated: boolean }\n\n/** One task (full record) response. */\nexport type TaskResponse = TaskRecord\n\n/** Summary response used by list-ish endpoints. */\nexport type SummaryResponse = { tasks: TaskSummary[] }\n\n// ---------------------------------------------------------------------------\n// SSE\n// ---------------------------------------------------------------------------\n\n/** Change frame pushed on every committed ledger mutation. */\nexport type ChangeEvent = {\n revision: number\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n tasks: TaskSummary[]\n}\n"],"mappings":";;AAYA,MAAa,eAAe;;AAG5B,MAAa,WAAW"}
1
+ {"version":3,"file":"api.js","names":[],"sources":["../../src/shared/api.ts"],"sourcesContent":["/**\n * Wire contract for the /taskboard host routes: the JSON envelope,\n * request/response shapes, and SSE event payloads shared by the host routes\n * and the browser client.\n *\n * @module dsh-taskboard/shared/api\n */\nimport type { BoardSettings, TaskLedger, TaskModel, TaskRecord, TaskSummary } from './protocol.ts'\n\nexport type { TaskModel, TaskRecord }\n\n/** Route prefix on the shared DSH webserver (same origin as the GUI). */\nexport const ROUTE_PREFIX = '/dsh-taskboard'\n\n/** SSE stream path (exact route; longest-prefix wins keep it disjoint). */\nexport const SSE_PATH = '/dsh-taskboard/events'\n\n/** Stable error codes (mirror the tool-level codes plus HTTP mapping). */\nexport type ApiErrorCode =\n | 'invalid_input'\n | 'not_found'\n | 'version_conflict'\n | 'invalid_transition'\n | 'forbidden'\n | 'internal'\n\n/** Success envelope. */\nexport type ApiOk<T> = { ok: true; value: T }\n\n/** Failure envelope. */\nexport type ApiFail = { ok: false; error: { code: ApiErrorCode; message: string } }\n\n/** The envelope either way. */\nexport type ApiResult<T> = ApiOk<T> | ApiFail\n\n// ---------------------------------------------------------------------------\n// payloads\n// ---------------------------------------------------------------------------\n\n/** Full-state response (the reconnect baseline after an SSE gap). */\nexport type StateResponse = TaskLedger\n\n/** Workspace listing for the UI pickers. */\nexport type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean }\n\n/** Create-task request body (actor is always the GUI user). */\nexport type CreateTaskBody = {\n title: string\n workspaceId: string\n urgency: string\n description?: string\n prompt?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n /** Code isolation for executions ('worktree' | 'none'); omitted = default. */\n isolation?: string\n /** Agent preset for execution sessions; omitted = deployment default. */\n presetId?: string\n /** Acceptance checklist item texts (host mints ids, all unchecked). */\n checklist?: string[]\n}\n\n/** Update-task request body (ifVersion mandatory). */\nexport type UpdateTaskBody = {\n ifVersion: number\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n blocked?: boolean\n /** Rebind the task to another project (GUI owner surface only). */\n workspaceId?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel | null\n /** Change isolation; locked once the task has execution history. */\n isolation?: string\n /** Change the execution preset (takes effect on the next run). */\n presetId?: string | null\n /** Replace the whole checklist (GUI owner surface); null clears it. */\n checklist?: unknown\n}\n\n/** Move-task request body (ifVersion mandatory; the user MAY move to done). */\nexport type MoveTaskBody = { ifVersion: number; status: string }\n\n/**\n * Quick-reject request body (card ✗ button): move back to todo plus an\n * optional user comment, committed as ONE ledger mutation so a failed move\n * can never strand an orphan comment.\n */\nexport type RejectTaskBody = { ifVersion: number; body?: string }\n\n/** Comment request body. */\nexport type CommentBody = { body: string }\n\n/** Delete request body (purge=true physically removes a trashed task). */\nexport type DeleteTaskBody = { ifVersion?: number; purge?: boolean }\n\n/** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */\nexport type RunTaskBody = { reuse?: boolean }\n\n/** Merge outcome: `noop: true` = the branch had no commits over HEAD (nothing merged). */\nexport type MergeBranchResponse = { merged: boolean; noop?: boolean; branch: string }\n\n/** Remove a task's worktree; optionally delete its branch too. */\nexport type WorktreeRemoveBody = { deleteBranch?: boolean }\n\n/** One orphan worktree directory (exists on disk, owned by no live task). */\nexport type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }\n\n/** A git-enabled workspace whose .gitignore does not cover the worktree dir. */\nexport type GitignoreSuggestion = { workspaceId: string; workspacePath: string }\n\n/** Health-diagnostics response (⚙ panel). */\nexport type DiagnosticsResponse = {\n revision: number\n tasks: number\n /** Executions currently marked `running`. */\n staleRunning: number\n /** Worktree directories whose task no longer exists in the ledger. */\n orphanWorktrees: OrphanWorktree[]\n /** Git workspaces whose .gitignore does not ignore the worktree dir. */\n gitIgnoreSuggestions: GitignoreSuggestion[]\n}\n\n/** Fields a task template may prefill (0.4.0). */\nexport type TaskTemplateSpec = {\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n isolation?: string\n presetId?: string\n /** Checklist item texts (host mints ids at create time). */\n checklist?: string[]\n}\n\n/** One reusable task template (0.4.0). */\nexport type TaskTemplate = {\n id: string\n name: string\n task: TaskTemplateSpec\n /** Seeded built-in templates (kept on load, deletable like any other). */\n builtin?: boolean\n createdAt: number\n updatedAt: number\n}\n\n/** Templates listing response. */\nexport type TemplatesResponse = { templates: TaskTemplate[] }\n\n/** Board-settings response (0.5.0; absent fields follow factory defaults). */\nexport type SettingsResponse = BoardSettings\n\n/** Update-board-settings request body (0.5.0; whole-object replace semantics). */\nexport type UpdateSettingsBody = {\n /** Default code isolation for NEW tasks ('worktree' | 'none'). */\n defaultIsolation?: string\n /** Automatically capture external workspace sessions into the taskboard. */\n syncExternalSessions?: boolean\n}\n\n/** Import dry-run response (0.4.0): every task classified, nothing written. */\nexport type ImportPreviewResponse = {\n plan: {\n create: Array<{ id: string; title: string; status: string }>\n overwrite: Array<{ id: string; title: string; status: string }>\n invalid: Array<{ id?: string; reason: string }>\n }\n}\n\n/** Import commit response. */\nexport type ImportCommitResponse = {\n mode: 'merge' | 'replace'\n created: number\n overwritten: number\n replacedTotal?: number\n /** The backup file written BEFORE a replace wiped the live ledger. */\n backupFile?: string\n}\n\n/** Diff-viewer response (0.4.0). */\nexport type DiffResponse = { diff: string; truncated: boolean }\n\n/** One task (full record) response. */\nexport type TaskResponse = TaskRecord\n\n/** Summary response used by list-ish endpoints. */\nexport type SummaryResponse = { tasks: TaskSummary[] }\n\n// ---------------------------------------------------------------------------\n// SSE\n// ---------------------------------------------------------------------------\n\n/** Change frame pushed on every committed ledger mutation. */\nexport type ChangeEvent = {\n revision: number\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n tasks: TaskSummary[]\n}\n"],"mappings":";;AAYA,MAAa,eAAe;;AAG5B,MAAa,WAAW"}
@@ -84,12 +84,20 @@ function asBoardSettings(raw) {
84
84
  if (typeof e.defaultIsolation !== "string") throw new Error("defaultIsolation must be 'worktree' or 'none'");
85
85
  out.defaultIsolation = asIsolation(e.defaultIsolation);
86
86
  }
87
+ if (e.syncExternalSessions !== void 0) {
88
+ if (typeof e.syncExternalSessions !== "boolean") throw new Error("syncExternalSessions must be a boolean");
89
+ out.syncExternalSessions = e.syncExternalSessions;
90
+ }
87
91
  return out;
88
92
  }
89
93
  /** The effective default isolation for NEW tasks (board setting → factory default). */
90
94
  function defaultIsolationOf(settings) {
91
95
  return settings?.defaultIsolation ?? "none";
92
96
  }
97
+ /** The effective external session sync switch (board setting → factory default false). */
98
+ function defaultSyncExternalSessionsOf(settings) {
99
+ return settings?.syncExternalSessions ?? false;
100
+ }
93
101
  /**
94
102
  * Parse a five-field cron expression. Supported field syntax: star, star/step
95
103
  * (`* / n` without spaces), a single number, an `a-b` range, and comma lists
@@ -656,6 +664,6 @@ function summarize(task) {
656
664
  };
657
665
  }
658
666
  //#endregion
659
- export { ALL_STATUSES, DEFAULT_ISOLATION, MAIN_STATUSES, SECONDARY_STATUSES, URGENCIES, asBoardSettings, asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, checklistProgress, defaultIsolationOf, effectiveIsolation, effectivePrompt, emptyLedger, isClaim, isClaimedBy, isPlausibleTaskRecord, isValidTaskId, newChecklistItemId, newCommentId, newExecutionId, newTaskId, nextCronTime, normalizeBody, normalizeChecklist, normalizeChecklistText, normalizeExecution, normalizeExecutionReport, normalizeModel, normalizePrompt, normalizeTitle, parseCron, pruneExecutions, summarize, syncClaim, validateImportedTask, validateLedgerImport };
667
+ export { ALL_STATUSES, DEFAULT_ISOLATION, MAIN_STATUSES, SECONDARY_STATUSES, URGENCIES, asBoardSettings, asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, checklistProgress, defaultIsolationOf, defaultSyncExternalSessionsOf, effectiveIsolation, effectivePrompt, emptyLedger, isClaim, isClaimedBy, isPlausibleTaskRecord, isValidTaskId, newChecklistItemId, newCommentId, newExecutionId, newTaskId, nextCronTime, normalizeBody, normalizeChecklist, normalizeChecklistText, normalizeExecution, normalizeExecutionReport, normalizeModel, normalizePrompt, normalizeTitle, parseCron, pruneExecutions, summarize, syncClaim, validateImportedTask, validateLedgerImport };
660
668
 
661
669
  //# sourceMappingURL=protocol.js.map