dsh-plugin-weapp-gateway 0.8.23 → 0.8.34

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 (71) hide show
  1. package/LICENSE +21 -21
  2. package/PROTOCOL.md +1545 -1486
  3. package/README.md +503 -464
  4. package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -31
  5. package/bin/setup-ip.mjs +474 -474
  6. package/cordis.patch.yml +50 -50
  7. package/docs/agent-team-app-integration.md +945 -0
  8. package/docs/blog-mobile-gateway.md +542 -542
  9. package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -255
  10. package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -122
  11. package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -52
  12. package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -109
  13. package/docs/dsh-rc2-mobile-integration.md +155 -155
  14. package/docs/file-upload-app-integration.md +250 -0
  15. package/docs/multi-gateway-app-integration.md +141 -125
  16. package/docs/multi-gateway-phase1-acceptance.md +179 -179
  17. package/docs/multi-gateway-todo.md +137 -137
  18. package/docs/remote-gateway-refactor-plan.md +52 -52
  19. package/docs/runtime-architecture.architecture.json +264 -264
  20. package/docs/runtime-architecture.html +15001 -15001
  21. package/docs/runtime-architecture.visual-check.html +32 -32
  22. package/docs/runtime-architecture.visual-check.json +548 -548
  23. package/docs/session-agent-preset-app-integration.md +93 -93
  24. package/docs/turn-outline-app-integration.md +196 -0
  25. package/docs/typert-remote-gateway-feature-checklist.md +294 -294
  26. package/docs/voice-input-implementation.md +219 -219
  27. package/docs/workspace-changes-mobile-integration.md +32 -32
  28. package/helper/dsh_mobile_gateway_helper.py +227 -227
  29. package/lib/account-bridge.mjs +57 -57
  30. package/lib/client.js +1409 -1361
  31. package/lib/cloudflare-tunnel.mjs +314 -314
  32. package/lib/cloudflared-binary.mjs +135 -135
  33. package/lib/context-bridge.mjs +3338 -1715
  34. package/lib/cost-meter-bridge.mjs +794 -794
  35. package/lib/cost-meter-dashboard-bridge.mjs +1655 -1449
  36. package/lib/devices.js +273 -245
  37. package/lib/dsh-host-adapter.mjs +508 -426
  38. package/lib/file-upload.mjs +205 -0
  39. package/lib/gateway-probe.mjs +6 -6
  40. package/lib/gateway-state.mjs +57 -57
  41. package/lib/gateway-tools.mjs +36 -36
  42. package/lib/history-cache.mjs +66 -0
  43. package/lib/index.mjs +4390 -3803
  44. package/lib/plugin-management.mjs +251 -233
  45. package/lib/public-endpoint-check.mjs +61 -61
  46. package/lib/relay-connector.mjs +213 -181
  47. package/lib/relay-state.mjs +83 -76
  48. package/lib/session-follower.mjs +140 -136
  49. package/lib/session-resume-bridge.mjs +86 -0
  50. package/lib/session-target.mjs +29 -0
  51. package/lib/speech-input.mjs +192 -192
  52. package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -123
  53. package/lib/vendor/cost-meter/coding-plans.mjs +1668 -1668
  54. package/lib/vendor/cost-meter/credentials-shim.mjs +8 -8
  55. package/lib/vendor/cost-meter/custom-balance.mjs +281 -281
  56. package/lib/vendor/cost-meter/factory-quota.mjs +69 -69
  57. package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -1006
  58. package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -664
  59. package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -13
  60. package/lib/vendor/cost-meter/net.mjs +183 -183
  61. package/lib/vendor/cost-meter/opendesign.mjs +204 -204
  62. package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -95
  63. package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -44
  64. package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -38
  65. package/lib/vendor/cost-meter/workbuddy.mjs +356 -356
  66. package/lib/web-restart.mjs +47 -47
  67. package/lib/wire-json.mjs +7 -7
  68. package/lib/workspace-changes-bridge.mjs +32 -32
  69. package/package.json +54 -54
  70. package/relay/agent-connector.js +588 -569
  71. package/relay/vendor/tweetnacl.js +2391 -2391
@@ -1,76 +1,83 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import crypto from 'node:crypto'
4
-
5
- // Pairing payload shapes. `direct` keeps the historical QR that advertises the
6
- // gateway's own ws/wss endpoints; `relay` emits the outbound-relay payload the
7
- // WeChat mini-program consumes. Both stay supported and the operator picks one.
8
- export const PAIRING_MODES = ['direct', 'relay']
9
-
10
- const NODE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
11
- const BASE64 = /^[A-Za-z0-9+/]{20,}={0,2}$/
12
-
13
- const isRelay = (value) => value && typeof value === 'object' && !Array.isArray(value)
14
- && typeof value.relay === 'string' && /^wss?:\/\//.test(value.relay)
15
- && typeof value.nodeId === 'string' && NODE_ID.test(value.nodeId)
16
- && typeof value.agentPubKey === 'string' && BASE64.test(value.agentPubKey)
17
- && typeof value.gatewayName === 'string' && value.gatewayName.length > 0
18
- && value.gatewayName.length <= 80
19
- && Number.isSafeInteger(value.updatedAt)
20
-
21
- // A dedicated file keeps the relay registration independent of device
22
- // revocation and of the gateway identity. A malformed file must fail startup
23
- // rather than silently drop a registration the operator relies on.
24
- export function createRelayState(file) {
25
- const validate = (value) => {
26
- if (!value || value.version !== 1) throw new Error('invalid relay state')
27
- if (!PAIRING_MODES.includes(value.pairingMode)) throw new Error('invalid pairing mode')
28
- if (value.relay !== null && !isRelay(value.relay)) throw new Error('invalid relay registration')
29
- return value
30
- }
31
- const read = () => validate(JSON.parse(fs.readFileSync(file, 'utf8')))
32
- const write = (value, initial = false) => {
33
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
34
- const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
35
- try {
36
- fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
37
- if (initial) fs.linkSync(tmp, file) // Never overwrite a concurrently created file.
38
- else fs.renameSync(tmp, file)
39
- } finally {
40
- fs.rmSync(tmp, { force: true })
41
- }
42
- }
43
- let state
44
- try {
45
- try {
46
- state = read()
47
- } catch (error) {
48
- if (error.code !== 'ENOENT') throw error
49
- // `direct` is the historical behaviour, so an existing installation that
50
- // has never seen a relay keeps producing exactly the QR it always did.
51
- state = { version: 1, pairingMode: 'direct', relay: null }
52
- try { write(state, true) } catch (cause) {
53
- if (cause.code !== 'EEXIST') throw cause
54
- state = read()
55
- }
56
- }
57
- fs.chmodSync(file, 0o600)
58
- } catch (error) {
59
- throw new Error(`failed to load relay state ${file}: ${error.message}`, { cause: error })
60
- }
61
- return {
62
- get pairingMode() { return state.pairingMode },
63
- get relay() { return state.relay },
64
- setPairingMode(mode) {
65
- if (!PAIRING_MODES.includes(mode)) throw new TypeError('invalid pairing mode')
66
- const next = { ...state, pairingMode: mode }
67
- write(next)
68
- state = next
69
- },
70
- setRelay(relay) {
71
- const next = { ...state, relay: relay === null ? null : { ...relay, updatedAt: Date.now() } }
72
- write(next)
73
- state = next
74
- },
75
- }
76
- }
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ // Pairing payload shapes. `direct` keeps the historical QR that advertises the
6
+ // gateway's own ws/wss endpoints; `relay` emits the outbound-relay payload the
7
+ // WeChat mini-program consumes. Both stay supported; selecting one only changes
8
+ // the payload emitted for newly generated pairing codes.
9
+ export const PAIRING_MODES = ['direct', 'relay']
10
+
11
+ // Pairing mode chooses the payload for the next QR; a registered relay is an
12
+ // independent live route and must stay up while direct clients are paired.
13
+ export function shouldRunRelayConnector(state) {
14
+ return state?.pairingMode === 'relay' || state?.relay != null
15
+ }
16
+
17
+ const NODE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
18
+ const BASE64 = /^[A-Za-z0-9+/]{20,}={0,2}$/
19
+
20
+ const isRelay = (value) => value && typeof value === 'object' && !Array.isArray(value)
21
+ && typeof value.relay === 'string' && /^wss?:\/\//.test(value.relay)
22
+ && typeof value.nodeId === 'string' && NODE_ID.test(value.nodeId)
23
+ && typeof value.agentPubKey === 'string' && BASE64.test(value.agentPubKey)
24
+ && typeof value.gatewayName === 'string' && value.gatewayName.length > 0
25
+ && value.gatewayName.length <= 80
26
+ && Number.isSafeInteger(value.updatedAt)
27
+
28
+ // A dedicated file keeps the relay registration independent of device
29
+ // revocation and of the gateway identity. A malformed file must fail startup
30
+ // rather than silently drop a registration the operator relies on.
31
+ export function createRelayState(file) {
32
+ const validate = (value) => {
33
+ if (!value || value.version !== 1) throw new Error('invalid relay state')
34
+ if (!PAIRING_MODES.includes(value.pairingMode)) throw new Error('invalid pairing mode')
35
+ if (value.relay !== null && !isRelay(value.relay)) throw new Error('invalid relay registration')
36
+ return value
37
+ }
38
+ const read = () => validate(JSON.parse(fs.readFileSync(file, 'utf8')))
39
+ const write = (value, initial = false) => {
40
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
41
+ const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
42
+ try {
43
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
44
+ if (initial) fs.linkSync(tmp, file) // Never overwrite a concurrently created file.
45
+ else fs.renameSync(tmp, file)
46
+ } finally {
47
+ fs.rmSync(tmp, { force: true })
48
+ }
49
+ }
50
+ let state
51
+ try {
52
+ try {
53
+ state = read()
54
+ } catch (error) {
55
+ if (error.code !== 'ENOENT') throw error
56
+ // `direct` is the historical behaviour, so an existing installation that
57
+ // has never seen a relay keeps producing exactly the QR it always did.
58
+ state = { version: 1, pairingMode: 'direct', relay: null }
59
+ try { write(state, true) } catch (cause) {
60
+ if (cause.code !== 'EEXIST') throw cause
61
+ state = read()
62
+ }
63
+ }
64
+ fs.chmodSync(file, 0o600)
65
+ } catch (error) {
66
+ throw new Error(`failed to load relay state ${file}: ${error.message}`, { cause: error })
67
+ }
68
+ return {
69
+ get pairingMode() { return state.pairingMode },
70
+ get relay() { return state.relay },
71
+ setPairingMode(mode) {
72
+ if (!PAIRING_MODES.includes(mode)) throw new TypeError('invalid pairing mode')
73
+ const next = { ...state, pairingMode: mode }
74
+ write(next)
75
+ state = next
76
+ },
77
+ setRelay(relay) {
78
+ const next = { ...state, relay: relay === null ? null : { ...relay, updatedAt: Date.now() } }
79
+ write(next)
80
+ state = next
81
+ },
82
+ }
83
+ }
@@ -1,136 +1,140 @@
1
- import crypto from 'node:crypto'
2
- import { readSessionSnapshot } from './dsh-host-adapter.mjs'
3
-
4
- function integer(value, minimum = 0) {
5
- return Number.isSafeInteger(value) && value >= minimum
6
- }
7
-
8
- function requireAttempt(attempt) {
9
- if (!attempt || typeof attempt.attemptId !== 'string' || !attempt.attemptId
10
- || !integer(attempt.turn) || !integer(attempt.step)
11
- || !integer(attempt.startedAfterSeq, -1)) throw new Error('invalid assistant attempt')
12
- return attempt
13
- }
14
-
15
- // Validate the two independent clocks. No transient frame owns a Session seq.
16
- export function createFollowDecoder(sessionId) {
17
- let cursor
18
- let revision
19
- let active
20
- return frame => {
21
- if (cursor === undefined) {
22
- const history = readSessionSnapshot(frame, sessionId)
23
- const baseline = frame.assistantStream
24
- if (!baseline || !integer(baseline.revision)) throw new Error('missing assistant stream baseline')
25
- if (baseline.activeAttempt !== undefined) {
26
- const attempt = requireAttempt(baseline.activeAttempt)
27
- if (!integer(attempt.nextIndex) || !Array.isArray(attempt.stream)
28
- || attempt.startedAfterSeq > history.cursor) throw new Error('invalid assistant baseline')
29
- active = { ...attempt }
30
- }
31
- cursor = history.cursor
32
- revision = baseline.revision
33
- return { type: 'snapshot', history, assistantStream: baseline }
34
- }
35
- if (frame?.type === 'event') {
36
- if (!frame.event || frame.event.seq !== cursor + 1) throw new Error('session event sequence gap')
37
- cursor = frame.event.seq
38
- return frame
39
- }
40
- if (frame?.type !== 'assistant-stream') throw new Error('unexpected session follow frame')
41
- const next = frame.frame
42
- if (!next || next.revision !== revision + 1) throw new Error('assistant stream revision gap')
43
- if (next.type === 'start') {
44
- requireAttempt(next)
45
- if (active || next.startedAfterSeq > cursor) throw new Error('unexpected assistant start')
46
- active = { ...next, nextIndex: 0 }
47
- } else {
48
- if (!active || next.attemptId !== active.attemptId || next.index !== active.nextIndex) {
49
- throw new Error('assistant stream attempt/index gap')
50
- }
51
- if (next.type === 'chunk') {
52
- if (!integer(next.time) || !next.chunk || typeof next.chunk.type !== 'string') {
53
- throw new Error('invalid assistant chunk')
54
- }
55
- active.nextIndex += 1
56
- } else if (next.type === 'end') {
57
- const outcome = next.outcome
58
- if (!outcome || (outcome.kind !== 'abandoned' && (outcome.kind !== 'committed'
59
- || !['assistant/message', 'assistant/attempt'].includes(outcome.eventType)
60
- || !integer(outcome.seq) || outcome.seq > cursor || outcome.seq <= active.startedAfterSeq))) {
61
- throw new Error('invalid assistant settlement')
62
- }
63
- } else throw new Error('unexpected assistant frame')
64
- }
65
- revision = next.revision
66
- const normalized = { ...next, turn: active.turn, step: active.step }
67
- if (next.type === 'end') active = undefined
68
- return { type: 'assistant-stream', frame: normalized }
69
- }
70
- }
71
-
72
- function delay(ms, signal) {
73
- return new Promise(resolve => {
74
- if (signal.aborted) return resolve()
75
- const finish = () => {
76
- clearTimeout(timer)
77
- signal.removeEventListener('abort', finish)
78
- resolve()
79
- }
80
- const timer = setTimeout(finish, ms)
81
- signal.addEventListener('abort', finish, { once: true })
82
- })
83
- }
84
-
85
- // One follow per opted-in connection. Each opening owns a new stream identity
86
- // and atomic snapshot, including a reconnecting Agent's active attempt.
87
- export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 }) {
88
- let current
89
- const stop = () => {
90
- const previous = current
91
- current = undefined
92
- previous?.abort.abort()
93
- }
94
- const start = (sessionId, subscriptionId) => {
95
- stop()
96
- const state = { abort: new AbortController() }
97
- current = state
98
- const task = (async () => {
99
- let failures = 0
100
- while (current === state && !state.abort.signal.aborted) {
101
- const attemptAbort = new AbortController()
102
- const signal = AbortSignal.any([state.abort.signal, attemptAbort.signal])
103
- const streamId = crypto.randomUUID()
104
- const context = { sessionId, subscriptionId, streamId }
105
- const decode = createFollowDecoder(sessionId)
106
- let iterator
107
- let permanent = false
108
- try {
109
- const stream = await api.openSessionStream(sessionId, signal)
110
- iterator = stream[Symbol.asyncIterator]()
111
- while (current === state && !signal.aborted) {
112
- const item = await iterator.next()
113
- if (current !== state || signal.aborted) break
114
- if (item.done) throw new Error('session follow ended')
115
- const decoded = decode(item.value)
116
- onFrame(decoded, context)
117
- }
118
- } catch (error) {
119
- if (current !== state || signal.aborted) break
120
- permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request'].includes(error.code)
121
- || (error.code === 'SESSION_QUERY_PERSISTENCE_FAILED'
122
- && /refuses this format .*Session/.test(error.message || ''))
123
- onError(error, { ...context, retrying: !permanent })
124
- } finally {
125
- // Abort before return: the producer may be waiting for another event.
126
- attemptAbort.abort()
127
- try { await iterator?.return?.() } catch { /* The opening failure was already reported. */ }
128
- }
129
- if (permanent) break
130
- await delay(Math.min(retryMs * 2 ** Math.min(failures++, 5), 30_000), state.abort.signal)
131
- }
132
- })()
133
- return task
134
- }
135
- return { start, stop }
136
- }
1
+ import crypto from 'node:crypto'
2
+ import { readSessionSnapshot } from './dsh-host-adapter.mjs'
3
+
4
+ function integer(value, minimum = 0) {
5
+ return Number.isSafeInteger(value) && value >= minimum
6
+ }
7
+
8
+ function requireAttempt(attempt) {
9
+ if (!attempt || typeof attempt.attemptId !== 'string' || !attempt.attemptId
10
+ || !integer(attempt.turn) || !integer(attempt.step)
11
+ || !integer(attempt.startedAfterSeq, -1)) throw new Error('invalid assistant attempt')
12
+ return attempt
13
+ }
14
+
15
+ // Validate the two independent clocks. No transient frame owns a Session seq.
16
+ export function createFollowDecoder(sessionId) {
17
+ let cursor
18
+ let revision
19
+ let active
20
+ return frame => {
21
+ if (cursor === undefined) {
22
+ const history = readSessionSnapshot(frame, sessionId)
23
+ const baseline = frame.assistantStream
24
+ if (!baseline || !integer(baseline.revision)) throw new Error('missing assistant stream baseline')
25
+ if (baseline.activeAttempt !== undefined) {
26
+ const attempt = requireAttempt(baseline.activeAttempt)
27
+ if (!integer(attempt.nextIndex) || !Array.isArray(attempt.stream)
28
+ || attempt.startedAfterSeq > history.cursor) throw new Error('invalid assistant baseline')
29
+ active = { ...attempt }
30
+ }
31
+ cursor = history.cursor
32
+ revision = baseline.revision
33
+ return { type: 'snapshot', history, assistantStream: baseline }
34
+ }
35
+ if (frame?.type === 'event') {
36
+ if (!frame.event || frame.event.seq !== cursor + 1) throw new Error('session event sequence gap')
37
+ cursor = frame.event.seq
38
+ return frame
39
+ }
40
+ if (frame?.type !== 'assistant-stream') throw new Error('unexpected session follow frame')
41
+ const next = frame.frame
42
+ if (!next || next.revision !== revision + 1) throw new Error('assistant stream revision gap')
43
+ if (next.type === 'start') {
44
+ requireAttempt(next)
45
+ if (active || next.startedAfterSeq > cursor) throw new Error('unexpected assistant start')
46
+ active = { ...next, nextIndex: 0 }
47
+ } else {
48
+ if (!active || next.attemptId !== active.attemptId || next.index !== active.nextIndex) {
49
+ throw new Error('assistant stream attempt/index gap')
50
+ }
51
+ if (next.type === 'chunk') {
52
+ if (!integer(next.time) || !next.chunk || typeof next.chunk.type !== 'string') {
53
+ throw new Error('invalid assistant chunk')
54
+ }
55
+ active.nextIndex += 1
56
+ } else if (next.type === 'end') {
57
+ const outcome = next.outcome
58
+ if (!outcome || (outcome.kind !== 'abandoned' && (outcome.kind !== 'committed'
59
+ || !['assistant/message', 'assistant/attempt'].includes(outcome.eventType)
60
+ || !integer(outcome.seq) || outcome.seq > cursor || outcome.seq <= active.startedAfterSeq))) {
61
+ throw new Error('invalid assistant settlement')
62
+ }
63
+ } else throw new Error('unexpected assistant frame')
64
+ }
65
+ revision = next.revision
66
+ const normalized = { ...next, turn: active.turn, step: active.step }
67
+ if (next.type === 'end') active = undefined
68
+ return { type: 'assistant-stream', frame: normalized }
69
+ }
70
+ }
71
+
72
+ function delay(ms, signal) {
73
+ return new Promise(resolve => {
74
+ if (signal.aborted) return resolve()
75
+ const finish = () => {
76
+ clearTimeout(timer)
77
+ signal.removeEventListener('abort', finish)
78
+ resolve()
79
+ }
80
+ const timer = setTimeout(finish, ms)
81
+ signal.addEventListener('abort', finish, { once: true })
82
+ })
83
+ }
84
+
85
+ // One follow per opted-in connection. Each opening owns a new stream identity
86
+ // and atomic snapshot, including a reconnecting Agent's active attempt.
87
+ export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 }) {
88
+ let current
89
+ const stop = () => {
90
+ const previous = current
91
+ current = undefined
92
+ previous?.abort.abort()
93
+ }
94
+ const start = (targetValue, subscriptionId, afterSeq) => {
95
+ stop()
96
+ const target = typeof targetValue === 'string' ? { sessionId: targetValue } : targetValue
97
+ const sessionId = target?.sessionId
98
+ if (typeof sessionId !== 'string' || !sessionId) throw new Error('session follower requires a sessionId')
99
+ const state = { abort: new AbortController() }
100
+ current = state
101
+ const task = (async () => {
102
+ let failures = 0
103
+ while (current === state && !state.abort.signal.aborted) {
104
+ const attemptAbort = new AbortController()
105
+ const signal = AbortSignal.any([state.abort.signal, attemptAbort.signal])
106
+ const streamId = crypto.randomUUID()
107
+ const context = { ...target, sessionId, subscriptionId, streamId,
108
+ ...(Number.isSafeInteger(afterSeq) && afterSeq >= 0 ? { afterSeq } : {}) }
109
+ const decode = createFollowDecoder(sessionId)
110
+ let iterator
111
+ let permanent = false
112
+ try {
113
+ const stream = await api.openSessionStream(target.parentSessionId ? target : sessionId, signal)
114
+ iterator = stream[Symbol.asyncIterator]()
115
+ while (current === state && !signal.aborted) {
116
+ const item = await iterator.next()
117
+ if (current !== state || signal.aborted) break
118
+ if (item.done) throw new Error('session follow ended')
119
+ const decoded = decode(item.value)
120
+ onFrame(decoded, context)
121
+ }
122
+ } catch (error) {
123
+ if (current !== state || signal.aborted) break
124
+ permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request'].includes(error.code)
125
+ || (error.code === 'SESSION_QUERY_PERSISTENCE_FAILED'
126
+ && /refuses this format .*Session/.test(error.message || ''))
127
+ onError(error, { ...context, retrying: !permanent })
128
+ } finally {
129
+ // Abort before return: the producer may be waiting for another event.
130
+ attemptAbort.abort()
131
+ try { await iterator?.return?.() } catch { /* The opening failure was already reported. */ }
132
+ }
133
+ if (permanent) break
134
+ await delay(Math.min(retryMs * 2 ** Math.min(failures++, 5), 30_000), state.abort.signal)
135
+ }
136
+ })()
137
+ return task
138
+ }
139
+ return { start, stop }
140
+ }
@@ -0,0 +1,86 @@
1
+ const DEFAULT_LIMITS = Object.freeze({
2
+ maxSessions: 32,
3
+ maxEventsPerSession: 2048,
4
+ maxBytesPerSession: 512 * 1024,
5
+ maxReplayEvents: 256,
6
+ maxReplayBytes: 256 * 1024,
7
+ maxAgeMs: 60 * 60 * 1000,
8
+ })
9
+
10
+ function byteLength(value) {
11
+ return Buffer.byteLength(JSON.stringify(value), 'utf8')
12
+ }
13
+
14
+ // Keep only bounded wire-safe events. Recovery succeeds only when the entire
15
+ // sequence interval is present; otherwise callers must trust a fresh snapshot.
16
+ export function createSessionReplay(options = {}) {
17
+ const limits = { ...DEFAULT_LIMITS, ...options }
18
+ const sessions = new Map()
19
+
20
+ function prune(now = Date.now()) {
21
+ for (const [sessionId, entry] of sessions) {
22
+ if (now - entry.touchedAt > limits.maxAgeMs) sessions.delete(sessionId)
23
+ }
24
+ while (sessions.size > limits.maxSessions) sessions.delete(sessions.keys().next().value)
25
+ }
26
+
27
+ function append(sessionId, frame) {
28
+ if (typeof sessionId !== 'string' || !sessionId || !Number.isSafeInteger(frame?.seq) || frame.seq < 0) return false
29
+ const now = Date.now()
30
+ prune(now)
31
+ let entry = sessions.get(sessionId)
32
+ if (!entry) entry = { events: [], bytes: 0, touchedAt: now }
33
+ const copy = { ...frame }
34
+ const bytes = byteLength(copy)
35
+ if (bytes > limits.maxBytesPerSession) return false
36
+ const last = entry.events[entry.events.length - 1]
37
+ if (!last || frame.seq > last.seq) {
38
+ entry.events.push({ seq: frame.seq, frame: copy, bytes })
39
+ } else {
40
+ const existing = entry.events.findIndex(item => item.seq === frame.seq)
41
+ if (existing >= 0) {
42
+ entry.bytes -= entry.events[existing].bytes
43
+ entry.events.splice(existing, 1)
44
+ }
45
+ let low = 0, high = entry.events.length
46
+ while (low < high) {
47
+ const middle = (low + high) >> 1
48
+ if (entry.events[middle].seq < frame.seq) low = middle + 1
49
+ else high = middle
50
+ }
51
+ entry.events.splice(low, 0, { seq: frame.seq, frame: copy, bytes })
52
+ }
53
+ entry.bytes += bytes
54
+ entry.touchedAt = now
55
+ while (entry.events.length > limits.maxEventsPerSession || entry.bytes > limits.maxBytesPerSession) {
56
+ const removed = entry.events.shift()
57
+ entry.bytes -= removed.bytes
58
+ }
59
+ sessions.delete(sessionId)
60
+ sessions.set(sessionId, entry)
61
+ prune(now)
62
+ return true
63
+ }
64
+
65
+ function recover(sessionId, afterSeq, throughSeq) {
66
+ const invalid = { status: 'snapshot', events: [] }
67
+ if (!Number.isSafeInteger(afterSeq) || afterSeq < 0 || !Number.isSafeInteger(throughSeq) || throughSeq < afterSeq) return invalid
68
+ if (afterSeq === throughSeq) return { status: 'replayed', events: [], fromSeq: afterSeq, toSeq: throughSeq }
69
+ if (throughSeq - afterSeq > limits.maxReplayEvents) return invalid
70
+ prune()
71
+ const entry = sessions.get(sessionId)
72
+ if (!entry) return invalid
73
+ const picked = entry.events.filter(item => item.seq > afterSeq && item.seq <= throughSeq)
74
+ if (picked.length !== throughSeq - afterSeq) return invalid
75
+ for (let i = 0; i < picked.length; i += 1) {
76
+ if (picked[i].seq !== afterSeq + i + 1) return invalid
77
+ }
78
+ const events = picked.map(item => item.frame)
79
+ if (byteLength(events) > limits.maxReplayBytes) return invalid
80
+ entry.touchedAt = Date.now()
81
+ return { status: 'replayed', events, fromSeq: afterSeq, toSeq: throughSeq }
82
+ }
83
+
84
+ return { append, recover }
85
+ }
86
+
@@ -0,0 +1,29 @@
1
+ // 移动端保留 sessionId 为当前视图 ID,父子关系只作为显式路由地址。
2
+ export function parseSessionTarget(msg, { optional = false } = {}) {
3
+ const fail = message => ({ error: { kind: 'error', code: 'bad-request', requestType: msg.type, message,
4
+ ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}) } })
5
+ const sessionId = typeof msg.sessionId === 'string' ? msg.sessionId.trim() : ''
6
+ const hasChildAddress = msg.parentSessionId !== undefined || msg.subagentMode !== undefined
7
+ if (!sessionId && (!optional || hasChildAddress)) return fail('sessionId must be a non-empty string')
8
+ if (!hasChildAddress) return { value: sessionId ? { sessionId } : {} }
9
+ const parentSessionId = typeof msg.parentSessionId === 'string' ? msg.parentSessionId.trim() : ''
10
+ if (!parentSessionId || parentSessionId === sessionId) return fail('parentSessionId must identify a different direct parent')
11
+ if (!['continuable', 'one-shot', 'unknown'].includes(msg.subagentMode)) {
12
+ return fail('subagentMode must be continuable, one-shot, or unknown')
13
+ }
14
+ return { value: { sessionId, parentSessionId, subagentMode: msg.subagentMode } }
15
+ }
16
+
17
+ export function sessionAddress(target) {
18
+ return target.parentSessionId === undefined
19
+ ? { kind: 'session', sessionId: target.sessionId }
20
+ : { kind: 'subagent', parentSessionId: target.parentSessionId,
21
+ childSessionId: target.sessionId, mode: target.subagentMode }
22
+ }
23
+
24
+ export const TEAM_PROJECTION_KINDS = {
25
+ agentTeam: 'agent-team-updated',
26
+ subagentCatalog: 'subagents-updated',
27
+ subagent: 'subagent-identity-updated',
28
+ subagentTiming: 'subagent-timing-updated',
29
+ }