@shendeguize/dsh-agent-sidecar 0.1.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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
package/src/send-cli.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar `send` CLI executor — injection path two of design §4.d
|
|
3
|
+
* (.local/tasks/make_dsh_mode/design/dsh_plugin_design.md): external agents
|
|
4
|
+
* (claude / codex / cursor-cli) are reached by spawning
|
|
5
|
+
* `agent-sidecar send <session_prefix> --message-stdin --allow-write --json`.
|
|
6
|
+
*
|
|
7
|
+
* Contract facts verified against sidecar/cli.py and sidecar/inject.py:
|
|
8
|
+
*
|
|
9
|
+
* - The send subcommand takes the target as the `session_prefix` positional;
|
|
10
|
+
* there is no `--agent`, `--session`, or `--mode` flag. The queue/steer
|
|
11
|
+
* distinction only exists on the dsh in-process path — a sidecar send is
|
|
12
|
+
* always a one-shot headless resume, so `mode` is logged but not forwarded.
|
|
13
|
+
* - The message NEVER enters argv: it is written to the child's stdin and the
|
|
14
|
+
* write end is closed (`--message-stdin`, S7).
|
|
15
|
+
* - `--json` prints one `SendResult.to_dict()` object on stdout:
|
|
16
|
+
* `{agent, session_id, outcome, delivery, returncode, response, stderr,
|
|
17
|
+
* error_code, request_id, replayed}` with `delivery ∈ {delivered, unknown}`.
|
|
18
|
+
* `delivery` is the authoritative signal: `delivered` → outcome 'delivered',
|
|
19
|
+
* `unknown` → outcome 'unknown' (terminal; S6 — never retried).
|
|
20
|
+
* - Exit codes: 0 completed / 1 non-completed result / 2 usage (SendError) /
|
|
21
|
+
* 130 interrupted. Used only as a fallback when stdout is not a receipt.
|
|
22
|
+
* - `--timeout` accepts 1..900 seconds (MAX_SEND_TIMEOUT_SECONDS).
|
|
23
|
+
*
|
|
24
|
+
* Pure DI: no cordis/dsh imports. index.ts adapts `ctx.subprocess.spawn`
|
|
25
|
+
* into {@link SpawnLike}; tests adapt `node:child_process`. The log callback
|
|
26
|
+
* never receives the message body (S8).
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { InjectExecutionRequest, InjectExecutor, InjectResult } from './inject-gateway.ts'
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Spawn seam.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Minimal handle over one spawned CLI process. Both `ctx.subprocess.spawn`
|
|
39
|
+
* and `node:child_process.spawn` adapt onto this surface.
|
|
40
|
+
*
|
|
41
|
+
* Adapter obligations:
|
|
42
|
+
* - `exited` settles with the exit code (null when signal-killed) and
|
|
43
|
+
* rejects when the process could not be started at all (ENOENT et al.).
|
|
44
|
+
* - `stdin` write/end must not throw asynchronously for a dead pipe
|
|
45
|
+
* (swallow EPIPE-style errors; the failure surfaces via `exited`).
|
|
46
|
+
*/
|
|
47
|
+
export interface SpawnedProcess {
|
|
48
|
+
stdin: {
|
|
49
|
+
write(chunk: Uint8Array | string): void
|
|
50
|
+
end(): void
|
|
51
|
+
}
|
|
52
|
+
onStdout(listener: (chunk: Uint8Array | string) => void): void
|
|
53
|
+
onStderr(listener: (chunk: Uint8Array | string) => void): void
|
|
54
|
+
/** Exit code (null for signal death); rejects on spawn failure. */
|
|
55
|
+
readonly exited: Promise<number | null>
|
|
56
|
+
/** Best-effort hard kill, used by the process-level timeout. */
|
|
57
|
+
kill(): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type SpawnLike = (argv: readonly string[]) => SpawnedProcess
|
|
61
|
+
|
|
62
|
+
export type SendCliLogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
63
|
+
|
|
64
|
+
export type SendCliLog = (
|
|
65
|
+
level: SendCliLogLevel,
|
|
66
|
+
msg: string,
|
|
67
|
+
meta?: Record<string, unknown>,
|
|
68
|
+
) => void
|
|
69
|
+
|
|
70
|
+
export interface SendCliOptions {
|
|
71
|
+
/** Sidecar CLI invocation prefix; argv, never a shell string. */
|
|
72
|
+
command?: readonly string[]
|
|
73
|
+
/**
|
|
74
|
+
* Budget forwarded to `send --timeout` as floor(timeoutMs / 1000),
|
|
75
|
+
* clamped into the CLI's accepted 1..900s range. Default 30000.
|
|
76
|
+
*/
|
|
77
|
+
timeoutMs?: number
|
|
78
|
+
/**
|
|
79
|
+
* Extra slack on top of {@link timeoutMs} before the process-level hard
|
|
80
|
+
* kill fires. Spec default 5000; injectable so tests stay fast.
|
|
81
|
+
*/
|
|
82
|
+
hardTimeoutBufferMs?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Tunables.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_SEND_CLI_COMMAND: readonly string[] = Object.freeze(['agent-sidecar'])
|
|
90
|
+
export const DEFAULT_SEND_TIMEOUT_MS = 30_000
|
|
91
|
+
export const HARD_TIMEOUT_BUFFER_MS = 5_000
|
|
92
|
+
/** Detail cap for collected stderr (2 KiB). */
|
|
93
|
+
export const STDERR_DETAIL_BYTES = 2 * 1024
|
|
94
|
+
|
|
95
|
+
/** send --json responses are ≤4 MiB; anything past this is garbage. */
|
|
96
|
+
const MAX_STDOUT_BYTES = 8 * 1024 * 1024
|
|
97
|
+
/** sidecar/inject.py MAX_SEND_TIMEOUT_SECONDS. */
|
|
98
|
+
const MAX_CLI_TIMEOUT_SECONDS = 900
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Internals.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Byte-bounded chunk accumulator; excess input is dropped, not buffered. */
|
|
105
|
+
class BoundedCollector {
|
|
106
|
+
private readonly chunks: Buffer[] = []
|
|
107
|
+
private size = 0
|
|
108
|
+
|
|
109
|
+
constructor(private readonly limit: number) {}
|
|
110
|
+
|
|
111
|
+
append(chunk: Uint8Array | string): void {
|
|
112
|
+
if (this.size >= this.limit) return
|
|
113
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk)
|
|
114
|
+
const room = this.limit - this.size
|
|
115
|
+
const kept = buf.byteLength > room ? buf.subarray(0, room) : buf
|
|
116
|
+
this.chunks.push(Buffer.from(kept))
|
|
117
|
+
this.size += kept.byteLength
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get bytes(): number {
|
|
121
|
+
return this.size
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
text(): string {
|
|
125
|
+
return Buffer.concat(this.chunks).toString('utf8')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface ParsedReceipt {
|
|
130
|
+
delivery: 'delivered' | 'unknown'
|
|
131
|
+
errorCode?: string
|
|
132
|
+
replayed: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse stdout as one `send --json` receipt. Anything that is not a JSON
|
|
137
|
+
* object carrying a valid `delivery` field yields null (exit-code fallback).
|
|
138
|
+
*/
|
|
139
|
+
function parseReceipt(stdoutText: string): ParsedReceipt | null {
|
|
140
|
+
const trimmed = stdoutText.trim()
|
|
141
|
+
if (!trimmed) return null
|
|
142
|
+
let value: unknown
|
|
143
|
+
try {
|
|
144
|
+
value = JSON.parse(trimmed)
|
|
145
|
+
} catch {
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
|
149
|
+
const record = value as Record<string, unknown>
|
|
150
|
+
const delivery = record['delivery']
|
|
151
|
+
if (delivery !== 'delivered' && delivery !== 'unknown') return null
|
|
152
|
+
const rawErrorCode = record['error_code']
|
|
153
|
+
const errorCode =
|
|
154
|
+
typeof rawErrorCode === 'string' && rawErrorCode !== '' ? rawErrorCode : undefined
|
|
155
|
+
return {
|
|
156
|
+
delivery,
|
|
157
|
+
...(errorCode !== undefined ? { errorCode } : {}),
|
|
158
|
+
replayed: record['replayed'] === true,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function describeError(error: unknown): string {
|
|
163
|
+
return error instanceof Error ? error.message : String(error)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Executor.
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
type Settled =
|
|
171
|
+
| { kind: 'exit'; code: number | null }
|
|
172
|
+
| { kind: 'spawn-error'; error: unknown }
|
|
173
|
+
| { kind: 'timeout' }
|
|
174
|
+
|
|
175
|
+
export function createSendCliExecutor(deps: {
|
|
176
|
+
spawn: SpawnLike
|
|
177
|
+
log?: SendCliLog
|
|
178
|
+
opts?: SendCliOptions
|
|
179
|
+
}): InjectExecutor {
|
|
180
|
+
const command = deps.opts?.command ?? DEFAULT_SEND_CLI_COMMAND
|
|
181
|
+
const timeoutMs = deps.opts?.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS
|
|
182
|
+
const bufferMs = deps.opts?.hardTimeoutBufferMs ?? HARD_TIMEOUT_BUFFER_MS
|
|
183
|
+
const log: SendCliLog = deps.log ?? (() => {})
|
|
184
|
+
|
|
185
|
+
const cliTimeoutSecs = Math.min(
|
|
186
|
+
MAX_CLI_TIMEOUT_SECONDS,
|
|
187
|
+
Math.max(1, Math.floor(timeoutMs / 1000)),
|
|
188
|
+
)
|
|
189
|
+
const hardTimeoutMs = timeoutMs + bufferMs
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
kind: 'send-cli',
|
|
193
|
+
async execute(req: InjectExecutionRequest): Promise<InjectResult> {
|
|
194
|
+
// Target = session_prefix positional; message deliberately absent.
|
|
195
|
+
// M2 review F-4 (recorded, unchanged): the positional is not fenced
|
|
196
|
+
// behind `--`, so a hypothetical '-'-leading sessionId would be read
|
|
197
|
+
// as a flag — every such misparse fail-closes as a usage error
|
|
198
|
+
// (exit 2), and the id comes from local session-file scans inside the
|
|
199
|
+
// machine trust boundary. Reordering to `send [flags] -- <prefix>`
|
|
200
|
+
// is a CLI-contract change (argparse `--` handling must be verified
|
|
201
|
+
// across the supported Python range first) deferred to its own PR.
|
|
202
|
+
const argv: string[] = [
|
|
203
|
+
...command,
|
|
204
|
+
'send',
|
|
205
|
+
req.target.sessionId,
|
|
206
|
+
'--message-stdin',
|
|
207
|
+
'--allow-write',
|
|
208
|
+
'--json',
|
|
209
|
+
'--request-id',
|
|
210
|
+
req.requestId,
|
|
211
|
+
'--timeout',
|
|
212
|
+
String(cliTimeoutSecs),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
// `mode` has no CLI flag: a sidecar send is a one-shot headless resume.
|
|
216
|
+
log('debug', 'spawning sidecar send CLI', {
|
|
217
|
+
requestId: req.requestId,
|
|
218
|
+
agent: req.target.agent,
|
|
219
|
+
sessionId: req.target.sessionId,
|
|
220
|
+
mode: req.mode,
|
|
221
|
+
timeoutSecs: cliTimeoutSecs,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
let proc: SpawnedProcess
|
|
225
|
+
try {
|
|
226
|
+
proc = deps.spawn(argv)
|
|
227
|
+
} catch (error) {
|
|
228
|
+
log('warn', 'send CLI spawn failed', {
|
|
229
|
+
requestId: req.requestId,
|
|
230
|
+
error: describeError(error),
|
|
231
|
+
})
|
|
232
|
+
return { outcome: 'failed', errorCode: 'cli_not_found', detail: describeError(error) }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const stdout = new BoundedCollector(MAX_STDOUT_BYTES)
|
|
236
|
+
const stderr = new BoundedCollector(STDERR_DETAIL_BYTES)
|
|
237
|
+
proc.onStdout((chunk) => stdout.append(chunk))
|
|
238
|
+
proc.onStderr((chunk) => stderr.append(chunk))
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
proc.stdin.write(Buffer.from(req.message, 'utf8'))
|
|
242
|
+
proc.stdin.end()
|
|
243
|
+
} catch {
|
|
244
|
+
// A dead stdin pipe means the process is already gone; the exit /
|
|
245
|
+
// spawn-error path below reports the authoritative failure.
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
249
|
+
const settled = await Promise.race<Settled>([
|
|
250
|
+
proc.exited.then(
|
|
251
|
+
(code): Settled => ({ kind: 'exit', code }),
|
|
252
|
+
(error: unknown): Settled => ({ kind: 'spawn-error', error }),
|
|
253
|
+
),
|
|
254
|
+
new Promise<Settled>((resolve) => {
|
|
255
|
+
timer = setTimeout(() => resolve({ kind: 'timeout' }), hardTimeoutMs)
|
|
256
|
+
}),
|
|
257
|
+
])
|
|
258
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
259
|
+
|
|
260
|
+
const stderrText = stderr.text()
|
|
261
|
+
|
|
262
|
+
if (settled.kind === 'timeout') {
|
|
263
|
+
try {
|
|
264
|
+
proc.kill()
|
|
265
|
+
} catch {
|
|
266
|
+
// Best effort; the child may have exited in the race window.
|
|
267
|
+
}
|
|
268
|
+
log('warn', 'send CLI hard timeout; process killed', {
|
|
269
|
+
requestId: req.requestId,
|
|
270
|
+
hardTimeoutMs,
|
|
271
|
+
})
|
|
272
|
+
// Not 'failed': the CLI may have delivered before we lost patience —
|
|
273
|
+
// same semantics as a delivery:'unknown' receipt (S6, terminal).
|
|
274
|
+
return {
|
|
275
|
+
outcome: 'unknown',
|
|
276
|
+
errorCode: 'timeout',
|
|
277
|
+
detail: stderrText
|
|
278
|
+
? `no exit within ${hardTimeoutMs}ms; killed; stderr: ${stderrText}`
|
|
279
|
+
: `no exit within ${hardTimeoutMs}ms; killed`,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (settled.kind === 'spawn-error') {
|
|
284
|
+
log('warn', 'send CLI could not be started', {
|
|
285
|
+
requestId: req.requestId,
|
|
286
|
+
error: describeError(settled.error),
|
|
287
|
+
})
|
|
288
|
+
return {
|
|
289
|
+
outcome: 'failed',
|
|
290
|
+
errorCode: 'cli_not_found',
|
|
291
|
+
detail: describeError(settled.error),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const receipt = parseReceipt(stdout.text())
|
|
296
|
+
log('info', 'send CLI exited', {
|
|
297
|
+
requestId: req.requestId,
|
|
298
|
+
agent: req.target.agent,
|
|
299
|
+
sessionId: req.target.sessionId,
|
|
300
|
+
exitCode: settled.code,
|
|
301
|
+
parsedReceipt: receipt !== null,
|
|
302
|
+
...(receipt !== null
|
|
303
|
+
? {
|
|
304
|
+
delivery: receipt.delivery,
|
|
305
|
+
...(receipt.errorCode !== undefined ? { errorCode: receipt.errorCode } : {}),
|
|
306
|
+
replayed: receipt.replayed,
|
|
307
|
+
}
|
|
308
|
+
: {}),
|
|
309
|
+
stdoutBytes: stdout.bytes,
|
|
310
|
+
stderrBytes: stderr.bytes,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
if (receipt !== null) {
|
|
314
|
+
return {
|
|
315
|
+
outcome: receipt.delivery === 'delivered' ? 'delivered' : 'unknown',
|
|
316
|
+
...(receipt.errorCode !== undefined ? { errorCode: receipt.errorCode } : {}),
|
|
317
|
+
...(receipt.replayed ? { replayed: true } : {}),
|
|
318
|
+
...(stderrText ? { detail: stderrText } : {}),
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// stdout was empty or not a receipt: fall back to exit-code vocabulary.
|
|
323
|
+
const code = settled.code
|
|
324
|
+
if (code === 0) {
|
|
325
|
+
return {
|
|
326
|
+
outcome: 'delivered',
|
|
327
|
+
detail: stderrText
|
|
328
|
+
? `parse_warning: exit 0 but stdout was not a send --json receipt; stderr: ${stderrText}`
|
|
329
|
+
: 'parse_warning: exit 0 but stdout was not a send --json receipt',
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const base: InjectResult = { outcome: 'failed' }
|
|
333
|
+
if (code === 2) base.errorCode = 'usage_error'
|
|
334
|
+
else if (code === 130) base.errorCode = 'interrupted'
|
|
335
|
+
else if (code !== 1) base.errorCode = `exit_${code ?? 'signal'}`
|
|
336
|
+
if (stderrText) base.detail = stderrText
|
|
337
|
+
return base
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session state cache for the host half (design §4.b / ADR-2).
|
|
3
|
+
*
|
|
4
|
+
* Ownership rules:
|
|
5
|
+
* - `status` snapshots are authoritative: `applySnapshot` fully replaces
|
|
6
|
+
* the session set and clears per-session gap flags (the snapshot IS the
|
|
7
|
+
* reconciliation).
|
|
8
|
+
* - subscribe events are incremental hints only: they refresh the
|
|
9
|
+
* last-event summary and feed seq continuity tracking, but never mutate
|
|
10
|
+
* session status (the daemon queue is lossy, drop-oldest, unsignalled).
|
|
11
|
+
* - dsh seq gap detection: only events whose `extra.seq` is a valid
|
|
12
|
+
* integer participate in continuity checks; events without a seq are
|
|
13
|
+
* skipped entirely so they can never produce a false gap. A detected
|
|
14
|
+
* gap sticks until the next snapshot reconciliation clears it.
|
|
15
|
+
*
|
|
16
|
+
* Pure state container: no I/O, no cordis/dsh imports.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { SessionRow, SidecarEvent, StreamHealth } from './bridge.ts'
|
|
22
|
+
|
|
23
|
+
/** Compact summary of the most recent event seen for a session. */
|
|
24
|
+
export interface SessionEventSummary {
|
|
25
|
+
ts: string
|
|
26
|
+
kind: string
|
|
27
|
+
text: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One board row: authoritative snapshot fields plus stream-derived hints. */
|
|
31
|
+
export interface SessionView {
|
|
32
|
+
agent: string
|
|
33
|
+
session_id: string
|
|
34
|
+
status: string
|
|
35
|
+
title: string
|
|
36
|
+
project: string
|
|
37
|
+
updated_at: number
|
|
38
|
+
last_event: SessionEventSummary | null
|
|
39
|
+
/** True when a dsh seq discontinuity was observed since the last snapshot. */
|
|
40
|
+
gap: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Full board state handed to routes/UI. */
|
|
44
|
+
export interface BoardState {
|
|
45
|
+
sessions: SessionView[]
|
|
46
|
+
streamHealth: StreamHealth
|
|
47
|
+
lastReconcileAt: number | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Bound for the last-event text summary kept per session. */
|
|
51
|
+
const EVENT_TEXT_LIMIT = 160
|
|
52
|
+
|
|
53
|
+
interface EventSideState {
|
|
54
|
+
lastEvent: SessionEventSummary | null
|
|
55
|
+
lastSeq: number | null
|
|
56
|
+
gap: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sessionKey(agent: string, sessionId: string): string {
|
|
60
|
+
return `${agent}\u0000${sessionId}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncate(text: string, limit: number): string {
|
|
64
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extract a usable seq cursor. Mirrors `_sequence` in
|
|
69
|
+
* `sidecar/adapters/dsh.py` (integer only); JSON cannot distinguish
|
|
70
|
+
* `3.0` from `3` on the JS side, so `Number.isInteger` is the closest
|
|
71
|
+
* faithful check.
|
|
72
|
+
*/
|
|
73
|
+
function extractSeq(extra: Record<string, unknown>): number | null {
|
|
74
|
+
const value = extra['seq']
|
|
75
|
+
return typeof value === 'number' && Number.isInteger(value) ? value : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** In-memory session cache reconciled by snapshots, hinted by events. */
|
|
79
|
+
export class SessionStore {
|
|
80
|
+
private rows = new Map<string, SessionRow>()
|
|
81
|
+
private eventState = new Map<string, EventSideState>()
|
|
82
|
+
private streamHealth: StreamHealth = 'unknown'
|
|
83
|
+
private lastReconcileAt: number | null = null
|
|
84
|
+
private readonly listeners = new Set<() => void>()
|
|
85
|
+
|
|
86
|
+
/** Replace the full session set with an authoritative snapshot. */
|
|
87
|
+
applySnapshot(rows: SessionRow[]): void {
|
|
88
|
+
const next = new Map<string, SessionRow>()
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
if (typeof row.session_id !== 'string' || row.session_id === '') continue
|
|
91
|
+
next.set(sessionKey(row.agent, row.session_id), row)
|
|
92
|
+
}
|
|
93
|
+
this.rows = next
|
|
94
|
+
const staleKeys: string[] = []
|
|
95
|
+
for (const [key, state] of this.eventState) {
|
|
96
|
+
if (next.has(key)) {
|
|
97
|
+
// The snapshot reconciled this session: the gap flag is served.
|
|
98
|
+
state.gap = false
|
|
99
|
+
} else {
|
|
100
|
+
staleKeys.push(key)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const key of staleKeys) this.eventState.delete(key)
|
|
104
|
+
this.lastReconcileAt = Date.now()
|
|
105
|
+
this.notify()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Fold one stream event in as a hint (summary + seq continuity only). */
|
|
109
|
+
applyEvent(ev: SidecarEvent): void {
|
|
110
|
+
const key = sessionKey(ev.agent, ev.session_id)
|
|
111
|
+
let state = this.eventState.get(key)
|
|
112
|
+
if (state === undefined) {
|
|
113
|
+
state = { lastEvent: null, lastSeq: null, gap: false }
|
|
114
|
+
this.eventState.set(key, state)
|
|
115
|
+
}
|
|
116
|
+
state.lastEvent = {
|
|
117
|
+
ts: ev.ts,
|
|
118
|
+
kind: ev.kind,
|
|
119
|
+
text: truncate(ev.text, EVENT_TEXT_LIMIT),
|
|
120
|
+
}
|
|
121
|
+
const seq = extractSeq(ev.extra)
|
|
122
|
+
if (seq !== null) {
|
|
123
|
+
// Only a forward hole is a gap; a backward jump means the source
|
|
124
|
+
// stream restarted, so the cursor resets without flagging.
|
|
125
|
+
if (state.lastSeq !== null && seq > state.lastSeq + 1) {
|
|
126
|
+
state.gap = true
|
|
127
|
+
}
|
|
128
|
+
state.lastSeq = seq
|
|
129
|
+
}
|
|
130
|
+
this.notify()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Stream health is owned by the Reconciler; the store just exposes it. */
|
|
134
|
+
setStreamHealth(health: StreamHealth): void {
|
|
135
|
+
if (this.streamHealth === health) return
|
|
136
|
+
this.streamHealth = health
|
|
137
|
+
this.notify()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** True when any snapshot session is currently `working` (active cadence). */
|
|
141
|
+
hasWorkingSessions(): boolean {
|
|
142
|
+
for (const row of this.rows.values()) {
|
|
143
|
+
if (row.status === 'working') return true
|
|
144
|
+
}
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getBoardState(): BoardState {
|
|
149
|
+
const sessions: SessionView[] = []
|
|
150
|
+
for (const [key, row] of this.rows) {
|
|
151
|
+
const state = this.eventState.get(key)
|
|
152
|
+
sessions.push({
|
|
153
|
+
agent: row.agent,
|
|
154
|
+
session_id: row.session_id,
|
|
155
|
+
status: row.status,
|
|
156
|
+
title: row.title,
|
|
157
|
+
project: row.project,
|
|
158
|
+
updated_at: row.updated_at,
|
|
159
|
+
last_event: state?.lastEvent ?? null,
|
|
160
|
+
gap: state?.gap ?? false,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
sessions.sort(
|
|
164
|
+
(a, b) => b.updated_at - a.updated_at || a.session_id.localeCompare(b.session_id),
|
|
165
|
+
)
|
|
166
|
+
return {
|
|
167
|
+
sessions,
|
|
168
|
+
streamHealth: this.streamHealth,
|
|
169
|
+
lastReconcileAt: this.lastReconcileAt,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Subscribe to store mutations; returns the unsubscribe function. */
|
|
174
|
+
onChange(cb: () => void): () => void {
|
|
175
|
+
this.listeners.add(cb)
|
|
176
|
+
return () => {
|
|
177
|
+
this.listeners.delete(cb)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private notify(): void {
|
|
182
|
+
for (const listener of this.listeners) listener()
|
|
183
|
+
}
|
|
184
|
+
}
|