@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/index.ts
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Sidecar — dsh host-half plugin entry (M1 + M2 + M3 assembly).
|
|
3
|
+
*
|
|
4
|
+
* Wires the pure modules onto the cordis context:
|
|
5
|
+
* SessionStore → SidecarSocketClient → FusionQuery (holder) → Reconciler
|
|
6
|
+
* → DaemonSupervisor → InjectGateway (dsh + send-cli executors)
|
|
7
|
+
* → AnalysisEngine (lazy agents.create + fusion input adapter)
|
|
8
|
+
* → createRoutes → ctx.webServer prefix route.
|
|
9
|
+
*
|
|
10
|
+
* M3 fusion wiring in brief: FusionQuery lives behind a holder facade so
|
|
11
|
+
* the dsh event feed can bind lazily (`ctx.inject(['sessions'])`, swap-in/
|
|
12
|
+
* swap-out); sessionQuery resolves per call via reflect `get`; the daemon
|
|
13
|
+
* `replay` op arrives through a paging adapter over `client.replay`; and
|
|
14
|
+
* the reconciler's store face tees subscribe events into fusion's ring.
|
|
15
|
+
*
|
|
16
|
+
* Named exports only: postmortem 0001 documents that a default-exported
|
|
17
|
+
* plugin object silently drops `inject`, so the loader must see the named
|
|
18
|
+
* `name`/`inject`/`Config`/`apply` faces directly on the module namespace.
|
|
19
|
+
*
|
|
20
|
+
* `inject` declares only the two services every milestone needs (webServer,
|
|
21
|
+
* subprocess). `agents` — the M2 dsh in-process injection path — is
|
|
22
|
+
* consumed through a LAZY `ctx.inject(['agents'], …)` instead: cordis
|
|
23
|
+
* `inject` knows no optional tier (`Inject = (keyof M)[] | map`, all
|
|
24
|
+
* required), so a top-level declaration would pend the whole fiber in any
|
|
25
|
+
* composition without dsh-agent and take the M1 read surface down with it.
|
|
26
|
+
* dsh-base does bundle `@deepseek-ai/dsh-agent`, so in standard
|
|
27
|
+
* compositions the lazy callback fires at boot anyway; in agent-less
|
|
28
|
+
* compositions the plugin still loads and the injection surface degrades
|
|
29
|
+
* to the send-cli path only (a dsh-target execute fails with an honest
|
|
30
|
+
* "agents service unavailable" detail) while the M3 analysis actions
|
|
31
|
+
* answer 501 `analysis_unavailable` (same binding gates both).
|
|
32
|
+
*
|
|
33
|
+
* Service contracts consumed here were verified against the installed dsh
|
|
34
|
+
* 0.1.1-rc.2 type declarations, not docs:
|
|
35
|
+
* - `ctx.webServer.register({kind:'prefix', path, handler})` → disposer;
|
|
36
|
+
* handler is plain node:http and owns the response lifecycle
|
|
37
|
+
* (@deepseek-ai/dsh-host-webserver lib/types/index.d.ts).
|
|
38
|
+
* - `ctx.subprocess.spawn(spec)` is fully explicit (argv/cwd/stdio/graceMs/
|
|
39
|
+
* env, argv never shell-interpreted); the handle exposes `done`,
|
|
40
|
+
* `stdin` (iff spawned with `stdin: 'pipe'`), tree-scoped `terminate()`
|
|
41
|
+
* (SIGTERM → graceMs → SIGKILL) and `waitForExit()`
|
|
42
|
+
* (@deepseek-ai/dsh-subprocess lib/types/types.d.ts).
|
|
43
|
+
* - `ctx.agents` is dsh-agent's AgentRegistry (`get(id)` live lookup,
|
|
44
|
+
* `resume({resumeSessionId})` → AgentHandle); the structural
|
|
45
|
+
* {@link AgentsServiceFace} in dsh-inject.ts is satisfied directly.
|
|
46
|
+
* The faces below are structural on purpose: the plugin's type surface
|
|
47
|
+
* stays on the two devDependency SDKs (cordis, schemastery) while the
|
|
48
|
+
* service packages resolve at runtime from the dsh profile tree.
|
|
49
|
+
*
|
|
50
|
+
* @module @shendeguize/dsh-agent-sidecar
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
54
|
+
import { homedir } from 'node:os'
|
|
55
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
56
|
+
import { createInterface } from 'node:readline'
|
|
57
|
+
import type { Readable, Writable } from 'node:stream'
|
|
58
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
AnalysisEngine,
|
|
62
|
+
type AnalysisAgentFace,
|
|
63
|
+
type AnalysisInput,
|
|
64
|
+
type AnalysisSession,
|
|
65
|
+
} from './analysis.ts'
|
|
66
|
+
import { Config } from './config.ts'
|
|
67
|
+
import { Reconciler, SidecarSocketClient } from './bridge.ts'
|
|
68
|
+
import { createDshInjectExecutor, type AgentsServiceFace } from './dsh-inject.ts'
|
|
69
|
+
import {
|
|
70
|
+
FusionQuery,
|
|
71
|
+
type DshEventFace,
|
|
72
|
+
type SessionQueryFace,
|
|
73
|
+
type SidecarEventFace,
|
|
74
|
+
type SidecarReplayFace,
|
|
75
|
+
type UnifiedSession,
|
|
76
|
+
} from './fusion.ts'
|
|
77
|
+
import type { GuardOptions } from './guard.ts'
|
|
78
|
+
import {
|
|
79
|
+
InjectGateway,
|
|
80
|
+
type InjectTarget,
|
|
81
|
+
type TargetStatus,
|
|
82
|
+
} from './inject-gateway.ts'
|
|
83
|
+
import {
|
|
84
|
+
API_PREFIX,
|
|
85
|
+
createRoutes,
|
|
86
|
+
type AnalysisTargetRequest,
|
|
87
|
+
type FusionApi,
|
|
88
|
+
} from './routes.ts'
|
|
89
|
+
import { createSendCliExecutor, type SpawnLike } from './send-cli.ts'
|
|
90
|
+
import { SessionStore } from './session-store.ts'
|
|
91
|
+
import {
|
|
92
|
+
registerSidecarSkillProvider,
|
|
93
|
+
type SkillsServiceFace,
|
|
94
|
+
} from './skills-provider.ts'
|
|
95
|
+
import { DaemonSupervisor, type DaemonProcess, type LogLevel } from './supervisor.ts'
|
|
96
|
+
|
|
97
|
+
export { Config } from './config.ts'
|
|
98
|
+
|
|
99
|
+
export const name = 'agent-sidecar'
|
|
100
|
+
|
|
101
|
+
/** Required services; see the module doc for why `agents` is lazy instead. */
|
|
102
|
+
export const inject = ['webServer', 'subprocess']
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Structural faces of the consumed dsh services (see module doc for sources).
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** `ctx.webServer` face (route registration only). */
|
|
109
|
+
export interface WebServerService {
|
|
110
|
+
register(route: {
|
|
111
|
+
kind: 'exact' | 'prefix'
|
|
112
|
+
path: string
|
|
113
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
114
|
+
}): () => void
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Bounded in-memory collection for one child output stream. */
|
|
118
|
+
export interface SubprocessCollectSpec {
|
|
119
|
+
maxBytes: number
|
|
120
|
+
spill?: { maxBytes: number }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fully-specified spawn request (`ctx.subprocess` applies no defaults). */
|
|
124
|
+
export interface SubprocessSpawnSpec {
|
|
125
|
+
argv: readonly string[]
|
|
126
|
+
cwd: string
|
|
127
|
+
stdio: {
|
|
128
|
+
stdin: 'ignore' | 'pipe' | { readonly data: string }
|
|
129
|
+
stdout: 'pipe' | 'inherit' | SubprocessCollectSpec
|
|
130
|
+
stderr: 'pipe' | 'inherit' | SubprocessCollectSpec
|
|
131
|
+
}
|
|
132
|
+
graceMs: number
|
|
133
|
+
signal?: AbortSignal
|
|
134
|
+
env?: NodeJS.ProcessEnv
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Exit facts of one closed process (Node `close`-event vocabulary). */
|
|
138
|
+
export interface SubprocessOutcome {
|
|
139
|
+
exitCode: number | null
|
|
140
|
+
signal: NodeJS.Signals | null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Offset-based, non-consuming reader over one collect-mode stream. */
|
|
144
|
+
export interface SubprocessOutputReader {
|
|
145
|
+
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Live child-process handle rooted in its own process tree. */
|
|
149
|
+
export interface SubprocessHandle {
|
|
150
|
+
readonly pid: number
|
|
151
|
+
/** Present iff spawned with `stdin: 'pipe'` (dsh-subprocess types.d.ts:158). */
|
|
152
|
+
readonly stdin: Writable | undefined
|
|
153
|
+
readonly stdout: Readable | undefined
|
|
154
|
+
readonly stderr: Readable | undefined
|
|
155
|
+
readonly collected: {
|
|
156
|
+
readonly stdout?: SubprocessOutputReader
|
|
157
|
+
readonly stderr?: SubprocessOutputReader
|
|
158
|
+
}
|
|
159
|
+
readonly done: Promise<SubprocessOutcome>
|
|
160
|
+
terminate(): void
|
|
161
|
+
waitForExit(signal?: AbortSignal): Promise<boolean>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `ctx.subprocess` face (managed pipe-process primitive only). */
|
|
165
|
+
export interface SubprocessService {
|
|
166
|
+
spawn(spec: SubprocessSpawnSpec): SubprocessHandle
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Owner scope returned by `ctx.settings.register` (read/observe subset). */
|
|
170
|
+
export interface SettingsScopeFace<T> {
|
|
171
|
+
get(): T
|
|
172
|
+
watch(callback: (next: T, prev: T) => void): () => void
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `ctx.settings` face (namespace registration only). Source:
|
|
177
|
+
* dsh-settings SettingsProvider.register — `register(ns, schema, {base,
|
|
178
|
+
* applies})` → owner scope; the namespace brand is compile-time only, so a
|
|
179
|
+
* plain string is structurally sound. Registration rides the CALLER's
|
|
180
|
+
* fiber (service proxy binds this.ctx), so disposal is automatic.
|
|
181
|
+
*/
|
|
182
|
+
export interface SettingsServiceFace {
|
|
183
|
+
register<T>(
|
|
184
|
+
ns: string,
|
|
185
|
+
schema: unknown,
|
|
186
|
+
options?: { base?: Partial<T>; applies?: 'live' | 'restart' },
|
|
187
|
+
): SettingsScopeFace<T>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The lazily-bound `ctx.agents` registry surface: M2 injection consumes
|
|
192
|
+
* `get`/`resume` (dsh-inject's {@link AgentsServiceFace}), M3 analysis
|
|
193
|
+
* consumes `create` (analysis.ts's {@link AnalysisAgentFace}). One lazy
|
|
194
|
+
* binding serves both paths — and gates both degradations.
|
|
195
|
+
*/
|
|
196
|
+
export type AgentsRegistryFace = AgentsServiceFace & Pick<AnalysisAgentFace, 'create'>
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `ctx.agentDefaultModel` face (dsh-agent-default-model index.d.ts:40-56):
|
|
200
|
+
* the host's default model selection — the SAME source dsh's own entry
|
|
201
|
+
* points read when creating agents (dsh-headless `run()` passes
|
|
202
|
+
* `agentOptions: {provider, model}` from `currentSelection()`;
|
|
203
|
+
* dsh-host-apiproxy exposes it as `defaultModelSelection`). Resolved per
|
|
204
|
+
* call via reflect `get` (never a hard inject): the service is core in
|
|
205
|
+
* dsh-base compositions but the plugin must degrade honestly without it.
|
|
206
|
+
*/
|
|
207
|
+
export interface AgentDefaultModelFace {
|
|
208
|
+
currentSelection(): { provider: string; model: string }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The plugin context with the two hard-injected services visible. */
|
|
212
|
+
export type HostContext = Context & {
|
|
213
|
+
webServer: WebServerService
|
|
214
|
+
subprocess: SubprocessService
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Sidecar invocation facts (mirroring sidecar/daemon.py and launchd.py).
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/** `SOCKET_NAME` in sidecar/daemon.py. */
|
|
222
|
+
const SOCKET_NAME = 'daemon.sock'
|
|
223
|
+
/** `RUNTIME_ENV` / `LEGACY_RUNTIME_ENV` in sidecar/daemon.py. */
|
|
224
|
+
const RUNTIME_ENV = 'AGENT_SIDECAR_RUNTIME_DIR'
|
|
225
|
+
const LEGACY_RUNTIME_ENV = 'AGENT_SIDECAR_HOME'
|
|
226
|
+
|
|
227
|
+
/** SIGTERM → grace → SIGKILL window for a hosted daemon (design §4.a: 5s). */
|
|
228
|
+
const DAEMON_GRACE_MS = 5000
|
|
229
|
+
/** Whole-run bound for one `service status` detection probe. */
|
|
230
|
+
const DETECT_TIMEOUT_MS = 10_000
|
|
231
|
+
/** SIGTERM → grace → SIGKILL window when the send-cli hard timeout kills. */
|
|
232
|
+
const SEND_CLI_GRACE_MS = 2000
|
|
233
|
+
/** Output cap for the detection probe (one sanitized message line). */
|
|
234
|
+
const DETECT_OUTPUT_BYTES = 4096
|
|
235
|
+
/** Per-line clamp when forwarding daemon output into ctx.logger (S8). */
|
|
236
|
+
const LOG_LINE_LIMIT = 400
|
|
237
|
+
/** Per-page `replay` limit forwarded to the daemon (its own cap is 1024). */
|
|
238
|
+
const REPLAY_PAGE_LIMIT = 512
|
|
239
|
+
/** Page cap per fusion replay pull: bounds one timeline fan-out to ≤2048 events. */
|
|
240
|
+
const REPLAY_MAX_PAGES = 4
|
|
241
|
+
|
|
242
|
+
// Bounds of the fusion→AnalysisInput adapter (§7-B: the engine re-bounds
|
|
243
|
+
// the whole text to maxInputChars anyway; these keep the assembly cheap
|
|
244
|
+
// and the head of the text — which survives engine truncation — useful).
|
|
245
|
+
/** Timeline entries pulled into one session-analysis summary. */
|
|
246
|
+
const ANALYSIS_TIMELINE_LIMIT = 120
|
|
247
|
+
/** Sessions listed per project-analysis overview. */
|
|
248
|
+
const ANALYSIS_MAX_SESSIONS = 30
|
|
249
|
+
/** Project groups listed in a cross-agent analysis overview. */
|
|
250
|
+
const ANALYSIS_MAX_GROUPS = 12
|
|
251
|
+
/** Sessions listed per group in a cross-agent analysis overview. */
|
|
252
|
+
const ANALYSIS_CROSS_SESSIONS = 5
|
|
253
|
+
/** Clamp on one line of untrusted text (titles, event text). */
|
|
254
|
+
const ANALYSIS_LINE_CLAMP = 200
|
|
255
|
+
/** Clamp on the user question (placed at the head, so it survives truncation). */
|
|
256
|
+
const ANALYSIS_QUESTION_CLAMP = 2000
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `service status` messages that mean "a LaunchAgent owns daemon liveness"
|
|
260
|
+
* (sidecar/launchd.py `_status`): exit 0 is `service is running (pid N)`;
|
|
261
|
+
* exit 1 covers `service is loaded but daemon is not running` and
|
|
262
|
+
* `service is degraded; ...` (both installed) as well as
|
|
263
|
+
* `service is unloaded...` (not installed). There is no `--json` face —
|
|
264
|
+
* the single sanitized message line IS the contract.
|
|
265
|
+
*/
|
|
266
|
+
const SERVICE_PRESENT = /^service is (?:running|loaded|degraded)/m
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Resolve the effective runtime directory the way sidecar/daemon.py
|
|
270
|
+
* `default_runtime_dir()` does: explicit config wins, then the
|
|
271
|
+
* AGENT_SIDECAR_RUNTIME_DIR / legacy AGENT_SIDECAR_HOME environment of the
|
|
272
|
+
* dsh host process, then `~/.agent_sidecar`.
|
|
273
|
+
*/
|
|
274
|
+
function resolveRuntimeDir(configured: string, env: NodeJS.ProcessEnv): string {
|
|
275
|
+
const raw =
|
|
276
|
+
configured.trim() !== ''
|
|
277
|
+
? configured.trim()
|
|
278
|
+
: (env[RUNTIME_ENV] ?? env[LEGACY_RUNTIME_ENV] ?? '').trim()
|
|
279
|
+
if (raw === '') return join(homedir(), '.agent_sidecar')
|
|
280
|
+
const expanded =
|
|
281
|
+
raw === '~' ? homedir() : raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw
|
|
282
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Fusion → AnalysisInput assembly helpers (pure; bounded per the constants).
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/** Flatten and clamp one line of untrusted text for an analysis summary. */
|
|
290
|
+
function clampAnalysisText(text: string, max = ANALYSIS_LINE_CLAMP): string {
|
|
291
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
292
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Same trailing-slash normalization fusion uses for project group keys. */
|
|
296
|
+
function normalizeAnalysisProject(project: string): string {
|
|
297
|
+
if (project.length > 1 && project.endsWith('/')) {
|
|
298
|
+
const stripped = project.replace(/\/+$/, '')
|
|
299
|
+
return stripped === '' ? '/' : stripped
|
|
300
|
+
}
|
|
301
|
+
return project
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** One unified-session line in a project / cross-agent overview. */
|
|
305
|
+
function describeUnifiedSession(session: UnifiedSession): string {
|
|
306
|
+
const title = session.title !== '' ? clampAnalysisText(session.title) : '(untitled)'
|
|
307
|
+
const live = session.live ? '|live' : ''
|
|
308
|
+
const updated = new Date(session.lastActivityAt).toISOString()
|
|
309
|
+
return `- [${session.agent}|${session.status}${live}] ${title} (updated ${updated})`
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Assemble the M1 host half.
|
|
314
|
+
*
|
|
315
|
+
* Teardown is order-sensitive, so the whole assembly lives in ONE
|
|
316
|
+
* `ctx.effect` disposer (design §4.a: "顺序敏感拆除放同一 disposer"):
|
|
317
|
+
* supervisor first (terminates a self-hosted daemon, never an adopted one),
|
|
318
|
+
* then the reconciler (closes the subscribe stream and timers), then
|
|
319
|
+
* `routes.dispose()` (ends SSE clients, unsubscribes), and the webServer
|
|
320
|
+
* route disposer last.
|
|
321
|
+
*
|
|
322
|
+
* @param ctx - plugin context handed by the cordis loader.
|
|
323
|
+
* @param config - schema-validated composition config (defaults filled).
|
|
324
|
+
*/
|
|
325
|
+
export function apply(ctx: HostContext, config: Config): void {
|
|
326
|
+
const runtimeDir = resolveRuntimeDir(config.sidecar.runtimeDir, process.env)
|
|
327
|
+
const socketPath = join(runtimeDir, SOCKET_NAME)
|
|
328
|
+
const command = config.sidecar.command
|
|
329
|
+
/** Explicit redirect only when configured; the ambient env already flows. */
|
|
330
|
+
const childEnv: NodeJS.ProcessEnv | undefined =
|
|
331
|
+
config.sidecar.runtimeDir.trim() !== '' ? { [RUNTIME_ENV]: runtimeDir } : undefined
|
|
332
|
+
|
|
333
|
+
const log = (level: LogLevel, msg: string, meta?: object): void => {
|
|
334
|
+
ctx.logger[level](
|
|
335
|
+
meta === undefined ? `agent-sidecar: ${msg}` : `agent-sidecar: ${msg} ${JSON.stringify(meta)}`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Clamped per-line forwarding of daemon output (design §4.c, S8-safe). */
|
|
340
|
+
const forwardLines = (stream: Readable | undefined, level: 'debug' | 'warn'): void => {
|
|
341
|
+
if (stream === undefined) return
|
|
342
|
+
stream.on('error', () => {})
|
|
343
|
+
const lines = createInterface({ input: stream })
|
|
344
|
+
lines.on('line', (line) => {
|
|
345
|
+
const text = line.length > LOG_LINE_LIMIT ? `${line.slice(0, LOG_LINE_LIMIT)}…` : line
|
|
346
|
+
if (text.trim() !== '') ctx.logger[level](`agent-sidecar daemon: ${text}`)
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Spawn `<command> daemon run` as a supervised foreground child. */
|
|
351
|
+
const spawnDaemon = (): DaemonProcess => {
|
|
352
|
+
const handle = ctx.subprocess.spawn({
|
|
353
|
+
argv: [...command, 'daemon', 'run'],
|
|
354
|
+
cwd: homedir(),
|
|
355
|
+
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
356
|
+
graceMs: DAEMON_GRACE_MS,
|
|
357
|
+
env: childEnv,
|
|
358
|
+
})
|
|
359
|
+
forwardLines(handle.stdout, 'debug')
|
|
360
|
+
forwardLines(handle.stderr, 'warn')
|
|
361
|
+
return {
|
|
362
|
+
exited: handle.done.then((outcome) => outcome.exitCode),
|
|
363
|
+
// Gentle by construction: subprocess `terminate()` is the tree-scoped
|
|
364
|
+
// SIGTERM → graceMs → SIGKILL escalation; waitForExit observes the
|
|
365
|
+
// whole tree so teardown returns on real quiescence.
|
|
366
|
+
terminate: async () => {
|
|
367
|
+
handle.terminate()
|
|
368
|
+
await handle.waitForExit()
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Read-only LaunchAgent detection: darwin-only, one bounded
|
|
375
|
+
* `service status` run, parsed per {@link SERVICE_PRESENT}. Any failure
|
|
376
|
+
* (non-zero control exit, timeout, unspawnable CLI) reads as "absent" —
|
|
377
|
+
* the supervisor already treats detection errors that way.
|
|
378
|
+
*/
|
|
379
|
+
const detectLaunchAgent = async (): Promise<boolean> => {
|
|
380
|
+
if (process.platform !== 'darwin') return false
|
|
381
|
+
const handle = ctx.subprocess.spawn({
|
|
382
|
+
argv: [...command, 'service', 'status'],
|
|
383
|
+
cwd: homedir(),
|
|
384
|
+
stdio: {
|
|
385
|
+
stdin: 'ignore',
|
|
386
|
+
stdout: { maxBytes: DETECT_OUTPUT_BYTES },
|
|
387
|
+
stderr: { maxBytes: DETECT_OUTPUT_BYTES },
|
|
388
|
+
},
|
|
389
|
+
graceMs: 2000,
|
|
390
|
+
signal: AbortSignal.timeout(DETECT_TIMEOUT_MS),
|
|
391
|
+
env: childEnv,
|
|
392
|
+
})
|
|
393
|
+
const outcome = await handle.done
|
|
394
|
+
if (outcome.exitCode === 0) return true
|
|
395
|
+
if (outcome.exitCode !== 1) return false
|
|
396
|
+
const text = handle.collected.stdout?.readFrom(0).text ?? ''
|
|
397
|
+
return SERVICE_PRESENT.test(text)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ------------------------------------------------------------- assembly
|
|
401
|
+
|
|
402
|
+
const store = new SessionStore()
|
|
403
|
+
const client = new SidecarSocketClient({ socketPath })
|
|
404
|
+
|
|
405
|
+
// ------------------------------------------------------ M3 fusion assembly
|
|
406
|
+
|
|
407
|
+
// SidecarReplayFace → bridge.replay: one fusion pull pages the daemon op
|
|
408
|
+
// until the history is exhausted, bounded to REPLAY_MAX_PAGES so a huge
|
|
409
|
+
// transcript can never wedge one HTTP request. Coded daemon errors
|
|
410
|
+
// (unknown_session / replay_unsupported / ...) propagate as rejections;
|
|
411
|
+
// fusion degrades that source and reports it via `sources` (design §4.e).
|
|
412
|
+
const replayFace: SidecarReplayFace = {
|
|
413
|
+
replay: async ({ sessionId, afterSeq }) => {
|
|
414
|
+
const events: SidecarEventFace[] = []
|
|
415
|
+
let cursor = afterSeq ?? 0
|
|
416
|
+
for (let page = 0; page < REPLAY_MAX_PAGES; page += 1) {
|
|
417
|
+
const result = await client.replay(sessionId, cursor, REPLAY_PAGE_LIMIT)
|
|
418
|
+
events.push(...result.events)
|
|
419
|
+
if (!result.truncated || result.lastSeq === null || result.lastSeq <= cursor) break
|
|
420
|
+
cursor = result.lastSeq
|
|
421
|
+
}
|
|
422
|
+
return events
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// SessionQueryFace → ctx.sessionQuery, re-resolved on EVERY use through
|
|
427
|
+
// the reflect `get` (never a hard inject): dsh-session-query may mount
|
|
428
|
+
// late or never, and fusion degrades per call instead of pending.
|
|
429
|
+
const getSessionQuery = (): SessionQueryFace | null => {
|
|
430
|
+
const getter = (ctx as { get?: (name: string) => unknown }).get
|
|
431
|
+
if (typeof getter !== 'function') return null
|
|
432
|
+
const engine = getter.call(ctx, 'sessionQuery')
|
|
433
|
+
return engine === undefined || engine === null ? null : (engine as SessionQueryFace)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const buildFusion = (dshEvents: DshEventFace | null): FusionQuery =>
|
|
437
|
+
new FusionQuery({
|
|
438
|
+
store,
|
|
439
|
+
dshEvents,
|
|
440
|
+
getSessionQuery,
|
|
441
|
+
replay: replayFace,
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// DshEventFace is bound through the lazy `sessions` inject below, but
|
|
445
|
+
// fusion must exist NOW (routes capture it). Holder pattern: construct
|
|
446
|
+
// sidecar-only first, swap in a feed-backed instance when dsh-session
|
|
447
|
+
// binds, swap back on release — so `getCapabilities().dshEvents` always
|
|
448
|
+
// reports the truth instead of a permanently-optimistic facade. The swap
|
|
449
|
+
// drops the old instance's bounded event rings; replay and the live
|
|
450
|
+
// stream repopulate them, so no timeline data is lost, only hints.
|
|
451
|
+
const fusionHolder = { current: buildFusion(null) }
|
|
452
|
+
const fusion: FusionApi = {
|
|
453
|
+
getUnifiedSessions: () => fusionHolder.current.getUnifiedSessions(),
|
|
454
|
+
getSessionTimeline: (sessionId, opts) =>
|
|
455
|
+
fusionHolder.current.getSessionTimeline(sessionId, opts),
|
|
456
|
+
getProjectGroups: (opts) => fusionHolder.current.getProjectGroups(opts),
|
|
457
|
+
getLineage: (sessionId) => fusionHolder.current.getLineage(sessionId),
|
|
458
|
+
searchSessions: (query, opts) => fusionHolder.current.searchSessions(query, opts),
|
|
459
|
+
getCapabilities: () => fusionHolder.current.getCapabilities(),
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// policy=off still reconciles read-only against an externally managed
|
|
463
|
+
// daemon: off means "lifecycle is not ours", not "do not read data".
|
|
464
|
+
// The store face tees each subscribe-stream event into fusion's bounded
|
|
465
|
+
// ring (timeline hints) on its way into the session cache.
|
|
466
|
+
const reconciler = new Reconciler(
|
|
467
|
+
client,
|
|
468
|
+
{
|
|
469
|
+
applySnapshot: (rows) => {
|
|
470
|
+
store.applySnapshot(rows)
|
|
471
|
+
},
|
|
472
|
+
applyEvent: (ev) => {
|
|
473
|
+
store.applyEvent(ev)
|
|
474
|
+
fusionHolder.current.ingestSidecarEvent(ev)
|
|
475
|
+
},
|
|
476
|
+
setStreamHealth: (health) => {
|
|
477
|
+
store.setStreamHealth(health)
|
|
478
|
+
},
|
|
479
|
+
hasWorkingSessions: () => store.hasWorkingSessions(),
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
activeMs: config.stream.reconcileActiveMs,
|
|
483
|
+
idleMs: config.stream.reconcileIdleMs,
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
const supervisor = new DaemonSupervisor(
|
|
487
|
+
{ ping: () => client.ping(), spawnDaemon, detectLaunchAgent, log },
|
|
488
|
+
{ policy: config.daemon.policy, backoffLimit: config.daemon.backoffLimit },
|
|
489
|
+
)
|
|
490
|
+
// `effective` tracks the settings-resolved config once the settings
|
|
491
|
+
// namespace registers below; until then (and in compositions without
|
|
492
|
+
// dsh-settings) it IS the entry config.
|
|
493
|
+
let effective: Config = config
|
|
494
|
+
const guardOptions: GuardOptions = {
|
|
495
|
+
allowWriteActions: () => effective.inject.enabled,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ------------------------------------------------- M2 injection assembly
|
|
499
|
+
|
|
500
|
+
// dsh in-process path (§4.d path one). `liveAgents` is bound by the lazy
|
|
501
|
+
// agents inject below; until then (and in compositions without dsh-agent)
|
|
502
|
+
// the face reports the path unavailable: `get` misses, `resume` rejects,
|
|
503
|
+
// and the executor surfaces an honest failure while send-cli keeps working.
|
|
504
|
+
// The same binding carries `create` for the M3 analysis engine.
|
|
505
|
+
let liveAgents: AgentsRegistryFace | null = null
|
|
506
|
+
const agentsFace: AgentsServiceFace = {
|
|
507
|
+
get: (sessionId) => liveAgents?.get(sessionId),
|
|
508
|
+
resume: (options) =>
|
|
509
|
+
liveAgents === null
|
|
510
|
+
? Promise.reject(
|
|
511
|
+
new Error('dsh agents service is not available in this composition'),
|
|
512
|
+
)
|
|
513
|
+
: liveAgents.resume(options),
|
|
514
|
+
}
|
|
515
|
+
const dshExecutor = createDshInjectExecutor({
|
|
516
|
+
agents: agentsFace,
|
|
517
|
+
log,
|
|
518
|
+
pluginName: name,
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
// send-cli path (§4.d path two): adapt `ctx.subprocess.spawn` onto the
|
|
522
|
+
// executor's SpawnLike seam. stdin is a real pipe (the message travels
|
|
523
|
+
// via `--message-stdin`, never argv); the runtimeDir redirect flows via
|
|
524
|
+
// the same childEnv the daemon paths use, so send talks to the same
|
|
525
|
+
// daemon. `done` rejects only on spawn-level failures — exactly the
|
|
526
|
+
// `exited` contract — and `kill()` maps to the tree-scoped terminate.
|
|
527
|
+
const spawnSendCli: SpawnLike = (argv) => {
|
|
528
|
+
const handle = ctx.subprocess.spawn({
|
|
529
|
+
argv,
|
|
530
|
+
cwd: homedir(),
|
|
531
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
532
|
+
graceMs: SEND_CLI_GRACE_MS,
|
|
533
|
+
env: childEnv,
|
|
534
|
+
})
|
|
535
|
+
// Dead-pipe writes must not throw asynchronously (adapter obligation);
|
|
536
|
+
// the authoritative failure surfaces through `exited`.
|
|
537
|
+
handle.stdin?.on('error', () => {})
|
|
538
|
+
return {
|
|
539
|
+
stdin: {
|
|
540
|
+
write: (chunk) => {
|
|
541
|
+
handle.stdin?.write(chunk)
|
|
542
|
+
},
|
|
543
|
+
end: () => {
|
|
544
|
+
handle.stdin?.end()
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
onStdout: (listener) => {
|
|
548
|
+
handle.stdout?.on('data', listener)
|
|
549
|
+
},
|
|
550
|
+
onStderr: (listener) => {
|
|
551
|
+
handle.stderr?.on('data', listener)
|
|
552
|
+
},
|
|
553
|
+
exited: handle.done.then((outcome) => outcome.exitCode),
|
|
554
|
+
kill: () => {
|
|
555
|
+
handle.terminate()
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const sendCliExecutor = createSendCliExecutor({
|
|
560
|
+
spawn: spawnSendCli,
|
|
561
|
+
log,
|
|
562
|
+
opts: { command },
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
/** Live target re-check against the reconciled store (§4.f.5 prepare). */
|
|
566
|
+
const verifyTarget = async (target: InjectTarget): Promise<TargetStatus | null> => {
|
|
567
|
+
const view = store
|
|
568
|
+
.getBoardState()
|
|
569
|
+
.sessions.find(
|
|
570
|
+
(s) => s.agent === target.agent && s.session_id === target.sessionId,
|
|
571
|
+
)
|
|
572
|
+
if (view === undefined) return null
|
|
573
|
+
return {
|
|
574
|
+
agent: view.agent,
|
|
575
|
+
sessionId: view.session_id,
|
|
576
|
+
status: view.status,
|
|
577
|
+
title: view.title,
|
|
578
|
+
project: view.project,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Constructed even when inject.enabled=false: `allowWrite` reads the live
|
|
583
|
+
// `effective` value on every prepare, and the route layer's
|
|
584
|
+
// guardWriteAction blocks first anyway (two independent gates, no
|
|
585
|
+
// duplication). Gateway audit entries are body-free by construction
|
|
586
|
+
// (byte size + sha256 prefix only), so forwarding them whole is S8-safe.
|
|
587
|
+
const injectGateway = new InjectGateway({
|
|
588
|
+
executors: { dsh: dshExecutor, sendCli: sendCliExecutor },
|
|
589
|
+
verifyTarget,
|
|
590
|
+
allowWrite: () => effective.inject.enabled,
|
|
591
|
+
log: (entry) => log(entry.ok ? 'info' : 'warn', `inject ${entry.phase}`, entry),
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
// -------------------------------------------------- M3 analysis assembly
|
|
595
|
+
|
|
596
|
+
// In-flight analysis sessions created through this assembly, tracked at
|
|
597
|
+
// the wiring layer (the engine keeps its bookkeeping private): every
|
|
598
|
+
// engine cleanup path goes through `handle.dispose()`, which unregisters
|
|
599
|
+
// here, so whatever is left when the plugin unloads is exactly the set
|
|
600
|
+
// the effect disposer must cancel (design: 在途分析会话随 dispose 清理).
|
|
601
|
+
const liveAnalysisSessions = new Set<AnalysisSession>()
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Resolve the provider/model the analysis agent runs on (A-1 fix: an
|
|
605
|
+
* agent created without agentOptions has no model — `{{model}}` prompt
|
|
606
|
+
* assembly and `buildRequest` both fail, yielding an empty summary).
|
|
607
|
+
* Explicit `analysis.provider`+`analysis.model` config wins (both
|
|
608
|
+
* non-empty, read live); otherwise the host's default model selection is
|
|
609
|
+
* reused via `ctx.agentDefaultModel` — the same source dsh's own entry
|
|
610
|
+
* points (headless/apiproxy) read. `null` = no model anywhere: routes
|
|
611
|
+
* pre-reject `analysis.request` as `analysis_model_unconfigured`.
|
|
612
|
+
*/
|
|
613
|
+
const resolveAnalysisModel = (): { provider: string; model: string } | null => {
|
|
614
|
+
const provider = effective.analysis.provider.trim()
|
|
615
|
+
const model = effective.analysis.model.trim()
|
|
616
|
+
if (provider !== '' && model !== '') return { provider, model }
|
|
617
|
+
const getter = (ctx as { get?: (name: string) => unknown }).get
|
|
618
|
+
if (typeof getter !== 'function') return null
|
|
619
|
+
const service = getter.call(ctx, 'agentDefaultModel') as
|
|
620
|
+
| AgentDefaultModelFace
|
|
621
|
+
| undefined
|
|
622
|
+
| null
|
|
623
|
+
if (service === undefined || service === null) return null
|
|
624
|
+
try {
|
|
625
|
+
const selection = service.currentSelection()
|
|
626
|
+
if (
|
|
627
|
+
typeof selection?.provider === 'string' &&
|
|
628
|
+
selection.provider !== '' &&
|
|
629
|
+
typeof selection.model === 'string' &&
|
|
630
|
+
selection.model !== ''
|
|
631
|
+
) {
|
|
632
|
+
return { provider: selection.provider, model: selection.model }
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
// A throwing selection reads as "no default available" — the routes'
|
|
636
|
+
// pre-check turns that into an honest analysis_model_unconfigured.
|
|
637
|
+
}
|
|
638
|
+
return null
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const createAnalysisAgent: AnalysisAgentFace['create'] = async (options) => {
|
|
642
|
+
const agents = liveAgents
|
|
643
|
+
if (agents === null) {
|
|
644
|
+
// Raced past the routes' availability probe: surfaces as an honest
|
|
645
|
+
// create_failed result, never a crash.
|
|
646
|
+
throw new Error('dsh agents service is not available in this composition')
|
|
647
|
+
}
|
|
648
|
+
const selection = resolveAnalysisModel()
|
|
649
|
+
if (selection === null) {
|
|
650
|
+
// Raced past the routes' model pre-check (config/settings flipped
|
|
651
|
+
// mid-flight): surfaces as an honest create_failed, never an agent
|
|
652
|
+
// that assembles `{{model}}`-less prompts into empty summaries (A-1).
|
|
653
|
+
throw new Error(
|
|
654
|
+
'no analysis model available: set analysis.provider/analysis.model or mount agentDefaultModel',
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
const handle = await agents.create({
|
|
658
|
+
...options,
|
|
659
|
+
agentOptions: { provider: selection.provider, model: selection.model },
|
|
660
|
+
// The deployment persona's `{{cwd}}` variable reads session.header.cwd,
|
|
661
|
+
// which only meta.cwd populates — same as dsh-headless's own create
|
|
662
|
+
// call (A-1: without it prompt assembly errors and the summary is '').
|
|
663
|
+
meta: { cwd: process.cwd() },
|
|
664
|
+
})
|
|
665
|
+
const tracked: AnalysisSession = {
|
|
666
|
+
agent: handle.agent,
|
|
667
|
+
dispose: async () => {
|
|
668
|
+
liveAnalysisSessions.delete(tracked)
|
|
669
|
+
await handle.dispose()
|
|
670
|
+
},
|
|
671
|
+
}
|
|
672
|
+
liveAnalysisSessions.add(tracked)
|
|
673
|
+
return tracked
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Engine log entries are body-free by the engine's own contract (S8):
|
|
677
|
+
// kind/title/ids/outcomes/sizes only — safe to forward whole.
|
|
678
|
+
const analysisEngine = new AnalysisEngine({
|
|
679
|
+
createAgent: createAnalysisAgent,
|
|
680
|
+
allowAnalysis: () => effective.analysis.enabled,
|
|
681
|
+
log: (entry) =>
|
|
682
|
+
log(entry.errorCode !== undefined ? 'warn' : 'info', `analysis ${entry.op}`, entry),
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Assemble the bounded AnalysisInput for one target from fusion data
|
|
687
|
+
* (design §4.e.3: summaries come from the fused timelines/overviews).
|
|
688
|
+
* `null` = target unknown to fusion → the routes answer 404. The user
|
|
689
|
+
* question rides the HEAD of the text so it survives the engine's
|
|
690
|
+
* tail truncation, and the session timeline lists NEWEST events first
|
|
691
|
+
* for the same reason: when the engine's head-keep truncation bites,
|
|
692
|
+
* it should shed the oldest — least informative — events (F5).
|
|
693
|
+
*/
|
|
694
|
+
const buildAnalysisInput = async (
|
|
695
|
+
req: AnalysisTargetRequest,
|
|
696
|
+
): Promise<AnalysisInput | null> => {
|
|
697
|
+
const questionLines =
|
|
698
|
+
req.question !== undefined && req.question.trim() !== ''
|
|
699
|
+
? [
|
|
700
|
+
'[用户问题 / question]',
|
|
701
|
+
clampAnalysisText(req.question, ANALYSIS_QUESTION_CLAMP),
|
|
702
|
+
'',
|
|
703
|
+
]
|
|
704
|
+
: []
|
|
705
|
+
|
|
706
|
+
if (req.targetKind === 'session') {
|
|
707
|
+
const targetId = req.targetId ?? ''
|
|
708
|
+
const session =
|
|
709
|
+
fusion.getUnifiedSessions().find((s) => s.sessionId === targetId) ?? null
|
|
710
|
+
if (session === null) return null
|
|
711
|
+
const page = await fusion.getSessionTimeline(targetId, {
|
|
712
|
+
limit: ANALYSIS_TIMELINE_LIMIT,
|
|
713
|
+
})
|
|
714
|
+
const sources = page.sources
|
|
715
|
+
const summaryText = [
|
|
716
|
+
...questionLines,
|
|
717
|
+
`[会话概览 / session] agent=${session.agent} status=${session.status} live=${session.live}`,
|
|
718
|
+
`title: ${session.title !== '' ? clampAnalysisText(session.title) : '(untitled)'}`,
|
|
719
|
+
`project: ${session.project}`,
|
|
720
|
+
`last activity: ${new Date(session.lastActivityAt).toISOString()}`,
|
|
721
|
+
'',
|
|
722
|
+
`[时间线 / timeline,最新在前 / newest first] ${page.entries.length} events (sources: dshLive=${sources.dshLive} dshCold=${sources.dshCold} replay=${sources.sidecarReplay} buffer=${sources.sidecarBuffer})`,
|
|
723
|
+
...[...page.entries].reverse().map(
|
|
724
|
+
(entry) =>
|
|
725
|
+
`- [${new Date(entry.ts).toISOString()}] ${entry.kind}` +
|
|
726
|
+
`${entry.seq !== null ? ` seq=${entry.seq}` : ''}` +
|
|
727
|
+
`${entry.text !== '' ? ` ${clampAnalysisText(entry.text)}` : ''}`,
|
|
728
|
+
),
|
|
729
|
+
].join('\n')
|
|
730
|
+
return {
|
|
731
|
+
kind: 'session',
|
|
732
|
+
title:
|
|
733
|
+
session.title !== '' ? session.title : `${session.agent} ${session.sessionId}`,
|
|
734
|
+
summaryText,
|
|
735
|
+
meta: { targetId, agent: session.agent },
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (req.targetKind === 'project') {
|
|
740
|
+
const wanted = normalizeAnalysisProject(req.targetId ?? '')
|
|
741
|
+
const group =
|
|
742
|
+
fusion
|
|
743
|
+
.getProjectGroups()
|
|
744
|
+
.find((g) => normalizeAnalysisProject(g.project) === wanted) ?? null
|
|
745
|
+
if (group === null) return null
|
|
746
|
+
const omitted = group.sessions.length - ANALYSIS_MAX_SESSIONS
|
|
747
|
+
const summaryText = [
|
|
748
|
+
...questionLines,
|
|
749
|
+
`[项目概览 / project] ${group.project}`,
|
|
750
|
+
`agents: ${group.agents.join(', ')} | sessions: ${group.sessions.length} | last activity: ${new Date(group.lastActivityAt).toISOString()}`,
|
|
751
|
+
'',
|
|
752
|
+
...group.sessions.slice(0, ANALYSIS_MAX_SESSIONS).map(describeUnifiedSession),
|
|
753
|
+
...(omitted > 0 ? [`… ${omitted} more sessions omitted`] : []),
|
|
754
|
+
].join('\n')
|
|
755
|
+
return {
|
|
756
|
+
kind: 'project',
|
|
757
|
+
title: `project ${group.project}`,
|
|
758
|
+
summaryText,
|
|
759
|
+
meta: { targetId: group.project },
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// cross-agent: whole-board overview, always resolvable (possibly empty).
|
|
764
|
+
const groups = fusion.getProjectGroups()
|
|
765
|
+
const sessionsTotal = groups.reduce((n, g) => n + g.sessions.length, 0)
|
|
766
|
+
const omittedGroups = groups.length - ANALYSIS_MAX_GROUPS
|
|
767
|
+
const summaryText = [
|
|
768
|
+
...questionLines,
|
|
769
|
+
`[跨 agent 概览 / cross-agent overview] ${groups.length} projects, ${sessionsTotal} sessions in the correlation window`,
|
|
770
|
+
'',
|
|
771
|
+
...groups.slice(0, ANALYSIS_MAX_GROUPS).flatMap((group) => [
|
|
772
|
+
`[${group.project}] agents: ${group.agents.join(', ')} | sessions: ${group.sessions.length}`,
|
|
773
|
+
...group.sessions.slice(0, ANALYSIS_CROSS_SESSIONS).map(describeUnifiedSession),
|
|
774
|
+
'',
|
|
775
|
+
]),
|
|
776
|
+
...(omittedGroups > 0 ? [`… ${omittedGroups} more projects omitted`] : []),
|
|
777
|
+
].join('\n')
|
|
778
|
+
return { kind: 'cross-agent', title: 'cross-agent overview', summaryText }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const routes = createRoutes({
|
|
782
|
+
store,
|
|
783
|
+
supervisor,
|
|
784
|
+
guardOptions,
|
|
785
|
+
injectGateway,
|
|
786
|
+
fusion,
|
|
787
|
+
// The analysis write gate reads the LIVE setting, same posture as the
|
|
788
|
+
// inject gate; the engine's allowAnalysis reads the same value (two
|
|
789
|
+
// independent gates, no duplication).
|
|
790
|
+
analysisEnabled: () => effective.analysis.enabled,
|
|
791
|
+
analysis: {
|
|
792
|
+
engine: analysisEngine,
|
|
793
|
+
buildInput: buildAnalysisInput,
|
|
794
|
+
available: () => liveAgents !== null,
|
|
795
|
+
modelConfigured: () => resolveAnalysisModel() !== null,
|
|
796
|
+
},
|
|
797
|
+
log,
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
ctx.effect(() => {
|
|
801
|
+
const removeRoute = ctx.webServer.register({
|
|
802
|
+
kind: 'prefix',
|
|
803
|
+
path: API_PREFIX,
|
|
804
|
+
handler: routes.handle,
|
|
805
|
+
})
|
|
806
|
+
// Cold-start latency (M1 acceptance ②): the moment the supervisor
|
|
807
|
+
// confirms a reachable daemon — ADOPTED and HOSTED are both entered off
|
|
808
|
+
// a successful ping, so the socket exists — reconcile immediately
|
|
809
|
+
// instead of waiting out whatever poll the reconciler has pending.
|
|
810
|
+
const offStateChange = supervisor.onStateChange((state) => {
|
|
811
|
+
if (state === 'adopted' || state === 'hosted') void reconciler.reconcileNow()
|
|
812
|
+
})
|
|
813
|
+
fusionHolder.current.start()
|
|
814
|
+
reconciler.start()
|
|
815
|
+
supervisor.start()
|
|
816
|
+
return async () => {
|
|
817
|
+
offStateChange()
|
|
818
|
+
await supervisor.stop()
|
|
819
|
+
reconciler.stop()
|
|
820
|
+
// In-flight analysis sessions die with the plugin: dispose() stops
|
|
821
|
+
// the agent loop and removes the session (the UI "stop" semantics),
|
|
822
|
+
// bounding token burn across plugin reloads (§7-B / design risk 12).
|
|
823
|
+
await Promise.all(
|
|
824
|
+
[...liveAnalysisSessions].map((handle) => handle.dispose().catch(() => {})),
|
|
825
|
+
)
|
|
826
|
+
routes.dispose()
|
|
827
|
+
removeRoute()
|
|
828
|
+
// Whichever instance the holder points at by now (idempotent stop;
|
|
829
|
+
// a feed-bound instance is also stopped by its own inject release).
|
|
830
|
+
fusionHolder.current.stop()
|
|
831
|
+
}
|
|
832
|
+
}, 'agent-sidecar: host assembly (route + reconciler + supervisor + fusion + analysis)')
|
|
833
|
+
|
|
834
|
+
// Fusion dsh event feed binding (M3). Lazy inject on `sessions`
|
|
835
|
+
// (dsh-session's service key; the feed itself is the cordis event bus —
|
|
836
|
+
// `ctx.on('session/event' | 'session/created' | 'session/disposed')` —
|
|
837
|
+
// but only meaningful while the service is mounted, and gating on it
|
|
838
|
+
// keeps `getCapabilities().dshEvents.available` honest). Compositions
|
|
839
|
+
// without dsh-session simply never run this: fusion stays sidecar-only
|
|
840
|
+
// and every route keeps working (degradation, not failure).
|
|
841
|
+
ctx.inject(['sessions'], (injected) => {
|
|
842
|
+
const sctx = injected as HostContext
|
|
843
|
+
// 'session/*' keys live in dsh-session's Events augmentation, which
|
|
844
|
+
// this package deliberately does not import (structural-faces rule);
|
|
845
|
+
// the cast keeps the listener registration honest at runtime.
|
|
846
|
+
const bus = sctx as unknown as {
|
|
847
|
+
on(event: string, handler: (...args: never[]) => void): () => void
|
|
848
|
+
}
|
|
849
|
+
const feed: DshEventFace = {
|
|
850
|
+
on: (event: string, handler: (...args: never[]) => void) => bus.on(event, handler),
|
|
851
|
+
}
|
|
852
|
+
const withFeed = buildFusion(feed)
|
|
853
|
+
withFeed.start()
|
|
854
|
+
const previous = fusionHolder.current
|
|
855
|
+
fusionHolder.current = withFeed
|
|
856
|
+
previous.stop()
|
|
857
|
+
sctx.effect(() => () => {
|
|
858
|
+
// Service departing: swap back to a sidecar-only fusion so queries
|
|
859
|
+
// keep answering (and capabilities report the feed as gone).
|
|
860
|
+
const downgraded = buildFusion(null)
|
|
861
|
+
downgraded.start()
|
|
862
|
+
fusionHolder.current = downgraded
|
|
863
|
+
withFeed.stop()
|
|
864
|
+
}, 'agent-sidecar: fusion dsh feed release')
|
|
865
|
+
log('debug', 'fusion dsh event feed online (sessions service bound)')
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
// dsh injection + analysis path binding (M2/M3). Lazy inject, same
|
|
869
|
+
// pattern as settings below: `agents` (dsh-agent AgentRegistry) is
|
|
870
|
+
// present in every dsh-base composition, but a top-level hard inject
|
|
871
|
+
// would pend the whole fiber in agent-less compositions (see module
|
|
872
|
+
// doc). The callback rides its own fiber: cordis unloads and re-runs it
|
|
873
|
+
// whenever the service changes, and the effect disposer unbinds so the
|
|
874
|
+
// executor AND the analysis engine degrade cleanly again. Binding the
|
|
875
|
+
// service reference here (not per call) keeps resume/create's owner
|
|
876
|
+
// context on this fiber, so handles created for injection or analysis
|
|
877
|
+
// are drained by cordis if the plugin unloads.
|
|
878
|
+
ctx.inject(['agents'], (injected) => {
|
|
879
|
+
const actx = injected as HostContext & { agents: AgentsRegistryFace }
|
|
880
|
+
liveAgents = actx.agents
|
|
881
|
+
actx.effect(() => () => {
|
|
882
|
+
liveAgents = null
|
|
883
|
+
}, 'agent-sidecar: agents binding release')
|
|
884
|
+
log('debug', 'dsh inject + analysis paths online (agents service bound)')
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
// Skill path two (T6.2, design §7): register the embedded agent-sidecar
|
|
888
|
+
// skill provider. Lazy inject on `skills` (dsh-skill's service key):
|
|
889
|
+
// compositions without dsh-skill simply never run this — silent,
|
|
890
|
+
// capability-honest skip. Registration rides this callback's fiber, so
|
|
891
|
+
// cordis unregisters the provider on plugin unload / service departure
|
|
892
|
+
// (dsh-skill d.ts:243-244 "Fiber disposal unregisters the provider and
|
|
893
|
+
// invalidates catalog caches"); no manual effect wrapping, same posture
|
|
894
|
+
// as settings.register below. The gate reads the APPLY-TIME config value
|
|
895
|
+
// on purpose (restart semantics, documented in the schema description):
|
|
896
|
+
// a live settings flip cannot re-run this callback anyway.
|
|
897
|
+
ctx.inject(['skills'], (injected) => {
|
|
898
|
+
const sctx = injected as HostContext & { skills: SkillsServiceFace }
|
|
899
|
+
registerSidecarSkillProvider({
|
|
900
|
+
skills: sctx.skills,
|
|
901
|
+
provide: config.skill.provide,
|
|
902
|
+
log,
|
|
903
|
+
})
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
// Settings namespace 'agent-sidecar' (T2.4): pairs the browser settings
|
|
907
|
+
// card (keyed `settings.plugin.item` slot) and persists user edits into
|
|
908
|
+
// dsh's settings document. Lazy inject: compositions without dsh-settings
|
|
909
|
+
// simply never run this, and nothing else depends on it.
|
|
910
|
+
// `applies` (installed dsh-settings 0.1.1-rc.2, verified at source) is
|
|
911
|
+
// namespace-level UI-badge metadata surfaced through `describe()` only —
|
|
912
|
+
// commit() swaps the resolved value and notifies watchers regardless, so
|
|
913
|
+
// the `scope.watch → effective` chain below always takes effect
|
|
914
|
+
// immediately. `applies: 'live'` ('live' | 'restart' are the only
|
|
915
|
+
// values) is the honest badge for the security-relevant `inject.enabled`
|
|
916
|
+
// gate, which IS read live on every prepare/execute (M2 review F-1): a
|
|
917
|
+
// 'restart' badge would tell the user a gate they just closed is still
|
|
918
|
+
// open. Trade-off, documented: daemon.*/stream.*/sidecar values are
|
|
919
|
+
// baked into this assembly at apply time, so their edits still need a
|
|
920
|
+
// plugin reload despite the badge — a UX understatement, versus a badge
|
|
921
|
+
// that misstates a kill switch.
|
|
922
|
+
ctx.inject(['settings'], (injected) => {
|
|
923
|
+
try {
|
|
924
|
+
const sctx = injected as HostContext & { settings: SettingsServiceFace }
|
|
925
|
+
const scope = sctx.settings.register<Config>(name, Config, {
|
|
926
|
+
base: config,
|
|
927
|
+
applies: 'live',
|
|
928
|
+
})
|
|
929
|
+
effective = scope.get()
|
|
930
|
+
const unwatch = scope.watch((next) => {
|
|
931
|
+
effective = next
|
|
932
|
+
})
|
|
933
|
+
sctx.effect(() => () => {
|
|
934
|
+
unwatch()
|
|
935
|
+
effective = config
|
|
936
|
+
}, 'agent-sidecar: settings scope release')
|
|
937
|
+
log('debug', 'settings namespace registered', { applies: 'live' })
|
|
938
|
+
} catch (err) {
|
|
939
|
+
log('warn', `settings namespace registration failed: ${String(err)}`)
|
|
940
|
+
}
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
// Single startup line; the stable "host half assembled" marker is what the
|
|
944
|
+
// S0 triple evidence chain greps for (info does not reach the terminal in
|
|
945
|
+
// dsh 0.1.1-rc.2 web — s0_smoke.md F-4 — but the in-process log face and
|
|
946
|
+
// boot-completion probe both consume it).
|
|
947
|
+
ctx.logger.info(
|
|
948
|
+
`agent-sidecar: host half assembled (policy=${config.daemon.policy}, socket=${socketPath}, route=${API_PREFIX})`,
|
|
949
|
+
)
|
|
950
|
+
}
|