dsh-plan-completion 0.1.0 → 0.1.2

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/index.js CHANGED
@@ -1,1326 +1,1381 @@
1
- /**
2
- * Host half of dsh-plan-completion.
3
- *
4
- * Responsibilities (design doc v1.2 §3.5):
5
- * - watch committed Session events for `turn/end` and run one plan job per session;
6
- * - call the model through the injected `llm` service (never through the agent loop);
7
- * - keep the implicit plan and the next-message suggestion per session in memory;
8
- * - expose the state to the Client half over a local HTTP route registered on `webServer`;
9
- * - register the `/roadmap` and `/replan` human commands.
10
- *
11
- * The Client half only talks to this half through `/plan-completion/*`
12
- * (verified contract: same-origin fetch from the Electron page works and the body is readable).
13
- */
14
- import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
15
-
16
- import { DEFAULTS } from './host/config.js'
17
- import { normalizePlan, patchPlan, rewritePlan, statusFor, applyUserEdit } from './host/plan.js'
18
- import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput } from './host/prompt.js'
19
- import { heuristicSuggestion, fallbackPlan } from './host/heuristics.js'
20
- import { collectFromEvents } from './host/evidence.js'
21
- import { createMemoryStore } from './host/store.js'
22
- import { runExperiment } from './host/experiment.js'
23
- import { sliceEvidence } from './host/experiment.js'
24
-
25
- const ROUTE_PREFIX = '/plan-completion/'
26
-
27
- // Debug log location is configurable (`config.debugDir` or DSH_PLAN_COMPLETION_DEBUG_DIR).
28
- // Empty means "do not write files", so a published copy of this bundle carries no local paths.
29
- let debugDir = ''
30
- function debugLogPath() {
31
- return debugDir ? `${debugDir}\\plan-completion-runtime.jsonl` : ''
32
- }
33
-
34
- function debugLog(record) {
35
- try {
36
- if (!debugDir) return
37
- mkdirSync(debugDir, { recursive: true })
38
- appendFileSync(debugLogPath(), `${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`, 'utf8')
39
- } catch {
40
- /* ignore */
41
- }
42
- }
43
-
44
- function readJsonBody(req) {
45
- return new Promise(resolve => {
46
- let size = 0
47
- const chunks = []
48
- req.on('data', chunk => {
49
- size += chunk.length
50
- if (size > 512 * 1024) {
51
- resolve(null)
52
- req.destroy()
53
- return
54
- }
55
- chunks.push(chunk)
56
- })
57
- req.on('end', () => {
58
- try {
59
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
60
- } catch {
61
- resolve(null)
62
- }
63
- })
64
- req.on('error', () => resolve(null))
65
- })
66
- }
67
-
68
- function jsonResponse(res, status, value, origin) {
69
- let body = ''
70
- try {
71
- body = JSON.stringify(value)
72
- } catch {
73
- body = '{"ok":false}'
74
- }
75
- try {
76
- const headers = {
77
- 'content-type': 'application/json; charset=utf-8',
78
- 'cache-control': 'no-store',
79
- 'access-control-allow-origin': origin || '*',
80
- 'access-control-allow-credentials': 'true',
81
- 'access-control-allow-headers': 'content-type',
82
- 'access-control-allow-methods': 'GET,POST,OPTIONS',
83
- }
84
- if (typeof res.writeHead === 'function') {
85
- res.writeHead(status, headers)
86
- res.end(body)
87
- } else {
88
- res.end(body)
89
- }
90
- } catch {
91
- /* ignore */
92
- }
93
- }
94
-
95
- function createGate(limit) {
96
- let active = 0
97
- const waiters = []
98
- return {
99
- async acquire() {
100
- if (active < limit) {
101
- active += 1
102
- return
103
- }
104
- await new Promise(resolve => waiters.push(resolve))
105
- active += 1
106
- },
107
- release() {
108
- active = Math.max(0, active - 1)
109
- const next = waiters.shift()
110
- if (next) next()
111
- },
112
- }
113
- }
114
-
115
- /** Convert one request message to the block form the `llm` service accepts. */
116
- function toRequestMessage(message) {
117
- if (!message || typeof message !== 'object') return null
118
- const role = message.role === 'assistant' ? 'assistant' : message.role === 'user' ? 'user' : null
119
- if (!role) return null
120
- const content = message.content
121
- if (Array.isArray(content)) return { role, content }
122
- if (typeof content === 'string') {
123
- return role === 'assistant'
124
- ? { role, content: [{ type: 'text', text: content }] }
125
- : { role, content: [{ type: 'text', text: content }] }
126
- }
127
- return null
128
- }
129
-
130
- function normalizePromptMessages(prompt) {
131
- const messages = Array.isArray(prompt?.messages) ? prompt.messages : []
132
- return messages.map(toRequestMessage).filter(Boolean)
133
- }
134
-
135
- let modelRouteCache = null
136
- let modelRouteAt = 0
137
-
138
- /**
139
- * Pick a suggestion model that is likely to answer without spending the whole
140
- * token budget on reasoning. Resolution is cached for 10 minutes.
141
- */
142
- async function pickModel(services) {
143
- if (modelRouteCache && Date.now() - modelRouteAt < 10 * 60 * 1000) return modelRouteCache
144
- const llm = services.llm
145
- const candidates = []
146
- try {
147
- const selection = services.agentDefaultModel?.currentSelection?.()
148
- if (selection?.provider && selection?.model) {
149
- candidates.push({ provider: selection.provider, model: selection.model, source: 'session-default' })
150
- }
151
- } catch {
152
- /* ignore */
153
- }
154
- try {
155
- const providers = llm?.listProviders?.() ?? []
156
- for (const provider of providers) {
157
- const id = typeof provider === 'string' ? provider : provider?.id
158
- if (!id) continue
159
- let models = null
160
- try {
161
- models = await llm.listModels?.(id)
162
- } catch {
163
- models = null
164
- }
165
- if (!Array.isArray(models)) continue
166
- for (const model of models.slice(0, 20)) {
167
- if (!model?.id) continue
168
- if (candidates.some(item => item.provider === id && item.model === model.id)) continue
169
- candidates.push({ provider: id, model: model.id, source: 'route' })
170
- }
171
- }
172
- } catch {
173
- /* ignore */
174
- }
175
- if (!candidates.length) return null
176
- // Prefer a route whose resolved model advertises no reasoning effort.
177
- for (const candidate of candidates) {
178
- try {
179
- const info = await llm.resolveModelInfo?.(candidate.provider, candidate.model)
180
- if (info && !info.reasoning) {
181
- modelRouteCache = { ...candidate, reasoning: false }
182
- modelRouteAt = Date.now()
183
- return modelRouteCache
184
- }
185
- } catch {
186
- /* ignore */
187
- }
188
- }
189
- // Fall back to the first candidate, pinning the lowest advertised reasoning effort.
190
- const fallback = { ...candidates[0] }
191
- try {
192
- const info = await llm.resolveModelInfo?.(fallback.provider, fallback.model)
193
- const efforts = info?.reasoning?.efforts
194
- if (Array.isArray(efforts) && efforts.length) {
195
- fallback.reasoningEffort = efforts[0]?.id
196
- }
197
- } catch {
198
- /* ignore */
199
- }
200
- modelRouteCache = fallback
201
- modelRouteAt = Date.now()
202
- return modelRouteCache
203
- }
204
-
205
- let judgeRouteCache = null
206
-
207
- /**
208
- * Pick a *cross-family* judge model: a different provider first, then a different model on
209
- * the main provider. Each candidate is preflighted with a tiny call because some registered
210
- * routes have no usable credentials (deepseek-official in this profile rejects the local key).
211
- */
212
- async function pickJudgeRoute(services, mainRoute) {
213
- const cacheKey = `${mainRoute?.provider ?? ''}/${mainRoute?.model ?? ''}`
214
- if (judgeRouteCache && judgeRouteCache.main === cacheKey) return judgeRouteCache.route
215
- const llm = services.llm
216
- const fallback = mainRoute
217
- if (!llm || !mainRoute) return fallback
218
- const candidates = []
219
- try {
220
- const providers = llm.listProviders?.() ?? []
221
- for (const provider of providers) {
222
- const id = typeof provider === 'string' ? provider : provider?.id
223
- if (!id) continue
224
- let models = null
225
- try {
226
- models = await llm.listModels?.(id)
227
- } catch {
228
- models = null
229
- }
230
- if (!Array.isArray(models)) continue
231
- for (const model of models) {
232
- if (!model?.id) continue
233
- if (id === mainRoute.provider && model.id === mainRoute.model) continue
234
- // Prefer other providers (= different family) by pushing them first.
235
- const entry = { provider: id, model: model.id, crossFamily: id !== mainRoute.provider }
236
- candidates.push(entry)
237
- }
238
- }
239
- } catch {
240
- /* ignore */
241
- }
242
- candidates.sort((a, b) => (b.crossFamily ? 1 : 0) - (a.crossFamily ? 1 : 0));
243
- for (const candidate of candidates) {
244
- try {
245
- const info = await llm.resolveModelInfo?.(candidate.provider, candidate.model)
246
- if (info && info.reasoning) candidate.reasoningEffort = 'off'
247
- const probe = await callModel(services, candidate, {
248
- system: '只回复 ok',
249
- messages: [{ role: 'user', content: 'ok?' }],
250
- maxTokens: 8,
251
- temperature: 0,
252
- });
253
- if (String(probe.text ?? '').trim().length >= 0) {
254
- judgeRouteCache = { main: cacheKey, route: candidate }
255
- return candidate
256
- }
257
- } catch (error) {
258
- /* try the next candidate */
259
- }
260
- }
261
- judgeRouteCache = { main: cacheKey, route: fallback }
262
- return fallback
263
- }
264
-
265
- async function callModel(services, route, prompt, signal) {
266
- if (!services.llm || !route) throw new Error('no-llm-route')
267
- const messages = normalizePromptMessages(prompt)
268
- if (!messages.length) throw new Error('empty-messages')
269
- let text = ''
270
- let usage = null
271
- const request = {
272
- provider: route.provider,
273
- model: route.model,
274
- system: prompt.system,
275
- messages,
276
- maxTokens: prompt.maxTokens,
277
- temperature: prompt.temperature,
278
- signal,
279
- }
280
- if (route.reasoningEffort) request.reasoningEffort = route.reasoningEffort
281
- const stream = services.llm.stream(request)
282
- for await (const chunk of stream) {
283
- if (!chunk || typeof chunk !== 'object') continue
284
- if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
285
- else if (chunk.type === 'usage' && chunk.usage) usage = chunk.usage
286
- else if (chunk.type === 'finish') {
287
- const kind = chunk.reason?.kind
288
- if (kind === 'error' || kind === 'aborted') {
289
- throw new Error(`model-${kind}:${chunk.reason?.failure?.message ?? ''}`)
290
- }
291
- }
292
- }
293
- return { text, usage }
294
- }
295
-
296
- const HOST_GLOBAL_KEY = '__dsh_plan_completion_host__'
297
-
298
- /**
299
- * Process-wide host state. DSH may re-apply this plugin (toggle, hot reload) while the old
300
- * module instance is still alive: keeping state and route disposers in `globalThis` makes the
301
- * new instance reuse the same maps and dispose the previous route handlers, so we never end
302
- * up with two live handler sets answering different state.
303
- */
304
- function hostGlobal() {
305
- const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), disposers: [] }
306
- if (typeof globalThis === 'undefined') return fallback
307
- if (!globalThis[HOST_GLOBAL_KEY] || typeof globalThis[HOST_GLOBAL_KEY] !== 'object') {
308
- globalThis[HOST_GLOBAL_KEY] = fallback
309
- }
310
- const shared = globalThis[HOST_GLOBAL_KEY]
311
- if (!(shared.states instanceof Map)) shared.states = new Map()
312
- if (!(shared.sessions instanceof Map)) shared.sessions = new Map()
313
- if (!(shared.handledTurns instanceof Map)) shared.handledTurns = new Map()
314
- if (!(shared.warmAttempts instanceof Map)) shared.warmAttempts = new Map()
315
- if (!Array.isArray(shared.disposers)) shared.disposers = []
316
- return shared
317
- }
318
-
319
- export function apply(ctx, config = {}) {
320
- // Production settings, frozen by the round-1/round-2 isolation tests:
321
- // evidenceChars 1500 — compact evidence slice for the patch path
322
- // maxTokensPatch 400 — patch output only needs ~65-120 tokens
323
- // maxTokensRewrite 1200 — rewrite must emit the full step table
324
- const readInt = (value, fallback, min, max) => {
325
- const number = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback
326
- return Math.min(max, Math.max(min, number))
327
- }
328
- const rawConfig = config && typeof config === 'object' ? config : {}
329
- const settings = {
330
- evidenceChars: readInt(rawConfig.evidenceChars, 1500, 400, 8000),
331
- maxTokensPatch: readInt(rawConfig.maxTokensPatch, 400, 120, 4000),
332
- maxTokensRewrite: readInt(rawConfig.maxTokensRewrite, 1200, 400, 8000),
333
- warmBackfill: rawConfig.warmBackfill !== false,
334
- enableDebugRoutes: rawConfig.enableDebugRoutes === true,
335
- debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
336
- ? rawConfig.debugDir.trim()
337
- : (typeof process !== 'undefined' && process.env && typeof process.env.DSH_PLAN_COMPLETION_DEBUG_DIR === 'string'
338
- ? process.env.DSH_PLAN_COMPLETION_DEBUG_DIR.trim()
339
- : ''),
340
- }
341
- debugDir = settings.debugDir
342
-
343
- // Services may come up after this plugin on a cold boot, so resolve them lazily on every
344
- // access instead of snapshotting them during apply(). Registration that needs a service is
345
- // wrapped in ctx.inject([...]) below, which re-runs once the service becomes available.
346
- const services = {
347
- get llm() {
348
- return ctx.get('llm')
349
- },
350
- get webServer() {
351
- return ctx.get('webServer')
352
- },
353
- get commands() {
354
- return ctx.get('commands')
355
- },
356
- get agentDefaultModel() {
357
- return ctx.get('agentDefaultModel')
358
- },
359
- get agents() {
360
- return ctx.get('agents')
361
- },
362
- get sessionPersistence() {
363
- return ctx.get('sessionPersistence')
364
- },
365
- }
366
-
367
- const shared = hostGlobal()
368
- const states = shared.states // sessionId -> { plan, suggestion, stale }
369
- const sessions = shared.sessions // sessionId -> Session
370
- const warmAttempts = shared.warmAttempts // sessionId -> { at, seq }
371
- const inflight = new Map() // sessionId -> AbortController
372
- const gate = createGate(DEFAULTS.globalConcurrency)
373
- const store = createMemoryStore()
374
- const debug = { lastTurnEnd: null, lastJob: null, model: null }
375
- let debugTheme = null
376
- const experimentState = { running: false, result: null, error: null }
377
-
378
- const publicView = sessionId => {
379
- const state = states.get(sessionId)
380
- const plan = state?.plan ?? null
381
- return {
382
- sessionId,
383
- plan,
384
- version: plan?.version ?? null,
385
- status: plan?.status ?? null,
386
- intent: plan?.intent ?? null,
387
- steps: Array.isArray(plan?.steps) ? plan.steps : [],
388
- updatedAt: plan?.updatedAt ?? state?.updatedAt ?? null,
389
- reason: plan?.reason ?? null,
390
- suggestion: state?.suggestion ?? null,
391
- stale: state ? Boolean(state.stale) : true,
392
- debugTheme,
393
- }
394
- }
395
-
396
- function publishState(sessionId, patch) {
397
- const previous = states.get(sessionId) ?? { plan: null, suggestion: null, stale: false }
398
- states.set(sessionId, { ...previous, ...patch })
399
- }
400
-
401
- function seedPlan(sessionId, evidence) {
402
- const intent = evidence?.intent || evidence?.firstUserMessage || '推进当前会话目标'
403
- const fallback = (() => {
404
- try {
405
- return fallbackPlan(intent)
406
- } catch {
407
- return null
408
- }
409
- })()
410
- const steps = fallback?.ok && Array.isArray(fallback.steps) ? fallback.steps : [
411
- { id: 's1', title: '确认目标与约束', status: 'done', source: 'model' },
412
- { id: 's2', title: '完成核心改动', status: 'doing', source: 'model' },
413
- { id: 's3', title: '验证并收尾', status: 'todo', source: 'model' },
414
- ]
415
- const candidate = {
416
- sessionId,
417
- version: { gen: 0, rev: 0 },
418
- status: 'active',
419
- intent: fallback?.intent || intent,
420
- steps,
421
- updatedAt: Date.now(),
422
- reason: '启发式初始化计划',
423
- watermark: evidence?.watermark ?? { turn: 0, seq: 0 },
424
- editedByUser: false,
425
- }
426
- try {
427
- const normalized = normalizePlan(candidate)
428
- if (normalized?.ok && normalized.plan) return normalized.plan
429
- } catch {
430
- /* ignore */
431
- }
432
- return null
433
- }
434
-
435
- function heuristicText(evidence) {
436
- try {
437
- const result = heuristicSuggestion(evidence)
438
- if (result?.ok && typeof result.text === 'string' && result.text.trim()) return result.text.trim()
439
- } catch {
440
- /* ignore */
441
- }
442
- return ''
443
- }
444
-
445
- // ── Warm backfill: build an implicit plan for a session the plugin has never observed ──
446
- // Live sessions come from `sessions.get()`; older sessions are read through the durable
447
- // session store so opening them also produces a plan + suggestion.
448
- async function loadStoredEvents(sessionId, limit = 240) {
449
- const persistence = services.sessionPersistence
450
- if (!persistence || typeof persistence.open !== 'function') return null
451
- let handle = null
452
- try {
453
- handle = await persistence.open(sessionId, 'read')
454
- let total = 0
455
- let page = null
456
- try {
457
- const stat = await persistence.stat?.(sessionId)
458
- total = Number(stat?.eventCount ?? 0)
459
- page = stat
460
- } catch {
461
- total = 0
462
- }
463
- const from = Math.max(0, total - limit)
464
- let read = await handle.read(from, limit)
465
- let events = Array.isArray(read?.events) ? read.events.slice() : []
466
- // Fallback: some backends report no eventCount — page from the beginning until the end.
467
- if (!events.length && !page) {
468
- let offset = 0
469
- for (let guard = 0; guard < 40; guard += 1) {
470
- const chunk = await handle.read(offset, 400)
471
- const batch = Array.isArray(chunk?.events) ? chunk.events : []
472
- if (!batch.length) break
473
- events = events.concat(batch)
474
- offset += batch.length
475
- }
476
- events = events.slice(-limit)
477
- }
478
- return events
479
- } catch {
480
- return null
481
- } finally {
482
- try {
483
- await handle?.close?.()
484
- } catch {
485
- /* ignore */
486
- }
487
- }
488
- }
489
-
490
- function warmSessionShim(sessionId, events) {
491
- const last = events[events.length - 1] ?? null
492
- let lastTurn = 0
493
- let maxTurn = 0
494
- let hasConversation = false
495
- for (const event of events) {
496
- const type = String(event?.type ?? '')
497
- if (type === 'turn/end') lastTurn = Math.max(lastTurn, Number(event.data?.turn ?? 0))
498
- if (type === 'turn/start') maxTurn = Math.max(maxTurn, Number(event.data?.turn ?? 0))
499
- if (type === 'user/message' || type === 'assistant/message') hasConversation = true
500
- }
501
- return {
502
- warm: true,
503
- id: sessionId,
504
- seq: Number(last?.seq ?? 0),
505
- lastTurn: lastTurn || maxTurn,
506
- hasConversation,
507
- snapshotEvents(from) {
508
- const start = Number(from ?? 0)
509
- return events.filter(event => Number(event?.seq ?? 0) >= start)
510
- },
511
- }
512
- }
513
-
514
- async function runWarmBackfill(sessionId, options = {}) {
515
- if (!settings.warmBackfill) return { ok: false, reason: 'disabled' }
516
- if (!sessionId) return { ok: false, reason: 'no-session-id' }
517
- if (states.get(sessionId)?.plan) return { ok: true, already: true }
518
- const now = Date.now()
519
- const attempt = warmAttempts.get(sessionId)
520
- if (!options.force && attempt && now - Number(attempt.at ?? 0) < 10 * 60 * 1000) {
521
- return { ok: true, skipped: 'recent', at: attempt.at }
522
- }
523
- warmAttempts.set(sessionId, { at: now, seq: attempt?.seq ?? 0 })
524
-
525
- const live = sessions.get(sessionId) ?? null
526
- if (live) {
527
- const turn = Number(debug.lastTurnEnd?.sessionId === sessionId ? debug.lastTurnEnd.turn : 0)
528
- void runGeneration(live, { seq: Number(live.seq ?? 0), data: { turn } }, { force: true })
529
- return { ok: true, started: true, source: 'live' }
530
- }
531
-
532
- const events = await loadStoredEvents(sessionId, 1500)
533
- if (!events || events.length === 0) {
534
- debugLog({ phase: 'warm-no-history', sessionId })
535
- return { ok: false, reason: 'no-history' }
536
- }
537
- const shim = warmSessionShim(sessionId, events)
538
- if (!shim.hasConversation) {
539
- return {
540
- ok: false,
541
- reason: 'no-conversation',
542
- detail: { events: events.length, types: events.slice(-8).map(event => event?.type) },
543
- }
544
- }
545
- warmAttempts.set(sessionId, { at: now, seq: shim.seq })
546
- debugLog({ phase: 'warm-start', sessionId, events: events.length, turn: shim.lastTurn })
547
- try {
548
- await runGeneration(shim, { seq: shim.seq, data: { turn: shim.lastTurn } }, { force: true })
549
- } catch (error) {
550
- debugLog({ phase: 'warm-failed', sessionId, error: String(error) })
551
- return { ok: false, reason: 'generation-failed', detail: String(error) }
552
- }
553
- const view = publicView(sessionId)
554
- debugLog({ phase: 'warm-done', sessionId, version: view.version, hasSuggestion: Boolean(view.suggestion) })
555
- return { ok: true, warmed: true, version: view.version, suggestion: view.suggestion, plan: view.plan }
556
- }
557
-
558
- async function runGeneration(session, turnEndEvent, options = {}) {
559
- const sessionId = session?.id
560
- if (!sessionId) return
561
- const seq = Number(turnEndEvent?.seq ?? 0)
562
- const turn = Number(turnEndEvent?.data?.turn ?? 0)
563
- const force = Boolean(options.force)
564
- const instruction = typeof options.instruction === 'string' ? options.instruction.trim() : ''
565
- const budget = force ? DEFAULTS.forceRewriteDeadlineMs : DEFAULTS.planDeadlineMs
566
- const startedAt = Date.now()
567
- debugLog({ phase: 'job-start', sessionId, turn, force })
568
-
569
- const previous = inflight.get(sessionId)
570
- if (previous) previous.abort(new Error('superseded'))
571
- const controller = new AbortController()
572
- inflight.set(sessionId, controller)
573
- const timer = setTimeout(() => controller.abort(new Error('plan-deadline')), budget)
574
-
575
- await gate.acquire()
576
- try {
577
- let events = []
578
- try {
579
- events = Array.from(session.snapshotEvents(Math.max(0, seq - 300)) ?? [])
580
- } catch {
581
- events = []
582
- }
583
-
584
- const basePlan = states.get(sessionId)?.plan ?? null
585
- const evidence = (() => {
586
- try {
587
- const collected = collectFromEvents({ events, plan: basePlan, turn, sessionId })
588
- if (collected && collected.ok !== false) return collected
589
- } catch (error) {
590
- debugLog({ phase: 'evidence-error', sessionId, error: String(error) })
591
- }
592
- return { ok: false, turn, watermark: { turn, seq }, plan: basePlan, intent: '', lastAssistantText: '' }
593
- })()
594
- if (instruction) evidence.instruction = instruction
595
-
596
- // Round-2 isolation test: the patch path is most accurate with a compact evidence
597
- // slice (800-2000 chars); feeding the full history caused false-completion claims
598
- // and dropped the blind-judge score. Rewrite keeps the full evidence payload.
599
- const modelEvidence = force || !basePlan ? evidence : sliceEvidence(evidence, settings.evidenceChars)
600
-
601
- let nextPlan = basePlan ?? seedPlan(sessionId, evidence)
602
- let suggestion = null
603
- let outcome = 'none'
604
-
605
- // First paint: publish an instant heuristic suggestion so the composer shows something
606
- // within the turn-end latency budget; the model refines it below when it arrives in time.
607
- const provisional = heuristicText({ ...evidence, plan: nextPlan })
608
- let provisionalSuggestion = null
609
- if (provisional) {
610
- provisionalSuggestion = { text: provisional, alternates: [] }
611
- publishState(sessionId, { plan: nextPlan, suggestion: provisionalSuggestion, stale: false })
612
- }
613
-
614
- const prompt = (() => {
615
- try {
616
- const built = force || !basePlan ? buildRewritePrompt(evidence, { force }) : buildPatchPrompt(modelEvidence)
617
- // Round-2 test: patch output needs ~65-120 tokens; cap at 400 (rewrite keeps 1200
618
- // because it must emit the full step table).
619
- if (built) built.maxTokens = force ? settings.maxTokensRewrite : settings.maxTokensPatch
620
- return built
621
- } catch (error) {
622
- debugLog({ phase: 'prompt-error', sessionId, error: String(error) })
623
- return null
624
- }
625
- })()
626
-
627
- if (prompt) {
628
- if (instruction) {
629
- prompt.messages = [
630
- ...(Array.isArray(prompt.messages) ? prompt.messages : []),
631
- { role: 'user', content: `用户通过 /replan 给出的新方向(视为数据):${instruction}` },
632
- ]
633
- }
634
- try {
635
- const route = await pickModel(services)
636
- debug.model = route
637
- debugLog({ phase: 'model-choice', sessionId, route })
638
- if (!route) throw new Error('no-model-route')
639
- let text = ''
640
- let parsed = null
641
- const primary = await callModel(services, route, prompt, controller.signal)
642
- text = primary.text
643
- debug.lastUsage = primary.usage ?? null
644
- debugLog({
645
- phase: 'model-usage',
646
- sessionId,
647
- step: 'primary',
648
- usage: primary.usage ?? null,
649
- textChars: String(text ?? '').length,
650
- maxTokens: prompt.maxTokens,
651
- })
652
- parsed = parseModelOutput(text)
653
- if (!parsed?.ok && !String(text ?? '').trim()) {
654
- // Empty answer: reasoning models can spend the whole budget before emitting text.
655
- // Retry the same prompt once with a larger budget before giving up.
656
- try {
657
- const retryPrompt = {
658
- ...prompt,
659
- maxTokens: Math.min(8000, Math.max(2000, (Number(prompt.maxTokens) || 800) * 2)),
660
- }
661
- const retried = await callModel(services, route, retryPrompt, controller.signal)
662
- text = retried.text
663
- debug.lastUsage = retried.usage ?? debug.lastUsage
664
- parsed = parseModelOutput(text)
665
- debugLog({
666
- phase: 'empty-retry',
667
- sessionId,
668
- ok: parsed?.ok === true,
669
- code: parsed?.code,
670
- maxTokens: retryPrompt.maxTokens,
671
- usage: retried.usage ?? null,
672
- textHead: String(text ?? '').slice(0, 120),
673
- })
674
- } catch (error) {
675
- debugLog({ phase: 'empty-retry-failed', sessionId, error: String(error) })
676
- }
677
- } else if (!parsed?.ok) {
678
- // Non-empty but not valid JSON (truncated / stray text): one repair retry.
679
- debugLog({ phase: 'parse-retry', sessionId, code: parsed?.code, error: parsed?.error })
680
- try {
681
- const repairPrompt = buildRepairPrompt(prompt, text, parsed?.error ?? '')
682
- if (repairPrompt) repairPrompt.maxTokens = Math.max(Number(repairPrompt.maxTokens) || 0, 1600)
683
- const repairedRes = await callModel(services, route, repairPrompt, controller.signal)
684
- debug.lastUsage = repairedRes.usage ?? debug.lastUsage
685
- const reparsed = parseModelOutput(repairedRes.text)
686
- if (reparsed?.ok) parsed = reparsed
687
- } catch (error) {
688
- debugLog({ phase: 'repair-failed', sessionId, error: String(error) })
689
- }
690
- }
691
- if (parsed?.ok && parsed.output) {
692
- const output = parsed.output
693
- const meta = {
694
- reason: output.reason || '模型更新',
695
- watermark: { turn, seq },
696
- now: Date.now(),
697
- sessionId,
698
- force,
699
- }
700
- let applied = null
701
- if (output.op === 'patch' && nextPlan) {
702
- applied = patchPlan(nextPlan, output.changes, meta)
703
- } else {
704
- const steps = output.op === 'rewrite' ? output.steps : null
705
- if (steps && steps.length) {
706
- applied = rewritePlan(nextPlan, {
707
- steps,
708
- intent: output.intent || undefined,
709
- reason: output.reason,
710
- }, meta)
711
- }
712
- }
713
- if (applied?.ok && applied.plan) {
714
- nextPlan = applied.plan
715
- nextPlan.status = statusFor(nextPlan.steps)
716
- outcome = applied.changed === false ? 'unchanged' : 'committed'
717
- } else if (applied && !applied.ok) {
718
- debugLog({ phase: 'plan-apply-rejected', sessionId, code: applied.code, error: applied.error })
719
- }
720
- if (typeof output.suggestion === 'string' && output.suggestion.trim()) {
721
- suggestion = {
722
- text: output.suggestion.trim(),
723
- alternates: Array.isArray(output.alternates) ? output.alternates.slice(0, DEFAULTS.alternatesMax) : [],
724
- }
725
- }
726
- } else {
727
- debugLog({ phase: 'parse-failed', sessionId, code: parsed?.code, error: parsed?.error, textHead: String(text).slice(0, 240) })
728
- }
729
- } catch (error) {
730
- debugLog({ phase: 'model-error', sessionId, error: String(error) })
731
- }
732
- }
733
-
734
- if (!suggestion) {
735
- const fallbackText = heuristicText({ ...evidence, plan: nextPlan })
736
- if (fallbackText) suggestion = { text: fallbackText, alternates: [] }
737
- }
738
-
739
- if (nextPlan) {
740
- try {
741
- const committed = nextPlan === basePlan || outcome === 'unchanged'
742
- ? true
743
- : store.put(nextPlan, basePlan ? { cas: basePlan.version } : {})
744
- if (!committed) {
745
- debugLog({ phase: 'store-cas-miss', sessionId })
746
- nextPlan = basePlan
747
- }
748
- } catch (error) {
749
- debugLog({ phase: 'store-error', sessionId, error: String(error) })
750
- }
751
- }
752
-
753
- const elapsed = Date.now() - startedAt
754
- const suggestionDeadline = force ? Number.POSITIVE_INFINITY : Math.max(DEFAULTS.suggestionDeadlineMs, 6000)
755
- const withinSuggestionDeadline = elapsed <= suggestionDeadline
756
- const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
757
- publishState(sessionId, {
758
- plan: nextPlan,
759
- suggestion: finalSuggestion,
760
- stale: false,
761
- })
762
- debug.lastJob = { sessionId, turn, elapsed, outcome, suggestion: Boolean(suggestion), withinSuggestionDeadline }
763
- debugLog({ phase: 'job-done', sessionId, turn, elapsed, outcome, withinSuggestionDeadline })
764
- } catch (error) {
765
- debugLog({ phase: 'job-failed', sessionId, turn, error: String(error) })
766
- const existing = states.get(sessionId)
767
- publishState(sessionId, { plan: existing?.plan ?? null, suggestion: existing?.suggestion ?? null, stale: Boolean(existing?.plan) })
768
- } finally {
769
- clearTimeout(timer)
770
- if (inflight.get(sessionId) === controller) inflight.delete(sessionId)
771
- gate.release()
772
- }
773
- }
774
-
775
- // ── Session event subscription: one plan job per completed turn ──────────
776
- const handledTurns = shared.handledTurns // sessionId -> last handled turn number
777
-
778
- function triggerTurnEnd(session, seq, turn, source) {
779
- if (!session?.id) return
780
- sessions.set(session.id, session)
781
- const turnNumber = Number(turn) || 0
782
- const key = `${session.id}:${turnNumber}`
783
- if (handledTurns.get(session.id) === turnNumber) return
784
- handledTurns.set(session.id, turnNumber)
785
- debug.lastTurnEnd = { sessionId: session.id, seq: Number(seq) || 0, turn: turnNumber, source }
786
- debugLog({ phase: 'turn-end-trigger', sessionId: session.id, turn: turnNumber, source })
787
- void runGeneration(session, { seq: Number(seq) || 0, data: { turn: turnNumber } }, { force: false })
788
- }
789
-
790
- // Deterministic idle fallback: after the session log goes quiet for a moment, read the last
791
- // committed `turn/end` straight from the session log. This does not depend on the turn-boundary
792
- // event reaching the plugin context (it may be missed across a plugin reload window).
793
- const idleTimers = new Map()
794
- const IDLE_QUIET_MS = 2000
795
- ctx.effect(() => () => {
796
- for (const timer of idleTimers.values()) clearTimeout(timer)
797
- idleTimers.clear()
798
- })
799
-
800
- function checkIdleTurn(session) {
801
- if (!session?.id) return
802
- let events = []
803
- try {
804
- const from = Math.max(0, Number(session.seq ?? 0) - 160)
805
- events = Array.from(session.snapshotEvents(from) ?? [])
806
- } catch {
807
- return
808
- }
809
- for (let i = events.length - 1; i >= 0; i -= 1) {
810
- const event = events[i]
811
- if (event?.type !== 'turn/end') continue
812
- if (event.data?.reason?.kind !== 'completed') return
813
- const eventTime = Number(event.time ?? 0)
814
- // Only act on a turn that just ended (plugin reloads must not replay old turns).
815
- if (eventTime > 0 && Date.now() - eventTime > 5 * 60 * 1000) return
816
- const turn = Number(event.data?.turn ?? 0)
817
- if (handledTurns.get(session.id) === turn) return
818
- debugLog({ phase: 'idle-turn-end', sessionId: session.id, turn })
819
- triggerTurnEnd(session, event.seq, turn, 'idle-log-scan')
820
- return
821
- }
822
- }
823
-
824
- function scheduleIdleCheck(session) {
825
- if (!session?.id) return
826
- const previous = idleTimers.get(session.id)
827
- if (previous) clearTimeout(previous)
828
- const timer = setTimeout(() => {
829
- idleTimers.delete(session.id)
830
- try {
831
- checkIdleTurn(sessions.get(session.id) ?? session)
832
- } catch (error) {
833
- debugLog({ phase: 'idle-check-error', sessionId: session.id, error: String(error) })
834
- }
835
- }, IDLE_QUIET_MS)
836
- idleTimers.set(session.id, timer)
837
- }
838
-
839
- ctx.on('session/event', (session, event) => {
840
- try {
841
- debug.eventCount = (debug.eventCount ?? 0) + 1
842
- debug.eventTypes = debug.eventTypes ?? {}
843
- const type = String(event?.type ?? 'unknown')
844
- debug.eventTypes[type] = (debug.eventTypes[type] ?? 0) + 1
845
- if (debug.eventCount <= 12) debugLog({ phase: 'session-event', type, sessionId: session?.id })
846
- if (!session?.id) return
847
- sessions.set(session.id, session)
848
- scheduleIdleCheck(session)
849
- if (event?.type !== 'turn/end') return
850
- debugLog({ phase: 'turn-end-raw', sessionId: session.id, turn: event.data?.turn, reason: event.data?.reason?.kind, seq: event.seq })
851
- if (event.data?.reason?.kind !== 'completed') return
852
- triggerTurnEnd(session, event.seq, event.data?.turn, 'session/event')
853
- } catch (error) {
854
- debugLog({ phase: 'listener-error', error: String(error) })
855
- }
856
- })
857
-
858
- // Fallback trigger: if a `turn/end` event is not observed through `session/event` for any reason,
859
- // the transition to `idle` right after the turn still lets us read the last committed turn from
860
- // the session log and run the same job exactly once per turn.
861
- ctx.on('agent/status', payload => {
862
- try {
863
- if (payload?.status !== 'idle') return
864
- const sessionId = payload?.agent?.id
865
- if (!sessionId) return
866
- const session = sessions.get(sessionId)
867
- if (!session) {
868
- debugLog({ phase: 'agent-idle-no-session', sessionId })
869
- return
870
- }
871
- let events = []
872
- try {
873
- events = Array.from(session.snapshotEvents(Math.max(0, Number(session.seq ?? 0) - 120)) ?? [])
874
- } catch {
875
- events = []
876
- }
877
- for (let i = events.length - 1; i >= 0; i -= 1) {
878
- const event = events[i]
879
- if (event?.type !== 'turn/end') continue
880
- debugLog({ phase: 'agent-idle-turn-end', sessionId, turn: event.data?.turn, reason: event.data?.reason?.kind })
881
- if (event.data?.reason?.kind === 'completed') triggerTurnEnd(session, event.seq, event.data?.turn, 'agent/status-idle')
882
- return
883
- }
884
- debugLog({ phase: 'agent-idle-no-turn-end', sessionId })
885
- } catch (error) {
886
- debugLog({ phase: 'agent-status-error', error: String(error) })
887
- }
888
- })
889
-
890
- ctx.on('agent/turn-stopping', payload => {
891
- try {
892
- debugLog({ phase: 'turn-stopping', sessionId: payload?.agent?.id, turn: payload?.turn })
893
- } catch {
894
- /* ignore */
895
- }
896
- })
897
-
898
- // ── HTTP routes for the Client half ──────────────────────────────────────
899
- const handleRoute = async (req, res) => {
900
- const origin = (() => {
901
- try {
902
- return req.headers?.origin ?? null
903
- } catch {
904
- return null
905
- }
906
- })()
907
- let url = null
908
- try {
909
- url = new URL(req.url, 'http://127.0.0.1')
910
- } catch {
911
- url = null
912
- }
913
- const pathname = url?.pathname ?? String(req.url ?? '')
914
- const method = String(req.method ?? 'GET').toUpperCase()
915
- debug.requestCount = (debug.requestCount ?? 0) + 1
916
- if (debug.requestCount <= 200) {
917
- debugLog({
918
- phase: 'http',
919
- method,
920
- pathname,
921
- query: url?.search ?? '',
922
- ua: String(req.headers?.['user-agent'] ?? '').slice(0, 48),
923
- cookie: Boolean(req.headers?.cookie),
924
- })
925
- }
926
-
927
- if (method === 'OPTIONS') {
928
- jsonResponse(res, 204, {}, origin)
929
- return
930
- }
931
- if (pathname === `${ROUTE_PREFIX}warm` && method === 'POST') {
932
- const body = (await readJsonBody(req)) ?? {}
933
- const sessionId = String(body.sessionId ?? '')
934
- if (!sessionId) {
935
- jsonResponse(res, 200, { ok: false, reason: 'no-session-id' }, origin)
936
- return
937
- }
938
- const outcome = await runWarmBackfill(sessionId, { force: body.force === true })
939
- jsonResponse(res, 200, outcome, origin)
940
- return
941
- }
942
- if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
943
- jsonResponse(res, 200, publicView(url?.searchParams?.get('sessionId') ?? ''), origin)
944
- return
945
- }
946
- if (pathname === `${ROUTE_PREFIX}debug` && method === 'GET') {
947
- const probeSessionId = url?.searchParams?.get('sessionId') ?? ''
948
- const logTail = (() => {
949
- if (!probeSessionId) return null
950
- const session = sessions.get(probeSessionId)
951
- if (!session) return null
952
- try {
953
- const from = Math.max(0, Number(session.seq ?? 0) - 60)
954
- return Array.from(session.snapshotEvents(from) ?? []).map(event => `${event.seq}:${event.type}`)
955
- } catch (error) {
956
- return [`error:${String(error)}`]
957
- }
958
- })()
959
- jsonResponse(res, 200, {
960
- ...debug,
961
- sessions: Array.from(sessions.keys()),
962
- states: Array.from(states.keys()),
963
- probeSessionId,
964
- logTail,
965
- }, origin)
966
- return
967
- }
968
- if (pathname === `${ROUTE_PREFIX}replan` && method === 'POST') {
969
- const body = (await readJsonBody(req)) ?? {}
970
- const sessionId = String(body.sessionId ?? '')
971
- const session = sessions.get(sessionId)
972
- if (!session) {
973
- jsonResponse(res, 200, { ok: false, error: 'session-not-observed' }, origin)
974
- return
975
- }
976
- const turn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
977
- void runGeneration(
978
- session,
979
- { seq: Number(debug.lastTurnEnd?.seq ?? 0), data: { turn } },
980
- { force: true, instruction: typeof body.instruction === 'string' ? body.instruction : '' },
981
- )
982
- jsonResponse(res, 200, { ok: true, queued: true }, origin)
983
- return
984
- }
985
- if (pathname === `${ROUTE_PREFIX}diag` && method === 'GET') {
986
- jsonResponse(res, 200, { ok: true, lastDiag: debug.lastDiag ?? null }, origin)
987
- return
988
- }
989
- if (pathname === `${ROUTE_PREFIX}diag` && method === 'POST') {
990
- const body = (await readJsonBody(req)) ?? {}
991
- debug.lastDiag = { at: Date.now(), body }
992
- debugLog({ phase: 'client-diag', body })
993
- jsonResponse(res, 200, { ok: true }, origin)
994
- return
995
- }
996
- if (pathname === `${ROUTE_PREFIX}autojob` && method === 'POST') {
997
- const body = (await readJsonBody(req)) ?? {}
998
- const sessionId = String(body.sessionId ?? '')
999
- const session = sessions.get(sessionId)
1000
- if (!session) {
1001
- jsonResponse(res, 200, { ok: false, error: 'session-not-observed' }, origin)
1002
- return
1003
- }
1004
- if (!states.get(sessionId)?.plan) {
1005
- jsonResponse(res, 200, { ok: false, error: 'no-plan' }, origin)
1006
- return
1007
- }
1008
- const baseTurn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
1009
- const startedAt = Date.now()
1010
- try {
1011
- await runGeneration(
1012
- session,
1013
- { seq: Number(session.seq ?? 0), data: { turn: baseTurn + 1 } },
1014
- { force: false },
1015
- )
1016
- } catch (error) {
1017
- debugLog({ phase: 'autojob-error', sessionId, error: String(error) })
1018
- }
1019
- const view = publicView(sessionId)
1020
- jsonResponse(res, 200, {
1021
- ok: true,
1022
- elapsedMs: Date.now() - startedAt,
1023
- usage: debug.lastUsage ?? null,
1024
- version: view.version,
1025
- suggestion: view.suggestion,
1026
- stepCount: Array.isArray(view.steps) ? view.steps.length : 0,
1027
- reason: view.reason,
1028
- lastJob: debug.lastJob ?? null,
1029
- }, origin)
1030
- return
1031
- }
1032
- if (pathname === `${ROUTE_PREFIX}reset` && method === 'POST') {
1033
- const body = (await readJsonBody(req)) ?? {}
1034
- const sessionId = String(body.sessionId ?? '')
1035
- const existed = states.has(sessionId)
1036
- states.delete(sessionId)
1037
- debugLog({ phase: 'state-reset', sessionId, existed })
1038
- jsonResponse(res, 200, { ok: true, sessionId, existed }, origin)
1039
- return
1040
- }
1041
- if (pathname === `${ROUTE_PREFIX}steps` && method === 'POST') {
1042
- const body = (await readJsonBody(req)) ?? {}
1043
- const sessionId = String(body.sessionId ?? '')
1044
- const plan = states.get(sessionId)?.plan ?? null
1045
- if (!plan) {
1046
- jsonResponse(res, 200, { ok: false, error: 'no-plan' }, origin)
1047
- return
1048
- }
1049
- const allowed = ['todo', 'doing', 'done', 'dropped']
1050
- let nextSteps = null
1051
- let editReason = ''
1052
- if (Array.isArray(body.steps) && body.steps.length) {
1053
- // Whole-plan user edit (used by /replan-free "整理并更新计划" requests).
1054
- nextSteps = body.steps
1055
- .map((step, index) => ({
1056
- id: String(step?.id ?? `s${index + 1}`),
1057
- title: String(step?.title ?? '').slice(0, 160),
1058
- status: allowed.includes(String(step?.status)) ? String(step.status) : 'todo',
1059
- source: 'user',
1060
- }))
1061
- .filter(step => step.title)
1062
- editReason = typeof body.reason === 'string' && body.reason.trim()
1063
- ? body.reason.trim()
1064
- : '用户整理并更新计划'
1065
- } else {
1066
- const stepId = String(body.stepId ?? '')
1067
- const status = String(body.status ?? 'done')
1068
- if (!allowed.includes(status)) {
1069
- jsonResponse(res, 200, { ok: false, error: 'bad-status' }, origin)
1070
- return
1071
- }
1072
- nextSteps = (plan.steps || []).map(step => (
1073
- step.id === stepId ? { ...step, status, source: 'user' } : step
1074
- ))
1075
- if (!nextSteps.some(step => step.id === stepId)) {
1076
- jsonResponse(res, 200, { ok: false, error: 'unknown-step', stepId }, origin)
1077
- return
1078
- }
1079
- editReason = typeof body.reason === 'string' && body.reason.trim()
1080
- ? body.reason.trim()
1081
- : `用户手动标记 ${stepId} 为 ${status}`
1082
- }
1083
- const edit = { steps: nextSteps }
1084
- if (typeof body.intent === 'string' && body.intent.trim()) edit.intent = body.intent.trim().slice(0, 200)
1085
- const applied = applyUserEdit(plan, edit, {
1086
- reason: editReason,
1087
- watermark: plan.watermark,
1088
- now: Date.now(),
1089
- })
1090
- if (!applied?.ok) {
1091
- jsonResponse(res, 200, { ok: false, error: 'apply-failed', detail: applied }, origin)
1092
- return
1093
- }
1094
- try {
1095
- store.put(applied.plan, { cas: plan.version })
1096
- } catch (error) {
1097
- debugLog({ phase: 'user-edit-store-error', sessionId, error: String(error) })
1098
- }
1099
- const previous = states.get(sessionId)
1100
- publishState(sessionId, {
1101
- plan: applied.plan,
1102
- suggestion: previous?.suggestion ?? null,
1103
- stale: false,
1104
- })
1105
- debugLog({ phase: 'user-edit', sessionId, stepCount: nextSteps.length, version: applied.plan.version })
1106
- jsonResponse(res, 200, {
1107
- ok: true,
1108
- changed: applied.changed === true,
1109
- version: applied.plan.version,
1110
- editedByUser: applied.plan.editedByUser === true,
1111
- steps: applied.plan.steps,
1112
- }, origin)
1113
- return
1114
- }
1115
- if (pathname === `${ROUTE_PREFIX}experiment` && method === 'GET') {
1116
- jsonResponse(res, 200, {
1117
- running: experimentState.running,
1118
- error: experimentState.error,
1119
- result: experimentState.result,
1120
- }, origin)
1121
- return
1122
- }
1123
- if (pathname === `${ROUTE_PREFIX}experiment` && method === 'POST') {
1124
- const body = (await readJsonBody(req)) ?? {}
1125
- if (experimentState.running) {
1126
- jsonResponse(res, 200, { ok: true, running: true }, origin)
1127
- return
1128
- }
1129
- const sessionId = String(body.sessionId ?? '')
1130
- const session = sessions.get(sessionId)
1131
- if (!session) {
1132
- jsonResponse(res, 200, { ok: false, error: 'session-not-observed' }, origin)
1133
- return
1134
- }
1135
- const basePlan = states.get(sessionId)?.plan ?? null
1136
- if (!basePlan) {
1137
- jsonResponse(res, 200, { ok: false, error: 'no-plan' }, origin)
1138
- return
1139
- }
1140
- experimentState.running = true
1141
- experimentState.result = null
1142
- experimentState.error = null
1143
- void (async () => {
1144
- try {
1145
- const route = await pickModel(services)
1146
- if (!route) throw new Error('no-model-route')
1147
- const judgeRoute = await pickJudgeRoute(services, route)
1148
- debugLog({ phase: 'experiment-start', sessionId, route, judgeRoute })
1149
- const result = await runExperiment({
1150
- services,
1151
- route,
1152
- judgeRoute,
1153
- session,
1154
- sessionId,
1155
- basePlan,
1156
- collectFromEvents,
1157
- buildPatchPrompt,
1158
- parseModelOutput,
1159
- callModel,
1160
- debugLog,
1161
- evidenceChars: body.evidenceChars,
1162
- maxTokens: body.maxTokens,
1163
- reps: body.reps,
1164
- onProgress: (index, total, variant) => debugLog({
1165
- phase: 'experiment-progress',
1166
- index,
1167
- total,
1168
- windowSize: variant?.windowSize,
1169
- maxTokens: variant?.maxTokens,
1170
- }),
1171
- })
1172
- experimentState.result = result
1173
- try {
1174
- if (debugDir) {
1175
- mkdirSync(debugDir, { recursive: true })
1176
- writeFileSync(`${debugDir}\\plan-completion-experiment.json`, JSON.stringify(result, null, 2), 'utf8')
1177
- }
1178
- } catch (error) {
1179
- /* ignore */
1180
- }
1181
- debugLog({ phase: 'experiment-done', total: result.total })
1182
- } catch (error) {
1183
- experimentState.error = String(error)
1184
- debugLog({ phase: 'experiment-failed', error: String(error) })
1185
- } finally {
1186
- experimentState.running = false
1187
- }
1188
- })()
1189
- jsonResponse(res, 200, { ok: true, started: true, planSteps: Array.isArray(basePlan.steps) ? basePlan.steps.length : 0 }, origin)
1190
- return
1191
- }
1192
- if (pathname === `${ROUTE_PREFIX}theme` && method === 'POST') {
1193
- const body = (await readJsonBody(req)) ?? {}
1194
- const mode = typeof body.mode === 'string' ? body.mode : null
1195
- debugTheme = mode === 'dark' || mode === 'light' || mode === 'system' ? mode : null
1196
- debugLog({ phase: 'theme-request', mode: debugTheme })
1197
- jsonResponse(res, 200, { ok: true, theme: debugTheme }, origin)
1198
- return
1199
- }
1200
- jsonResponse(res, 404, { ok: false, error: 'unknown-route', pathname }, origin)
1201
- }
1202
-
1203
- // The shipped web server matches only routes already registered for the request path;
1204
- // register one exact route per endpoint (verified working with the runtime probe).
1205
- // `ctx.inject` defers this until the webServer service exists (cold boot ordering).
1206
- ctx.inject(['webServer'], child => {
1207
- const web = child.webServer
1208
- if (!web?.register) {
1209
- debugLog({ phase: 'route-register-skipped', reason: 'webServer unavailable' })
1210
- return
1211
- }
1212
- // Production endpoints are always registered; development/debug endpoints require
1213
- // `config.enableDebugRoutes: true` so a published bundle exposes a minimal surface.
1214
- const paths = [
1215
- `${ROUTE_PREFIX}state`,
1216
- `${ROUTE_PREFIX}debug`,
1217
- `${ROUTE_PREFIX}replan`,
1218
- `${ROUTE_PREFIX}diag`,
1219
- `${ROUTE_PREFIX}warm`,
1220
- ]
1221
- if (settings.enableDebugRoutes) {
1222
- paths.push(
1223
- `${ROUTE_PREFIX}theme`,
1224
- `${ROUTE_PREFIX}experiment`,
1225
- `${ROUTE_PREFIX}autojob`,
1226
- `${ROUTE_PREFIX}steps`,
1227
- `${ROUTE_PREFIX}reset`,
1228
- )
1229
- }
1230
- const handles = []
1231
- // Dispose route handlers registered by a previous instance of this plugin (toggle/reload).
1232
- for (const dispose of shared.disposers.splice(0, shared.disposers.length)) {
1233
- try {
1234
- dispose()
1235
- } catch {
1236
- /* ignore */
1237
- }
1238
- }
1239
- try {
1240
- for (const path of paths) {
1241
- handles.push(web.register({ kind: 'exact', path, handler: handleRoute }))
1242
- }
1243
- for (const dispose of handles) shared.disposers.push(dispose)
1244
- debugLog({ phase: 'routes-registered', paths })
1245
- } catch (error) {
1246
- debugLog({ phase: 'route-register-error', error: String(error) })
1247
- }
1248
- child.effect(() => () => {
1249
- for (const dispose of handles) {
1250
- try {
1251
- dispose()
1252
- } catch {
1253
- /* ignore */
1254
- }
1255
- }
1256
- })
1257
- })
1258
-
1259
- // ── Human commands ────────────────────────────────────────────────────────
1260
- ctx.inject(['commands'], child => {
1261
- const commands = child.commands
1262
- if (commands?.register) {
1263
- const disposers = []
1264
- try {
1265
- disposers.push(commands.register({
1266
- name: 'roadmap',
1267
- description: '查看当前会话的隐式计划(只读)',
1268
- handler: invocation => {
1269
- const view = publicView(invocation?.agent?.id ?? '')
1270
- if (!view.plan) return { kind: 'success', text: '当前会话还没有计划。下一次回合结束后会自动生成。' }
1271
- const lines = [`会话计划 v${view.version?.gen ?? 0}.${view.version?.rev ?? 0} · ${view.status ?? ''}`]
1272
- if (view.intent) lines.push(`目标:${view.intent}`)
1273
- for (const step of view.steps) {
1274
- const mark = step.status === 'done' ? '✓' : step.status === 'doing' ? '●' : step.status === 'dropped' ? '×' : '○'
1275
- lines.push(`${mark} ${step.title}`)
1276
- }
1277
- if (view.reason) lines.push(`更新原因:${view.reason}`)
1278
- if (view.suggestion?.text) lines.push(`下一步建议:${view.suggestion.text}`)
1279
- return { kind: 'success', text: lines.join('\n') }
1280
- },
1281
- }))
1282
- disposers.push(commands.register({
1283
- name: 'replan',
1284
- description: '强制重写当前会话的隐式计划',
1285
- input: { hint: '可选:一句话说明新的方向' },
1286
- handler: invocation => {
1287
- const sessionId = invocation?.agent?.id ?? ''
1288
- const session = sessions.get(sessionId)
1289
- if (!session) return { kind: 'error', text: '还没有观察到这个会话,请先在会话里发一条消息。' }
1290
- const turn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
1291
- void runGeneration(
1292
- session,
1293
- { seq: Number(debug.lastTurnEnd?.seq ?? 0), data: { turn } },
1294
- { force: true, instruction: String(invocation?.rawInput ?? '') },
1295
- )
1296
- return { kind: 'success', text: '计划重写已开始…' }
1297
- },
1298
- }))
1299
- child.effect(() => () => {
1300
- for (const dispose of disposers) {
1301
- try {
1302
- dispose()
1303
- } catch {
1304
- /* ignore */
1305
- }
1306
- }
1307
- // Drop matching entries so the next apply() does not dispose them twice.
1308
- for (const dispose of disposers) {
1309
- const index = shared.disposers.indexOf(dispose)
1310
- if (index >= 0) shared.disposers.splice(index, 1)
1311
- }
1312
- })
1313
- } catch (error) {
1314
- debugLog({ phase: 'command-register-error', error: String(error) })
1315
- }
1316
- }
1317
- })
1318
-
1319
- debugLog({
1320
- phase: 'host-ready',
1321
- settings,
1322
- config: rawConfig,
1323
- services: Object.fromEntries(Object.entries(services).map(([key, value]) => [key, Boolean(value)])),
1324
- })
1325
- }
1
+ /**
2
+ * Host half of dsh-plan-completion.
3
+ *
4
+ * Responsibilities (design doc v1.2 §3.5):
5
+ * - watch committed Session events for `turn/end` and run one plan job per session;
6
+ * - call the model through the injected `llm` service (never through the agent loop);
7
+ * - keep the implicit plan and the next-message suggestion per session in memory;
8
+ * - expose the state to the Client half over a local HTTP route registered on `webServer`;
9
+ * - register the `/roadmap` and `/replan` human commands.
10
+ *
11
+ * The Client half only talks to this half through `/plan-completion/*`
12
+ * (verified contract: same-origin fetch from the Electron page works and the body is readable).
13
+ */
14
+ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+
17
+ import { DEFAULTS } from './host/config.js'
18
+ import { requestRejection, safeDebugRecord, safeDiagnostics } from './host/security.js'
19
+ import { join } from 'node:path'
20
+ import { normalizePlan, patchPlan, rewritePlan, statusFor, applyUserEdit } from './host/plan.js'
21
+ import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput } from './host/prompt.js'
22
+ import { heuristicSuggestion, fallbackPlan } from './host/heuristics.js'
23
+ import { collectFromEvents } from './host/evidence.js'
24
+ import { createMemoryStore, createFileStore } from './host/store.js'
25
+ import { runExperiment } from './host/experiment.js'
26
+ import { sliceEvidence } from './host/experiment.js'
27
+
28
+ const ROUTE_PREFIX = '/plan-completion/'
29
+
30
+ // Debug log location is configurable (`config.debugDir` or DSH_PLAN_COMPLETION_DEBUG_DIR).
31
+ // Empty means "do not write files", so a published copy of this bundle carries no local paths.
32
+ let debugDir = ''
33
+ function debugLogPath() {
34
+ return debugDir ? join(debugDir, 'plan-completion-runtime.jsonl') : ''
35
+ }
36
+
37
+ function debugLog(record) {
38
+ try {
39
+ if (!debugDir) return
40
+ mkdirSync(debugDir, { recursive: true })
41
+ appendFileSync(debugLogPath(), `${JSON.stringify({ time: new Date().toISOString(), ...safeDebugRecord(record) })}\n`, 'utf8')
42
+ } catch {
43
+ /* ignore */
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve the local plan-store directory. `~` is expanded so a shipped config can point at the
49
+ * user's home without hard-coding an absolute path. Empty means "memory only".
50
+ */
51
+ function expandStorageDir(value, envValue) {
52
+ const raw = typeof value === 'string' && value.trim() !== ''
53
+ ? value.trim()
54
+ : (typeof envValue === 'string' ? envValue.trim() : '')
55
+ if (raw === '') return ''
56
+ if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
57
+ try {
58
+ return raw === '~' ? homedir() : join(homedir(), raw.slice(2))
59
+ } catch {
60
+ return ''
61
+ }
62
+ }
63
+ return raw
64
+ }
65
+
66
+ function readJsonBody(req) {
67
+ return new Promise(resolve => {
68
+ let size = 0
69
+ const chunks = []
70
+ req.on('data', chunk => {
71
+ size += chunk.length
72
+ if (size > 512 * 1024) {
73
+ resolve(null)
74
+ req.destroy()
75
+ return
76
+ }
77
+ chunks.push(chunk)
78
+ })
79
+ req.on('end', () => {
80
+ try {
81
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
82
+ } catch {
83
+ resolve(null)
84
+ }
85
+ })
86
+ req.on('error', () => resolve(null))
87
+ })
88
+ }
89
+
90
+ function jsonResponse(res, status, value) {
91
+ let body = ''
92
+ try {
93
+ body = JSON.stringify(value)
94
+ } catch {
95
+ body = '{"ok":false}'
96
+ }
97
+ try {
98
+ const headers = {
99
+ 'content-type': 'application/json; charset=utf-8',
100
+ 'cache-control': 'no-store',
101
+ 'x-content-type-options': 'nosniff',
102
+ }
103
+ if (typeof res.writeHead === 'function') {
104
+ res.writeHead(status, headers)
105
+ res.end(body)
106
+ } else {
107
+ res.end(body)
108
+ }
109
+ } catch {
110
+ /* ignore */
111
+ }
112
+ }
113
+
114
+ function createGate(limit) {
115
+ let active = 0
116
+ const waiters = []
117
+ return {
118
+ async acquire() {
119
+ if (active < limit) {
120
+ active += 1
121
+ return
122
+ }
123
+ await new Promise(resolve => waiters.push(resolve))
124
+ active += 1
125
+ },
126
+ release() {
127
+ active = Math.max(0, active - 1)
128
+ const next = waiters.shift()
129
+ if (next) next()
130
+ },
131
+ }
132
+ }
133
+
134
+ /** Convert one request message to the block form the `llm` service accepts. */
135
+ function toRequestMessage(message) {
136
+ if (!message || typeof message !== 'object') return null
137
+ const role = message.role === 'assistant' ? 'assistant' : message.role === 'user' ? 'user' : null
138
+ if (!role) return null
139
+ const content = message.content
140
+ if (Array.isArray(content)) return { role, content }
141
+ if (typeof content === 'string') {
142
+ return role === 'assistant'
143
+ ? { role, content: [{ type: 'text', text: content }] }
144
+ : { role, content: [{ type: 'text', text: content }] }
145
+ }
146
+ return null
147
+ }
148
+
149
+ function normalizePromptMessages(prompt) {
150
+ const messages = Array.isArray(prompt?.messages) ? prompt.messages : []
151
+ return messages.map(toRequestMessage).filter(Boolean)
152
+ }
153
+
154
+ /**
155
+ * Use the user's current default route only. Never send session evidence to
156
+ * another configured provider/model just because it may respond faster.
157
+ */
158
+ async function pickModel(services) {
159
+ const llm = services.llm
160
+ const selection = services.agentDefaultModel?.currentSelection?.()
161
+ if (!llm || !selection?.provider || !selection?.model) return null
162
+ const route = { provider: selection.provider, model: selection.model, source: 'session-default' }
163
+ try {
164
+ const info = await llm.resolveModelInfo?.(route.provider, route.model)
165
+ const efforts = info?.reasoning?.efforts
166
+ if (Array.isArray(efforts) && efforts.length) route.reasoningEffort = efforts[0]?.id
167
+ } catch {
168
+ // Resolution failure must not silently choose a different data destination.
169
+ }
170
+ return route
171
+ }
172
+
173
+ /** Experiments also stay on the explicitly selected route (not an independent judge). */
174
+ async function pickJudgeRoute(_services, mainRoute) {
175
+ return mainRoute
176
+ }
177
+
178
+ async function callModel(services, route, prompt, signal) {
179
+ if (!services.llm || !route) throw new Error('no-llm-route')
180
+ const messages = normalizePromptMessages(prompt)
181
+ if (!messages.length) throw new Error('empty-messages')
182
+ let text = ''
183
+ let usage = null
184
+ const request = {
185
+ provider: route.provider,
186
+ model: route.model,
187
+ system: prompt.system,
188
+ messages,
189
+ maxTokens: prompt.maxTokens,
190
+ temperature: prompt.temperature,
191
+ signal,
192
+ }
193
+ if (route.reasoningEffort) request.reasoningEffort = route.reasoningEffort
194
+ const stream = services.llm.stream(request)
195
+ for await (const chunk of stream) {
196
+ if (!chunk || typeof chunk !== 'object') continue
197
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
198
+ else if (chunk.type === 'usage' && chunk.usage) usage = chunk.usage
199
+ else if (chunk.type === 'finish') {
200
+ const kind = chunk.reason?.kind
201
+ if (kind === 'error' || kind === 'aborted') {
202
+ throw new Error(`model-${kind}:${chunk.reason?.failure?.message ?? ''}`)
203
+ }
204
+ }
205
+ }
206
+ return { text, usage }
207
+ }
208
+
209
+ const HOST_GLOBAL_KEY = '__dsh_plan_completion_host__'
210
+
211
+ /**
212
+ * Process-wide host state. DSH may re-apply this plugin (toggle, hot reload) while the old
213
+ * module instance is still alive: keeping state and route disposers in `globalThis` makes the
214
+ * new instance reuse the same maps and dispose the previous route handlers, so we never end
215
+ * up with two live handler sets answering different state.
216
+ */
217
+ function hostGlobal() {
218
+ const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), disposers: [] }
219
+ if (typeof globalThis === 'undefined') return { ...fallback, store: createMemoryStore() }
220
+ if (!globalThis[HOST_GLOBAL_KEY] || typeof globalThis[HOST_GLOBAL_KEY] !== 'object') {
221
+ globalThis[HOST_GLOBAL_KEY] = fallback
222
+ }
223
+ const shared = globalThis[HOST_GLOBAL_KEY]
224
+ if (!(shared.states instanceof Map)) shared.states = new Map()
225
+ if (!(shared.sessions instanceof Map)) shared.sessions = new Map()
226
+ if (!(shared.handledTurns instanceof Map)) shared.handledTurns = new Map()
227
+ if (!(shared.warmAttempts instanceof Map)) shared.warmAttempts = new Map()
228
+ if (!Array.isArray(shared.disposers)) shared.disposers = []
229
+ if (!shared.store || typeof shared.store.get !== 'function' || typeof shared.store.put !== 'function') {
230
+ shared.store = createMemoryStore()
231
+ // Adopt plans retained by older plugin versions during an in-process upgrade.
232
+ for (const state of shared.states.values()) {
233
+ if (state?.plan) shared.store.put(state.plan, { cas: null })
234
+ }
235
+ }
236
+ return shared
237
+ }
238
+
239
+ export function apply(ctx, config = {}) {
240
+ // Production settings, frozen by the round-1/round-2 isolation tests:
241
+ // evidenceChars 1500 — compact evidence slice for the patch path
242
+ // maxTokensPatch 400 — patch output only needs ~65-120 tokens
243
+ // maxTokensRewrite 1200 — rewrite must emit the full step table
244
+ const readInt = (value, fallback, min, max) => {
245
+ const number = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback
246
+ return Math.min(max, Math.max(min, number))
247
+ }
248
+ const rawConfig = config && typeof config === 'object' ? config : {}
249
+ const settings = {
250
+ evidenceChars: readInt(rawConfig.evidenceChars, 1500, 400, 8000),
251
+ maxTokensPatch: readInt(rawConfig.maxTokensPatch, 400, 120, 4000),
252
+ maxTokensRewrite: readInt(rawConfig.maxTokensRewrite, 1200, 400, 8000),
253
+ warmBackfill: rawConfig.warmBackfill !== false,
254
+ modelCalls: rawConfig.modelCalls !== false,
255
+ persistPlans: rawConfig.persistPlans !== false,
256
+ storageDir: expandStorageDir(rawConfig.storageDir, process.env.DSH_PLAN_COMPLETION_STORE_DIR),
257
+ enableDebugRoutes: rawConfig.enableDebugRoutes === true,
258
+ debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
259
+ ? rawConfig.debugDir.trim()
260
+ : (typeof process !== 'undefined' && process.env && typeof process.env.DSH_PLAN_COMPLETION_DEBUG_DIR === 'string'
261
+ ? process.env.DSH_PLAN_COMPLETION_DEBUG_DIR.trim()
262
+ : ''),
263
+ }
264
+ debugDir = settings.debugDir
265
+
266
+ // Services may come up after this plugin on a cold boot, so resolve them lazily on every
267
+ // access instead of snapshotting them during apply(). Registration that needs a service is
268
+ // wrapped in ctx.inject([...]) below, which re-runs once the service becomes available.
269
+ const services = {
270
+ get llm() {
271
+ return settings.modelCalls ? ctx.get('llm') : null
272
+ },
273
+ get webServer() {
274
+ return ctx.get('webServer')
275
+ },
276
+ get connection() {
277
+ return ctx.get('connection')
278
+ },
279
+ get commands() {
280
+ return ctx.get('commands')
281
+ },
282
+ get agentDefaultModel() {
283
+ return ctx.get('agentDefaultModel')
284
+ },
285
+ get agents() {
286
+ return ctx.get('agents')
287
+ },
288
+ get sessionPersistence() {
289
+ return ctx.get('sessionPersistence')
290
+ },
291
+ }
292
+
293
+ const shared = hostGlobal()
294
+ // A new apply can run before the old context has finished disposing.
295
+ shared.stopGeneration?.()
296
+ const states = shared.states // sessionId -> { plan, suggestion, stale }
297
+ const sessions = shared.sessions // sessionId -> Session
298
+ const warmAttempts = shared.warmAttempts // sessionId -> { at, seq }
299
+ const inflight = new Map() // sessionId -> AbortController
300
+ const gate = createGate(DEFAULTS.globalConcurrency)
301
+ // Persistence: a file-backed plan store when a directory is configured; otherwise memory.
302
+ // The store keeps the last plan + suggestion per session so a restart can restore the UI.
303
+ const fileStore = settings.persistPlans && settings.storageDir ? createFileStore(settings.storageDir) : null
304
+ const store = fileStore ?? createMemoryStore()
305
+ shared.store = store
306
+ let disposed = false
307
+ const stopGeneration = () => {
308
+ disposed = true
309
+ for (const controller of inflight.values()) controller.abort(new Error('plugin-disposed'))
310
+ inflight.clear()
311
+ if (shared.stopGeneration === stopGeneration) delete shared.stopGeneration
312
+ }
313
+ shared.stopGeneration = stopGeneration
314
+ ctx.effect(() => stopGeneration)
315
+ const debug = { lastTurnEnd: null, lastJob: null, model: null }
316
+ let debugTheme = null
317
+ const experimentState = { running: false, result: null, error: null }
318
+
319
+ const publicView = sessionId => {
320
+ const state = states.get(sessionId)
321
+ const plan = state?.plan ?? null
322
+ return {
323
+ sessionId,
324
+ plan,
325
+ version: plan?.version ?? null,
326
+ status: plan?.status ?? null,
327
+ intent: plan?.intent ?? null,
328
+ steps: Array.isArray(plan?.steps) ? plan.steps : [],
329
+ updatedAt: plan?.updatedAt ?? state?.updatedAt ?? null,
330
+ reason: plan?.reason ?? null,
331
+ suggestion: state?.suggestion ?? null,
332
+ stale: state ? Boolean(state.stale) : true,
333
+ debugTheme,
334
+ }
335
+ }
336
+
337
+ function publishState(sessionId, patch) {
338
+ const previous = states.get(sessionId) ?? { plan: null, suggestion: null, stale: false }
339
+ states.set(sessionId, { ...previous, ...patch })
340
+ }
341
+
342
+ // ── Persistence ⇄ in-memory state ────────────────────────────────────────
343
+ const hydration = new Map() // sessionId -> Promise (in-flight disk reads)
344
+
345
+ // Adopt plans retained in memory across a hot reload into the file store. `cas:null` means
346
+ // "only when the file has no plan yet", so a newer on-disk plan is never overwritten.
347
+ if (fileStore) {
348
+ for (const [sessionId, state] of states.entries()) {
349
+ if (!state?.plan) continue
350
+ void fileStore
351
+ .put(state.plan, { cas: null, suggestion: state.suggestion ?? null })
352
+ .catch(() => {})
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Load a persisted plan (+ last suggestion) into the shared in-memory state. The UI can show
358
+ * the previous plan right after a DSH restart, before the next turn runs.
359
+ */
360
+ async function hydrateSession(sessionId) {
361
+ if (!sessionId) return
362
+ if (states.get(sessionId)?.plan) return
363
+ const pending = hydration.get(sessionId)
364
+ if (pending) return pending
365
+ const task = (async () => {
366
+ try {
367
+ const stored = typeof store.getState === 'function'
368
+ ? await store.getState(sessionId)
369
+ : { plan: await store.get(sessionId), suggestion: null }
370
+ if (!stored?.plan || states.get(sessionId)?.plan) return
371
+ const normalized = normalizePlan(stored.plan)
372
+ if (!normalized?.ok || !normalized.plan) return
373
+ states.set(sessionId, {
374
+ plan: normalized.plan,
375
+ suggestion: stored.suggestion && typeof stored.suggestion === 'object' ? stored.suggestion : null,
376
+ stale: false,
377
+ })
378
+ debugLog({ phase: 'state-hydrated', sessionId })
379
+ } catch (error) {
380
+ debugLog({ phase: 'state-hydrate-error', sessionId, error: String(error) })
381
+ } finally {
382
+ hydration.delete(sessionId)
383
+ }
384
+ })()
385
+ hydration.set(sessionId, task)
386
+ return task
387
+ }
388
+
389
+ function seedPlan(sessionId, evidence) {
390
+ const intent = evidence?.intent || evidence?.firstUserMessage || '推进当前会话目标'
391
+ const fallback = (() => {
392
+ try {
393
+ return fallbackPlan(intent)
394
+ } catch {
395
+ return null
396
+ }
397
+ })()
398
+ const steps = fallback?.ok && Array.isArray(fallback.steps) ? fallback.steps : [
399
+ { id: 's1', title: '确认目标与约束', status: 'done', source: 'model' },
400
+ { id: 's2', title: '完成核心改动', status: 'doing', source: 'model' },
401
+ { id: 's3', title: '验证并收尾', status: 'todo', source: 'model' },
402
+ ]
403
+ const candidate = {
404
+ sessionId,
405
+ version: { gen: 0, rev: 0 },
406
+ status: 'active',
407
+ intent: fallback?.intent || intent,
408
+ steps,
409
+ updatedAt: Date.now(),
410
+ reason: '启发式初始化计划',
411
+ watermark: evidence?.watermark ?? { turn: 0, seq: 0 },
412
+ editedByUser: false,
413
+ }
414
+ try {
415
+ const normalized = normalizePlan(candidate)
416
+ if (normalized?.ok && normalized.plan) return normalized.plan
417
+ } catch {
418
+ /* ignore */
419
+ }
420
+ return null
421
+ }
422
+
423
+ function heuristicText(evidence) {
424
+ try {
425
+ const result = heuristicSuggestion(evidence)
426
+ if (result?.ok && typeof result.text === 'string' && result.text.trim()) return result.text.trim()
427
+ } catch {
428
+ /* ignore */
429
+ }
430
+ return ''
431
+ }
432
+
433
+ // ── Warm backfill: build an implicit plan for a session the plugin has never observed ──
434
+ // Live sessions come from `sessions.get()`; older sessions are read through the durable
435
+ // session store so opening them also produces a plan + suggestion.
436
+ async function loadStoredEvents(sessionId, limit = 240) {
437
+ const persistence = services.sessionPersistence
438
+ if (!persistence || typeof persistence.open !== 'function') return null
439
+ let handle = null
440
+ try {
441
+ handle = await persistence.open(sessionId, 'read')
442
+ let total = 0
443
+ let page = null
444
+ try {
445
+ const stat = await persistence.stat?.(sessionId)
446
+ total = Number(stat?.eventCount ?? 0)
447
+ page = stat
448
+ } catch {
449
+ total = 0
450
+ }
451
+ const from = Math.max(0, total - limit)
452
+ let read = await handle.read(from, limit)
453
+ let events = Array.isArray(read?.events) ? read.events.slice() : []
454
+ // Fallback: some backends report no eventCount — page from the beginning until the end.
455
+ if (!events.length && !page) {
456
+ let offset = 0
457
+ for (let guard = 0; guard < 40; guard += 1) {
458
+ const chunk = await handle.read(offset, 400)
459
+ const batch = Array.isArray(chunk?.events) ? chunk.events : []
460
+ if (!batch.length) break
461
+ events = events.concat(batch)
462
+ offset += batch.length
463
+ }
464
+ events = events.slice(-limit)
465
+ }
466
+ return events
467
+ } catch {
468
+ return null
469
+ } finally {
470
+ try {
471
+ await handle?.close?.()
472
+ } catch {
473
+ /* ignore */
474
+ }
475
+ }
476
+ }
477
+
478
+ function warmSessionShim(sessionId, events) {
479
+ const last = events[events.length - 1] ?? null
480
+ let lastTurn = 0
481
+ let maxTurn = 0
482
+ let hasConversation = false
483
+ for (const event of events) {
484
+ const type = String(event?.type ?? '')
485
+ if (type === 'turn/end') lastTurn = Math.max(lastTurn, Number(event.data?.turn ?? 0))
486
+ if (type === 'turn/start') maxTurn = Math.max(maxTurn, Number(event.data?.turn ?? 0))
487
+ if (type === 'user/message' || type === 'assistant/message') hasConversation = true
488
+ }
489
+ return {
490
+ warm: true,
491
+ id: sessionId,
492
+ seq: Number(last?.seq ?? 0),
493
+ lastTurn: lastTurn || maxTurn,
494
+ hasConversation,
495
+ snapshotEvents(from) {
496
+ const start = Number(from ?? 0)
497
+ return events.filter(event => Number(event?.seq ?? 0) >= start)
498
+ },
499
+ }
500
+ }
501
+
502
+ async function runWarmBackfill(sessionId, options = {}) {
503
+ if (!settings.warmBackfill) return { ok: false, reason: 'disabled' }
504
+ if (!sessionId) return { ok: false, reason: 'no-session-id' }
505
+ await hydrateSession(sessionId)
506
+ if (states.get(sessionId)?.plan) return { ok: true, already: true }
507
+ const now = Date.now()
508
+ const attempt = warmAttempts.get(sessionId)
509
+ if (!options.force && attempt && now - Number(attempt.at ?? 0) < 10 * 60 * 1000) {
510
+ return { ok: true, skipped: 'recent', at: attempt.at }
511
+ }
512
+ warmAttempts.set(sessionId, { at: now, seq: attempt?.seq ?? 0 })
513
+
514
+ const live = sessions.get(sessionId) ?? null
515
+ if (live) {
516
+ const turn = Number(debug.lastTurnEnd?.sessionId === sessionId ? debug.lastTurnEnd.turn : 0)
517
+ void runGeneration(live, { seq: Number(live.seq ?? 0), data: { turn } }, { force: true })
518
+ return { ok: true, started: true, source: 'live' }
519
+ }
520
+
521
+ const events = await loadStoredEvents(sessionId, 1500)
522
+ if (!events || events.length === 0) {
523
+ debugLog({ phase: 'warm-no-history', sessionId })
524
+ return { ok: false, reason: 'no-history' }
525
+ }
526
+ const shim = warmSessionShim(sessionId, events)
527
+ if (!shim.hasConversation) {
528
+ return {
529
+ ok: false,
530
+ reason: 'no-conversation',
531
+ detail: { events: events.length, types: events.slice(-8).map(event => event?.type) },
532
+ }
533
+ }
534
+ warmAttempts.set(sessionId, { at: now, seq: shim.seq })
535
+ debugLog({ phase: 'warm-start', sessionId, events: events.length, turn: shim.lastTurn })
536
+ try {
537
+ await runGeneration(shim, { seq: shim.seq, data: { turn: shim.lastTurn } }, { force: true })
538
+ } catch (error) {
539
+ debugLog({ phase: 'warm-failed', sessionId, error: String(error) })
540
+ return { ok: false, reason: 'generation-failed', detail: String(error) }
541
+ }
542
+ const view = publicView(sessionId)
543
+ debugLog({ phase: 'warm-done', sessionId, version: view.version, hasSuggestion: Boolean(view.suggestion) })
544
+ return { ok: true, warmed: true, version: view.version, suggestion: view.suggestion, plan: view.plan }
545
+ }
546
+
547
+ async function runGeneration(session, turnEndEvent, options = {}) {
548
+ const sessionId = session?.id
549
+ if (!sessionId || disposed) return
550
+ const seq = Number(turnEndEvent?.seq ?? 0)
551
+ const turn = Number(turnEndEvent?.data?.turn ?? 0)
552
+ const force = Boolean(options.force)
553
+ const instruction = typeof options.instruction === 'string' ? options.instruction.trim() : ''
554
+ const budget = force ? DEFAULTS.forceRewriteDeadlineMs : DEFAULTS.planDeadlineMs
555
+ const startedAt = Date.now()
556
+ debugLog({ phase: 'job-start', sessionId, turn, force })
557
+
558
+ const previous = inflight.get(sessionId)
559
+ if (previous) previous.abort(new Error('superseded'))
560
+ const controller = new AbortController()
561
+ inflight.set(sessionId, controller)
562
+ const timer = setTimeout(() => controller.abort(new Error('plan-deadline')), budget)
563
+ const isCurrent = () => !disposed && !controller.signal.aborted && inflight.get(sessionId) === controller
564
+
565
+ await gate.acquire()
566
+ try {
567
+ if (!isCurrent()) return
568
+ await hydrateSession(sessionId)
569
+ if (!isCurrent()) return
570
+ let events = []
571
+ try {
572
+ events = Array.from(session.snapshotEvents(Math.max(0, seq - 300)) ?? [])
573
+ } catch {
574
+ events = []
575
+ }
576
+
577
+ const basePlan = states.get(sessionId)?.plan ?? null
578
+ const storedPlan = typeof store.getState === 'function'
579
+ ? (await store.getState(sessionId))?.plan ?? null
580
+ : await store.get(sessionId)
581
+ const storedVersion = storedPlan?.version ?? null
582
+ const evidence = (() => {
583
+ try {
584
+ const collected = collectFromEvents({ events, plan: basePlan, turn, sessionId })
585
+ if (collected && collected.ok !== false) return collected
586
+ } catch (error) {
587
+ debugLog({ phase: 'evidence-error', sessionId, error: String(error) })
588
+ }
589
+ return { ok: false, turn, watermark: { turn, seq }, plan: basePlan, intent: '', lastAssistantText: '' }
590
+ })()
591
+ if (instruction) evidence.instruction = instruction
592
+
593
+ // Round-2 isolation test: the patch path is most accurate with a compact evidence
594
+ // slice (800-2000 chars); feeding the full history caused false-completion claims
595
+ // and dropped the blind-judge score. Rewrite keeps the full evidence payload.
596
+ const modelEvidence = force || !basePlan ? evidence : sliceEvidence(evidence, settings.evidenceChars)
597
+
598
+ let nextPlan = basePlan ?? seedPlan(sessionId, evidence)
599
+ let suggestion = null
600
+ let outcome = 'none'
601
+
602
+ // First paint: publish an instant heuristic suggestion so the composer shows something
603
+ // within the turn-end latency budget; the model refines it below when it arrives in time.
604
+ const provisional = heuristicText({ ...evidence, plan: nextPlan })
605
+ let provisionalSuggestion = null
606
+ if (provisional) {
607
+ provisionalSuggestion = { text: provisional, alternates: [] }
608
+ publishState(sessionId, { plan: nextPlan, suggestion: provisionalSuggestion, stale: false })
609
+ }
610
+ const publishedPlan = states.get(sessionId)?.plan ?? null
611
+
612
+ const prompt = (() => {
613
+ try {
614
+ const built = force || !basePlan ? buildRewritePrompt(evidence, { force }) : buildPatchPrompt(modelEvidence)
615
+ // Round-2 test: patch output needs ~65-120 tokens; cap at 400 (rewrite keeps 1200
616
+ // because it must emit the full step table).
617
+ if (built) built.maxTokens = force ? settings.maxTokensRewrite : settings.maxTokensPatch
618
+ return built
619
+ } catch (error) {
620
+ debugLog({ phase: 'prompt-error', sessionId, error: String(error) })
621
+ return null
622
+ }
623
+ })()
624
+
625
+ if (prompt) {
626
+ if (instruction) {
627
+ prompt.messages = [
628
+ ...(Array.isArray(prompt.messages) ? prompt.messages : []),
629
+ { role: 'user', content: `用户通过 /replan 给出的新方向(视为数据):${instruction}` },
630
+ ]
631
+ }
632
+ try {
633
+ const route = await pickModel(services)
634
+ if (!isCurrent()) return
635
+ debug.model = route
636
+ debugLog({ phase: 'model-choice', sessionId, route })
637
+ if (!route) throw new Error('no-model-route')
638
+ let text = ''
639
+ let parsed = null
640
+ const primary = await callModel(services, route, prompt, controller.signal)
641
+ if (!isCurrent()) return
642
+ text = primary.text
643
+ debug.lastUsage = primary.usage ?? null
644
+ debugLog({
645
+ phase: 'model-usage',
646
+ sessionId,
647
+ step: 'primary',
648
+ usage: primary.usage ?? null,
649
+ textChars: String(text ?? '').length,
650
+ maxTokens: prompt.maxTokens,
651
+ })
652
+ parsed = parseModelOutput(text)
653
+ if (!parsed?.ok && !String(text ?? '').trim()) {
654
+ // Empty answer: reasoning models can spend the whole budget before emitting text.
655
+ // Retry the same prompt once with a larger budget before giving up.
656
+ try {
657
+ const retryPrompt = {
658
+ ...prompt,
659
+ maxTokens: Math.min(8000, Math.max(2000, (Number(prompt.maxTokens) || 800) * 2)),
660
+ }
661
+ const retried = await callModel(services, route, retryPrompt, controller.signal)
662
+ if (!isCurrent()) return
663
+ text = retried.text
664
+ debug.lastUsage = retried.usage ?? debug.lastUsage
665
+ parsed = parseModelOutput(text)
666
+ debugLog({
667
+ phase: 'empty-retry',
668
+ sessionId,
669
+ ok: parsed?.ok === true,
670
+ code: parsed?.code,
671
+ maxTokens: retryPrompt.maxTokens,
672
+ usage: retried.usage ?? null,
673
+ textHead: String(text ?? '').slice(0, 120),
674
+ })
675
+ } catch (error) {
676
+ debugLog({ phase: 'empty-retry-failed', sessionId, error: String(error) })
677
+ }
678
+ } else if (!parsed?.ok) {
679
+ // Non-empty but not valid JSON (truncated / stray text): one repair retry.
680
+ debugLog({ phase: 'parse-retry', sessionId, code: parsed?.code, error: parsed?.error })
681
+ try {
682
+ const repairPrompt = buildRepairPrompt(prompt, text, parsed?.error ?? '')
683
+ if (repairPrompt) repairPrompt.maxTokens = Math.max(Number(repairPrompt.maxTokens) || 0, 1600)
684
+ const repairedRes = await callModel(services, route, repairPrompt, controller.signal)
685
+ if (!isCurrent()) return
686
+ debug.lastUsage = repairedRes.usage ?? debug.lastUsage
687
+ const reparsed = parseModelOutput(repairedRes.text)
688
+ if (reparsed?.ok) parsed = reparsed
689
+ } catch (error) {
690
+ debugLog({ phase: 'repair-failed', sessionId, error: String(error) })
691
+ }
692
+ }
693
+ if (parsed?.ok && parsed.output) {
694
+ const output = parsed.output
695
+ const meta = {
696
+ reason: output.reason || '模型更新',
697
+ watermark: { turn, seq },
698
+ now: Date.now(),
699
+ sessionId,
700
+ force,
701
+ }
702
+ let applied = null
703
+ if (output.op === 'patch' && nextPlan) {
704
+ applied = patchPlan(nextPlan, output.changes, meta)
705
+ } else {
706
+ const steps = output.op === 'rewrite' ? output.steps : null
707
+ if (steps && steps.length) {
708
+ applied = rewritePlan(nextPlan, {
709
+ steps,
710
+ intent: output.intent || undefined,
711
+ reason: output.reason,
712
+ }, meta)
713
+ }
714
+ }
715
+ if (applied?.ok && applied.plan) {
716
+ nextPlan = applied.plan
717
+ nextPlan.status = statusFor(nextPlan.steps)
718
+ outcome = applied.changed === false ? 'unchanged' : 'committed'
719
+ } else if (applied && !applied.ok) {
720
+ debugLog({ phase: 'plan-apply-rejected', sessionId, code: applied.code, error: applied.error })
721
+ }
722
+ if (typeof output.suggestion === 'string' && output.suggestion.trim()) {
723
+ suggestion = {
724
+ text: output.suggestion.trim(),
725
+ alternates: Array.isArray(output.alternates) ? output.alternates.slice(0, DEFAULTS.alternatesMax) : [],
726
+ }
727
+ }
728
+ } else {
729
+ debugLog({ phase: 'parse-failed', sessionId, code: parsed?.code, error: parsed?.error, textHead: String(text).slice(0, 240) })
730
+ }
731
+ } catch (error) {
732
+ debugLog({ phase: 'model-error', sessionId, error: String(error) })
733
+ }
734
+ }
735
+
736
+ if (!isCurrent()) return
737
+ // A user edit or reset can replace the visible plan while the model is waiting.
738
+ // Its newer plan and suggestion belong to that writer, not to this job.
739
+ if ((states.get(sessionId)?.plan ?? null) !== publishedPlan) {
740
+ debug.lastJob = { sessionId, turn, elapsed: Date.now() - startedAt, outcome: 'conflict', suggestion: false }
741
+ debugLog({ phase: 'state-conflict', sessionId, turn })
742
+ return
743
+ }
744
+
745
+ if (!suggestion) {
746
+ const fallbackText = heuristicText({ ...evidence, plan: nextPlan })
747
+ if (fallbackText) suggestion = { text: fallbackText, alternates: [] }
748
+ }
749
+
750
+ const elapsed = Date.now() - startedAt
751
+ const suggestionDeadline = force ? Number.POSITIVE_INFINITY : Math.max(DEFAULTS.suggestionDeadlineMs, 6000)
752
+ const withinSuggestionDeadline = elapsed <= suggestionDeadline
753
+ const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
754
+
755
+ if (nextPlan) {
756
+ try {
757
+ // Check CAS even for an unchanged plan; an old snapshot is never an
758
+ // unconditional write. null also protects the first commit from races.
759
+ // The last suggestion is persisted in the same write so a restart can restore it.
760
+ const committed = await store.put(nextPlan, { cas: storedVersion, suggestion: finalSuggestion })
761
+ if (!committed) {
762
+ debugLog({ phase: 'store-cas-miss', sessionId })
763
+ const winner = typeof store.getState === 'function'
764
+ ? await store.getState(sessionId)
765
+ : { plan: await store.get(sessionId), suggestion: null }
766
+ publishState(sessionId, {
767
+ plan: winner?.plan ?? null,
768
+ suggestion: null,
769
+ stale: Boolean(winner?.plan),
770
+ })
771
+ debug.lastJob = { sessionId, turn, elapsed, outcome: 'conflict', suggestion: false }
772
+ return
773
+ }
774
+ } catch (error) {
775
+ debugLog({ phase: 'store-error', sessionId, error: String(error) })
776
+ return
777
+ }
778
+ }
779
+
780
+ publishState(sessionId, {
781
+ plan: nextPlan,
782
+ suggestion: finalSuggestion,
783
+ stale: false,
784
+ })
785
+ debug.lastJob = { sessionId, turn, elapsed, outcome, suggestion: Boolean(suggestion), withinSuggestionDeadline }
786
+ debugLog({ phase: 'job-done', sessionId, turn, elapsed, outcome, withinSuggestionDeadline })
787
+ } catch (error) {
788
+ if (!isCurrent()) return
789
+ debugLog({ phase: 'job-failed', sessionId, turn, error: String(error) })
790
+ const existing = states.get(sessionId)
791
+ publishState(sessionId, { plan: existing?.plan ?? null, suggestion: existing?.suggestion ?? null, stale: Boolean(existing?.plan) })
792
+ } finally {
793
+ clearTimeout(timer)
794
+ if (inflight.get(sessionId) === controller) inflight.delete(sessionId)
795
+ gate.release()
796
+ }
797
+ }
798
+
799
+ // ── Session event subscription: one plan job per completed turn ──────────
800
+ const handledTurns = shared.handledTurns // sessionId -> last handled turn number
801
+
802
+ function triggerTurnEnd(session, seq, turn, source) {
803
+ if (!session?.id || disposed) return
804
+ sessions.set(session.id, session)
805
+ const turnNumber = Number(turn) || 0
806
+ if (handledTurns.get(session.id) === turnNumber) return
807
+ handledTurns.set(session.id, turnNumber)
808
+ debug.lastTurnEnd = { sessionId: session.id, seq: Number(seq) || 0, turn: turnNumber, source }
809
+ debugLog({ phase: 'turn-end-trigger', sessionId: session.id, turn: turnNumber, source })
810
+ void runGeneration(session, { seq: Number(seq) || 0, data: { turn: turnNumber } }, { force: false })
811
+ }
812
+
813
+ // Deterministic idle fallback: after the session log goes quiet for a moment, read the last
814
+ // committed `turn/end` straight from the session log. This does not depend on the turn-boundary
815
+ // event reaching the plugin context (it may be missed across a plugin reload window).
816
+ const idleTimers = new Map()
817
+ const IDLE_QUIET_MS = 2000
818
+ ctx.effect(() => () => {
819
+ for (const timer of idleTimers.values()) clearTimeout(timer)
820
+ idleTimers.clear()
821
+ })
822
+
823
+ function checkIdleTurn(session) {
824
+ if (!session?.id || disposed) return
825
+ let events = []
826
+ try {
827
+ const from = Math.max(0, Number(session.seq ?? 0) - 160)
828
+ events = Array.from(session.snapshotEvents(from) ?? [])
829
+ } catch {
830
+ return
831
+ }
832
+ for (let i = events.length - 1; i >= 0; i -= 1) {
833
+ const event = events[i]
834
+ if (event?.type !== 'turn/end') continue
835
+ if (event.data?.reason?.kind !== 'completed') return
836
+ const eventTime = Number(event.time ?? 0)
837
+ // Only act on a turn that just ended (plugin reloads must not replay old turns).
838
+ if (eventTime > 0 && Date.now() - eventTime > 5 * 60 * 1000) return
839
+ const turn = Number(event.data?.turn ?? 0)
840
+ if (handledTurns.get(session.id) === turn) return
841
+ debugLog({ phase: 'idle-turn-end', sessionId: session.id, turn })
842
+ triggerTurnEnd(session, event.seq, turn, 'idle-log-scan')
843
+ return
844
+ }
845
+ }
846
+
847
+ function scheduleIdleCheck(session) {
848
+ if (!session?.id || disposed) return
849
+ const previous = idleTimers.get(session.id)
850
+ if (previous) clearTimeout(previous)
851
+ const timer = setTimeout(() => {
852
+ idleTimers.delete(session.id)
853
+ try {
854
+ checkIdleTurn(sessions.get(session.id) ?? session)
855
+ } catch (error) {
856
+ debugLog({ phase: 'idle-check-error', sessionId: session.id, error: String(error) })
857
+ }
858
+ }, IDLE_QUIET_MS)
859
+ idleTimers.set(session.id, timer)
860
+ }
861
+
862
+ ctx.on('session/event', (session, event) => {
863
+ try {
864
+ if (disposed) return
865
+ debug.eventCount = (debug.eventCount ?? 0) + 1
866
+ debug.eventTypes = debug.eventTypes ?? {}
867
+ const type = String(event?.type ?? 'unknown')
868
+ debug.eventTypes[type] = (debug.eventTypes[type] ?? 0) + 1
869
+ if (debug.eventCount <= 12) debugLog({ phase: 'session-event', type, sessionId: session?.id })
870
+ if (!session?.id) return
871
+ sessions.set(session.id, session)
872
+ scheduleIdleCheck(session)
873
+ if (event?.type !== 'turn/end') return
874
+ debugLog({ phase: 'turn-end-raw', sessionId: session.id, turn: event.data?.turn, reason: event.data?.reason?.kind, seq: event.seq })
875
+ if (event.data?.reason?.kind !== 'completed') return
876
+ triggerTurnEnd(session, event.seq, event.data?.turn, 'session/event')
877
+ } catch (error) {
878
+ debugLog({ phase: 'listener-error', error: String(error) })
879
+ }
880
+ })
881
+
882
+ // Fallback trigger: if a `turn/end` event is not observed through `session/event` for any reason,
883
+ // the transition to `idle` right after the turn still lets us read the last committed turn from
884
+ // the session log and run the same job exactly once per turn.
885
+ ctx.on('agent/status', payload => {
886
+ try {
887
+ if (disposed) return
888
+ if (payload?.status !== 'idle') return
889
+ const sessionId = payload?.agent?.id
890
+ if (!sessionId) return
891
+ const session = sessions.get(sessionId)
892
+ if (!session) {
893
+ debugLog({ phase: 'agent-idle-no-session', sessionId })
894
+ return
895
+ }
896
+ let events = []
897
+ try {
898
+ events = Array.from(session.snapshotEvents(Math.max(0, Number(session.seq ?? 0) - 120)) ?? [])
899
+ } catch {
900
+ events = []
901
+ }
902
+ for (let i = events.length - 1; i >= 0; i -= 1) {
903
+ const event = events[i]
904
+ if (event?.type !== 'turn/end') continue
905
+ debugLog({ phase: 'agent-idle-turn-end', sessionId, turn: event.data?.turn, reason: event.data?.reason?.kind })
906
+ if (event.data?.reason?.kind === 'completed') triggerTurnEnd(session, event.seq, event.data?.turn, 'agent/status-idle')
907
+ return
908
+ }
909
+ debugLog({ phase: 'agent-idle-no-turn-end', sessionId })
910
+ } catch (error) {
911
+ debugLog({ phase: 'agent-status-error', error: String(error) })
912
+ }
913
+ })
914
+
915
+ ctx.on('agent/turn-stopping', payload => {
916
+ try {
917
+ debugLog({ phase: 'turn-stopping', sessionId: payload?.agent?.id, turn: payload?.turn })
918
+ } catch {
919
+ /* ignore */
920
+ }
921
+ })
922
+
923
+ // ── HTTP routes for the Client half ──────────────────────────────────────
924
+ const handleRouteUnsafe = async (req, res) => {
925
+ let connection
926
+ try { connection = services.connection } catch { /* unavailable */ }
927
+ const rejected = requestRejection(connection, req)
928
+ if (rejected !== undefined) {
929
+ jsonResponse(res, rejected, { ok: false, error: rejected === 503 ? 'auth-unavailable' : 'request-rejected' })
930
+ return
931
+ }
932
+ let url = null
933
+ try {
934
+ url = new URL(req.url, 'http://127.0.0.1')
935
+ } catch {
936
+ url = null
937
+ }
938
+ const pathname = url?.pathname ?? String(req.url ?? '')
939
+ const method = String(req.method ?? 'GET').toUpperCase()
940
+ debug.requestCount = (debug.requestCount ?? 0) + 1
941
+ if (debug.requestCount <= 200) {
942
+ debugLog({
943
+ phase: 'http',
944
+ method,
945
+ pathname,
946
+ query: url?.search ?? '',
947
+ ua: String(req.headers?.['user-agent'] ?? '').slice(0, 48),
948
+ cookie: Boolean(req.headers?.cookie),
949
+ })
950
+ }
951
+
952
+ if (method === 'OPTIONS') {
953
+ jsonResponse(res, 204, {})
954
+ return
955
+ }
956
+ if (pathname === `${ROUTE_PREFIX}warm` && method === 'POST') {
957
+ const body = (await readJsonBody(req)) ?? {}
958
+ const sessionId = String(body.sessionId ?? '')
959
+ if (!sessionId) {
960
+ jsonResponse(res, 200, { ok: false, reason: 'no-session-id' })
961
+ return
962
+ }
963
+ const outcome = await runWarmBackfill(sessionId, { force: body.force === true })
964
+ jsonResponse(res, 200, outcome)
965
+ return
966
+ }
967
+ if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
968
+ const sessionId = url?.searchParams?.get('sessionId') ?? ''
969
+ await hydrateSession(sessionId)
970
+ jsonResponse(res, 200, publicView(sessionId))
971
+ return
972
+ }
973
+ if (pathname === `${ROUTE_PREFIX}debug` && method === 'GET') {
974
+ const probeSessionId = url?.searchParams?.get('sessionId') ?? ''
975
+ const logTail = (() => {
976
+ if (!probeSessionId) return null
977
+ const session = sessions.get(probeSessionId)
978
+ if (!session) return null
979
+ try {
980
+ const from = Math.max(0, Number(session.seq ?? 0) - 60)
981
+ return Array.from(session.snapshotEvents(from) ?? []).map(event => `${event.seq}:${event.type}`)
982
+ } catch (error) {
983
+ return [`error:${String(error)}`]
984
+ }
985
+ })()
986
+ jsonResponse(res, 200, {
987
+ ...debug,
988
+ sessions: Array.from(sessions.keys()),
989
+ states: Array.from(states.keys()),
990
+ probeSessionId,
991
+ logTail,
992
+ })
993
+ return
994
+ }
995
+ if (pathname === `${ROUTE_PREFIX}replan` && method === 'POST') {
996
+ const body = (await readJsonBody(req)) ?? {}
997
+ const sessionId = String(body.sessionId ?? '')
998
+ const session = sessions.get(sessionId)
999
+ if (!session) {
1000
+ jsonResponse(res, 200, { ok: false, error: 'session-not-observed' })
1001
+ return
1002
+ }
1003
+ const turn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
1004
+ void runGeneration(
1005
+ session,
1006
+ { seq: Number(debug.lastTurnEnd?.seq ?? 0), data: { turn } },
1007
+ { force: true, instruction: typeof body.instruction === 'string' ? body.instruction : '' },
1008
+ )
1009
+ jsonResponse(res, 200, { ok: true, queued: true })
1010
+ return
1011
+ }
1012
+ if (pathname === `${ROUTE_PREFIX}diag` && method === 'GET') {
1013
+ jsonResponse(res, 200, { ok: true, lastDiag: debug.lastDiag ?? null })
1014
+ return
1015
+ }
1016
+ if (pathname === `${ROUTE_PREFIX}diag` && method === 'POST') {
1017
+ const body = (await readJsonBody(req)) ?? {}
1018
+ debug.lastDiag = { at: Date.now(), body: safeDiagnostics(body) }
1019
+ debugLog({ phase: 'client-diag' })
1020
+ jsonResponse(res, 200, { ok: true })
1021
+ return
1022
+ }
1023
+ if (pathname === `${ROUTE_PREFIX}autojob` && method === 'POST') {
1024
+ const body = (await readJsonBody(req)) ?? {}
1025
+ const sessionId = String(body.sessionId ?? '')
1026
+ const session = sessions.get(sessionId)
1027
+ if (!session) {
1028
+ jsonResponse(res, 200, { ok: false, error: 'session-not-observed' })
1029
+ return
1030
+ }
1031
+ if (!states.get(sessionId)?.plan) {
1032
+ jsonResponse(res, 200, { ok: false, error: 'no-plan' })
1033
+ return
1034
+ }
1035
+ const baseTurn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
1036
+ const startedAt = Date.now()
1037
+ try {
1038
+ await runGeneration(
1039
+ session,
1040
+ { seq: Number(session.seq ?? 0), data: { turn: baseTurn + 1 } },
1041
+ { force: false },
1042
+ )
1043
+ } catch (error) {
1044
+ debugLog({ phase: 'autojob-error', sessionId, error: String(error) })
1045
+ }
1046
+ const view = publicView(sessionId)
1047
+ jsonResponse(res, 200, {
1048
+ ok: true,
1049
+ elapsedMs: Date.now() - startedAt,
1050
+ usage: debug.lastUsage ?? null,
1051
+ version: view.version,
1052
+ suggestion: view.suggestion,
1053
+ stepCount: Array.isArray(view.steps) ? view.steps.length : 0,
1054
+ reason: view.reason,
1055
+ lastJob: debug.lastJob ?? null,
1056
+ })
1057
+ return
1058
+ }
1059
+ if (pathname === `${ROUTE_PREFIX}reset` && method === 'POST') {
1060
+ const body = (await readJsonBody(req)) ?? {}
1061
+ const sessionId = String(body.sessionId ?? '')
1062
+ const existed = states.has(sessionId)
1063
+ inflight.get(sessionId)?.abort(new Error('state-reset'))
1064
+ try {
1065
+ await store.clear(sessionId)
1066
+ } catch (error) {
1067
+ debugLog({ phase: 'reset-store-error', sessionId, error: String(error) })
1068
+ }
1069
+ hydration.delete(sessionId)
1070
+ states.delete(sessionId)
1071
+ warmAttempts.delete(sessionId)
1072
+ handledTurns.delete(sessionId)
1073
+ debugLog({ phase: 'state-reset', sessionId, existed })
1074
+ jsonResponse(res, 200, { ok: true, sessionId, existed })
1075
+ return
1076
+ }
1077
+ if (pathname === `${ROUTE_PREFIX}steps` && method === 'POST') {
1078
+ const body = (await readJsonBody(req)) ?? {}
1079
+ const sessionId = String(body.sessionId ?? '')
1080
+ await hydrateSession(sessionId)
1081
+ const plan = states.get(sessionId)?.plan ?? null
1082
+ if (!plan) {
1083
+ jsonResponse(res, 200, { ok: false, error: 'no-plan' })
1084
+ return
1085
+ }
1086
+ const allowed = ['todo', 'doing', 'done', 'dropped']
1087
+ let nextSteps = null
1088
+ let editReason = ''
1089
+ if (Array.isArray(body.steps) && body.steps.length) {
1090
+ // Whole-plan user edit (used by /replan-free "整理并更新计划" requests).
1091
+ nextSteps = body.steps
1092
+ .map((step, index) => ({
1093
+ id: String(step?.id ?? `s${index + 1}`),
1094
+ title: String(step?.title ?? '').slice(0, 160),
1095
+ status: allowed.includes(String(step?.status)) ? String(step.status) : 'todo',
1096
+ source: 'user',
1097
+ }))
1098
+ .filter(step => step.title)
1099
+ editReason = typeof body.reason === 'string' && body.reason.trim()
1100
+ ? body.reason.trim()
1101
+ : '用户整理并更新计划'
1102
+ } else {
1103
+ const stepId = String(body.stepId ?? '')
1104
+ const status = String(body.status ?? 'done')
1105
+ if (!allowed.includes(status)) {
1106
+ jsonResponse(res, 200, { ok: false, error: 'bad-status' })
1107
+ return
1108
+ }
1109
+ nextSteps = (plan.steps || []).map(step => (
1110
+ step.id === stepId ? { ...step, status, source: 'user' } : step
1111
+ ))
1112
+ if (!nextSteps.some(step => step.id === stepId)) {
1113
+ jsonResponse(res, 200, { ok: false, error: 'unknown-step', stepId })
1114
+ return
1115
+ }
1116
+ editReason = typeof body.reason === 'string' && body.reason.trim()
1117
+ ? body.reason.trim()
1118
+ : `用户手动标记 ${stepId} 为 ${status}`
1119
+ }
1120
+ const edit = { steps: nextSteps }
1121
+ if (typeof body.intent === 'string' && body.intent.trim()) edit.intent = body.intent.trim().slice(0, 200)
1122
+ const applied = applyUserEdit(plan, edit, {
1123
+ reason: editReason,
1124
+ watermark: plan.watermark,
1125
+ now: Date.now(),
1126
+ })
1127
+ if (!applied?.ok) {
1128
+ jsonResponse(res, 200, { ok: false, error: 'apply-failed', detail: applied })
1129
+ return
1130
+ }
1131
+ try {
1132
+ const committed = await store.put(applied.plan, {
1133
+ cas: plan.version,
1134
+ suggestion: states.get(sessionId)?.suggestion ?? null,
1135
+ })
1136
+ if (!committed) await store.put(applied.plan, { suggestion: states.get(sessionId)?.suggestion ?? null })
1137
+ } catch (error) {
1138
+ debugLog({ phase: 'user-edit-store-error', sessionId, error: String(error) })
1139
+ }
1140
+ const previous = states.get(sessionId)
1141
+ publishState(sessionId, {
1142
+ plan: applied.plan,
1143
+ suggestion: previous?.suggestion ?? null,
1144
+ stale: false,
1145
+ })
1146
+ debugLog({ phase: 'user-edit', sessionId, stepCount: nextSteps.length, version: applied.plan.version })
1147
+ jsonResponse(res, 200, {
1148
+ ok: true,
1149
+ changed: applied.changed === true,
1150
+ version: applied.plan.version,
1151
+ editedByUser: applied.plan.editedByUser === true,
1152
+ steps: applied.plan.steps,
1153
+ })
1154
+ return
1155
+ }
1156
+ if (pathname === `${ROUTE_PREFIX}experiment` && method === 'GET') {
1157
+ jsonResponse(res, 200, {
1158
+ running: experimentState.running,
1159
+ error: experimentState.error,
1160
+ result: experimentState.result,
1161
+ })
1162
+ return
1163
+ }
1164
+ if (pathname === `${ROUTE_PREFIX}experiment` && method === 'POST') {
1165
+ const body = (await readJsonBody(req)) ?? {}
1166
+ if (experimentState.running) {
1167
+ jsonResponse(res, 200, { ok: true, running: true })
1168
+ return
1169
+ }
1170
+ const sessionId = String(body.sessionId ?? '')
1171
+ const session = sessions.get(sessionId)
1172
+ if (!session) {
1173
+ jsonResponse(res, 200, { ok: false, error: 'session-not-observed' })
1174
+ return
1175
+ }
1176
+ const basePlan = states.get(sessionId)?.plan ?? null
1177
+ if (!basePlan) {
1178
+ jsonResponse(res, 200, { ok: false, error: 'no-plan' })
1179
+ return
1180
+ }
1181
+ experimentState.running = true
1182
+ experimentState.result = null
1183
+ experimentState.error = null
1184
+ void (async () => {
1185
+ try {
1186
+ const route = await pickModel(services)
1187
+ if (!route) throw new Error('no-model-route')
1188
+ const judgeRoute = await pickJudgeRoute(services, route)
1189
+ debugLog({ phase: 'experiment-start', sessionId, route, judgeRoute })
1190
+ const result = await runExperiment({
1191
+ services,
1192
+ route,
1193
+ judgeRoute,
1194
+ session,
1195
+ sessionId,
1196
+ basePlan,
1197
+ collectFromEvents,
1198
+ buildPatchPrompt,
1199
+ parseModelOutput,
1200
+ callModel,
1201
+ debugLog,
1202
+ evidenceChars: body.evidenceChars,
1203
+ maxTokens: body.maxTokens,
1204
+ reps: body.reps,
1205
+ onProgress: (index, total, variant) => debugLog({
1206
+ phase: 'experiment-progress',
1207
+ index,
1208
+ total,
1209
+ windowSize: variant?.windowSize,
1210
+ maxTokens: variant?.maxTokens,
1211
+ }),
1212
+ })
1213
+ experimentState.result = result
1214
+ try {
1215
+ if (debugDir) {
1216
+ mkdirSync(debugDir, { recursive: true })
1217
+ writeFileSync(`${debugDir}\\plan-completion-experiment.json`, JSON.stringify(result, null, 2), 'utf8')
1218
+ }
1219
+ } catch (error) {
1220
+ /* ignore */
1221
+ }
1222
+ debugLog({ phase: 'experiment-done', total: result.total })
1223
+ } catch (error) {
1224
+ experimentState.error = String(error)
1225
+ debugLog({ phase: 'experiment-failed', error: String(error) })
1226
+ } finally {
1227
+ experimentState.running = false
1228
+ }
1229
+ })()
1230
+ jsonResponse(res, 200, { ok: true, started: true, planSteps: Array.isArray(basePlan.steps) ? basePlan.steps.length : 0 })
1231
+ return
1232
+ }
1233
+ if (pathname === `${ROUTE_PREFIX}theme` && method === 'POST') {
1234
+ const body = (await readJsonBody(req)) ?? {}
1235
+ const mode = typeof body.mode === 'string' ? body.mode : null
1236
+ debugTheme = mode === 'dark' || mode === 'light' || mode === 'system' ? mode : null
1237
+ debugLog({ phase: 'theme-request', mode: debugTheme })
1238
+ jsonResponse(res, 200, { ok: true, theme: debugTheme })
1239
+ return
1240
+ }
1241
+ jsonResponse(res, 404, { ok: false, error: 'unknown-route', pathname })
1242
+ }
1243
+
1244
+ // Defense in depth: an unexpected throw must still close the HTTP response instead
1245
+ // of leaving the client fetch hanging.
1246
+ const handleRoute = async (req, res) => {
1247
+ try {
1248
+ await handleRouteUnsafe(req, res)
1249
+ } catch (error) {
1250
+ try {
1251
+ jsonResponse(res, 500, { ok: false, error: 'internal-error' })
1252
+ } catch {
1253
+ /* ignore */
1254
+ }
1255
+ try {
1256
+ debugLog({ phase: 'route-error', method: String(req?.method ?? ''), error: true })
1257
+ } catch {
1258
+ /* ignore */
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ // The shipped web server matches only routes already registered for the request path;
1264
+ // register one exact route per endpoint (verified working with the runtime probe).
1265
+ // `ctx.inject` defers this until the webServer service exists (cold boot ordering).
1266
+ ctx.inject(['webServer'], child => {
1267
+ const web = child.webServer
1268
+ if (!web?.register) {
1269
+ debugLog({ phase: 'route-register-skipped', reason: 'webServer unavailable' })
1270
+ return
1271
+ }
1272
+ // Production endpoints are always registered; development/debug endpoints require
1273
+ // `config.enableDebugRoutes: true` so a published bundle exposes a minimal surface.
1274
+ const paths = [
1275
+ `${ROUTE_PREFIX}state`,
1276
+ `${ROUTE_PREFIX}replan`,
1277
+ `${ROUTE_PREFIX}warm`,
1278
+ ]
1279
+ if (settings.enableDebugRoutes) {
1280
+ paths.push(
1281
+ `${ROUTE_PREFIX}debug`,
1282
+ `${ROUTE_PREFIX}diag`,
1283
+ `${ROUTE_PREFIX}theme`,
1284
+ `${ROUTE_PREFIX}experiment`,
1285
+ `${ROUTE_PREFIX}autojob`,
1286
+ `${ROUTE_PREFIX}steps`,
1287
+ `${ROUTE_PREFIX}reset`,
1288
+ )
1289
+ }
1290
+ const handles = []
1291
+ // Dispose route handlers registered by a previous instance of this plugin (toggle/reload).
1292
+ for (const dispose of shared.disposers.splice(0, shared.disposers.length)) {
1293
+ try {
1294
+ dispose()
1295
+ } catch {
1296
+ /* ignore */
1297
+ }
1298
+ }
1299
+ try {
1300
+ for (const path of paths) {
1301
+ handles.push(web.register({ kind: 'exact', path, handler: handleRoute }))
1302
+ }
1303
+ for (const dispose of handles) shared.disposers.push(dispose)
1304
+ debugLog({ phase: 'routes-registered', paths })
1305
+ } catch (error) {
1306
+ debugLog({ phase: 'route-register-error', error: String(error) })
1307
+ }
1308
+ child.effect(() => () => {
1309
+ for (const dispose of handles) {
1310
+ try {
1311
+ dispose()
1312
+ } catch {
1313
+ /* ignore */
1314
+ }
1315
+ }
1316
+ })
1317
+ })
1318
+
1319
+ // ── Human commands ────────────────────────────────────────────────────────
1320
+ ctx.inject(['commands'], child => {
1321
+ const commands = child.commands
1322
+ if (commands?.register) {
1323
+ const disposers = []
1324
+ try {
1325
+ disposers.push(commands.register({
1326
+ name: 'roadmap',
1327
+ description: '查看当前会话的隐式计划(只读)',
1328
+ handler: invocation => {
1329
+ const view = publicView(invocation?.agent?.id ?? '')
1330
+ if (!view.plan) return { kind: 'success', text: '当前会话还没有计划。下一次回合结束后会自动生成。' }
1331
+ const lines = [`会话计划 v${view.version?.gen ?? 0}.${view.version?.rev ?? 0} · ${view.status ?? ''}`]
1332
+ if (view.intent) lines.push(`目标:${view.intent}`)
1333
+ for (const step of view.steps) {
1334
+ const mark = step.status === 'done' ? '✓' : step.status === 'doing' ? '●' : step.status === 'dropped' ? '×' : '○'
1335
+ lines.push(`${mark} ${step.title}`)
1336
+ }
1337
+ if (view.reason) lines.push(`更新原因:${view.reason}`)
1338
+ if (view.suggestion?.text) lines.push(`下一步建议:${view.suggestion.text}`)
1339
+ return { kind: 'success', text: lines.join('\n') }
1340
+ },
1341
+ }))
1342
+ disposers.push(commands.register({
1343
+ name: 'replan',
1344
+ description: '强制重写当前会话的隐式计划',
1345
+ input: { hint: '可选:一句话说明新的方向' },
1346
+ handler: invocation => {
1347
+ const sessionId = invocation?.agent?.id ?? ''
1348
+ const session = sessions.get(sessionId)
1349
+ if (!session) return { kind: 'error', text: '还没有观察到这个会话,请先在会话里发一条消息。' }
1350
+ const turn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
1351
+ void runGeneration(
1352
+ session,
1353
+ { seq: Number(debug.lastTurnEnd?.seq ?? 0), data: { turn } },
1354
+ { force: true, instruction: String(invocation?.rawInput ?? '') },
1355
+ )
1356
+ return { kind: 'success', text: '计划重写已开始…' }
1357
+ },
1358
+ }))
1359
+ child.effect(() => () => {
1360
+ for (const dispose of disposers) {
1361
+ try {
1362
+ dispose()
1363
+ } catch {
1364
+ /* ignore */
1365
+ }
1366
+ }
1367
+ })
1368
+ } catch (error) {
1369
+ debugLog({ phase: 'command-register-error', error: String(error) })
1370
+ }
1371
+ }
1372
+ })
1373
+
1374
+ debugLog({
1375
+ phase: 'host-ready',
1376
+ settings,
1377
+ config: rawConfig,
1378
+ services: Object.fromEntries(Object.entries(services).map(([key, value]) => [key, Boolean(value)])),
1379
+ })
1380
+ }
1326
1381