dsh-plan-completion 0.1.0 → 0.1.1

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