dsh-synapse 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -2,12 +2,18 @@ window.__ModuleLoader__.load({
2
2
  id: 'dsh-synapse',
3
3
  factory: () => {
4
4
  const module = { exports: {} }
5
+ // Consumer identity for owned Session references. The Client records
6
+ // positive reference counts per source; nothing else reads this string.
7
+ const SESSION_SOURCE = 'synapse'
8
+ // Shown when a Session is gone or no longer reachable from this browser.
9
+ const NO_SESSION = '关联的 DSH 会话已不可用'
5
10
  const currentSession = ctx => {
6
11
  const snapshot = ctx.sessions.list.getSnapshot()
7
- const id = snapshot.current
8
- if (id === undefined) return null
9
- const session = snapshot.byId[id]
10
- return session === undefined ? null : { id, title: session.displayTitle, cwd: session.cwd ?? null }
12
+ // 0.1.7 removed `list.current`: the Main view's own reference count is the
13
+ // Client's current-Session signal. `ui-workspace` derives the highlighted
14
+ // Session row from the same fact, so both stay in step by construction.
15
+ const session = Object.values(snapshot.byId).find(row => (row.retainedBy?.mainView ?? 0) > 0)
16
+ return session === undefined ? null : { id: session.id, title: session.displayTitle, cwd: session.cwd ?? null }
11
17
  }
12
18
  const sessionSnapshot = ctx => {
13
19
  const snapshot = ctx.sessions.list.getSnapshot()
@@ -26,14 +32,25 @@ window.__ModuleLoader__.load({
26
32
  ]
27
33
  }
28
34
 
29
- module.exports.inject = ['sessions', 'workspaces']
35
+ module.exports.inject = ['sessions', 'workspaces', 'uiWorkspace', 'uiConversation']
30
36
  module.exports.apply = ctx => {
31
37
  const prompt = async (sessionId, text) => {
32
- const scope = ctx.sessions.scope(sessionId)
33
- const session = scope === undefined ? undefined : ctx.sessions.sessionOf(scope)
34
- if (session === undefined) throw new Error('关联的 DSH 会话已不可用')
35
- const result = await session.prompt([{ type: 'text', text }], 'queue')
36
- if (!result.ok) throw new Error(result.error?.message ?? 'DSH 未接受这条消息')
38
+ // 0.1.7 owns Session lifetime explicitly: `scope()` only borrows an
39
+ // already-retained generation, so sending needs a reference of our own.
40
+ let reference
41
+ try {
42
+ reference = ctx.sessions.retain(sessionId, { source: SESSION_SOURCE })
43
+ await reference.ready
44
+ } catch {
45
+ reference?.release()
46
+ throw new Error(NO_SESSION)
47
+ }
48
+ try {
49
+ const result = await reference.binding.session.prompt([{ type: 'text', text }], 'queue')
50
+ if (!result.ok) throw new Error(result.error?.message ?? 'DSH 未接受这条消息')
51
+ } finally {
52
+ reference.release()
53
+ }
37
54
  }
38
55
  const style = document.createElement('style')
39
56
  style.textContent = '.dsh-synapse-switch{position:fixed;z-index:80;top:12px;left:50%;display:flex;gap:2px;transform:translateX(-50%);border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-synapse-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-synapse-switch button:hover{background:#f3f4f6;color:#111827}.dsh-synapse-switch button.active{background:#111827;color:#fff}.dsh-synapse-switch button:focus-visible{outline:2px solid #111827;outline-offset:2px}.dsh-synapse-overlay{position:fixed;z-index:100;inset:0;background:#f5f7fa}.dsh-synapse-overlay.is-opening{visibility:hidden}.dsh-synapse-overlay[hidden]{display:none}.dsh-synapse-overlay iframe{display:block;width:100%;height:100%;border:0}'
@@ -64,25 +81,91 @@ window.__ModuleLoader__.load({
64
81
  const send = (type, payload) => { frame.contentWindow?.postMessage({ source: 'dsh-synapse', type, ...payload }, location.origin) }
65
82
  let syncQueued = false
66
83
  let knownSessionIds = new Set()
67
- const liveUnsubscribers = new Map()
84
+ // One live record per catalogued Session. The pre-0.1.7 Client minted a
85
+ // Session scope on demand for any listed id, so live state was readable
86
+ // for every card; 0.1.7 mints none, so a Chat view is readable only while
87
+ // something holds a reference. A card whose Session is not observable
88
+ // still reports its `running` flag from the list row, because only the
89
+ // streaming text needs the view.
90
+ const liveSessions = new Map()
91
+ // References this plugin owns, so it can read a running Session's Chat
92
+ // view without depending on another surface retaining it.
93
+ const ownReferences = new Map()
94
+
95
+ const publishLiveSession = id => {
96
+ if (overlay.hidden) return
97
+ const text = liveSessions.get(id)?.chat?.getSnapshot()?.legacy.partial?.blocks
98
+ .filter(block => block.kind === 'text').map(block => block.text).join('\n') ?? ''
99
+ const running = ctx.sessions.list.getSnapshot().byId[id]?.running === true
100
+ send('synapse:live-reply', { sessionId: id, running, text })
101
+ }
102
+
103
+ const detachLiveSession = record => {
104
+ for (const unsubscribe of record.unsubscribers) unsubscribe()
105
+ }
106
+
107
+ // A running Session is the only kind that produces live text, so it is the
108
+ // only kind worth opening a reference — and its history read — for.
109
+ const syncObservedSessions = () => {
110
+ const rows = ctx.sessions.list.getSnapshot()
111
+ for (const id of rows.ids) {
112
+ const held = ownReferences.has(id)
113
+ const wanted = rows.byId[id]?.running === true && ctx.sessions.binding(id) === undefined
114
+ if (wanted && !held) {
115
+ try {
116
+ const reference = ctx.sessions.retain(id, { source: SESSION_SOURCE })
117
+ void reference.ready.catch(() => {})
118
+ ownReferences.set(id, reference)
119
+ } catch { /* the Session is not retainable right now */ }
120
+ } else if (!wanted && held) {
121
+ ownReferences.get(id).release()
122
+ ownReferences.delete(id)
123
+ }
124
+ }
125
+ for (const [id, reference] of ownReferences) {
126
+ if (rows.ids.includes(id)) continue
127
+ reference.release()
128
+ ownReferences.delete(id)
129
+ }
130
+ }
131
+
68
132
  const syncLiveSessions = () => {
69
- const snapshot = ctx.sessions.list.getSnapshot()
70
- for (const id of snapshot.ids) {
71
- if (liveUnsubscribers.has(id)) continue
72
- const scope = ctx.sessions.scope(id)
73
- const session = scope === undefined ? undefined : ctx.sessions.sessionOf(scope)
74
- if (session === undefined) continue
75
- const publish = () => {
76
- if (overlay.hidden) return
77
- const state = session.getSnapshot()
78
- const text = state.partial?.blocks.filter(block => block.kind === 'text').map(block => block.text).join('\n') ?? ''
79
- send('synapse:live-reply', { sessionId: id, running: state.running, text })
133
+ syncObservedSessions()
134
+ const ids = ctx.sessions.list.getSnapshot().ids
135
+ for (const id of ids) {
136
+ const binding = ctx.sessions.binding(id)
137
+ let record = liveSessions.get(id)
138
+ // Rebuild when the observable source appears or disappears, and when a
139
+ // same-id generation replaced the borrowed binding.
140
+ if (record !== undefined && record.binding !== binding) {
141
+ detachLiveSession(record)
142
+ record = undefined
80
143
  }
81
- liveUnsubscribers.set(id, session.subscribe(publish))
82
- publish()
144
+ if (record === undefined) {
145
+ let chat
146
+ if (binding !== undefined) {
147
+ try { chat = ctx.uiConversation.binding(id).target('chat') } catch { chat = undefined }
148
+ }
149
+ liveSessions.set(id, {
150
+ binding,
151
+ chat,
152
+ // `target('chat')` is the only reader that activates the view
153
+ // target: its snapshot stays undefined until something subscribes.
154
+ unsubscribers: chat === undefined
155
+ ? []
156
+ : [chat.subscribe(() => publishLiveSession(id)), binding.session.subscribe(() => publishLiveSession(id))],
157
+ })
158
+ }
159
+ }
160
+ for (const [id, record] of liveSessions) {
161
+ if (ids.includes(id)) continue
162
+ detachLiveSession(record)
163
+ liveSessions.delete(id)
83
164
  }
84
- for (const [id, unsubscribe] of liveUnsubscribers) if (!snapshot.ids.includes(id)) { unsubscribe(); liveUnsubscribers.delete(id) }
85
165
  }
166
+ // Always runs after `syncLiveSessions`, so a newly attached Session gets
167
+ // its first report here rather than publishing twice on the same sync.
168
+ const publishLiveSessions = () => { for (const id of liveSessions.keys()) publishLiveSession(id) }
86
169
  const syncSessions = () => {
87
170
  if (syncQueued) return
88
171
  syncQueued = true
@@ -102,6 +185,7 @@ window.__ModuleLoader__.load({
102
185
  const syncCurrentSession = () => {
103
186
  syncSessions()
104
187
  syncLiveSessions()
188
+ publishLiveSessions()
105
189
  syncTheme()
106
190
  if (!overlay.hidden) {
107
191
  send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
@@ -144,14 +228,46 @@ window.__ModuleLoader__.load({
144
228
  return send('synapse:current-session', { session: currentSession(ctx) })
145
229
  }
146
230
  if (event.data.type === 'synapse:open-session') {
147
- try { ctx.sessions.open(event.data.sessionId); close() } catch { send('synapse:bridge-error', { message: '关联的 DSH 会话已不可用' }) }
231
+ // 0.1.7 moved navigation out of the Session Controller: selecting a
232
+ // Session and showing its Conversation is one Workspace UI action.
233
+ try { ctx.uiWorkspace.openSession(event.data.sessionId); close() } catch { send('synapse:bridge-error', { message: NO_SESSION }) }
234
+ // Best-effort anchor to the requested turn: chat nodes expose their
235
+ // source event seq (anchorSeq) and render with data-chat-anchor-key,
236
+ // so resolve seq -> node key -> scroll once the view materializes.
237
+ const seq = event.data.seq
238
+ if (Number.isInteger(seq)) {
239
+ const tryScroll = attempt => {
240
+ const record = liveSessions.get(event.data.sessionId)
241
+ if (record === undefined) {
242
+ if (attempt < 3) window.setTimeout(() => tryScroll(attempt + 1), 500)
243
+ return
244
+ }
245
+ let key = undefined
246
+ for (const node of record.chat.getSnapshot()?.nodes.values() ?? []) {
247
+ if (node.anchorSeq === seq) { key = node.key; break }
248
+ }
249
+ if (key !== undefined) {
250
+ const row = document.querySelector(`[data-chat-anchor-key="${CSS.escape(key)}"]`)
251
+ if (row instanceof HTMLElement) row.scrollIntoView({ block: 'start' })
252
+ return
253
+ }
254
+ // The requested turn may sit outside the loaded window; the jump
255
+ // loader pages history back through the seq, then the retry reads
256
+ // the rebuilt node set.
257
+ if (attempt === 0 && typeof record.session.loadThrough === 'function') {
258
+ void record.session.loadThrough(seq).catch(() => {})
259
+ }
260
+ if (attempt < 3) window.setTimeout(() => tryScroll(attempt + 1), 500)
261
+ }
262
+ window.setTimeout(() => tryScroll(0), 300)
263
+ }
148
264
  return
149
265
  }
150
266
  if (event.data.type === 'synapse:activate-session') {
151
267
  // Bidirectional current-session sync: switch DSH's current session
152
268
  // without closing the map; the sessions-list subscription re-sends
153
269
  // synapse:current-session so the map follows the new highlight.
154
- try { ctx.sessions.open(event.data.sessionId) } catch { send('synapse:bridge-error', { message: '关联的 DSH 会话已不可用' }) }
270
+ try { ctx.uiWorkspace.openSession(event.data.sessionId) } catch { send('synapse:bridge-error', { message: NO_SESSION }) }
155
271
  return
156
272
  }
157
273
  if (event.data.type === 'synapse:fork-session') {
@@ -207,7 +323,10 @@ window.__ModuleLoader__.load({
207
323
  themeObserver?.disconnect()
208
324
  unsubscribeSessions()
209
325
  unsubscribeWorkspaces()
210
- for (const unsubscribe of liveUnsubscribers.values()) unsubscribe()
326
+ for (const record of liveSessions.values()) detachLiveSession(record)
327
+ liveSessions.clear()
328
+ for (const reference of ownReferences.values()) reference.release()
329
+ ownReferences.clear()
211
330
  host.remove()
212
331
  style.remove()
213
332
  }, 'synapse: web workspace switch')
@@ -28,6 +28,29 @@ DSH session logs remain the source of truth for conversation content and lifecyc
28
28
 
29
29
  Synapse projects committed DSH events into cards and sends user actions back through the native DSH session bridge.
30
30
 
31
+ ## Harness compatibility
32
+
33
+ This revision is built and verified against DeepSeek Harness `0.1.7-rc.2`. Both halves call Harness APIs directly, so a Harness release that changes them requires a matching plugin release.
34
+
35
+ Host half (`index.js`) calls:
36
+
37
+ - `ctx.webServer.register({ kind, path, handler })`, the route registry of `@deepseek-ai/dsh-host-webserver`;
38
+ - `ctx.sessions.list()`, the live `Session` objects of `@deepseek-ai/dsh-session`;
39
+ - the untagged (global) `session/created` and `session/event` listeners;
40
+ - `session.snapshotEvents()` for a projection replay, `session.inheritedEventCount` for the durable fork cut, and `session.header.cwd` for workspace grouping.
41
+
42
+ Client half (`client.js`), through `dsh.client.inject` in `package.json`:
43
+
44
+ - `sessions.list`, `sessions.binding`, `sessions.create`, `sessions.fork` from `@deepseek-ai/dsh-api-session-controller`, plus `sessions.retain`/`release` for two purposes: owning a reference across a prompt round-trip, and owning one for every *running* Session whose live reply the map mirrors, because no Harness release mints a Session scope implicitly any more and an unowned Session has no readable Chat view;
45
+ - `workspaces.list` from `@deepseek-ai/dsh-api-workspace-controller`;
46
+ - `uiWorkspace.openSession` from `@deepseek-ai/dsh-client-ui-workspace` for selecting a Session;
47
+ - `uiConversation.binding(sessionId).target('chat')` from `@deepseek-ai/dsh-client-ui-conversation` for the streaming reply and the turn anchor index, because the Client builds a view snapshot only after something subscribes to its target. A Session the browser does not observe still reports its `running` flag, which the list row carries.
48
+
49
+ Two Harness dependencies are worth re-checking on every upgrade:
50
+
51
+ - `snapshotEvents()` is marked deprecated: Harness is moving complete session logs out of memory. Rebuilding this canvas from history needs the full sequence, so the call remains, and it needs a storage-backed successor (an asynchronous paged read) before Harness removes it.
52
+ - The browser half must acquire its own `SessionReference` before sending a prompt; borrowing an Agent scope no longer opens one.
53
+
31
54
  ## Canvas metadata
32
55
 
33
56
  By default, Synapse stores canvas metadata at:
@@ -5,6 +5,7 @@ This document covers local validation, package inspection, GitHub Actions, and n
5
5
  ## Requirements
6
6
 
7
7
  - Node.js `>= 22.19.0`
8
+ - DeepSeek Harness `>= 0.1.7-rc.2` for integration runs; the test suite itself installs no Harness
8
9
  - pnpm through Corepack
9
10
  - A checkout of this repository
10
11
 
package/docs/en/README.md CHANGED
@@ -6,7 +6,7 @@ The plugin does not replace DSH models, tools, sessions, permissions, or the Web
6
6
 
7
7
  ## Prerequisites
8
8
 
9
- - A DeepSeek Harness release with the `dsh plugin` profile mechanism (2026-08 or later).
9
+ - DeepSeek Harness `>= 0.1.7-rc.2`; this revision targets that release's Host session and Client service interfaces.
10
10
  - Node.js `>= 22.19.0`.
11
11
  - The `web` profile; other profiles are not currently supported.
12
12
 
Binary file
Binary file
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 前提条件
8
8
 
9
- - 支持 `dsh plugin` profile 插件机制的 DeepSeek Harness(2026-08 或之后版本)。
9
+ - DeepSeek Harness `>= 0.1.7-rc.2`(本插件依赖该版本的 Host 会话与 Client 服务接口)。
10
10
  - Node.js `>= 22.19.0`。
11
11
  - 使用 `web` profile;其他 profile 暂不支持。
12
12
 
package/index.js CHANGED
@@ -200,7 +200,11 @@ export class WorkspaceStore {
200
200
  if (this.state.hiddenSessionIds.includes(session.id)) return null
201
201
  const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
202
202
  const thread = this.dshThread(workspace, session)
203
- for (const event of session.events) {
203
+ // `snapshotEvents()` replaces the removed `session.events` getter. It is
204
+ // marked deprecated because DSH is moving complete logs out of memory, but
205
+ // rebuilding this canvas from history needs the full sequence and the
206
+ // reader is the only complete-log access a plugin still has.
207
+ for (const event of session.snapshotEvents()) {
204
208
  if (event.seq >= replayFrom) this.projectEventInto(workspace, thread, event)
205
209
  }
206
210
  return structuredClone(thread)
@@ -380,9 +384,9 @@ export class WorkspaceStore {
380
384
  thread.title = title
381
385
  thread.dshSessionTitle = title
382
386
  }
383
- // `seedLength` is DSH's durable fork cut. Keep it even after the
384
- // session has been restored, when its in-process `firstLiveSeq` moves.
385
- const seedLength = session.header?.seedLength
387
+ // `inheritedEventCount` is DSH's durable fork cut, the successor of the
388
+ // removed `header.seedLength`. It survives restore, unlike `firstLiveSeq`.
389
+ const seedLength = session.inheritedEventCount
386
390
  if (Number.isSafeInteger(seedLength) && seedLength >= 0) thread.sourceSeedLength = seedLength
387
391
  return thread
388
392
  }
@@ -395,7 +399,7 @@ export class WorkspaceStore {
395
399
  title: typeof session.title === 'string' && session.title.trim() !== '' ? session.title.slice(0, MAX_TITLE_LENGTH) : (parent === undefined ? 'DSH 会话' : `${parent.title} 分支`),
396
400
  parentId: parent?.id ?? null,
397
401
  sourceParentSessionId: parentSessionId,
398
- sourceSeedLength: Number.isSafeInteger(session.header?.seedLength) && session.header.seedLength >= 0 ? session.header.seedLength : null,
402
+ sourceSeedLength: Number.isSafeInteger(session.inheritedEventCount) && session.inheritedEventCount >= 0 ? session.inheritedEventCount : null,
399
403
  dshSessionId: session.id,
400
404
  dshSessionTitle: typeof session.title === 'string' ? session.title.slice(0, MAX_TITLE_LENGTH) : null,
401
405
  color: TOPIC_COLORS[workspace.threads.length % TOPIC_COLORS.length],
@@ -406,6 +410,7 @@ export class WorkspaceStore {
406
410
  createdAt: now,
407
411
  updatedAt: now,
408
412
  messages: [],
413
+ pendingProcess: [],
409
414
  }
410
415
  workspace.threads.push(thread)
411
416
  // A child may arrive before its parent during startup replay. Repair that
@@ -439,10 +444,11 @@ export class WorkspaceStore {
439
444
  kind: projection.kind,
440
445
  sourceSeq: event.seq,
441
446
  at,
442
- ...(projection.kind === 'assistant'
443
- ? { turn: event.data.turn, step: event.data.step, process: [] }
447
+ ...(projection.kind === 'assistant' || projection.kind === 'error'
448
+ ? { turn: event.data?.turn, step: event.data?.step, process: [] }
444
449
  : {}),
445
450
  }
451
+ this.attachPendingProcess(thread, message)
446
452
  thread.messages.push(message)
447
453
  thread.updatedAt = at
448
454
  workspace.updatedAt = at
@@ -455,31 +461,31 @@ export class WorkspaceStore {
455
461
  /**
456
462
  * Fold one tool call or result into the assistant message of its own
457
463
  * turn/step, keyed by `callId`, so a tool invocation never becomes a
458
- * separate canvas card. A legacy assistant message without `turn`/`step` is
459
- * the fallback target; an event with no such target is dropped.
464
+ * separate canvas card. If a tool result arrives before its associated
465
+ * assistant/error message, retain it on the thread until that turn appears.
460
466
  */
461
467
  foldToolProcess(thread, event) {
462
468
  const at = new Date(event.time).toISOString()
469
+ const data = event.data ?? {}
463
470
  const target = [...thread.messages].reverse().find(message =>
464
- message.kind === 'assistant'
465
- && (message.turn === event.data.turn && message.step === event.data.step
471
+ (message.kind === 'assistant' || message.kind === 'error')
472
+ && (message.turn === data.turn && message.step === data.step
466
473
  || message.turn === undefined && message.step === undefined))
467
- if (target === undefined) return
468
- const process = target.process ??= []
469
- const callId = String(event.type === 'tool/call' ? event.data.callId : event.data.message?.source?.callId ?? '')
474
+ const process = target === undefined ? (thread.pendingProcess ??= []) : (target.process ??= [])
475
+ const callId = String(event.type === 'tool/call' ? data.callId : data.message?.source?.callId ?? '')
470
476
  const entry = process.find(item => item.callId === callId)
471
477
  if (event.type === 'tool/call') {
472
478
  if (entry === undefined) {
473
- process.push({ callId, name: event.data.name, arguments: event.data.arguments, result: null, error: null })
479
+ process.push({ callId, turn: data.turn, step: data.step, name: data.name, arguments: data.arguments, result: null, error: null })
474
480
  } else {
475
- entry.name = event.data.name
476
- entry.arguments = event.data.arguments
481
+ entry.name = data.name
482
+ entry.arguments = data.arguments
477
483
  }
478
484
  } else {
479
- const outcome = contentText(event.data.message?.content)
480
- const error = event.data.error === undefined ? null : `${event.data.error.name}: ${event.data.error.code}`
485
+ const outcome = contentText(data.message?.content)
486
+ const error = errorText(data.error)
481
487
  if (entry === undefined) {
482
- process.push({ callId, name: '工具调用', arguments: null, result: outcome, error })
488
+ process.push({ callId, turn: data.turn, step: data.step, name: '工具调用', arguments: null, result: outcome, error })
483
489
  } else {
484
490
  entry.result = outcome
485
491
  entry.error = error
@@ -488,6 +494,14 @@ export class WorkspaceStore {
488
494
  thread.updatedAt = at
489
495
  }
490
496
 
497
+ attachPendingProcess(thread, message) {
498
+ if (!Array.isArray(thread.pendingProcess) || thread.pendingProcess.length === 0 || !Array.isArray(message.process)) return
499
+ const matching = thread.pendingProcess.filter(entry => entry.turn === message.turn && entry.step === message.step)
500
+ if (matching.length === 0) return
501
+ message.process.push(...matching.map(({ turn, step, ...entry }) => entry))
502
+ thread.pendingProcess = thread.pendingProcess.filter(entry => entry.turn !== message.turn || entry.step !== message.step)
503
+ }
504
+
491
505
  thread({ title, parentId, dshSessionId, dshSessionTitle, position, color, now, order }) {
492
506
  return {
493
507
  id: randomUUID(),
@@ -500,6 +514,7 @@ export class WorkspaceStore {
500
514
  createdAt: now,
501
515
  updatedAt: now,
502
516
  messages: [],
517
+ pendingProcess: [],
503
518
  }
504
519
  }
505
520
 
@@ -528,7 +543,7 @@ function normalizeState(value) {
528
543
  migrated = true
529
544
  const notes = Array.isArray(thread.notes) ? thread.notes : []
530
545
  const { notes: _notes, ...rest } = thread
531
- return { ...rest, messages: notes }
546
+ return { ...rest, messages: notes, pendingProcess: [] }
532
547
  }) : [],
533
548
  }))
534
549
  state = { ...value, version: value.version, hiddenSessionIds, workspaces }
@@ -638,16 +653,32 @@ function projectableEvent(event) {
638
653
  return isRuntimeContextText(text) ? null : noteProjection('user', text)
639
654
  }
640
655
  case 'assistant/message':
641
- return noteProjection('assistant', contentText(event.data.message.content))
656
+ return noteProjection('assistant', contentText(event.data?.message?.content))
642
657
  case 'todo/write':
643
- return noteProjection('todo', event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n'))
644
- case 'turn/end':
645
- return event.data.reason.kind === 'error' ? noteProjection('error', event.data.reason.error.message) : null
646
- default:
658
+ return noteProjection('todo', Array.isArray(event.data?.todos) ? event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n') : '')
659
+ case 'turn/end': {
660
+ const reason = event.data?.reason
661
+ if (reason?.kind === 'error') return noteProjection('error', errorText(reason.error) ?? '本轮执行失败')
662
+ if (reason?.kind === 'cancelled' || reason?.kind === 'canceled' || reason?.kind === 'aborted') return noteProjection('error', '本轮已取消')
647
663
  return null
664
+ }
665
+ default:
666
+ return /(?:error|failed|failure|cancel(?:led)?|abort)/i.test(event.type)
667
+ ? noteProjection('error', errorText(event.data?.error ?? event.data?.reason ?? event.data) ?? 'Harness 运行失败')
668
+ : null
648
669
  }
649
670
  }
650
671
 
672
+ function errorText(value) {
673
+ if (typeof value === 'string') return value.trim() || null
674
+ if (value === null || value === undefined || typeof value !== 'object') return null
675
+ const name = typeof value.name === 'string' && value.name.trim() !== '' ? value.name.trim() : ''
676
+ const code = typeof value.code === 'string' && value.code.trim() !== '' ? value.code.trim() : ''
677
+ const message = typeof value.message === 'string' && value.message.trim() !== '' ? value.message.trim() : ''
678
+ if (message !== '') return [name, code].filter(Boolean).concat(message).join(': ')
679
+ return [name, code].filter(Boolean).join(': ') || null
680
+ }
681
+
651
682
  function noteProjection(kind, text) {
652
683
  const normalized = text.trim()
653
684
  if (normalized === '') return null
@@ -679,7 +710,9 @@ function titleFromText(text) {
679
710
  }
680
711
 
681
712
  function sessionCwd(session) {
682
- const cwd = session.header?.meta?.cwd ?? session.header?.cwd
713
+ // The Session header defines `cwd` as its only working-directory carrier; the
714
+ // old `header.meta.cwd` fallback never matched a real header field.
715
+ const cwd = session.header?.cwd
683
716
  return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : '未指定工作目录'
684
717
  }
685
718
 
@@ -726,8 +759,9 @@ export function apply(ctx, config) {
726
759
  }
727
760
  const replaySession = session => {
728
761
  // Forks inherit their parent's log. The canvas already represents that
729
- // history through the parent node, so only project the child's live tail.
730
- const replayFrom = session.header?.parentSession === undefined ? 0 : session.firstLiveSeq
762
+ // history through the parent node, so only project the child's live tail:
763
+ // `inheritedEventCount` is 0 without a fork and the durable cut with one.
764
+ const replayFrom = session.inheritedEventCount ?? 0
731
765
  void store.projectSession(session, replayFrom, projectionWorkspaceTitle).catch(reportProjectionFailure)
732
766
  }
733
767
  // Buffer live events per session and flush them in one write per microtask,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-synapse",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "A visual, non-linear conversation workspace plugin for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -32,7 +32,10 @@
32
32
  "client": {
33
33
  "platform": "web",
34
34
  "inject": [
35
- "@deepseek-ai/dsh-client-runtime"
35
+ "@deepseek-ai/dsh-api-session-controller",
36
+ "@deepseek-ai/dsh-api-workspace-controller",
37
+ "@deepseek-ai/dsh-client-ui-conversation",
38
+ "@deepseek-ai/dsh-client-ui-workspace"
36
39
  ],
37
40
  "immediately": false
38
41
  }