dsh-bailinghub 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/PRIVACY.md +16 -5
  3. package/README.md +21 -3
  4. package/SECURITY.md +10 -2
  5. package/docs/AGENT_CLIENT_CONTRACT.md +27 -7
  6. package/docs/CAPABILITY_FEEDBACK.md +199 -0
  7. package/docs/COMPATIBILITY.md +19 -6
  8. package/docs/CROSS_SYSTEM_CONVERSATIONS.md +4 -2
  9. package/docs/GENERATED_ARTIFACTS.md +77 -0
  10. package/docs/GETTING_STARTED.md +5 -5
  11. package/docs/GETTING_STARTED.zh-CN.md +5 -5
  12. package/docs/INVOCATION_RECOVERY.md +182 -0
  13. package/docs/MIGRATION_VNEXT.md +8 -0
  14. package/docs/README.zh-CN.md +37 -3
  15. package/docs/RELEASE_NOTES_v0.6.0.en.md +39 -0
  16. package/docs/RELEASE_NOTES_v0.6.0.md +39 -0
  17. package/docs/RELEASE_NOTES_v0.7.0.en.md +15 -0
  18. package/docs/RELEASE_NOTES_v0.7.0.md +15 -0
  19. package/docs/SESSION_TOOL_REUSE.md +213 -0
  20. package/docs/TASK_CONTROL.md +183 -0
  21. package/docs/UPGRADE_v0.6.0.en.md +71 -0
  22. package/docs/UPGRADE_v0.6.0.md +73 -0
  23. package/docs/UPGRADE_v0.7.0.en.md +15 -0
  24. package/docs/UPGRADE_v0.7.0.md +15 -0
  25. package/docs/USAGE_SERVICE.md +96 -0
  26. package/lib/artifact-store.js +56 -0
  27. package/lib/artifacts.js +132 -0
  28. package/lib/capability-feedback.js +138 -0
  29. package/lib/conversation-outbox.js +24 -0
  30. package/lib/index.js +16 -1
  31. package/lib/invocation-journal.js +228 -0
  32. package/lib/invocation-store.js +275 -0
  33. package/lib/model-gateway-transport.js +246 -0
  34. package/lib/runtime.js +951 -146
  35. package/lib/session-scope.js +47 -5
  36. package/lib/session-task-store.js +275 -0
  37. package/lib/session-task.js +237 -0
  38. package/lib/session-tool-cache.js +168 -0
  39. package/lib/session-tools.js +240 -0
  40. package/lib/session-usage-store.js +280 -0
  41. package/lib/tool-catalog.js +38 -0
  42. package/lib/transport.js +19 -0
  43. package/lib/usage-llm-stream.js +157 -0
  44. package/lib/usage-model-transport.js +206 -0
  45. package/lib/usage-provider-stream.js +82 -0
  46. package/package.json +18 -3
@@ -105,9 +105,10 @@ export class SessionScopeCoordinator {
105
105
  // A restored business snapshot is not a current authorization proof.
106
106
  // This in-memory fence never rewrites its identities or CAS revision.
107
107
  entry.retryRequired = Boolean(entry.record?.authorizations.length)
108
- } catch {
108
+ } catch (error) {
109
109
  entry.blocked = true
110
110
  entry.loadFailed = true
111
+ entry.failureCode = error?.code === 'INVALID_SCOPE_SNAPSHOT' ? error.code : 'SCOPE_STORE_UNAVAILABLE'
111
112
  }
112
113
  entry.loaded = true
113
114
  })()
@@ -161,8 +162,12 @@ export class SessionScopeCoordinator {
161
162
  try {
162
163
  const record = this.validate(await this.store.load(entry.sessionId), entry.sessionId)
163
164
  if (JSON.stringify(record) !== JSON.stringify(original)) throw scopeError('SESSION_SCOPE_CONFLICT')
164
- } catch {
165
- if (entry.record === original && entry.generation === generation) entry.blocked = true
165
+ } catch (error) {
166
+ if (entry.record === original && entry.generation === generation) {
167
+ entry.blocked = true
168
+ entry.failureCode = ['SESSION_SCOPE_CONFLICT', 'SCOPE_STORE_CONFLICT', 'INVALID_SCOPE_SNAPSHOT'].includes(error?.code)
169
+ ? error.code : 'SCOPE_STORE_UNAVAILABLE'
170
+ }
166
171
  }
167
172
  }
168
173
 
@@ -360,6 +365,40 @@ export class SessionScopeCoordinator {
360
365
 
361
366
  snapshot(sessionId) { return structuredClone(this.entry(sessionId).record) }
362
367
 
368
+ // Read-only host projection: drafts are valid selections, not dispatch grants.
369
+ // Retain object identity and generation across every await, including A -> B -> A.
370
+ async verifiedSelection(sessionId) {
371
+ const entry = await this.load(this.entry(sessionId))
372
+ const record = entry.record
373
+ const generation = entry.generation
374
+ const settleValidation = async () => {
375
+ // Another read can revalidate the same original group while this reader
376
+ // waits for local I/O. Join that proof; never mistake its temporary closed
377
+ // gate for a changed identity, nor ignore its eventual revocation result.
378
+ while (entry.record === record && entry.generation === generation && this.isValidating(entry)) await entry.validation.promise
379
+ }
380
+ const assertCurrent = () => {
381
+ if (entry.record !== record || entry.generation !== generation) throw scopeError('SESSION_SCOPE_CONFLICT')
382
+ if (entry.pending || entry.blocked || entry.retryRequired || this.isValidating(entry) || record?.state === 'needs_selection') {
383
+ throw scopeError(entry.failureCode ?? 'SESSION_SCOPE_UNAVAILABLE')
384
+ }
385
+ }
386
+ await this.get(sessionId)
387
+ await settleValidation()
388
+ // An identity failure increments generation; preserve its authoritative code.
389
+ if (entry.record === record && entry.generation !== generation && (entry.blocked || entry.retryRequired) && !entry.pending) {
390
+ throw scopeError(entry.failureCode ?? 'SESSION_SCOPE_UNAVAILABLE')
391
+ }
392
+ assertCurrent()
393
+ return { record: structuredClone(record), assertCurrent, check: async () => {
394
+ await settleValidation()
395
+ assertCurrent()
396
+ if (record) await this.checkStored(entry)
397
+ await settleValidation()
398
+ assertCurrent()
399
+ } }
400
+ }
401
+
363
402
  subjectDisplay(sessionId, connectionKey) {
364
403
  return structuredClone(this.entry(sessionId).subjectDisplays?.get(connectionKey) ?? projectSubjectDisplay(null))
365
404
  }
@@ -386,8 +425,11 @@ export class SessionScopeCoordinator {
386
425
  invalidate(sessionId, error) {
387
426
  const entry = this.entry(sessionId)
388
427
  entry.generation += 1
389
- entry.failureCode = error?.code
390
- if (!error || definitiveAuthorizationFailure(error)) entry.blocked = true
428
+ const definitive = !error || definitiveAuthorizationFailure(error)
429
+ entry.failureCode = definitive ? error?.code === 'CROSS_HUB_SCOPE_UNSUPPORTED' ? error.code : 'AUTHORIZATION_CHANGED'
430
+ : error?.name === 'AgentAuthHttpError' && (error.statusCode === 429 || error.statusCode >= 500) ? 'agent_transport_unavailable'
431
+ : error?.publicCode ?? error?.code ?? 'AUTHORIZATION_UNVERIFIED'
432
+ if (definitive) entry.blocked = true
391
433
  else entry.retryRequired = true
392
434
  }
393
435
  }
@@ -0,0 +1,275 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { setTimeout as delay } from 'node:timers/promises'
7
+ import { isProxy } from 'node:util/types'
8
+
9
+ const SCHEMAS = new Set(['bailing.agent-session-task.v1'])
10
+ const RECORD_KEYS = new Set(['schema', 'sessionId', 'revision', 'entries'])
11
+ const MAX_BYTES = 8 * 1024 * 1024
12
+ const LOCK_TIMEOUT_MS = 1_000
13
+ const PRIVATE_KEYS = new Set([
14
+ 'token', 'tokens', 'accesstoken', 'refreshtoken', 'password', 'clientsecret',
15
+ 'authorization', 'credential', 'credentials', 'secret', 'apikey',
16
+ 'args', 'arguments', 'content', 'contents', 'body', 'prompt', 'messages',
17
+ ])
18
+ const MAX_DEPTH = 128
19
+
20
+ function storeError(code = 'TASK_STORE_UNAVAILABLE') {
21
+ const error = new Error(code === 'TASK_STORE_CONFLICT'
22
+ ? 'The task binding changed or is being saved. Read it again before retrying.'
23
+ : 'The task binding store is unavailable. Recovery metadata was not safely confirmed.')
24
+ error.code = code
25
+ return error
26
+ }
27
+
28
+ function safeError(error) {
29
+ return ['TASK_STORE_CONFLICT', 'TASK_STORE_UNAVAILABLE'].includes(error?.code)
30
+ ? error : storeError()
31
+ }
32
+
33
+ function sessionKey(sessionId) {
34
+ if (typeof sessionId !== 'string' || !sessionId.length || sessionId.length > 4_096) throw storeError()
35
+ return createHash('sha256').update(sessionId).digest('hex')
36
+ }
37
+
38
+ // The coordinator validates each original execution binding. This boundary accepts
39
+ // only JSON metadata and refuses credential or business payload fields. Tool names
40
+ // are values, so a tool named "content" or "arguments" remains valid metadata.
41
+ function snapshotJson(value, seen = new Set(), depth = 0) {
42
+ if (depth > MAX_DEPTH) throw storeError()
43
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
44
+ if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) return value
45
+ if (!value || typeof value !== 'object' || seen.has(value) || isProxy(value)) throw storeError()
46
+ const array = Array.isArray(value)
47
+ if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw storeError()
48
+ seen.add(value)
49
+ try {
50
+ const entries = Reflect.ownKeys(value).filter((key) => !array || key !== 'length')
51
+ if (array && (entries.length !== value.length || entries.some((key, index) => key !== String(index)))) throw storeError()
52
+ const copy = array ? [] : {}
53
+ for (const key of entries) {
54
+ const property = Object.getOwnPropertyDescriptor(value, key)
55
+ if (typeof key !== 'string' || !property.enumerable || !Object.hasOwn(property, 'value') ||
56
+ PRIVATE_KEYS.has(key.replace(/[_-]/gu, '').toLowerCase()) || ['__proto__', 'constructor', 'prototype'].includes(key)) throw storeError()
57
+ copy[key] = snapshotJson(property.value, seen, depth + 1)
58
+ }
59
+ return copy
60
+ } finally {
61
+ seen.delete(value)
62
+ }
63
+ }
64
+
65
+ function snapshotRecord(sessionId, record) {
66
+ const copy = snapshotJson(record)
67
+ if (!copy || Array.isArray(copy) || !SCHEMAS.has(copy.schema) || copy.sessionId !== sessionId ||
68
+ !Number.isSafeInteger(copy.revision) || copy.revision < 1 || !Array.isArray(copy.entries) ||
69
+ Object.keys(copy).some((key) => !RECORD_KEYS.has(key))) throw storeError()
70
+ if (Buffer.byteLength(JSON.stringify(copy)) > MAX_BYTES) throw storeError()
71
+ return copy
72
+ }
73
+
74
+ function nextRecord(sessionId, record, expectedRevision) {
75
+ sessionKey(sessionId)
76
+ if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)) throw storeError()
77
+ const copy = snapshotRecord(sessionId, record)
78
+ if (copy.revision !== (expectedRevision ?? 0) + 1) throw storeError()
79
+ return copy
80
+ }
81
+
82
+ function assertRevision(current, expectedRevision) {
83
+ if ((current?.revision ?? null) !== expectedRevision) throw storeError('TASK_STORE_CONFLICT')
84
+ }
85
+
86
+ function assertOwned(stat, kind) {
87
+ if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile()) ||
88
+ (kind === 'file' && stat.nlink !== 1) ||
89
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
90
+ (process.platform !== 'win32' && (stat.mode & 0o777) !== (kind === 'directory' ? 0o700 : 0o600))) throw storeError()
91
+ }
92
+
93
+ function sameFile(a, b) {
94
+ return a.dev === b.dev && a.ino === b.ino
95
+ }
96
+
97
+ function defaultDirectory() {
98
+ const configured = process.env.DSH_HOME
99
+ const base = typeof configured === 'string' && configured.trim() ? configured : join(homedir(), '.dsh')
100
+ const expanded = base === '~' ? homedir() : /^~[/\\]/u.test(base) ? join(homedir(), base.slice(2)) : base
101
+ return join(resolve(expanded), 'plugins', 'dsh-bailinghub', 'session-tasks')
102
+ }
103
+
104
+ async function ensureDirectory(directory, create) {
105
+ if (create) await mkdir(directory, { recursive: true, mode: 0o700 })
106
+ let stat
107
+ try {
108
+ stat = await lstat(directory)
109
+ } catch (error) {
110
+ if (!create && error.code === 'ENOENT') return false
111
+ throw error
112
+ }
113
+ assertOwned(stat, 'directory')
114
+ return true
115
+ }
116
+
117
+ async function readRecord(path, sessionId) {
118
+ let before
119
+ try {
120
+ before = await lstat(path)
121
+ } catch (error) {
122
+ if (error.code === 'ENOENT') return null
123
+ throw error
124
+ }
125
+ assertOwned(before, 'file')
126
+ if (before.size > MAX_BYTES) throw storeError()
127
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
128
+ try {
129
+ const opened = await file.stat()
130
+ assertOwned(opened, 'file')
131
+ if (!sameFile(before, opened) || opened.size > MAX_BYTES) throw storeError()
132
+ // Bound the read even if an abnormal writer grows the file after fstat.
133
+ const bytes = Buffer.alloc(MAX_BYTES + 1)
134
+ let length = 0
135
+ while (length < bytes.length) {
136
+ const result = await file.read(bytes, length, bytes.length - length, length)
137
+ if (!result.bytesRead) break
138
+ length += result.bytesRead
139
+ }
140
+ if (length > MAX_BYTES) throw storeError()
141
+ return snapshotRecord(sessionId, JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length))))
142
+ } finally {
143
+ await file.close()
144
+ }
145
+ }
146
+
147
+ async function acquireLock(path) {
148
+ const deadline = performance.now() + LOCK_TIMEOUT_MS
149
+ while (true) {
150
+ let file
151
+ let stat
152
+ try {
153
+ file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
154
+ stat = await file.stat()
155
+ await file.chmod(0o600)
156
+ assertOwned(await file.stat(), 'file')
157
+ return { file, stat }
158
+ } catch (error) {
159
+ if (file) {
160
+ if (stat) await releaseLock(path, { file, stat })
161
+ else await file.close().catch(() => {})
162
+ }
163
+ if (error.code !== 'EEXIST') throw error
164
+ try {
165
+ assertOwned(await lstat(path), 'file')
166
+ } catch (inspectionError) {
167
+ if (inspectionError.code === 'ENOENT') continue
168
+ throw inspectionError
169
+ }
170
+ if (performance.now() >= deadline) throw storeError('TASK_STORE_CONFLICT')
171
+ // Never infer staleness or remove another process's lock.
172
+ await delay(20)
173
+ }
174
+ }
175
+ }
176
+
177
+ async function releaseLock(path, lock) {
178
+ try {
179
+ const current = await lstat(path)
180
+ if (!sameFile(current, lock.stat) || !current.isFile()) throw storeError()
181
+ await unlink(path)
182
+ } finally {
183
+ await lock.file.close()
184
+ }
185
+ }
186
+
187
+ async function syncDirectory(directory) {
188
+ // Windows does not expose fsync on directory handles through Node.
189
+ if (process.platform === 'win32') return
190
+ const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
191
+ try {
192
+ assertOwned(await handle.stat(), 'directory')
193
+ await handle.sync()
194
+ } finally {
195
+ await handle.close()
196
+ }
197
+ }
198
+
199
+ /** Durable execution metadata only. No user directory is touched until load/save. */
200
+ export function createFileSessionTaskStore({ directory } = {}) {
201
+ if (directory !== undefined && (typeof directory !== 'string' || !directory.trim())) throw storeError()
202
+ const root = directory === undefined ? defaultDirectory() : resolve(directory)
203
+ return {
204
+ async load(sessionId) {
205
+ try {
206
+ const key = sessionKey(sessionId)
207
+ if (!await ensureDirectory(root, false)) return null
208
+ return await readRecord(join(root, `${key}.json`), sessionId)
209
+ } catch (error) {
210
+ throw safeError(error)
211
+ }
212
+ },
213
+ async save(sessionId, record, expectedRevision) {
214
+ let lock
215
+ let temporaryPath
216
+ const key = sessionKey(sessionId)
217
+ const path = join(root, `${key}.json`)
218
+ const lockPath = join(root, `${key}.lock`)
219
+ try {
220
+ const copy = nextRecord(sessionId, record, expectedRevision)
221
+ await ensureDirectory(root, true)
222
+ lock = await acquireLock(lockPath)
223
+ assertRevision(await readRecord(path, sessionId), expectedRevision)
224
+ temporaryPath = join(root, `.${key}.${randomUUID()}.tmp`)
225
+ const temporary = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
226
+ try {
227
+ await temporary.chmod(0o600)
228
+ assertOwned(await temporary.stat(), 'file')
229
+ await temporary.writeFile(JSON.stringify(copy), 'utf8')
230
+ await temporary.sync()
231
+ } finally {
232
+ await temporary.close()
233
+ }
234
+ await rename(temporaryPath, path)
235
+ temporaryPath = undefined
236
+ await syncDirectory(root)
237
+ return snapshotRecord(sessionId, copy)
238
+ } catch (error) {
239
+ throw safeError(error)
240
+ } finally {
241
+ try {
242
+ if (temporaryPath) await unlink(temporaryPath).catch((error) => {
243
+ if (error.code !== 'ENOENT') throw storeError()
244
+ })
245
+ } finally {
246
+ if (lock) {
247
+ try {
248
+ await releaseLock(lockPath, lock)
249
+ } catch {
250
+ throw storeError()
251
+ }
252
+ }
253
+ }
254
+ }
255
+ },
256
+ }
257
+ }
258
+
259
+ /** Explicit in-memory adapter: CAS-compatible, but never survives process restart. */
260
+ export function createMemorySessionTaskStore() {
261
+ const records = new Map()
262
+ return {
263
+ async load(sessionId) {
264
+ sessionKey(sessionId)
265
+ const record = records.get(sessionId)
266
+ return record ? snapshotRecord(sessionId, record) : null
267
+ },
268
+ async save(sessionId, record, expectedRevision) {
269
+ const copy = nextRecord(sessionId, record, expectedRevision)
270
+ assertRevision(records.get(sessionId), expectedRevision)
271
+ records.set(sessionId, copy)
272
+ return snapshotRecord(sessionId, copy)
273
+ },
274
+ }
275
+ }
@@ -0,0 +1,237 @@
1
+ import { memberBinding } from './session-scope.js'
2
+ import { metadataHash, scopeFingerprint } from './invocation-journal.js'
3
+
4
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i
5
+ const HASH = /^[a-f0-9]{64}$/
6
+ const SCHEMA = 'bailing.agent-session-task.v1'
7
+ const TASK_STATES = new Set(['active', 'paused', 'blocked', 'cancelled'])
8
+ const copy = value => structuredClone(value)
9
+
10
+ export function taskError(code) {
11
+ return Object.assign(new Error('The original task binding cannot currently permit this operation.'), {
12
+ publicCode: code,
13
+ feedback: { schema: 'bailing.agent-feedback.v1', origin: 'dsh', operation: 'task_control', code,
14
+ category: code.startsWith('TASK_STORE_') ? 'storage_error' : code === 'TASK_UNSUPPORTED' ? 'unsupported' : 'task_control',
15
+ dispatch: 'not_dispatched', next_action: code.startsWith('TASK_STORE_') ? 'restore_task' : code === 'TASK_UNSUPPORTED' ? 'check_compatibility' : 'inspect_task' },
16
+ })
17
+ }
18
+
19
+ /** Host-only immutable association. It cannot create, control, or enlarge a task. */
20
+ export class SessionTaskCoordinator {
21
+ constructor({ store, scopes, getTransport, conversationId }) {
22
+ if (store != null && (typeof store.load !== 'function' || typeof store.save !== 'function')) throw new TypeError('A task store with load/save is required')
23
+ Object.assign(this, { store, scopes, getTransport, conversationId })
24
+ this.entries = new Map()
25
+ this.queues = new Map()
26
+ this.supportedBindings = new Set()
27
+ }
28
+
29
+ entry(id) {
30
+ if (!this.entries.has(id)) this.entries.set(id, { record: null, pending: null, snapshot: null, failure: null })
31
+ return this.entries.get(id)
32
+ }
33
+
34
+ serial(id, operation) {
35
+ const result = (this.queues.get(id) ?? Promise.resolve()).then(operation)
36
+ this.queues.set(id, result.catch(() => {}))
37
+ return result
38
+ }
39
+
40
+ validate(raw, id) {
41
+ if (!raw || raw.schema !== SCHEMA || raw.sessionId !== id || raw.revision !== 1 ||
42
+ Object.keys(raw).sort().join(',') !== 'entries,revision,schema,sessionId' || !Array.isArray(raw.entries) || raw.entries.length !== 1) throw taskError('TASK_RECORD_INVALID')
43
+ const value = raw.entries[0]
44
+ if (!value || Object.keys(value).sort().join(',') !== 'members,scopeHash,taskBinding' || !HASH.test(value.scopeHash ?? '') ||
45
+ value.taskBinding?.schema_version !== 'bailing.agent-task-binding.v1' || !UUID.test(value.taskBinding.task_id ?? '') ||
46
+ !HASH.test(value.taskBinding.scope_hash ?? '') || Object.keys(value.taskBinding).sort().join(',') !== 'schema_version,scope_hash,task_id' ||
47
+ !Array.isArray(value.members) || !value.members.length || value.members.length > 64 ||
48
+ value.members.some(member => !member || Object.keys(member).sort().join(',') !== 'allowed_tools,client_app_id,client_conversation_id,connectionKey,session_id,workspace' ||
49
+ !/^conn_[a-f0-9]{32}$/.test(member.connectionKey ?? '') || !UUID.test(member.session_id ?? '') ||
50
+ typeof member.client_app_id !== 'string' || typeof member.workspace !== 'string' || member.client_conversation_id !== this.conversationId(id) ||
51
+ !Array.isArray(member.allowed_tools) || member.allowed_tools.some(tool => typeof tool !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(tool)))) throw taskError('TASK_RECORD_INVALID')
52
+ return copy(raw)
53
+ }
54
+
55
+ async read(id) {
56
+ const entry = this.entry(id)
57
+ if (!this.store) return null
58
+ let raw
59
+ try { raw = await this.store.load(id) } catch (error) { throw taskError(error?.code === 'TASK_STORE_CONFLICT' ? error.code : 'TASK_STORE_UNAVAILABLE') }
60
+ const record = raw === null ? null : this.validate(raw, id)
61
+ if (entry.record && metadataHash(entry.record) !== metadataHash(record)) throw taskError('TASK_BINDING_CONFLICT')
62
+ if (record && entry.pending && metadataHash(record) !== metadataHash(entry.pending)) throw taskError('TASK_BINDING_CONFLICT')
63
+ entry.record = record
64
+ return record
65
+ }
66
+
67
+ async verify(id, taskId, selection) {
68
+ if (!selection) {
69
+ const scopeView = await this.scopes.get(id)
70
+ if (!taskId && ['chat', 'unselected'].includes(scopeView.mode)) return { supported: undefined, mode: 'optional', scope: this.scopes.snapshot(id) }
71
+ await this.scopes.assertUsable(id)
72
+ } else selection.assertCurrent()
73
+ const scope = selection?.record ?? this.scopes.snapshot(id)
74
+ const transport = await this.getTransport()
75
+ if (typeof transport.getTaskControlCapabilities !== 'function') {
76
+ if (taskId) throw taskError('TASK_UNSUPPORTED')
77
+ return { supported: false, mode: 'optional', scope }
78
+ }
79
+ const members = []
80
+ let snapshot
81
+ let required = false
82
+ let supported = true
83
+ for (const member of scope.authorizations) {
84
+ selection?.assertCurrent()
85
+ const expectedBinding = { ...memberBinding(scope, member), sessionId: member.sessionId }
86
+ const options = { connectionKey: member.connectionKey, workspace: member.workspace, expectedBinding,
87
+ clientConversationId: this.conversationId(id) }
88
+ // Only protocol support is stable for this exact original binding. The
89
+ // unbound/optional path must still read current enrollment on every call.
90
+ const protocolKey = metadataHash({ connectionKey: member.connectionKey, expectedBinding })
91
+ const supportedProtocol = Boolean(taskId && this.supportedBindings.has(protocolKey))
92
+ let caps
93
+ try { if (!supportedProtocol) caps = await transport.getTaskControlCapabilities(options) }
94
+ catch (error) {
95
+ if (!taskId && error.publicCode === 'TASK_UNSUPPORTED') caps = { supported: false, mode: 'optional' }
96
+ else throw error
97
+ }
98
+ if (!supportedProtocol) {
99
+ if (!caps || typeof caps.supported !== 'boolean' || !['optional', 'required'].includes(caps.mode)) throw taskError('TASK_UNAVAILABLE')
100
+ required ||= caps.mode === 'required'
101
+ supported &&= caps.supported
102
+ }
103
+ if (!taskId) continue
104
+ if ((!supportedProtocol && (!caps.supported || caps.inspect_invocation !== true)) || typeof transport.getTask !== 'function' ||
105
+ typeof transport.inspectInvocation !== 'function') throw taskError('TASK_UNSUPPORTED')
106
+ this.supportedBindings.add(protocolKey)
107
+ const task = await transport.getTask(taskId, options)
108
+ const own = task?.member
109
+ if (task?.schema_version !== 'bailing.agent-task.v1' || task.task_id !== taskId || !HASH.test(task.scope_hash ?? '') ||
110
+ task.member_count !== scope.authorizations.length || !TASK_STATES.has(task.state) || !Number.isSafeInteger(task.revision) ||
111
+ task.metering !== 'write_invocation' || task.snapshot_is_dispatch_permission !== false ||
112
+ !own || own.session_id !== member.sessionId || own.client_app_id !== expectedBinding.clientAppId ||
113
+ own.workspace !== member.workspace || own.client_conversation_id !== options.clientConversationId ||
114
+ !Array.isArray(own.allowed_tools) || new Set(own.allowed_tools).size !== own.allowed_tools.length) throw taskError('TASK_MEMBER_MISMATCH')
115
+ if (snapshot && (task.scope_hash !== snapshot.scope_hash || metadataHash(task.policy) !== metadataHash(snapshot.policy))) throw taskError('TASK_BINDING_CONFLICT')
116
+ members.push({ connectionKey: member.connectionKey, session_id: own.session_id, client_app_id: own.client_app_id,
117
+ workspace: own.workspace, client_conversation_id: own.client_conversation_id, allowed_tools: [...own.allowed_tools].sort() })
118
+ if (!snapshot || task.ledger_sequence >= snapshot.ledger_sequence) snapshot = task
119
+ }
120
+ if (selection) await selection.check()
121
+ else await this.scopes.assertUsable(id)
122
+ return { supported, mode: taskId ? undefined : required ? 'required' : 'optional', scope, snapshot, members: members.sort((a, b) => a.connectionKey.localeCompare(b.connectionKey)) }
123
+ }
124
+
125
+ coordinates(id) {
126
+ return this.serial(id, async () => {
127
+ const selection = await this.scopes.verifiedSelection(id)
128
+ const scope = selection.record
129
+ if (!scope?.authorizations.length) return { state: 'inactive', availability: 'not_checked',
130
+ reason: scope ? 'SESSION_SCOPE_CHAT_ONLY' : 'SESSION_SCOPE_UNSELECTED' }
131
+ if (new Set(scope.authorizations.map(member => member.sessionId.toLowerCase())).size !== scope.authorizations.length) throw taskError('TASK_MEMBER_MISMATCH')
132
+ const entry = this.entry(id)
133
+ if (entry.failure?.startsWith('TASK_STORE_')) throw taskError(entry.failure)
134
+ const record = await this.read(id)
135
+ if (entry.pending || (!record && entry.intendedId)) throw taskError(entry.failure ?? 'TASK_REQUIRED')
136
+ const verified = await this.verify(id, record?.entries[0].taskBinding.task_id, selection)
137
+ if (!verified.supported) throw taskError('TASK_UNSUPPORTED')
138
+ if (record) {
139
+ const expected = { scopeHash: scopeFingerprint(scope), members: verified.members,
140
+ taskBinding: { schema_version: 'bailing.agent-task-binding.v1', task_id: verified.snapshot.task_id, scope_hash: verified.snapshot.scope_hash } }
141
+ if (metadataHash(record.entries[0]) !== metadataHash(expected)) throw taskError('TASK_BINDING_CONFLICT')
142
+ }
143
+ // Catch a concurrent durable binding change; never write or repair sidecars here.
144
+ if (metadataHash(await this.read(id)) !== metadataHash(record)) throw taskError('TASK_BINDING_CONFLICT')
145
+ await selection.check()
146
+ return { state: 'ready', availability: 'supported', sessionId: id,
147
+ clientConversationId: this.conversationId(id), scopeRevision: scope.revision, scopeLocked: scope.locked,
148
+ members: scope.authorizations.map(member => ({ connectionKey: member.connectionKey,
149
+ agentSessionId: member.sessionId, clientAppId: memberBinding(scope, member).clientAppId, workspace: member.workspace })) }
150
+ })
151
+ }
152
+
153
+ async save(id, record) {
154
+ try {
155
+ const saved = await this.store.save(id, copy(record), null)
156
+ if (metadataHash(saved) !== metadataHash(record)) throw taskError('TASK_STORE_UNAVAILABLE')
157
+ } catch (error) { throw taskError(error?.code === 'TASK_STORE_CONFLICT' ? error.code : 'TASK_STORE_UNAVAILABLE') }
158
+ this.entry(id).record = copy(record)
159
+ this.entry(id).pending = null
160
+ }
161
+
162
+ set(id, request) {
163
+ return this.serial(id, async () => {
164
+ const entry = this.entry(id)
165
+ try {
166
+ if (!request || Object.keys(request).join(',') !== 'taskId' || !UUID.test(request.taskId ?? '')) throw taskError('TASK_RECORD_INVALID')
167
+ if (entry.intendedId && entry.intendedId !== request.taskId) throw taskError('TASK_BINDING_CONFLICT')
168
+ entry.intendedId = request.taskId
169
+ if (!this.store) throw taskError('TASK_STORE_UNAVAILABLE')
170
+ if (entry.failure?.startsWith('TASK_STORE_')) throw taskError(entry.failure)
171
+ await this.scopes.begin(id)
172
+ const current = await this.read(id)
173
+ if ((current ?? entry.pending)?.entries[0].taskBinding.task_id !== undefined &&
174
+ (current ?? entry.pending).entries[0].taskBinding.task_id !== request.taskId) throw taskError('TASK_BINDING_CONFLICT')
175
+ const verified = await this.verify(id, request.taskId)
176
+ const record = { schema: SCHEMA, sessionId: id, revision: 1, entries: [{ scopeHash: scopeFingerprint(verified.scope),
177
+ taskBinding: { schema_version: 'bailing.agent-task-binding.v1', task_id: request.taskId, scope_hash: verified.snapshot.scope_hash }, members: verified.members }] }
178
+ if (current && metadataHash(current) !== metadataHash(record)) throw taskError('TASK_BINDING_CONFLICT')
179
+ if (!current) { entry.pending = record; await this.save(id, record) }
180
+ entry.snapshot = verified.snapshot
181
+ entry.supported = true
182
+ entry.failure = null
183
+ return this.view(id)
184
+ } catch (error) { this.fail(id, error); throw error }
185
+ })
186
+ }
187
+
188
+ fail(id, error) {
189
+ const entry = this.entry(id)
190
+ // Persistence failure remains primary until explicit restore succeeds.
191
+ if (!entry.failure?.startsWith('TASK_STORE_')) entry.failure = error.publicCode ?? error.code ?? 'TASK_UNAVAILABLE'
192
+ }
193
+
194
+ refresh(id, restore = false) {
195
+ return this.serial(id, async () => {
196
+ const entry = this.entry(id)
197
+ try {
198
+ if (!restore && entry.failure?.startsWith('TASK_STORE_')) return this.view(id)
199
+ let record = await this.read(id)
200
+ let verified
201
+ if (restore && entry.pending && !record) { verified = await this.verify(id, entry.pending.entries[0].taskBinding.task_id); await this.save(id, entry.pending); record = entry.record }
202
+ if (!record && entry.intendedId) throw taskError(entry.failure ?? 'TASK_REQUIRED')
203
+ verified ??= await this.verify(id, record?.entries[0].taskBinding.task_id)
204
+ entry.supported = verified.supported
205
+ entry.mode = verified.mode
206
+ if (record) {
207
+ const expected = { scopeHash: scopeFingerprint(verified.scope), members: verified.members,
208
+ taskBinding: { schema_version: 'bailing.agent-task-binding.v1', task_id: verified.snapshot.task_id, scope_hash: verified.snapshot.scope_hash } }
209
+ if (metadataHash(record.entries[0]) !== metadataHash(expected)) throw taskError('TASK_BINDING_CONFLICT')
210
+ entry.snapshot = verified.snapshot
211
+ entry.pending = null
212
+ }
213
+ entry.failure = !record && verified.mode === 'required' ? 'TASK_REQUIRED' : null
214
+ } catch (error) { this.fail(id, error) }
215
+ return this.view(id)
216
+ })
217
+ }
218
+
219
+ view(id) {
220
+ const entry = this.entry(id)
221
+ const failure = entry.failure
222
+ const snapshot = entry.snapshot
223
+ return { state: failure ? failure.startsWith('TASK_STORE_') ? 'storage_error' : 'blocked' : snapshot?.state ?? 'inactive',
224
+ availability: failure === 'TASK_UNSUPPORTED' ? 'unsupported' : failure && ['TASK_UNAVAILABLE', 'agent_transport_unavailable', 'agent_request_timeout'].includes(failure) ? 'unavailable' : entry.supported === true ? 'supported' : entry.supported === false ? 'unsupported' : 'unavailable',
225
+ ...(failure ? { reason: failure } : {}), task_binding: copy(entry.record?.entries[0].taskBinding ?? null),
226
+ ...(snapshot ? { task_state: snapshot.state, revision: snapshot.revision, ledger_sequence: snapshot.ledger_sequence,
227
+ policy: copy(snapshot.policy), counters: copy(snapshot.counters), metering: 'write_invocation' } : {}),
228
+ snapshot_is_dispatch_permission: false, object_metering: 'unsupported', unsavedRecords: entry.pending ? 1 : 0 }
229
+ }
230
+
231
+ async binding(id, { dispatch = false } = {}) {
232
+ const view = await this.refresh(id)
233
+ if (view.reason) throw taskError(view.reason)
234
+ if (dispatch && view.task_binding && view.task_state !== 'active') throw taskError({ paused: 'TASK_PAUSED', cancelled: 'TASK_CANCELLED', blocked: 'TASK_SCOPE_BLOCKED' }[view.task_state])
235
+ return view.task_binding
236
+ }
237
+ }