@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.
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/TESTING.md +59 -0
- package/THIRD_PARTY_NOTICES.md +313 -0
- package/assets/icon.svg +7 -0
- package/cordis.patch.yml +3 -0
- package/dist/client.js +6012 -0
- package/package.json +102 -4
- package/src/core/constants.js +188 -0
- package/src/core/downgrade.js +80 -0
- package/src/core/engine.js +4669 -0
- package/src/core/errors.js +51 -0
- package/src/core/ledger.js +1174 -0
- package/src/core/migration.js +741 -0
- package/src/core/render.js +61 -0
- package/src/core/settings.js +147 -0
- package/src/core/validation.js +454 -0
- package/src/host/adapter.js +1152 -0
- package/src/host/connection.js +113 -0
- package/src/host/entry-0.2.0.js +13 -0
- package/src/host/entry-0.3.0.js +13 -0
- package/src/host/entry-0.4.0.js +13 -0
- package/src/host/entry-0.8.10.js +13 -0
- package/src/host/entry-0.8.11.js +13 -0
- package/src/host/entry-0.8.12.js +13 -0
- package/src/host/entry-0.8.13.js +13 -0
- package/src/host/entry-0.8.14.js +13 -0
- package/src/host/entry-0.8.15.js +13 -0
- package/src/host/entry-0.8.16-pre.1.js +13 -0
- package/src/host/entry-0.8.16-pre.2.js +13 -0
- package/src/host/entry-0.8.16-pre.3.js +13 -0
- package/src/host/entry-0.8.16-pre.4.js +13 -0
- package/src/host/entry-0.8.16-pre.5.js +13 -0
- package/src/host/entry-0.8.18.js +13 -0
- package/src/host/entry-0.8.5.js +13 -0
- package/src/host/entry-0.8.6.js +13 -0
- package/src/host/entry-0.8.7.js +13 -0
- package/src/host/entry-0.8.8.js +13 -0
- package/src/host/entry-0.8.9.js +13 -0
- package/src/host/index.js +74 -0
- package/src/host/plugin.js +403 -0
- package/src/host/prompt.js +22 -0
- 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
|
+
}
|