@wangmo-bit/ds-link 0.0.0-stage → 0.8.18

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/TESTING.md +59 -0
  4. package/THIRD_PARTY_NOTICES.md +313 -0
  5. package/assets/icon.svg +7 -0
  6. package/cordis.patch.yml +3 -0
  7. package/dist/client.js +6012 -0
  8. package/package.json +102 -4
  9. package/src/core/constants.js +188 -0
  10. package/src/core/downgrade.js +80 -0
  11. package/src/core/engine.js +4669 -0
  12. package/src/core/errors.js +51 -0
  13. package/src/core/ledger.js +1174 -0
  14. package/src/core/migration.js +741 -0
  15. package/src/core/render.js +61 -0
  16. package/src/core/settings.js +147 -0
  17. package/src/core/validation.js +454 -0
  18. package/src/host/adapter.js +1152 -0
  19. package/src/host/connection.js +113 -0
  20. package/src/host/entry-0.2.0.js +13 -0
  21. package/src/host/entry-0.3.0.js +13 -0
  22. package/src/host/entry-0.4.0.js +13 -0
  23. package/src/host/entry-0.8.10.js +13 -0
  24. package/src/host/entry-0.8.11.js +13 -0
  25. package/src/host/entry-0.8.12.js +13 -0
  26. package/src/host/entry-0.8.13.js +13 -0
  27. package/src/host/entry-0.8.14.js +13 -0
  28. package/src/host/entry-0.8.15.js +13 -0
  29. package/src/host/entry-0.8.16-pre.1.js +13 -0
  30. package/src/host/entry-0.8.16-pre.2.js +13 -0
  31. package/src/host/entry-0.8.16-pre.3.js +13 -0
  32. package/src/host/entry-0.8.16-pre.4.js +13 -0
  33. package/src/host/entry-0.8.16-pre.5.js +13 -0
  34. package/src/host/entry-0.8.18.js +13 -0
  35. package/src/host/entry-0.8.5.js +13 -0
  36. package/src/host/entry-0.8.6.js +13 -0
  37. package/src/host/entry-0.8.7.js +13 -0
  38. package/src/host/entry-0.8.8.js +13 -0
  39. package/src/host/entry-0.8.9.js +13 -0
  40. package/src/host/index.js +74 -0
  41. package/src/host/plugin.js +403 -0
  42. package/src/host/prompt.js +22 -0
  43. package/src/host/tools.js +312 -0
@@ -0,0 +1,1152 @@
1
+ /**
2
+ * rc.5 host adapter.
3
+ *
4
+ * Uses only public Host services: workspaceRegistry, sessionQuery, agents, the
5
+ * SessionStore flush checkpoint, Host session/event notifications,
6
+ * sessionController.resolveAgent for delivery, and the host createUserMessage
7
+ * builder injected by the package entry. It never imports a private module,
8
+ * never calls sessionController.prompt, never cancels a recipient's work, and
9
+ * never disposes a target Agent.
10
+ */
11
+
12
+ import fs from 'node:fs/promises'
13
+ import path from 'node:path'
14
+
15
+ import { LIMITS } from '../core/constants.js'
16
+ import { fail, errorCode } from '../core/errors.js'
17
+ import { sha256 } from '../core/migration.js'
18
+ import { renderInboundText } from '../core/render.js'
19
+ import { assertProjectSegment, isPlainObject } from '../core/validation.js'
20
+
21
+ const NOOP_LOGGER = Object.freeze({
22
+ debug() {},
23
+ info() {},
24
+ warn() {},
25
+ error() {},
26
+ })
27
+
28
+ export class HostAdapter {
29
+ /**
30
+ * @param {{
31
+ * ctx: Record<string, any>,
32
+ * createUserMessage: (input: Record<string, any>) => Record<string, any>,
33
+ * clock?: () => number,
34
+ * logger?: Record<string, (...args: any[]) => void>,
35
+ * cacheMs?: number,
36
+ * }} options
37
+ */
38
+ constructor(options) {
39
+ if (typeof options?.createUserMessage !== 'function') throw fail('internal', 'createUserMessage is required')
40
+ this.ctx = options.ctx
41
+ this.createUserMessage = options.createUserMessage
42
+ this.clock = options.clock ?? Date.now
43
+ this.logger = options.logger ?? NOOP_LOGGER
44
+ this.cacheMs = options.cacheMs ?? 1500
45
+ this.#cache = undefined
46
+ this.#lastSeq = new Map()
47
+ }
48
+
49
+ #cache
50
+ #lastSeq
51
+
52
+ /**
53
+ * @param {{ fresh?: boolean }} [options]
54
+ * @returns {Promise<Array<Record<string, any>>>}
55
+ */
56
+ async listSessions(options = {}) {
57
+ return this.#refresh(options.fresh === true)
58
+ }
59
+
60
+ /**
61
+ * @param {string} id
62
+ * @param {{ fresh?: boolean }} [options]
63
+ * @returns {Promise<Record<string, any> | undefined>}
64
+ */
65
+ async getSession(id, options = {}) {
66
+ const sessions = await this.#refresh(options.fresh === true)
67
+ return sessions.find((session) => session.id === id)
68
+ }
69
+
70
+ /** @param {boolean} fresh @returns {Promise<Array<Record<string, any>>>} */
71
+ async #refresh(fresh) {
72
+ if (!fresh && this.#cache !== undefined && this.clock() - this.#cache.at < this.cacheMs) {
73
+ return this.#cache.sessions
74
+ }
75
+ const [records, workspaces] = await Promise.all([
76
+ this.ctx.sessionQuery.listSessions(),
77
+ Promise.resolve(this.ctx.workspaceRegistry.list()),
78
+ ])
79
+ const workspaceBySession = new Map()
80
+ for (const workspace of workspaces) {
81
+ for (const sessionId of workspace.sessionIds) {
82
+ if (!workspaceBySession.has(sessionId)) {
83
+ workspaceBySession.set(sessionId, {
84
+ id: workspace.id,
85
+ title: workspace.title,
86
+ path: workspace.path,
87
+ })
88
+ }
89
+ }
90
+ }
91
+ const archived = new Set(this.ctx.workspaceRegistry.archivedSessionIds)
92
+ const registered = new Set(workspaceBySession.keys())
93
+ const visible = records.filter((record) => registered.has(record?.header?.id))
94
+ const ids = visible.map((record) => record.header.id)
95
+ let titles = new Map()
96
+ try {
97
+ const observations = ids.length === 0 ? [] : await this.ctx.sessionQuery.readTitleSnapshots(ids)
98
+ titles = new Map(
99
+ observations
100
+ .filter((observation) => observation.status === 'fulfilled')
101
+ .map((observation) => [observation.sessionId, observation.value?.title?.title]),
102
+ )
103
+ } catch (error) {
104
+ this.logger.warn(`session-link: titles unavailable (${errorCode(error)})`)
105
+ }
106
+
107
+ const sessions = visible
108
+ .map((record) => {
109
+ const header = record.header
110
+ const id = header.id
111
+ const workspace = workspaceBySession.get(id)
112
+ const subagent = header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0
113
+ const isArchived = archived.has(id)
114
+ const formatStatus = record.formatStatus ?? 'current'
115
+ let agentStatus = record.live === true ? 'idle' : 'cold'
116
+ try {
117
+ const agent = this.ctx.agents.get(id)
118
+ if (agent?.status !== undefined) agentStatus = agent.status
119
+ } catch {
120
+ // The registry is allowed to be absent in a narrow test harness.
121
+ }
122
+ const title = titles.get(id) ?? `Session ${String(id).slice(0, 8)}`
123
+ return {
124
+ id,
125
+ title,
126
+ workspaceId: workspace?.id ?? null,
127
+ workspaceTitle: workspace?.title ?? null,
128
+ workspacePath: workspace?.path ?? null,
129
+ createdAt: header.createdAt,
130
+ cwd: header.cwd ?? null,
131
+ parentSession: header.parentSession ?? null,
132
+ origin: header.origin ?? null,
133
+ delegationDepth: header.delegationDepth ?? 0,
134
+ live: record.live === true,
135
+ persisted: record.persisted === true,
136
+ formatStatus,
137
+ archived: isArchived,
138
+ subagent,
139
+ registered: true,
140
+ eligible: !isArchived && !subagent && formatStatus !== 'migration-required',
141
+ agentStatus,
142
+ }
143
+ })
144
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0) || a.id.localeCompare(b.id))
145
+
146
+ this.#cache = { at: this.clock(), sessions }
147
+ return sessions
148
+ }
149
+
150
+ /**
151
+ * Build the durable host user message for one delivery. The source is the
152
+ * host's standard agent-message attribution with exactly three fields.
153
+ * @param {Record<string, any>} message
154
+ * @returns {Promise<{ hostMessage: Record<string, any>, durableMessageId: string }>}
155
+ */
156
+ async buildDelivery(message) {
157
+ const source = {
158
+ kind: 'agent-message',
159
+ form: 'relay',
160
+ senderSessionId: message.from,
161
+ }
162
+ const hostMessage = this.createUserMessage({
163
+ content: [{ type: 'text', text: message.rendered ?? renderInboundText(message) }],
164
+ source,
165
+ })
166
+ if (typeof hostMessage?.id !== 'string' || hostMessage.id.length === 0) {
167
+ throw fail('message_build_failed', 'host createUserMessage did not return a message id')
168
+ }
169
+ return { hostMessage, durableMessageId: hostMessage.id }
170
+ }
171
+
172
+ /**
173
+ * Resolve the target's existing Agent (never a clone), recheck the caller's
174
+ * gate, call steer (nearest next-step; never abort the running tool batch),
175
+ * then require flush() === true. Only a failure before steer is provably
176
+ * un-inserted; the gate callback name is kept for the 0.2 adapter contract.
177
+ * @param {{
178
+ * targetId: string,
179
+ * hostMessage: Record<string, any>,
180
+ * beforeResolve: () => Promise<Record<string, any>>,
181
+ * beforeFollowup: () => Promise<Record<string, any>>,
182
+ * }} request
183
+ * @returns {Promise<Record<string, any>>}
184
+ */
185
+ async deliver(request) {
186
+ let before
187
+ try {
188
+ before = await request.beforeResolve()
189
+ } catch (error) {
190
+ return { stage: 'blocked', blocked: { action: 'failed', code: errorCode(error), message: 'pre-resolve gate failed' } }
191
+ }
192
+ if (before?.ok !== true) return { stage: 'blocked', blocked: before }
193
+
194
+ let resolved
195
+ try {
196
+ resolved = await this.ctx.sessionController.resolveAgent(request.targetId)
197
+ } catch (error) {
198
+ return { stage: 'resolve', error: { code: errorCode(error) } }
199
+ }
200
+ if (resolved === undefined || 'error' in resolved) {
201
+ return {
202
+ stage: 'resolve',
203
+ error: {
204
+ code: resolved?.error?.code ?? 'resolve_failed',
205
+ message: 'target agent could not be resolved',
206
+ },
207
+ }
208
+ }
209
+ const agent = resolved.agent
210
+ if (agent === undefined || agent.id !== request.targetId) {
211
+ return { stage: 'resolve', error: { code: 'agent_identity_mismatch' } }
212
+ }
213
+
214
+ try {
215
+ before = await request.beforeFollowup()
216
+ } catch (error) {
217
+ return { stage: 'blocked', blocked: { action: 'failed', code: errorCode(error), message: 'pre-step gate failed' } }
218
+ }
219
+ if (before?.ok !== true) return { stage: 'blocked', blocked: before }
220
+
221
+ let insertionStarted = false
222
+ try {
223
+ insertionStarted = true
224
+ if (typeof before?.commit === 'function') {
225
+ // The Engine-owned commit performs a final synchronous state read and
226
+ // calls run() in the same stack; no await sits between the gate and
227
+ // the host insertion.
228
+ const committed = before.commit(() => agent.steer(request.hostMessage))
229
+ if (committed?.ok !== true) return { stage: 'blocked', blocked: committed?.blocked ?? committed }
230
+ } else {
231
+ agent.steer(request.hostMessage)
232
+ }
233
+ } catch (error) {
234
+ return { stage: 'followup', error: { code: errorCode(error) } }
235
+ }
236
+ let flushed
237
+ try {
238
+ flushed = await this.ctx.sessions.flush(agent.session)
239
+ } catch (error) {
240
+ return { stage: 'flush', error: { code: errorCode(error) }, insertionStarted }
241
+ }
242
+ if (flushed !== true) return { stage: 'flush', flush: flushed, insertionStarted, error: { code: 'flush_false' } }
243
+ return { stage: 'accepted' }
244
+ }
245
+
246
+ /**
247
+ * Install the single live receipt observer. The callback receives only the
248
+ * normalized facts the engine needs; filtering against the ledger happens in
249
+ * the engine so observation never trusts an unrelated session event.
250
+ * @param {(event: Record<string, any>) => void} listener
251
+ * @returns {() => void}
252
+ */
253
+ installReceiptObserver(listener) {
254
+ if (typeof this.ctx.on !== 'function') {
255
+ throw fail('unsupported_host', 'target Host does not expose the session/event notification API')
256
+ }
257
+ const dispose = this.ctx.on('session/event', (session, event) => {
258
+ try {
259
+ if (session === undefined || event === undefined) return
260
+ if (Number.isSafeInteger(event.seq)) this.#lastSeq.set(session.id, event.seq)
261
+ if (event.type === 'user/message') {
262
+ const message = event.data
263
+ if (!isPlainObject(message) || typeof message.id !== 'string') return
264
+ listener({
265
+ kind: 'message',
266
+ sessionId: session.id,
267
+ messageId: message.id,
268
+ senderSessionId: message.source?.senderSessionId,
269
+ source: message.source,
270
+ seq: event.seq,
271
+ time: event.time,
272
+ })
273
+ return
274
+ }
275
+ if (event.type === 'agent/inbox/spliced') {
276
+ listener({ kind: 'inbox', sessionId: session.id, seq: event.seq, time: event.time })
277
+ }
278
+ } catch (error) {
279
+ this.logger.warn(`session-link receipt observer failed: ${errorCode(error)}`)
280
+ }
281
+ })
282
+ return typeof dispose === 'function' ? dispose : () => undefined
283
+ }
284
+
285
+ /**
286
+ * Resolve the live Session for a target without resolving or waking an Agent.
287
+ * The Agent's session is preferred; the SessionStore lookup covers a live
288
+ * session whose Agent is between registrations.
289
+ * @param {string} targetId
290
+ * @returns {Record<string, any> | undefined}
291
+ */
292
+ #liveSession(targetId) {
293
+ try {
294
+ const agent = this.ctx.agents.get(targetId)
295
+ if (agent?.session !== undefined) return agent.session
296
+ } catch {
297
+ // fall through to the store
298
+ }
299
+ try {
300
+ return this.ctx.sessions.get?.(targetId)
301
+ } catch {
302
+ return undefined
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Await the target SessionStore flush checkpoint. Only a live session can
308
+ * participate; a cold session returns false and never fakes confirmation.
309
+ * @param {string} targetId
310
+ * @returns {Promise<boolean>}
311
+ */
312
+ async flushTarget(targetId) {
313
+ const session = this.#liveSession(targetId)
314
+ if (session === undefined) return false
315
+ try {
316
+ return (await this.ctx.sessions.flush(session)) === true
317
+ } catch {
318
+ return false
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Cold, single-pass receipt evidence for one target. When the target is live,
324
+ * its SessionStore flush must participate and succeed before evidence is
325
+ * returned; a cold target is read from the durable log only.
326
+ * @param {string} targetId
327
+ * @returns {Promise<Record<string, any>>}
328
+ */
329
+ async readTargetReceipts(targetId) {
330
+ const info = await this.getSession(targetId, { fresh: true })
331
+ if (info === undefined || info === null || info.registered !== true || info.subagent === true) {
332
+ return { readable: false, errorCode: 'target_unavailable' }
333
+ }
334
+ const liveSession = this.#liveSession(targetId)
335
+ if (liveSession !== undefined) {
336
+ let flushed = false
337
+ try {
338
+ flushed = (await this.ctx.sessions.flush(liveSession)) === true
339
+ } catch {
340
+ flushed = false
341
+ }
342
+ if (!flushed) return { readable: false, errorCode: 'flush_failed' }
343
+ }
344
+ let snapshot
345
+ try {
346
+ snapshot = await this.ctx.sessionQuery.readSession(targetId)
347
+ } catch (error) {
348
+ return { readable: false, errorCode: errorCode(error) }
349
+ }
350
+ const inherited = Number.isSafeInteger(snapshot.inheritedEventCount) ? snapshot.inheritedEventCount : 0
351
+ const messages = []
352
+ const seen = new Set()
353
+ const lists = { 'next-turn': [], 'next-step': [] }
354
+ let pendingKnown = true
355
+ for (const event of snapshot.events ?? []) {
356
+ if (!Number.isSafeInteger(event?.seq) || event.seq < inherited) continue
357
+ if (event.type === 'user/message') {
358
+ const message = event.data
359
+ if (!isPlainObject(message) || typeof message.id !== 'string') continue
360
+ if (!Number.isSafeInteger(event.time) || event.time < 0) continue
361
+ if (seen.has(message.id)) continue
362
+ seen.add(message.id)
363
+ messages.push({ id: message.id, seq: event.seq, time: event.time, source: message.source })
364
+ continue
365
+ }
366
+ if (event.type === 'agent/inbox/spliced') {
367
+ const data = event.data
368
+ if (
369
+ !isPlainObject(data) ||
370
+ (data.target !== 'next-turn' && data.target !== 'next-step') ||
371
+ !Array.isArray(data.inserted)
372
+ ) {
373
+ pendingKnown = false
374
+ continue
375
+ }
376
+ const start = Number.isSafeInteger(data.start) ? data.start : 0
377
+ const removedCount = Number.isSafeInteger(data.removedCount) ? data.removedCount : 0
378
+ lists[data.target] = spliceMessages(lists[data.target], start, removedCount, data.inserted)
379
+ }
380
+ }
381
+ return {
382
+ readable: true,
383
+ messages,
384
+ pendingKnown,
385
+ pendingIds: [...lists['next-turn'], ...lists['next-step']]
386
+ .map((message) => message?.id)
387
+ .filter((id) => typeof id === 'string'),
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Synchronous current native presence for one durable message id. It never
393
+ * resolves or wakes a cold Agent.
394
+ * @param {string} targetId
395
+ * @param {string} durableMessageId
396
+ * @returns {{ known: boolean, pending?: boolean }}
397
+ */
398
+ nativePending(targetId, durableMessageId) {
399
+ if (typeof durableMessageId !== 'string') return { known: false }
400
+ let agent
401
+ try {
402
+ agent = this.ctx.agents.get(targetId)
403
+ } catch {
404
+ return { known: false }
405
+ }
406
+ if (agent?.inbox === undefined) return { known: false }
407
+ try {
408
+ const pending =
409
+ agent.inbox.nextTurn.some((message) => message?.id === durableMessageId) ||
410
+ agent.inbox.nextStep.some((message) => message?.id === durableMessageId)
411
+ return { known: true, pending }
412
+ } catch {
413
+ return { known: false }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Read-only live native inbox snapshot. No resolveAgent, no wake, no cold
419
+ * resume: only an already registered live Agent is inspected.
420
+ * @param {string} targetId
421
+ * @returns {Record<string, any>}
422
+ */
423
+ inspectNativeTarget(targetId) {
424
+ let agent
425
+ try {
426
+ agent = this.ctx.agents.get(targetId)
427
+ } catch {
428
+ return { live: false }
429
+ }
430
+ if (agent === undefined || agent === null) return { live: false }
431
+ let nextTurn
432
+ let nextStep
433
+ try {
434
+ nextTurn = agent.inbox.nextTurn.map((message) => structuredClone(message))
435
+ nextStep = agent.inbox.nextStep.map((message) => structuredClone(message))
436
+ } catch {
437
+ return { live: false }
438
+ }
439
+ let archived = false
440
+ try {
441
+ archived = new Set(this.ctx.workspaceRegistry.archivedSessionIds).has(targetId)
442
+ } catch {
443
+ archived = false
444
+ }
445
+ const hostSessionSeq = this.#lastSeq.get(targetId) ?? -1
446
+ return {
447
+ live: true,
448
+ running: agent.status === 'running',
449
+ archived,
450
+ subagent: false,
451
+ nextTurn,
452
+ nextStep,
453
+ hostSessionSeq,
454
+ }
455
+ }
456
+
457
+ /**
458
+ * One synchronous native move: verify the same queued object, remove it from
459
+ * next-turn, then steer that exact object. The SDK has no atomic move, so a
460
+ * throw after remove attempts an in-stack rollback only when both queues are
461
+ * provably empty and the driver is still running.
462
+ * @param {{
463
+ * targetId: string,
464
+ * durableMessageId: string,
465
+ * senderSessionId: string,
466
+ * queuePosition: number,
467
+ * digest: string,
468
+ * guard: () => boolean,
469
+ * }} request
470
+ * @returns {Record<string, any>}
471
+ */
472
+ moveNativePending(request) {
473
+ let agent
474
+ try {
475
+ agent = this.ctx.agents.get(request.targetId)
476
+ } catch {
477
+ return { outcome: 'not_live' }
478
+ }
479
+ if (agent === undefined || agent === null || agent.inbox === undefined) return { outcome: 'not_live' }
480
+ if (agent.status !== 'running') return { outcome: 'not_running' }
481
+ const inbox = agent.inbox
482
+ let nextTurn
483
+ let nextStep
484
+ try {
485
+ nextTurn = inbox.nextTurn
486
+ nextStep = inbox.nextStep
487
+ } catch {
488
+ return { outcome: 'not_running' }
489
+ }
490
+ if (nextStep.some((message) => message?.id === request.durableMessageId)) {
491
+ return { outcome: 'already_claimed' }
492
+ }
493
+ const index = nextTurn.findIndex((message) => message?.id === request.durableMessageId)
494
+ if (index < 0) return { outcome: 'missing' }
495
+ const message = nextTurn[index]
496
+ if (!isStandardSource(message?.source, request.senderSessionId)) return { outcome: 'mismatch' }
497
+ if (digestOf(message) !== request.digest) return { outcome: 'mismatch' }
498
+ let allowed = false
499
+ try {
500
+ allowed = request.guard() === true
501
+ } catch {
502
+ allowed = false
503
+ }
504
+ if (!allowed) return { outcome: 'blocked' }
505
+ let removed = false
506
+ try {
507
+ removed = inbox.remove(request.durableMessageId) === true
508
+ } catch (error) {
509
+ return { outcome: 'uncertain', error: errorCode(error) }
510
+ }
511
+ if (!removed) return { outcome: 'missing' }
512
+ try {
513
+ agent.steer(message)
514
+ } catch (error) {
515
+ let rolledBack = false
516
+ try {
517
+ if (
518
+ agent.status === 'running' &&
519
+ !inbox.nextTurn.some((entry) => entry?.id === request.durableMessageId) &&
520
+ !inbox.nextStep.some((entry) => entry?.id === request.durableMessageId)
521
+ ) {
522
+ const position = Math.max(0, Math.min(request.queuePosition, inbox.nextTurn.length))
523
+ inbox.splice('next-turn', position, 0, [message])
524
+ rolledBack =
525
+ inbox.nextTurn.some((entry) => entry?.id === request.durableMessageId) &&
526
+ !inbox.nextStep.some((entry) => entry?.id === request.durableMessageId)
527
+ }
528
+ } catch {
529
+ rolledBack = false
530
+ }
531
+ return rolledBack ? { outcome: 'rolled_back' } : { outcome: 'uncertain', error: errorCode(error) }
532
+ }
533
+ if (
534
+ inbox.nextStep.some((entry) => entry?.id === request.durableMessageId) &&
535
+ !inbox.nextTurn.some((entry) => entry?.id === request.durableMessageId)
536
+ ) {
537
+ return { outcome: 'moved' }
538
+ }
539
+ if (
540
+ inbox.nextTurn.some((entry) => entry?.id === request.durableMessageId) &&
541
+ !inbox.nextStep.some((entry) => entry?.id === request.durableMessageId)
542
+ ) {
543
+ return { outcome: 'fallback_next_turn' }
544
+ }
545
+ return { outcome: 'uncertain', error: 'post_steer_state_unknown' }
546
+ }
547
+
548
+ /**
549
+ * Cold, read-only evidence for one durable message id. Only child-owned
550
+ * events (after the fork-inherited prefix) count. A user/message match also
551
+ * carries the exact original event seq/time for the receipt.
552
+ * @param {{ targetId: string, durableMessageId?: string|null, senderSessionId: string }} request
553
+ * @returns {Promise<Record<string, any>>}
554
+ */
555
+ async readEvidence(request) {
556
+ if (typeof request.durableMessageId !== 'string' || request.durableMessageId.length === 0) {
557
+ return { found: false, readable: true, evidence: 'none' }
558
+ }
559
+ const info = await this.getSession(request.targetId, { fresh: true })
560
+ if (
561
+ info === undefined ||
562
+ info.registered !== true ||
563
+ info.subagent === true ||
564
+ info.archived === true ||
565
+ info.formatStatus === 'migration-required'
566
+ ) {
567
+ return { found: false, readable: false, invalidTarget: true, evidence: 'none' }
568
+ }
569
+ let snapshot
570
+ try {
571
+ snapshot = await this.ctx.sessionQuery.readSession(request.targetId)
572
+ } catch (error) {
573
+ return { found: false, readable: false, errorCode: errorCode(error), evidence: 'none' }
574
+ }
575
+ const inherited = Number.isSafeInteger(snapshot.inheritedEventCount) ? snapshot.inheritedEventCount : 0
576
+ let found = false
577
+ let evidence = 'none'
578
+ let inboxSeen = false
579
+ let identityConflict = false
580
+ let messageSeq
581
+ let messageTime
582
+ const matchesSource = (message) => {
583
+ const source = message?.source
584
+ return (
585
+ isPlainObject(source) &&
586
+ Object.keys(source).length === 3 &&
587
+ source.kind === 'agent-message' &&
588
+ source.form === 'relay' &&
589
+ source.senderSessionId === request.senderSessionId
590
+ )
591
+ }
592
+ for (const event of snapshot.events ?? []) {
593
+ if (!Number.isSafeInteger(event?.seq) || event.seq < inherited) continue
594
+ if (event.type === 'user/message') {
595
+ const message = event.data
596
+ if (message?.id !== request.durableMessageId) continue
597
+ if (!matchesSource(message)) {
598
+ identityConflict = true
599
+ continue
600
+ }
601
+ found = true
602
+ evidence = 'message'
603
+ if (messageSeq === undefined) {
604
+ messageSeq = event.seq
605
+ messageTime = event.time
606
+ }
607
+ continue
608
+ }
609
+ if (event.type === 'agent/inbox/spliced') {
610
+ const inserted = event.data?.inserted
611
+ if (!Array.isArray(inserted)) continue
612
+ for (const message of inserted) {
613
+ if (message?.id !== request.durableMessageId) continue
614
+ if (!matchesSource(message)) {
615
+ identityConflict = true
616
+ continue
617
+ }
618
+ inboxSeen = true
619
+ found = true
620
+ if (evidence !== 'message') evidence = 'inbox'
621
+ }
622
+ }
623
+ }
624
+ return {
625
+ found,
626
+ readable: true,
627
+ evidence,
628
+ inboxSeen,
629
+ identityConflict,
630
+ ...(messageSeq === undefined ? {} : { seq: messageSeq, time: messageTime }),
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Bounded, read-only history from the raw session log.
636
+ *
637
+ * Compaction replaces surface nodes but never erases the raw log, so a user
638
+ * asking for another session's history may still search text that is no
639
+ * longer in the current model surface. Only user/assistant text blocks are
640
+ * exposed: tool results, system/developer messages, and their contents are
641
+ * never returned. `readSurface` is used only to mark each raw event as
642
+ * current or shadowed.
643
+ * @param {{
644
+ * sessionId: string,
645
+ * mode: 'recent'|'search',
646
+ * query?: string,
647
+ * limit: number,
648
+ * beforeSeq?: number,
649
+ * }} request
650
+ * @returns {Promise<Record<string, any>>}
651
+ */
652
+ async history(request) {
653
+ let snapshot
654
+ try {
655
+ snapshot = await this.ctx.sessionQuery.readSession(request.sessionId)
656
+ } catch (error) {
657
+ throw fail('history_unavailable', 'session history could not be read', { reason: errorCode(error) })
658
+ }
659
+ let surfaceSeqs = null
660
+ try {
661
+ const surface = await this.ctx.sessionQuery.readSurface(request.sessionId)
662
+ surfaceSeqs = new Set(
663
+ (surface.events ?? [])
664
+ .filter((event) => Number.isSafeInteger(event?.seq))
665
+ .map((event) => event.seq),
666
+ )
667
+ } catch {
668
+ // Surface classification is best-effort; raw history remains readable.
669
+ surfaceSeqs = null
670
+ }
671
+
672
+ const entries = []
673
+ for (const event of snapshot.events ?? []) {
674
+ if (!Number.isSafeInteger(event?.seq)) continue
675
+ if (request.beforeSeq !== undefined && event.seq >= request.beforeSeq) continue
676
+ let role
677
+ let text
678
+ if (event.type === 'user/message') {
679
+ role = 'user'
680
+ text = textOfContent(event.data?.content)
681
+ } else if (event.type === 'assistant/message') {
682
+ role = 'assistant'
683
+ text = textOfContent(event.data?.message?.content)
684
+ } else {
685
+ continue
686
+ }
687
+ if (text.length === 0) continue
688
+ entries.push({
689
+ seq: event.seq,
690
+ time: event.time,
691
+ role,
692
+ text,
693
+ surface: surfaceSeqs === null ? 'unknown' : surfaceSeqs.has(event.seq) ? 'current' : 'shadowed',
694
+ })
695
+ }
696
+ // Select newest first so a page is always a contiguous newest slice; the
697
+ // returned order is chronological, and the cursor is the oldest selected
698
+ // seq so the next page cannot repeat an item.
699
+ entries.sort((a, b) => b.seq - a.seq)
700
+
701
+ const session = await this.getSession(request.sessionId, { fresh: false })
702
+ const base = {
703
+ sessionId: request.sessionId,
704
+ title: session?.title ?? request.sessionId,
705
+ workspaceTitle: session?.workspaceTitle ?? null,
706
+ archived: session?.archived === true,
707
+ }
708
+
709
+ if (request.mode === 'search') {
710
+ const needle = request.query.toLowerCase()
711
+ const matches = entries.filter((entry) => entry.text.toLowerCase().includes(needle))
712
+ const selected = []
713
+ let chars = 0
714
+ let truncated = false
715
+ for (const entry of matches) {
716
+ if (selected.length >= request.limit) {
717
+ truncated = true
718
+ break
719
+ }
720
+ const remaining = LIMITS.HISTORY_MAX_CHARS - chars
721
+ if (remaining <= 0) {
722
+ truncated = true
723
+ break
724
+ }
725
+ const fullSnippet = snippetAround(entry.text, needle)
726
+ let snippet = fullSnippet
727
+ if (snippet.length > remaining) {
728
+ snippet = snippet.slice(0, remaining)
729
+ truncated = true
730
+ }
731
+ chars += snippet.length
732
+ selected.push({
733
+ seq: entry.seq,
734
+ time: entry.time,
735
+ role: entry.role,
736
+ surface: entry.surface,
737
+ snippet,
738
+ })
739
+ if (snippet.length < fullSnippet.length) break
740
+ }
741
+ selected.reverse()
742
+ const nextBeforeSeq = selected.length > 0 ? selected[0].seq : null
743
+ return {
744
+ ...base,
745
+ mode: 'search',
746
+ hits: selected,
747
+ total: selected.length,
748
+ truncated,
749
+ chars,
750
+ nextBeforeSeq,
751
+ }
752
+ }
753
+
754
+ const selected = []
755
+ let chars = 0
756
+ let truncated = false
757
+ for (const entry of entries) {
758
+ if (selected.length >= request.limit) {
759
+ truncated = true
760
+ break
761
+ }
762
+ const remaining = LIMITS.HISTORY_MAX_CHARS - chars
763
+ if (remaining <= 0) {
764
+ truncated = true
765
+ break
766
+ }
767
+ let text = entry.text
768
+ if (text.length > remaining) {
769
+ text = text.slice(0, remaining)
770
+ truncated = true
771
+ }
772
+ chars += text.length
773
+ selected.push({
774
+ seq: entry.seq,
775
+ time: entry.time,
776
+ role: entry.role,
777
+ surface: entry.surface,
778
+ text,
779
+ })
780
+ if (text.length < entry.text.length) break
781
+ }
782
+ selected.reverse()
783
+ const nextBeforeSeq = selected.length > 0 ? selected[0].seq : null
784
+ return {
785
+ ...base,
786
+ mode: 'recent',
787
+ messages: selected,
788
+ total: selected.length,
789
+ truncated,
790
+ chars,
791
+ nextBeforeSeq,
792
+ }
793
+ }
794
+ // -------------------------------------------------------------------------
795
+ // Create-flow host boundary (session_link_create)
796
+ //
797
+ // Capability is validated at this call boundary, never in the plugin's
798
+ // mount-time required list: a Host without create/rename/workspace keeps
799
+ // mounting the original interop plugin and only this tool reports
800
+ // `unsupported_host`.
801
+ // -------------------------------------------------------------------------
802
+
803
+ /** @returns {Promise<void>} */
804
+ async assertCreateCapabilities() {
805
+ const missing = []
806
+ const required = [
807
+ ['workspaceRegistry.create', this.ctx.workspaceRegistry?.create],
808
+ ['workspaceRegistry.get', this.ctx.workspaceRegistry?.get],
809
+ ['sessionController.create', this.ctx.sessionController?.create],
810
+ ['sessionController.rename', this.ctx.sessionController?.rename],
811
+ ['sessionQuery.readSession', this.ctx.sessionQuery?.readSession],
812
+ ]
813
+ for (const [name, value] of required) if (typeof value !== 'function') missing.push(name)
814
+ if (missing.length > 0) {
815
+ throw fail('unsupported_host', 'this Host lacks the creation APIs session_link_create requires', {
816
+ missing,
817
+ })
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Read-only existence proof for the pure-random reserved session id. Only
823
+ * an explicit not-found answer means "absent"; any other failure fails
824
+ * closed before a create is attempted.
825
+ * @param {string} sessionId
826
+ * @returns {Promise<boolean>}
827
+ */
828
+ async sessionExists(sessionId) {
829
+ try {
830
+ await this.ctx.sessionQuery.readSession(sessionId)
831
+ return true
832
+ } catch (error) {
833
+ const code = typeof error?.code === 'string' ? error.code : ''
834
+ if (code === 'SESSION_QUERY_SESSION_NOT_FOUND') return false
835
+ throw fail('session_lookup_failed', 'the reserved session id could not be verified', {
836
+ reason: code.length > 0 ? code : errorCode(error),
837
+ })
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Read-only validation of a new-project location: an existing canonical
843
+ * parent directory and an absent single-segment target. No directory is
844
+ * created here; the caller still re-runs this check inside
845
+ * {@link ensureProjectDirectory} to keep the TOCTOU window fail-closed.
846
+ * @param {{ parentDir: string, directoryName: string }} request
847
+ * @returns {Promise<{ parentPath: string, targetPath: string }>}
848
+ */
849
+ async checkProjectLocation(request) {
850
+ const directoryName = assertProjectSegment(request?.directoryName, 'newProject.directoryName')
851
+ const parentDir = typeof request?.parentDir === 'string' ? request.parentDir : ''
852
+ if (parentDir.length === 0 || parentDir.includes('\u0000')) {
853
+ throw fail('invalid_argument', 'newProject.parentDir must be an absolute path')
854
+ }
855
+ let parentPath
856
+ try {
857
+ parentPath = await fs.realpath(parentDir)
858
+ } catch {
859
+ throw fail('parent_missing', 'the parent directory does not exist; create it first', {})
860
+ }
861
+ let parentStat
862
+ try {
863
+ parentStat = await fs.lstat(parentPath)
864
+ } catch {
865
+ throw fail('parent_missing', 'the parent directory is not accessible', {})
866
+ }
867
+ if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
868
+ throw fail('invalid_location', 'the parent path must be a real directory', {})
869
+ }
870
+ const targetPath = path.join(parentPath, directoryName)
871
+ let targetStat
872
+ try {
873
+ targetStat = await fs.lstat(targetPath)
874
+ } catch (error) {
875
+ if (error?.code !== 'ENOENT') {
876
+ throw fail('invalid_location', 'the target path could not be inspected', { reason: errorCode(error) })
877
+ }
878
+ return { parentPath, targetPath }
879
+ }
880
+ if (targetStat.isSymbolicLink()) {
881
+ throw fail('target_exists', 'the target path is a symlink; refusing to take it over', {})
882
+ }
883
+ throw fail('target_exists', 'the target directory already exists; refusing to take it over', {})
884
+ }
885
+
886
+ /**
887
+ * Create the single-segment project directory under an existing parent:
888
+ * non-recursive mkdir, no overwrite, endpoint symlink refused, then a
889
+ * post-create verification. A verification failure keeps the created
890
+ * directory in place - nothing is deleted as rollback.
891
+ * @param {{ parentDir: string, directoryName: string }} request
892
+ * @returns {Promise<{ path: string }>}
893
+ */
894
+ async ensureProjectDirectory(request) {
895
+ const { targetPath } = await this.checkProjectLocation(request)
896
+ try {
897
+ await fs.mkdir(targetPath)
898
+ } catch (error) {
899
+ if (error?.code === 'EEXIST') {
900
+ throw fail('target_exists', 'the target directory already exists; refusing to take it over', {})
901
+ }
902
+ if (error?.code === 'ENOENT') {
903
+ throw fail('parent_missing', 'the parent directory disappeared before creation', {})
904
+ }
905
+ throw fail('directory_create_failed', 'the project directory could not be created', {
906
+ reason: errorCode(error),
907
+ })
908
+ }
909
+ try {
910
+ const stat = await fs.lstat(targetPath)
911
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
912
+ throw fail('target_verification_failed', 'the created path is not a plain directory; verify it manually', {})
913
+ }
914
+ const real = await fs.realpath(targetPath)
915
+ if (real !== targetPath) {
916
+ throw fail('target_verification_failed', 'the created path resolves elsewhere; verify it manually', {})
917
+ }
918
+ } catch (error) {
919
+ if (error?.code === 'target_verification_failed') throw error
920
+ throw fail('target_verification_failed', 'the created directory could not be verified; verify it manually', {
921
+ reason: errorCode(error),
922
+ })
923
+ }
924
+ return { path: targetPath }
925
+ }
926
+
927
+ /**
928
+ * Validate an explicit existing-project directory without reading its
929
+ * contents or creating anything. The registry canonicalizes realpaths and
930
+ * reuses an already-registered identical directory.
931
+ * @param {string} directoryPath
932
+ * @returns {Promise<{ path: string }>}
933
+ */
934
+ async checkExistingProjectPath(directoryPath) {
935
+ if (typeof directoryPath !== 'string' || directoryPath.length === 0 || !directoryPath.startsWith('/') || directoryPath.includes('\u0000')) {
936
+ throw fail('invalid_argument', 'existingProject.path must be an absolute path')
937
+ }
938
+ let real
939
+ try {
940
+ real = await fs.realpath(directoryPath)
941
+ } catch (error) {
942
+ if (error?.code === 'ENOENT') throw fail('project_missing', 'the existing project directory does not exist', {})
943
+ throw fail('invalid_location', 'the existing project path could not be resolved', { reason: errorCode(error) })
944
+ }
945
+ let stat
946
+ try {
947
+ stat = await fs.lstat(real)
948
+ } catch (error) {
949
+ throw fail('invalid_location', 'the existing project path could not be inspected', { reason: errorCode(error) })
950
+ }
951
+ if (!stat.isDirectory()) throw fail('invalid_location', 'the existing project path must be a directory', {})
952
+ return { path: real }
953
+ }
954
+
955
+ /**
956
+ * Register an existing real directory as a workspace. The registry
957
+ * canonicalizes and reuses an identical realpath under host semantics.
958
+ * @param {string} directoryPath
959
+ * @param {string} [title]
960
+ * @returns {Promise<{ id: string, path: string, title: string }>}
961
+ */
962
+ async createWorkspace(directoryPath, title) {
963
+ if (typeof directoryPath !== 'string' || directoryPath.length === 0) {
964
+ throw fail('invalid_argument', 'a workspace path is required')
965
+ }
966
+ let workspace
967
+ try {
968
+ workspace = await this.ctx.workspaceRegistry.create(
969
+ directoryPath,
970
+ typeof title === 'string' && title.length > 0 ? title : undefined,
971
+ )
972
+ } catch (error) {
973
+ throw fail('workspace_register_failed', 'the directory could not be registered as a workspace', {
974
+ reason: typeof error?.code === 'string' ? error.code : errorCode(error),
975
+ })
976
+ }
977
+ if (workspace === undefined || typeof workspace?.id !== 'string') {
978
+ throw fail('workspace_register_failed', 'the workspace registry returned no identity', {})
979
+ }
980
+ return { id: workspace.id, path: workspace.path, title: workspace.title }
981
+ }
982
+
983
+ /**
984
+ * Resolve an existing registered workspace and prove its path is an
985
+ * accessible real directory.
986
+ * @param {string} workspaceId
987
+ * @returns {Promise<{ id: string, path: string, title: string }>}
988
+ */
989
+ async getWorkspace(workspaceId) {
990
+ const workspace = this.ctx.workspaceRegistry.get(workspaceId)
991
+ if (workspace === undefined || workspace === null) {
992
+ throw fail('workspace_not_found', 'the workspace is not registered', { workspaceId })
993
+ }
994
+ try {
995
+ const stat = await fs.stat(workspace.path)
996
+ if (!stat.isDirectory()) throw new Error('not a directory')
997
+ } catch {
998
+ throw fail('workspace_path_unavailable', 'the workspace path is not an accessible directory', { workspaceId })
999
+ }
1000
+ return { id: workspace.id, path: workspace.path, title: workspace.title }
1001
+ }
1002
+
1003
+ /**
1004
+ * Create one ordinary session under the reserved id. The host may return a
1005
+ * different identity when the reserved id is not reusable; an attach
1006
+ * failure still carries the real created id in `details.sessionId`, which
1007
+ * this wrapper surfaces so the journal can keep it.
1008
+ * @param {{ workspaceId: string, sessionId: string }} request
1009
+ * @returns {Promise<{ sessionId: string }>}
1010
+ */
1011
+ async createSession(request) {
1012
+ let created
1013
+ try {
1014
+ created = await this.ctx.sessionController.create({
1015
+ workspaceId: request.workspaceId,
1016
+ sessionId: request.sessionId,
1017
+ })
1018
+ } catch (error) {
1019
+ if (error?.code === 'session/workspace-attach-failed' && typeof error?.details?.sessionId === 'string') {
1020
+ throw fail(
1021
+ 'session_attach_failed',
1022
+ 'the session was created but could not attach to the workspace',
1023
+ { sessionId: error.details.sessionId, workspaceId: request.workspaceId },
1024
+ )
1025
+ }
1026
+ throw error
1027
+ }
1028
+ if (typeof created?.sessionId !== 'string' || created.sessionId.length === 0) {
1029
+ throw fail('create_result_unknown', 'the host returned no session identity', {})
1030
+ }
1031
+ return { sessionId: created.sessionId }
1032
+ }
1033
+
1034
+ /**
1035
+ * Durability barrier after the host reports a created session: a create
1036
+ * return only proves the session was published, not that its state (or a
1037
+ * title) is on disk. Resolve the actual live agent and require a successful
1038
+ * SessionStore flush checkpoint. A false/throw/missing agent is reported as
1039
+ * an actionable partial result and never implies the session may be rebuilt.
1040
+ * @param {string} sessionId
1041
+ * @returns {Promise<{ ok: true } | { ok: false, code: string, message: string }>}
1042
+ */
1043
+ async confirmSessionDurable(sessionId) {
1044
+ let resolved
1045
+ try {
1046
+ resolved = await this.ctx.sessionController.resolveAgent(sessionId)
1047
+ } catch (error) {
1048
+ return {
1049
+ ok: false,
1050
+ code: 'agent_unavailable',
1051
+ message: `the created session's live agent could not be resolved (${errorCode(error)})`,
1052
+ }
1053
+ }
1054
+ if (resolved === undefined || resolved === null || 'error' in resolved || resolved.agent === undefined) {
1055
+ return { ok: false, code: 'agent_unavailable', message: "the created session has no live agent to confirm" }
1056
+ }
1057
+ try {
1058
+ const flushed = await this.ctx.sessions.flush(resolved.agent.session)
1059
+ if (flushed !== true) {
1060
+ return { ok: false, code: 'flush_unconfirmed', message: 'the host durability flush did not confirm the created session' }
1061
+ }
1062
+ } catch (error) {
1063
+ return { ok: false, code: 'flush_unconfirmed', message: `the host durability flush failed (${errorCode(error)})` }
1064
+ }
1065
+ return { ok: true }
1066
+ }
1067
+
1068
+ /**
1069
+ * One-shot public rename. Failure is recorded by the caller and never
1070
+ * blocks the first task message.
1071
+ * @param {string} sessionId
1072
+ * @param {string} title
1073
+ * @returns {Promise<void>}
1074
+ */
1075
+ async renameSession(sessionId, title) {
1076
+ await this.ctx.sessionController.rename({ sessionId, title })
1077
+ }
1078
+
1079
+ /**
1080
+ * Registered workspaces in durable order with the minimal public fields.
1081
+ * @returns {Promise<Array<{ id: string, title: string, path: string }>>}
1082
+ */
1083
+ async listWorkspaces() {
1084
+ return this.ctx.workspaceRegistry.list().map((workspace) => ({
1085
+ id: workspace.id,
1086
+ title: workspace.title,
1087
+ path: workspace.path,
1088
+ }))
1089
+ }
1090
+ }
1091
+
1092
+ /**
1093
+ * @param {unknown} source
1094
+ * @param {string} senderSessionId
1095
+ * @returns {boolean}
1096
+ */
1097
+ function isStandardSource(source, senderSessionId) {
1098
+ if (!isPlainObject(source)) return false
1099
+ const keys = Object.keys(source)
1100
+ return (
1101
+ keys.length === 3 &&
1102
+ keys.every((key) => key === 'kind' || key === 'form' || key === 'senderSessionId') &&
1103
+ source.kind === 'agent-message' &&
1104
+ source.form === 'relay' &&
1105
+ source.senderSessionId === senderSessionId
1106
+ )
1107
+ }
1108
+
1109
+ /** @param {unknown} hostMessage @returns {string} */
1110
+ function digestOf(hostMessage) {
1111
+ return sha256(Buffer.from(JSON.stringify(hostMessage), 'utf8'))
1112
+ }
1113
+
1114
+ /**
1115
+ * Apply one durable inbox splice with the same truncation/clamp rules the host
1116
+ * projection uses, so a cold fold can decide whether one durable id is pending.
1117
+ * @param {Array<Record<string, any>>} list
1118
+ * @param {number} start
1119
+ * @param {number} deleteCount
1120
+ * @param {Array<Record<string, any>>} inserted
1121
+ * @returns {Array<Record<string, any>>}
1122
+ */
1123
+ function spliceMessages(list, start, deleteCount, inserted) {
1124
+ const actualStart = start < 0 ? Math.max(list.length + start, 0) : Math.min(start, list.length)
1125
+ const count = Math.min(Math.max(deleteCount, 0), list.length - actualStart)
1126
+ return [...list.slice(0, actualStart), ...inserted, ...list.slice(actualStart + count)]
1127
+ }
1128
+
1129
+ /**
1130
+ * @param {unknown} content
1131
+ * @returns {string}
1132
+ */
1133
+ function textOfContent(content) {
1134
+ if (!Array.isArray(content)) return ''
1135
+ return content
1136
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
1137
+ .map((block) => block.text)
1138
+ .join('\n')
1139
+ }
1140
+
1141
+ /**
1142
+ * @param {string} text
1143
+ * @param {string} needle
1144
+ * @returns {string}
1145
+ */
1146
+ function snippetAround(text, needle) {
1147
+ const index = text.toLowerCase().indexOf(needle)
1148
+ if (index < 0) return text.slice(0, 500)
1149
+ const start = Math.max(0, index - 160)
1150
+ const end = Math.min(text.length, index + needle.length + 160)
1151
+ return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`.slice(0, 500)
1152
+ }