@vergilye/dsh-auto-continue 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,1503 @@
1
+ /**
2
+ * dsh-auto-continue v2 — stop a turn from closing on an unfinished reply.
3
+ *
4
+ * Rewritten from scratch after v1. What v1 taught us, kept here as design rules:
5
+ *
6
+ * 1. A bracketed end-of-reply marker (`[DONE: …]`) puts constant "close the bracket"
7
+ * pressure on every reply and degenerates into runs of stray `]`. v2 uses a fenced
8
+ * block whose body is one checklist line — nothing to close wrong.
9
+ * 2. `systemPrompt.section()` returns a disposer. Ignoring it makes every plugin
10
+ * reload append another system-prompt node to live sessions. v2 registers the
11
+ * disposer through `ctx.effect`, so a reload always leaves exactly one section.
12
+ * 3. `ctx.get('system-prompt')` is wrong: the service key is `systemPrompt`.
13
+ * 4. The `didWork` gate must not block a turn whose prose announces more work — but
14
+ * counting the turn-opening prompt as a "human takeover" disables the cap. v2
15
+ * counts a takeover only for messages after the first assistant output.
16
+ * 5. `maxNudges` must be derived from the durable log (how many pushes this turn
17
+ * already contains), so duplicate plugin instances and reloads cannot overshoot.
18
+ * 6. Cleanup of a damaged stream must never be awaited: a provider that hangs would
19
+ * hold the retry hostage for exactly as long as it hangs.
20
+ *
21
+ * Three layers, cheapest first:
22
+ * - `llm/stream` guard: a stream that stops mid-thought or goes silent becomes a
23
+ * terminal `error`, so DSH's own retry re-sends inside the same step.
24
+ * - completion protocol: an injected system-prompt section asks for an explicit
25
+ * end-of-reply declaration. Declared ⇒ close; missing after real work ⇒ push.
26
+ * - heuristic scopes: reconnect / unsettled text / plan phrasing, for when the
27
+ * protocol is off.
28
+ *
29
+ * Zero runtime dependencies: only `node:` builtins.
30
+ *
31
+ * @module dsh-auto-continue
32
+ */
33
+
34
+ import { createHash, randomUUID } from 'node:crypto'
35
+ import fs from 'node:fs'
36
+ import os from 'node:os'
37
+ import path from 'node:path'
38
+
39
+ export const name = 'dsh-auto-continue'
40
+
41
+ /** Hard dependency: `agents` owns live agents and dispatches `agent/turn-stopping`. */
42
+ export const inject = ['agents']
43
+
44
+ /** Source kind of the messages this plugin pushes. Never `'user'`. */
45
+ export const SOURCE_KIND = 'auto-continue'
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // defaults
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export const DEFAULT_NUDGE_TEXT =
52
+ '继续。不要只描述计划:直接执行下一步;若任务已完成,给出结果汇报。'
53
+
54
+ export const DEFAULT_PLAN_PATTERNS = Object.freeze([
55
+ '接下来',
56
+ '下一步',
57
+ '现在(就)?(先)?(改|查|检查|确认|修|跑|执行|补|加|写|做|看|读|试|提交|清理|重跑)',
58
+ '我(继续|这就|马上|立刻)',
59
+ '继续(把|改|查|做|跑|补)',
60
+ '(执行|开始(改|做|跑|执行|检查))改动',
61
+ '(开始|准备)(改|做|执行|检查|动手)',
62
+ '先(查|读|看|确认|执行|改|跑|修|做)',
63
+ '让我(先)?',
64
+ '我要(先)?',
65
+ '待会',
66
+ '然后(再|就)',
67
+ 'Now (let|I)',
68
+ 'Next,? I',
69
+ "I'll",
70
+ 'Let me',
71
+ 'Now:',
72
+ ])
73
+
74
+ export const DEFAULT_ASKING_PATTERNS = Object.freeze([
75
+ '[??]',
76
+ '要不要',
77
+ '需不需要',
78
+ '是否要',
79
+ '是否需要',
80
+ '要我(来|先|把|改|提|加|帮)?',
81
+ '需要我',
82
+ '你确认',
83
+ '请确认',
84
+ '你同意',
85
+ '你定',
86
+ '你决定',
87
+ '你来定',
88
+ '可以吗',
89
+ '行吗',
90
+ '好吗',
91
+ '对吗',
92
+ '等你',
93
+ '待你',
94
+ '发我',
95
+ '截图发我',
96
+ '告诉我',
97
+ '你说了算',
98
+ '需要的话',
99
+ '可以的话',
100
+ '有需要',
101
+ '我可以(先|再|把|帮)?',
102
+ '如果要',
103
+ '愿意的话',
104
+ 'what do you want',
105
+ 'shall I',
106
+ 'should I',
107
+ 'do you want',
108
+ 'want me to',
109
+ 'let me know',
110
+ 'your call',
111
+ ])
112
+
113
+ /**
114
+ * Routes the plugin acts on. Empty means every route: a fresh install must work for
115
+ * whoever installed it, without first discovering and hand-editing an allowlist. Narrow
116
+ * it in the profile patch layer when a route needs to stay untouched.
117
+ */
118
+ export const DEFAULT_ALLOW_MODELS = Object.freeze([])
119
+
120
+ /** Provider failure codes that mean "the answer was cut". */
121
+ export const DEFAULT_RETRY_CODES = Object.freeze(['TRANSPORT', 'TIMEOUT', 'SERVER'])
122
+
123
+ const FENCE = '`'.repeat(3)
124
+
125
+ /**
126
+ * End-of-reply marker presets. `fence-check` is the default: a fenced block holding
127
+ * one checklist line, which renders as a compact code block.
128
+ */
129
+ export const MARKER_STYLES = Object.freeze({
130
+ 'fence-check': Object.freeze({
131
+ doneMarker: `${FENCE}done`,
132
+ askMarker: `${FENCE}ask`,
133
+ donePrefix: '✅ 已完成:',
134
+ askPrefix: '❓ 待确认:',
135
+ }),
136
+ fence: Object.freeze({
137
+ doneMarker: `${FENCE}done`,
138
+ askMarker: `${FENCE}ask`,
139
+ donePrefix: '',
140
+ askPrefix: '',
141
+ }),
142
+ line: Object.freeze({ doneMarker: 'DONE:', askMarker: 'ASK:', donePrefix: '', askPrefix: '' }),
143
+ plain: Object.freeze({
144
+ doneMarker: '[DONE:',
145
+ askMarker: '[ASK:',
146
+ donePrefix: '',
147
+ askPrefix: '',
148
+ terminator: ']',
149
+ }),
150
+ })
151
+
152
+ /** Section order: after the tool sections, before structured output. */
153
+ export const PROTOCOL_SECTION_ORDER = 9500
154
+
155
+ /** How much of the final body a decision log line records. */
156
+ const DECISION_TAIL_CHARS = 200
157
+ /** Length of the tail recorded in plugin logs. */
158
+ const LOG_TAIL_CHARS = 40
159
+ /** How much of the final line the asking test reads. */
160
+ const ASKING_PROBE_CHARS = 220
161
+ /** Upper bound for the message-source account. */
162
+ const SUMMARY_MAX_CHARS = 120
163
+
164
+ export const DEFAULTS = Object.freeze({
165
+ enabled: true,
166
+ maxNudges: 2,
167
+ nudgeMinIntervalMs: 5_000,
168
+ delayMs: 0,
169
+ dryRun: false,
170
+ retryScope: true,
171
+ truncationScope: true,
172
+ extendedScope: true,
173
+ streamGuard: true,
174
+ streamIdleMs: 90_000,
175
+ maxGuardRetries: 1,
176
+ completionProtocol: true,
177
+ markerStyle: 'fence-check',
178
+ protocolNudgeText:
179
+ '继续。若还有没做完的事就直接做完;完成时用 done 代码块收尾,需要我拍板时用 ask 代码块。',
180
+ giveUpNoticeText:
181
+ '(自动续接已停止:本轮多次推送都没有收到收尾声明。可能是上游断连或配额问题,请检查后手动继续。)',
182
+ nudgeText: DEFAULT_NUDGE_TEXT,
183
+ logDecisions: false,
184
+ logFile: '',
185
+ })
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // config
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Compile a pattern list, dropping unusable regular expressions. A typo in a
193
+ * deployment config must never take the agent loop down.
194
+ * @param {unknown} input - configured list.
195
+ * @param {readonly string[]} fallback - defaults.
196
+ * @returns {{ regexes: RegExp[], invalid: string[] }}
197
+ */
198
+ export function compilePatterns(input, fallback) {
199
+ const source = Array.isArray(input) ? input : [...fallback]
200
+ const regexes = []
201
+ const invalid = []
202
+ for (const entry of source) {
203
+ if (typeof entry !== 'string') {
204
+ invalid.push(String(entry))
205
+ continue
206
+ }
207
+ try {
208
+ regexes.push(new RegExp(entry, 'i'))
209
+ } catch {
210
+ invalid.push(entry)
211
+ }
212
+ }
213
+ return { regexes, invalid }
214
+ }
215
+
216
+ /**
217
+ * @param {unknown} value - candidate.
218
+ * @returns {boolean} whether it is a usable non-negative integer.
219
+ */
220
+ function isCount(value) {
221
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0
222
+ }
223
+
224
+ /**
225
+ * Resolve the marker preset from the row config.
226
+ * @param {object} input - raw row config.
227
+ * @returns {{ markerStyle: string, doneMarker: string, askMarker: string,
228
+ * donePrefix: string, askPrefix: string, markerTerminator: string }}
229
+ */
230
+ function markerSettings(input) {
231
+ const key = Object.hasOwn(MARKER_STYLES, input.markerStyle) ? input.markerStyle : 'fence-check'
232
+ const preset = MARKER_STYLES[key]
233
+ return {
234
+ markerStyle: key,
235
+ doneMarker:
236
+ typeof input.doneMarker === 'string' && input.doneMarker.length > 0
237
+ ? input.doneMarker
238
+ : preset.doneMarker,
239
+ askMarker:
240
+ typeof input.askMarker === 'string' && input.askMarker.length > 0
241
+ ? input.askMarker
242
+ : preset.askMarker,
243
+ donePrefix: preset.donePrefix,
244
+ askPrefix: preset.askPrefix,
245
+ markerTerminator:
246
+ typeof input.markerTerminator === 'string' && input.markerTerminator.length > 0
247
+ ? input.markerTerminator
248
+ : (preset.terminator ?? '\n'),
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Merge a Loader row's `config` over {@link DEFAULTS}.
254
+ * @param {unknown} raw - the row's `config`.
255
+ * @returns {object} resolved options.
256
+ */
257
+ export function resolveConfig(raw) {
258
+ const input = raw && typeof raw === 'object' ? raw : {}
259
+ const plan = compilePatterns(input.planPatterns, DEFAULT_PLAN_PATTERNS)
260
+ const asking = compilePatterns(input.isAskingPatterns, DEFAULT_ASKING_PATTERNS)
261
+ const markers = markerSettings(input)
262
+ const allowModels = Array.isArray(input.allowModels)
263
+ ? input.allowModels.filter((entry) => typeof entry === 'string' && entry.length > 0)
264
+ : [...DEFAULT_ALLOW_MODELS]
265
+
266
+ const bool = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
267
+ const text = (value, fallback) =>
268
+ typeof value === 'string' && value.trim().length > 0 ? value : fallback
269
+
270
+ return {
271
+ enabled: bool(input.enabled, DEFAULTS.enabled),
272
+ maxNudges: isCount(input.maxNudges) ? input.maxNudges : DEFAULTS.maxNudges,
273
+ nudgeMinIntervalMs: isCount(input.nudgeMinIntervalMs)
274
+ ? input.nudgeMinIntervalMs
275
+ : DEFAULTS.nudgeMinIntervalMs,
276
+ delayMs: isCount(input.delayMs) ? input.delayMs : DEFAULTS.delayMs,
277
+ dryRun: bool(input.dryRun, DEFAULTS.dryRun),
278
+ retryScope: bool(input.retryScope, DEFAULTS.retryScope),
279
+ truncationScope: bool(input.truncationScope, DEFAULTS.truncationScope),
280
+ extendedScope: bool(input.extendedScope, DEFAULTS.extendedScope),
281
+ streamGuard: bool(input.streamGuard, DEFAULTS.streamGuard),
282
+ streamIdleMs: isCount(input.streamIdleMs) ? input.streamIdleMs : DEFAULTS.streamIdleMs,
283
+ maxGuardRetries: isCount(input.maxGuardRetries)
284
+ ? input.maxGuardRetries
285
+ : DEFAULTS.maxGuardRetries,
286
+ completionProtocol: bool(input.completionProtocol, DEFAULTS.completionProtocol),
287
+ ...markers,
288
+ protocolNudgeText: text(input.protocolNudgeText, DEFAULTS.protocolNudgeText),
289
+ giveUpNoticeText: text(input.giveUpNoticeText, DEFAULTS.giveUpNoticeText),
290
+ nudgeText: text(input.nudgeText, DEFAULT_NUDGE_TEXT),
291
+ allowModels,
292
+ retryCodes: Array.isArray(input.retryCodes)
293
+ ? input.retryCodes.filter((entry) => typeof entry === 'string' && entry.length > 0)
294
+ : [...DEFAULT_RETRY_CODES],
295
+ logDecisions: bool(input.logDecisions, DEFAULTS.logDecisions),
296
+ logFile: typeof input.logFile === 'string' ? input.logFile : DEFAULTS.logFile,
297
+ planPatterns: plan.regexes,
298
+ isAskingPatterns: asking.regexes,
299
+ invalidPatterns: [...plan.invalid, ...asking.invalid],
300
+ }
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // message + text helpers
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /**
308
+ * @param {unknown} message - any message-shaped value.
309
+ * @returns {string} joined text blocks.
310
+ */
311
+ export function messageText(message) {
312
+ const content = Array.isArray(message?.content) ? message.content : []
313
+ let text = ''
314
+ for (const block of content) {
315
+ if (block && block.type === 'text' && typeof block.text === 'string') text += block.text
316
+ }
317
+ return text
318
+ }
319
+
320
+ /**
321
+ * @param {unknown} message - any message-shaped value.
322
+ * @returns {number} number of tool-call blocks.
323
+ */
324
+ export function toolCallCount(message) {
325
+ const content = Array.isArray(message?.content) ? message.content : []
326
+ let count = 0
327
+ for (const block of content) {
328
+ if (block && block.type === 'tool-call') count += 1
329
+ }
330
+ return count
331
+ }
332
+
333
+ /**
334
+ * Provider stop reason of one committed assistant message. Only the replay envelope
335
+ * carries it; a streaming snapshot has none.
336
+ * @param {unknown} message - a committed assistant message.
337
+ * @returns {string | undefined}
338
+ */
339
+ export function stopReasonOf(message) {
340
+ const replay = message?.source?.replayState
341
+ if (replay === undefined || replay === null) return undefined
342
+ const response = replay.response
343
+ if (typeof response === 'string') return response
344
+ const stopReason = response?.stopReason
345
+ return typeof stopReason === 'string' ? stopReason : undefined
346
+ }
347
+
348
+ /**
349
+ * @param {string} summary - free-form account.
350
+ * @returns {string} bounded to the source-summary limit.
351
+ */
352
+ function boundSummary(summary) {
353
+ return summary.length <= SUMMARY_MAX_CHARS
354
+ ? summary
355
+ : `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…`
356
+ }
357
+
358
+ /**
359
+ * Build the durable user-role message that re-opens the turn. `role: 'user'` is the
360
+ * only role the model answers, but `source.kind` records that no human spoke.
361
+ * @param {{ turn: number, rule: string, text: string }} input - construction input.
362
+ * @returns {object} a UserMessage ready for `agent.steer()`.
363
+ */
364
+ export function buildNudgeMessage({ turn, rule, text }) {
365
+ return {
366
+ id: randomUUID(),
367
+ role: 'user',
368
+ content: [{ type: 'text', text }],
369
+ source: {
370
+ kind: SOURCE_KIND,
371
+ form: 'notice',
372
+ rule,
373
+ turn,
374
+ summary: boundSummary(`${name}: ${rule} at turn ${turn}`),
375
+ },
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Content fingerprint of one final message.
381
+ * @param {object | null} finalMessage - folded final message.
382
+ * @returns {string | undefined}
383
+ */
384
+ export function fingerprintOf(finalMessage) {
385
+ if (finalMessage === undefined || finalMessage === null) return undefined
386
+ const hash = createHash('sha256')
387
+ hash.update(String(finalMessage.id ?? ''))
388
+ hash.update('\u0000')
389
+ hash.update(String(finalMessage.stopReason ?? ''))
390
+ hash.update('\u0000')
391
+ hash.update(finalMessage.text ?? '')
392
+ return hash.digest('hex')
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // session window
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /**
400
+ * Locate the committed event window of one open turn.
401
+ * @param {object} session - the agent's session.
402
+ * @param {number} turn - the turn being closed.
403
+ * @returns {{ events: readonly object[] } | undefined}
404
+ */
405
+ export function turnWindow(session, turn) {
406
+ if (typeof session?.eventAt !== 'function' || typeof session?.snapshotEvents !== 'function') {
407
+ return undefined
408
+ }
409
+ const end = typeof session.seq === 'number' ? session.seq : undefined
410
+ if (typeof end !== 'number') return undefined
411
+ for (let seq = end - 1; seq >= 0; seq -= 1) {
412
+ const event = session.eventAt(seq)
413
+ if (event === undefined || event === null) continue
414
+ if (event.type !== 'turn/start') continue
415
+ if (event.data?.turn !== turn) return undefined
416
+ return { events: session.snapshotEvents(event.seq) }
417
+ }
418
+ return undefined
419
+ }
420
+
421
+ /**
422
+ * Fold one turn's committed events into the facts the rules need.
423
+ * @param {readonly object[]} events - the turn's event window.
424
+ * @returns {object} folded facts.
425
+ */
426
+ export function foldTurn(events) {
427
+ let hadRetry = false
428
+ let toolCalls = 0
429
+ let userInputs = 0
430
+ let final = null
431
+ let firstAssistantSeq
432
+ let lastPushTime
433
+ const retryCodes = new Set()
434
+ // Every push we made this turn, plus whether the model actually did anything after
435
+ // it. A push that produced work was not wasted, so it must not eat the budget.
436
+ const pushes = []
437
+ let workSincePush = false
438
+
439
+ for (const event of events) {
440
+ if (event === undefined || event === null) continue
441
+ switch (event.type) {
442
+ case 'llm/retry': {
443
+ hadRetry = true
444
+ const code = event.data?.failure?.code
445
+ if (typeof code === 'string') retryCodes.add(code)
446
+ break
447
+ }
448
+ case 'tool/call':
449
+ toolCalls += 1
450
+ workSincePush = true
451
+ break
452
+ case 'user/message': {
453
+ const kind = event.data?.source?.kind
454
+ if (kind === SOURCE_KIND) {
455
+ // Durable record of our own pushes: survives reloads and duplicate instances.
456
+ const previous = pushes[pushes.length - 1]
457
+ if (previous !== undefined) previous.productive = workSincePush
458
+ pushes.push({ productive: false, time: event.time })
459
+ workSincePush = false
460
+ lastPushTime = event.time
461
+ break
462
+ }
463
+ // Only a message *after* the first assistant output is a takeover; the prompt
464
+ // that opened the turn arrives before it and must not reset the bookkeeping.
465
+ if (kind === 'user' && firstAssistantSeq !== undefined && event.seq > firstAssistantSeq) {
466
+ userInputs += 1
467
+ }
468
+ break
469
+ }
470
+ case 'assistant/message': {
471
+ if (firstAssistantSeq === undefined) firstAssistantSeq = event.seq
472
+ const message = event.data?.message
473
+ if (event.data?.interrupted === true) break
474
+ if (message?.source?.replayState === undefined) break
475
+ final = {
476
+ id: message.id,
477
+ seq: event.seq,
478
+ text: messageText(message),
479
+ toolCalls: toolCallCount(message),
480
+ stopReason: stopReasonOf(message),
481
+ provider: message.source?.provider,
482
+ model: message.source?.model,
483
+ }
484
+ break
485
+ }
486
+ default:
487
+ break
488
+ }
489
+ }
490
+
491
+ const last = pushes[pushes.length - 1]
492
+ if (last !== undefined) last.productive = workSincePush
493
+ // Trailing run of pushes that led nowhere: that is the only budget worth counting.
494
+ let unproductiveStreak = 0
495
+ for (let i = pushes.length - 1; i >= 0; i -= 1) {
496
+ if (pushes[i].productive) break
497
+ unproductiveStreak += 1
498
+ }
499
+
500
+ return {
501
+ hadRetry,
502
+ toolCalls,
503
+ userInputs,
504
+ pushes: pushes.length,
505
+ productivePushes: pushes.filter((push) => push.productive).length,
506
+ unproductiveStreak,
507
+ lastPushTime,
508
+ final,
509
+ retryCodes: [...retryCodes],
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Fold the newest `goal/change` event to learn whether a goal is live.
515
+ * @param {object} session - the agent's session.
516
+ * @returns {'active' | string | 'none' | 'unknown'}
517
+ */
518
+ export function latestGoalPhase(session) {
519
+ if (typeof session?.eventAt !== 'function' || typeof session?.seq !== 'number') return 'unknown'
520
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
521
+ const event = session.eventAt(seq)
522
+ if (event === undefined || event === null) continue
523
+ if (event.type !== 'goal/change') continue
524
+ if (event.data?.operation === 'clear') return 'none'
525
+ const phase = event.data?.goal?.phase
526
+ return typeof phase === 'string' ? phase : 'unknown'
527
+ }
528
+ return 'none'
529
+ }
530
+
531
+ /**
532
+ * @param {object} folded - folded turn facts.
533
+ * @param {object} session - the agent's session.
534
+ * @returns {string | undefined} `provider/model` of this turn.
535
+ */
536
+ export function routeOf(folded, session) {
537
+ const final = folded?.final
538
+ if (typeof final?.provider === 'string' && typeof final?.model === 'string') {
539
+ return `${final.provider}/${final.model}`
540
+ }
541
+ const header = typeof session?.requestHeader === 'function' ? session.requestHeader() : undefined
542
+ const config = header?.config
543
+ if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
544
+ return `${config.provider}/${config.model}`
545
+ }
546
+ return undefined
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // prose rules
551
+ // ---------------------------------------------------------------------------
552
+
553
+ /**
554
+ * The tail of the last non-empty line — what actually hands the turn back.
555
+ * Deliberately not sentence-split: `…吗?(只提交 X)` must keep its question mark.
556
+ * @param {string} text - final body.
557
+ * @returns {string}
558
+ */
559
+ export function lastSentence(text) {
560
+ const body = String(text ?? '').trim()
561
+ if (body.length === 0) return ''
562
+ const lines = body.split('\n').filter((line) => line.trim().length > 0)
563
+ const lastLine = (lines.length > 0 ? lines[lines.length - 1] : body).trim()
564
+ return lastLine.length <= ASKING_PROBE_CHARS ? lastLine : lastLine.slice(-ASKING_PROBE_CHARS)
565
+ }
566
+
567
+ /**
568
+ * The last two non-empty lines: the smallest window that still covers a plan written
569
+ * on the line before a one-line closer.
570
+ * @param {string} text - final body.
571
+ * @returns {string}
572
+ */
573
+ export function closingRegion(text) {
574
+ const lines = String(text ?? '')
575
+ .split('\n')
576
+ .map((line) => line.trim())
577
+ .filter((line) => line.length > 0)
578
+ return lines.slice(-2).join('\n')
579
+ }
580
+
581
+ const TRAILING_CLOSERS = /[)」』”"'\])】》〉\s]+$/u
582
+ const SETTLED_END = /[。!?!?…;;.]$/u
583
+ const OPEN_END = /[::,,、\-–—((\[【「『“"']$/u
584
+
585
+ /**
586
+ * Whether the body ends like a finished thought (as opposed to being cut mid-sentence,
587
+ * which is the silent-truncation shape).
588
+ * @param {string} text - final body.
589
+ * @returns {boolean}
590
+ */
591
+ export function isSettled(text) {
592
+ const body = String(text ?? '').trim()
593
+ if (body.length === 0) return true
594
+ if (/```\s*$/.test(body)) return true
595
+ const lines = body.split('\n').filter((line) => line.trim().length > 0)
596
+ const lastLine = (lines.length > 0 ? lines[lines.length - 1] : body).trim()
597
+ if (lastLine.length === 0) return true
598
+ if (/^\|/.test(lastLine) || /\|\s*$/.test(lastLine)) return true
599
+ if (OPEN_END.test(lastLine)) return false
600
+ const stripped = lastLine.replace(TRAILING_CLOSERS, '').replace(/[*_`]+$/u, '')
601
+ if (stripped.length === 0) return true
602
+ if (SETTLED_END.test(stripped)) return true
603
+ const lastToken = stripped.split(/\s+/u).at(-1) ?? ''
604
+ // A tail with no letters is a number/unit/code fragment: complete by nature.
605
+ if (lastToken.length > 0 && !/\p{L}/u.test(lastToken)) return true
606
+ return false
607
+ }
608
+
609
+ /**
610
+ * @param {object} folded - folded turn facts.
611
+ * @param {object} options - resolved config.
612
+ * @returns {boolean} whether the closing region announces a plan.
613
+ */
614
+ export function matchesPlanTail(folded, options) {
615
+ const text = folded.final?.text ?? ''
616
+ if (text.trim().length === 0) return false
617
+ const region = closingRegion(text)
618
+ if (region.length === 0) return false
619
+ return options.planPatterns.some((pattern) => pattern.test(region))
620
+ }
621
+
622
+ /**
623
+ * @param {object} folded - folded turn facts.
624
+ * @param {object} options - resolved config.
625
+ * @returns {boolean} whether the reply hands the turn back to the human.
626
+ */
627
+ export function matchesAsking(folded, options) {
628
+ const sentence = lastSentence(folded.final?.text ?? '')
629
+ if (sentence.length === 0) return false
630
+ return options.isAskingPatterns.some((pattern) => pattern.test(sentence))
631
+ }
632
+
633
+ /**
634
+ * @param {object} folded - folded turn facts.
635
+ * @param {object} options - resolved config.
636
+ * @returns {string[]} matched heuristic scopes.
637
+ */
638
+ export function matchedScopes(folded, options) {
639
+ const scopes = []
640
+ const text = folded.final?.text ?? ''
641
+ if (options.retryScope && folded.hadRetry) {
642
+ const codes = Array.isArray(folded.retryCodes) ? folded.retryCodes : []
643
+ const allowed =
644
+ !Array.isArray(options.retryCodes) || options.retryCodes.length === 0
645
+ ? true
646
+ : codes.some((code) => options.retryCodes.includes(code))
647
+ if (allowed || codes.length === 0) scopes.push('retry')
648
+ }
649
+ if (options.truncationScope && !isSettled(text)) scopes.push('truncated')
650
+ if (options.extendedScope && matchesPlanTail(folded, options)) scopes.push('plan')
651
+ return scopes
652
+ }
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // completion protocol
656
+ // ---------------------------------------------------------------------------
657
+
658
+ /**
659
+ * The instruction injected into the system prompt.
660
+ * @param {object} options - resolved config.
661
+ * @returns {string}
662
+ */
663
+ export function buildProtocolInstruction(options) {
664
+ const { doneMarker, askMarker, donePrefix, askPrefix } = options
665
+ const fenced = doneMarker.startsWith('`')
666
+ const done = `${donePrefix}<一句话结果摘要>`
667
+ const ask = `${askPrefix}<需要用户拍板的具体问题>`
668
+
669
+ if (fenced) {
670
+ return [
671
+ '## Turn-ending protocol (mandatory)',
672
+ '',
673
+ 'End EVERY reply with exactly one of these two fenced blocks, as the last thing',
674
+ 'in the message:',
675
+ '',
676
+ '````',
677
+ doneMarker,
678
+ done,
679
+ '```',
680
+ '````',
681
+ '',
682
+ 'when the requested work is finished and verified (the summary must state what',
683
+ 'was actually delivered), or',
684
+ '',
685
+ '````',
686
+ askMarker,
687
+ ask,
688
+ '```',
689
+ '````',
690
+ '',
691
+ 'when you cannot continue without the user (a decision, a credential, a manual',
692
+ 'check, missing information).',
693
+ '',
694
+ 'Never end a reply without one of them. Never write a plan ("next I will …") as',
695
+ 'the last thing you say: if work remains, keep working instead of ending the',
696
+ 'turn. The two blocks are the only endings that close a turn.',
697
+ ].join('\n')
698
+ }
699
+
700
+ return [
701
+ '## Turn-ending protocol (mandatory)',
702
+ '',
703
+ 'End EVERY reply with exactly one of these two markers on its own final line:',
704
+ '',
705
+ `- ${doneMarker} ${done}${options.markerTerminator}`,
706
+ `- ${askMarker} ${ask}${options.markerTerminator}`,
707
+ '',
708
+ 'Never end a reply without one of them. Never write a plan as the last thing you',
709
+ 'say: if work remains, keep working instead of ending the turn.',
710
+ ].join('\n')
711
+ }
712
+
713
+ /**
714
+ * Read the protocol verdict from a final body. Only the closing lines are inspected:
715
+ * a reply that *mentions* the markers mid-text is not making a declaration.
716
+ * @param {string} text - final body.
717
+ * @param {object} options - resolved config.
718
+ * @returns {{ verdict: 'done' | 'ask' | 'missing' | 'empty', summary: string }}
719
+ */
720
+ export function parseProtocol(text, options) {
721
+ const body = String(text ?? '')
722
+ const fenced = options.doneMarker.startsWith('`')
723
+ if (fenced) return parseFencedProtocol(body, options)
724
+
725
+ const tail = body.split('\n').filter((line) => line.trim().length > 0).slice(-3).join('\n')
726
+ const doneIndex = tail.lastIndexOf(options.doneMarker)
727
+ const askIndex = tail.lastIndexOf(options.askMarker)
728
+ const terminator = options.markerTerminator
729
+ const read = (index, marker) => {
730
+ if (index < 0) return undefined
731
+ const rest = tail.slice(index + marker.length)
732
+ const close = rest.indexOf(terminator)
733
+ return (close >= 0 ? rest.slice(0, close) : rest).replace(/^\s*:?\s*/, '').trim()
734
+ }
735
+ if (askIndex > doneIndex) {
736
+ const question = read(askIndex, options.askMarker)
737
+ if (question === undefined) return { verdict: 'missing', summary: '' }
738
+ return { verdict: question.length > 0 ? 'ask' : 'empty', summary: question }
739
+ }
740
+ if (doneIndex >= 0) {
741
+ const summary = read(doneIndex, options.doneMarker)
742
+ if (summary === undefined) return { verdict: 'missing', summary: '' }
743
+ return { verdict: summary.length > 0 ? 'done' : 'empty', summary }
744
+ }
745
+ return { verdict: 'missing', summary: '' }
746
+ }
747
+
748
+ /**
749
+ * Whether the declaration block is the *last* thing in the body.
750
+ *
751
+ * The protocol text asks for the block "as the last thing in the message", but a model
752
+ * that writes "```done" and then keeps talking ("wait, I am not finished yet") used to be
753
+ * read as done, which closed the turn mid-task. So the bar is structural: the final
754
+ * non-blank line must close a fenced block, and that block must be the declaration block.
755
+ * Only that shape counts as a finished reply — trailing prose, a second block, or a
756
+ * declaration that sits mid-text all mean the turn is not settled.
757
+ * @param {string} body - final body.
758
+ * @param {object} options - resolved config.
759
+ * @returns {boolean}
760
+ */
761
+ function declarationIsLastBlock(body, options) {
762
+ const lines = body.split('\n')
763
+ let end = -1
764
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
765
+ if (lines[i].trim().length > 0) {
766
+ end = i
767
+ break
768
+ }
769
+ }
770
+ if (end < 0) return false
771
+ // The closing fence must be the last thing: only backticks, nothing after it.
772
+ if (!/^`{3,}$/.test(lines[end].trim())) return false
773
+ // Walk up to the fence that opened this block. Whichever fence line we hit first is the
774
+ // opener, because fenced blocks do not interleave with prose.
775
+ for (let i = end - 1; i >= 0; i -= 1) {
776
+ const trimmed = lines[i].trim()
777
+ if (!trimmed.startsWith(FENCE)) continue
778
+ return trimmed.startsWith(options.doneMarker) || trimmed.startsWith(options.askMarker)
779
+ }
780
+ return false
781
+ }
782
+
783
+ /**
784
+ * Parse the fenced form: the marker opens a block whose first non-blank line is the
785
+ * declaration body.
786
+ * @param {string} body - final body.
787
+ * @param {object} options - resolved config.
788
+ * @returns {{ verdict: 'done' | 'ask' | 'missing' | 'empty', summary: string }}
789
+ */
790
+ function parseFencedProtocol(body, options) {
791
+ const lines = body.split('\n')
792
+ let found = -1
793
+ let kind = 'missing'
794
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
795
+ const trimmed = lines[i].trim()
796
+ if (trimmed.startsWith(options.doneMarker)) {
797
+ found = i
798
+ kind = 'done'
799
+ break
800
+ }
801
+ if (trimmed.startsWith(options.askMarker)) {
802
+ found = i
803
+ kind = 'ask'
804
+ break
805
+ }
806
+ }
807
+ if (found < 0) return { verdict: 'missing', summary: '' }
808
+
809
+ const collected = []
810
+ for (let j = found + 1; j < lines.length; j += 1) {
811
+ if (lines[j].trim().startsWith(FENCE)) break
812
+ collected.push(lines[j])
813
+ }
814
+ const summary = collected.join('\n').trim()
815
+ // A declaration that is not the final block does not close the turn: the model went on
816
+ // talking after it, so treat it as undeclared and let the listener push once.
817
+ if (!declarationIsLastBlock(body, options)) {
818
+ return { verdict: summary.length > 0 ? 'missing' : 'empty', summary }
819
+ }
820
+ return { verdict: summary.length > 0 ? kind : 'empty', summary }
821
+ }
822
+
823
+ /**
824
+ * Replace the whole trailing marker block with nothing, so the prose rules judge the
825
+ * prose and the stream guard does not read a closing fence as a cut-off sentence.
826
+ * @param {string} text - final body.
827
+ * @param {object} options - resolved config.
828
+ * @returns {string}
829
+ */
830
+ export function stripProtocolMarkers(text, options) {
831
+ const body = String(text ?? '')
832
+ if (!options.completionProtocol) return body
833
+
834
+ if (options.doneMarker.startsWith('`')) {
835
+ const lines = body.split('\n')
836
+ let start = -1
837
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
838
+ const trimmed = lines[i].trim()
839
+ if (trimmed.startsWith(options.doneMarker) || trimmed.startsWith(options.askMarker)) {
840
+ start = i
841
+ break
842
+ }
843
+ }
844
+ if (start < 0) return body.trimEnd()
845
+ let end = lines.length
846
+ for (let j = start + 1; j < lines.length; j += 1) {
847
+ if (lines[j].trim().startsWith(FENCE)) {
848
+ end = j + 1
849
+ break
850
+ }
851
+ }
852
+ return [...lines.slice(0, start), ...lines.slice(end)].join('\n').trimEnd()
853
+ }
854
+
855
+ return body
856
+ .split('\n')
857
+ .filter((line) => {
858
+ const trimmed = line.trim()
859
+ return !trimmed.startsWith(options.doneMarker) && !trimmed.startsWith(options.askMarker)
860
+ })
861
+ .join('\n')
862
+ .trimEnd()
863
+ }
864
+
865
+ // ---------------------------------------------------------------------------
866
+ // decision
867
+ // ---------------------------------------------------------------------------
868
+
869
+ /**
870
+ * Decide whether this turn should be re-opened.
871
+ * @param {object} folded - folded turn facts.
872
+ * @param {string | undefined} route - `provider/model`.
873
+ * @param {object} options - resolved config.
874
+ * @returns {{ nudge: boolean, rule: string, reason: string, tail: string, scopes: string[] }}
875
+ */
876
+ export function decide(folded, route, options) {
877
+ const tail = (folded.final?.text ?? '').trim().slice(-DECISION_TAIL_CHARS)
878
+ const early = (reason) => ({ nudge: false, rule: 'none', reason, tail, scopes: [] })
879
+
880
+ if (!options.enabled) return early('disabled')
881
+ if (options.maxNudges <= 0) return early('max-nudges-0')
882
+ if (options.allowModels.length > 0) {
883
+ if (route === undefined) return early('unknown-route')
884
+ if (!options.allowModels.includes(route)) return early('model-not-allowed')
885
+ }
886
+ if (folded.final === null) return early('no-final-message')
887
+ if (folded.final.stopReason !== 'stop') {
888
+ return early(`stop-reason:${String(folded.final.stopReason)}`)
889
+ }
890
+ if (folded.final.toolCalls > 0) return early('final-has-tool-call')
891
+
892
+ const prose = stripProtocolMarkers(folded.final.text ?? '', options)
893
+ const judged = { ...folded, final: { ...folded.final, text: prose } }
894
+ const scopes = matchedScopes(judged, options)
895
+
896
+ if (options.completionProtocol) {
897
+ // The protocol asks for a declaration at the end of every reply, so a missing one
898
+ // must never be allowed to close the turn — whatever the shape of the reply. An
899
+ // empty body, a plan announcement and ordinary chatter all read as "not declared".
900
+ const protocol = parseProtocol(folded.final.text ?? '', options)
901
+ if (protocol.verdict === 'done' || protocol.verdict === 'ask') {
902
+ return { nudge: false, rule: 'none', reason: `protocol-${protocol.verdict}`, tail, scopes }
903
+ }
904
+ const emptyProse = prose.trim().length === 0
905
+ return {
906
+ nudge: true,
907
+ rule: emptyProse
908
+ ? 'protocol-empty-text'
909
+ : protocol.verdict === 'empty'
910
+ ? 'protocol-empty'
911
+ : 'protocol-missing',
912
+ reason: 'protocol-missing',
913
+ tail,
914
+ scopes,
915
+ }
916
+ }
917
+
918
+ if (prose.trim().length === 0) return early('empty-final-text')
919
+
920
+ const planOnly = scopes.length > 0 && scopes.every((scope) => scope === 'plan')
921
+ if (folded.toolCalls === 0 && !planOnly) return early('no-work-this-turn')
922
+ if (scopes.length === 0) {
923
+ const allOff = !options.retryScope && !options.truncationScope && !options.extendedScope
924
+ return {
925
+ nudge: false,
926
+ rule: 'none',
927
+ reason: allOff ? 'all-scopes-off' : `no-scope:retry=${folded.hadRetry}`,
928
+ tail,
929
+ scopes,
930
+ }
931
+ }
932
+ if (matchesAsking(judged, options)) {
933
+ return { nudge: false, rule: 'none', reason: 'asking-user', tail, scopes }
934
+ }
935
+ return { nudge: true, rule: scopes.join('+'), reason: 'matched', tail, scopes }
936
+ }
937
+
938
+ // ---------------------------------------------------------------------------
939
+ // provider-side guard
940
+ // ---------------------------------------------------------------------------
941
+
942
+ export const STREAM_STALL_CODE = 'STREAM_STALL'
943
+ export const STREAM_TRUNCATED_CODE = 'STREAM_TRUNCATED'
944
+ export const GUARD_CODES = Object.freeze([STREAM_STALL_CODE, STREAM_TRUNCATED_CODE])
945
+
946
+ /**
947
+ * A terminal `error` finish chunk for a damaged stream.
948
+ * @param {string} code - guard code.
949
+ * @param {string} message - account for the failure.
950
+ * @returns {object} a `StreamChunk` of type `finish`.
951
+ */
952
+ export function guardFinishChunk(code, message) {
953
+ return { type: 'finish', reason: { kind: 'error', failure: { code, message, status: 0 } } }
954
+ }
955
+
956
+ /**
957
+ * Whether an adapter's terminal `finish` should be replaced by an error.
958
+ * @param {object} finishChunk - the adapter finish chunk.
959
+ * @param {{ text: string, toolCalls: number }} pending - accumulated stream facts.
960
+ * @param {object} options - resolved config.
961
+ * @returns {{ damage: boolean, reason: string }}
962
+ */
963
+ export function inspectFinish(finishChunk, pending, options) {
964
+ const reason = finishChunk?.reason
965
+ if (reason === undefined || reason === null) return { damage: false, reason: 'no-reason' }
966
+ if (reason.kind !== 'stop') return { damage: false, reason: `kind:${String(reason.kind)}` }
967
+ if (!options.streamGuard) return { damage: false, reason: 'guard-off' }
968
+ if (pending.toolCalls > 0) return { damage: false, reason: 'tool-calls' }
969
+ const text = stripProtocolMarkers(pending.text, options)
970
+ if (text.trim().length === 0) return { damage: false, reason: 'no-text' }
971
+ if (matchesAsking({ final: { text, toolCalls: 0 } }, options)) {
972
+ return { damage: false, reason: 'asking-user' }
973
+ }
974
+ if (isSettled(text)) return { damage: false, reason: 'settled' }
975
+ return { damage: true, reason: 'unsettled-text' }
976
+ }
977
+
978
+ class IdleTimeout extends Error {}
979
+
980
+ /**
981
+ * Await the next chunk, failing when the adapter goes silent for `idleMs`.
982
+ * @param {AsyncIterator<object>} iterator - upstream iterator.
983
+ * @param {number} idleMs - silence budget; non-positive disables the watchdog.
984
+ * @returns {Promise<object | undefined>}
985
+ */
986
+ function nextWithWatchdog(iterator, idleMs) {
987
+ if (!(idleMs > 0)) return iterator.next().then((step) => step.value)
988
+ let timer
989
+ const timeout = new Promise((_, reject) => {
990
+ timer = setTimeout(() => reject(new IdleTimeout('idle')), idleMs)
991
+ if (typeof timer?.unref === 'function') timer.unref()
992
+ })
993
+ const read = iterator.next().then((step) => step.value)
994
+ return Promise.race([read, timeout]).finally(() => clearTimeout(timer))
995
+ }
996
+
997
+ /**
998
+ * Wrap one adapter stream with the stall watchdog and the truncation guard.
999
+ * @param {AsyncIterable<object>} upstream - the adapter stream.
1000
+ * @param {object} context - guard context.
1001
+ * @param {object} context.options - resolved config.
1002
+ * @param {() => boolean} context.consumeTruncationBudget - budget check.
1003
+ * @param {(record: object) => void} context.log - decision sink.
1004
+ * @returns {AsyncIterable<object>}
1005
+ */
1006
+ export async function* guardStream(upstream, context) {
1007
+ const { options, consumeTruncationBudget, log } = context
1008
+ const pending = { text: '', toolCalls: 0 }
1009
+ const iterator = upstream[Symbol.asyncIterator]()
1010
+ let finished = false
1011
+
1012
+ try {
1013
+ for (;;) {
1014
+ let chunk
1015
+ try {
1016
+ chunk = await nextWithWatchdog(iterator, options.streamIdleMs)
1017
+ } catch (error) {
1018
+ if (error instanceof IdleTimeout) {
1019
+ log({ event: 'guard', damage: 'stall', action: 'error-finish' })
1020
+ yield guardFinishChunk(
1021
+ STREAM_STALL_CODE,
1022
+ `no stream chunk for ${options.streamIdleMs}ms; treating the request as failed`,
1023
+ )
1024
+ return
1025
+ }
1026
+ throw error
1027
+ }
1028
+
1029
+ if (chunk === undefined) {
1030
+ log({ event: 'guard', damage: 'no-finish', action: 'error-finish' })
1031
+ yield guardFinishChunk(STREAM_TRUNCATED_CODE, 'stream ended without a finish chunk')
1032
+ return
1033
+ }
1034
+
1035
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') pending.text += chunk.text
1036
+ else if (chunk.type === 'block-end' && chunk.block?.type === 'tool-call') pending.toolCalls += 1
1037
+ else if (chunk.type === 'block-start' && chunk.blockType === 'tool-call') {
1038
+ pending.toolCalls += 1
1039
+ }
1040
+
1041
+ if (chunk.type === 'finish') {
1042
+ finished = true
1043
+ const verdict = inspectFinish(chunk, pending, options)
1044
+ if (verdict.damage && consumeTruncationBudget()) {
1045
+ log({
1046
+ event: 'guard',
1047
+ damage: 'truncated',
1048
+ verdict: verdict.reason,
1049
+ action: 'error-finish',
1050
+ tail: pending.text.trim().slice(-LOG_TAIL_CHARS),
1051
+ })
1052
+ yield guardFinishChunk(
1053
+ STREAM_TRUNCATED_CODE,
1054
+ 'stream stopped mid-thought; treating the request as failed',
1055
+ )
1056
+ return
1057
+ }
1058
+ yield chunk
1059
+ return
1060
+ }
1061
+
1062
+ yield chunk
1063
+ }
1064
+ } finally {
1065
+ if (!finished) {
1066
+ // Never awaited: a hanging provider would hold the retry for as long as it hangs.
1067
+ try {
1068
+ const pendingReturn = iterator.return?.()
1069
+ if (pendingReturn !== undefined && typeof pendingReturn.then === 'function') {
1070
+ pendingReturn.then(undefined, () => {})
1071
+ }
1072
+ } catch {
1073
+ /* best-effort cancellation */
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ // ---------------------------------------------------------------------------
1080
+ // logging
1081
+ // ---------------------------------------------------------------------------
1082
+
1083
+ /**
1084
+ * @returns {string} the default plugin log path.
1085
+ */
1086
+ export function defaultLogFile() {
1087
+ const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
1088
+ return path.join(home, name, 'events.jsonl')
1089
+ }
1090
+
1091
+ /**
1092
+ * Append one plugin-log line. Every failure path is swallowed: logging must never be
1093
+ * able to break a turn.
1094
+ * @param {object} options - resolved config.
1095
+ * @param {object} ctx - plugin context, for `ctx.logger`.
1096
+ * @param {object} record - the line to write.
1097
+ * @returns {void}
1098
+ */
1099
+ export function writePluginLog(options, ctx, record) {
1100
+ const interesting =
1101
+ record.event === 'nudge' || record.event === 'error' || record.event === 'inert'
1102
+ if (!options.logDecisions && !interesting) return
1103
+ const line = JSON.stringify({ time: new Date().toISOString(), plugin: name, ...record })
1104
+ try {
1105
+ const file = options.logFile.length > 0 ? options.logFile : defaultLogFile()
1106
+ fs.mkdirSync(path.dirname(file), { recursive: true })
1107
+ fs.appendFileSync(file, `${line}\n`)
1108
+ } catch {
1109
+ /* a log write must never affect the loop */
1110
+ }
1111
+ try {
1112
+ if (record.event === 'error') ctx?.logger?.warn?.(`${name}: ${record.message ?? 'error'}`)
1113
+ else ctx?.logger?.info?.(`${name}: ${record.event}`)
1114
+ } catch {
1115
+ /* a logger must never affect the loop */
1116
+ }
1117
+ }
1118
+
1119
+ // ---------------------------------------------------------------------------
1120
+ // listener
1121
+ // ---------------------------------------------------------------------------
1122
+
1123
+ /**
1124
+ * Read (or start) the per-agent bookkeeping for the current turn.
1125
+ * @param {Map<string, object>} states - per-agent state.
1126
+ * @param {string} agentId - agent identity.
1127
+ * @param {number} turn - turn being closed.
1128
+ * @param {object} folded - folded turn facts.
1129
+ * @returns {{ turn: number, count: number, fingerprints: Set<string> }}
1130
+ */
1131
+ export function stateFor(states, agentId, turn, folded) {
1132
+ let state = states.get(agentId)
1133
+ if (state === undefined || state.turn !== turn) {
1134
+ state = { turn, count: 0, attempts: 0, gaveUp: false, lastPushAt: undefined, fingerprints: new Set() }
1135
+ states.set(agentId, state)
1136
+ } else if (folded.userInputs > 0) {
1137
+ state.count = 0
1138
+ state.attempts = 0
1139
+ state.gaveUp = false
1140
+ state.fingerprints.clear()
1141
+ }
1142
+ return state
1143
+ }
1144
+
1145
+ /**
1146
+ * @param {number} ms - delay.
1147
+ * @returns {Promise<void>}
1148
+ */
1149
+ function sleep(ms) {
1150
+ return new Promise((resolve) => {
1151
+ const timer = setTimeout(resolve, ms)
1152
+ if (typeof timer?.unref === 'function') timer.unref()
1153
+ })
1154
+ }
1155
+
1156
+ /**
1157
+ * One `agent/turn-stopping` evaluation.
1158
+ * @param {object} ctx - plugin context.
1159
+ * @param {object} options - resolved config.
1160
+ * @param {Map<string, object>} states - per-agent state.
1161
+ * @param {object} payload - event payload.
1162
+ * @returns {Promise<void>}
1163
+ */
1164
+ async function onTurnStopping(ctx, options, states, payload) {
1165
+ const agent = payload?.agent
1166
+ const turn = payload?.turn
1167
+ const signal = payload?.signal
1168
+ if (agent === undefined || agent === null || typeof agent.steer !== 'function') return
1169
+ if (!Number.isInteger(turn)) return
1170
+ const session = agent.session
1171
+ if (session === undefined || session === null) return
1172
+
1173
+ if (signal?.aborted === true) {
1174
+ writePluginLog(options, ctx, { event: 'skip', turn, reason: 'turn-aborted' })
1175
+ return
1176
+ }
1177
+ if (agent.status !== undefined && agent.status !== 'running') {
1178
+ writePluginLog(options, ctx, { event: 'skip', turn, reason: `status:${String(agent.status)}` })
1179
+ return
1180
+ }
1181
+ if (latestGoalPhase(session) === 'active') {
1182
+ writePluginLog(options, ctx, { event: 'skip', turn, reason: 'goal-active' })
1183
+ return
1184
+ }
1185
+
1186
+ const window = turnWindow(session, turn)
1187
+ if (window === undefined) {
1188
+ writePluginLog(options, ctx, { event: 'skip', turn, reason: 'no-turn-window' })
1189
+ return
1190
+ }
1191
+
1192
+ const folded = foldTurn(window.events)
1193
+ const route = routeOf(folded, session)
1194
+ const decision = decide(folded, route, options)
1195
+ const agentId = String(agent.id ?? 'unknown')
1196
+ const state = stateFor(states, agentId, turn, folded)
1197
+
1198
+ const base = {
1199
+ event: 'decision',
1200
+ agentId,
1201
+ sessionId: typeof session.id === 'string' ? session.id : null,
1202
+ turn,
1203
+ route: route ?? null,
1204
+ hadRetry: folded.hadRetry,
1205
+ toolCalls: folded.toolCalls,
1206
+ stopReason: folded.final?.stopReason ?? null,
1207
+ isAsking: matchesAsking(folded, options),
1208
+ planTail: matchesPlanTail(folded, options),
1209
+ scopes: matchedScopes(folded, options),
1210
+ userTakeovers: folded.userInputs,
1211
+ priorNudges: state.count,
1212
+ rule: decision.rule,
1213
+ reason: decision.reason,
1214
+ }
1215
+
1216
+ if (!decision.nudge) {
1217
+ writePluginLog(options, ctx, { ...base, nudge: false })
1218
+ return
1219
+ }
1220
+
1221
+ // Budget: only pushes that led nowhere count, and a push that made the model do real
1222
+ // work resets the streak. There is deliberately NO total-attempts ceiling: a turn that
1223
+ // keeps making progress must be re-opened as often as it stops. A model that stops
1224
+ // without producing anything simply adds to the streak and gets cut off at maxNudges.
1225
+ // The durable log is the authority, so a reload or a second instance cannot talk itself
1226
+ // past the cap.
1227
+ const durableStreak = folded.unproductiveStreak ?? 0
1228
+ state.count = durableStreak
1229
+ // `state.attempts` is kept for the log only (how many pushes this turn saw in total).
1230
+ // It must never gate a push.
1231
+ if (state.count >= options.maxNudges) {
1232
+ // R5: when we really give up, say so in the conversation instead of closing silently.
1233
+ if (!state.gaveUp) {
1234
+ state.gaveUp = true
1235
+ try {
1236
+ const notice = buildNudgeMessage({ turn, rule: 'give-up', text: options.giveUpNoticeText })
1237
+ agent.steer(notice)
1238
+ writePluginLog(options, ctx, {
1239
+ event: 'giveup',
1240
+ turn,
1241
+ reason: 'max-nudges-reached',
1242
+ attempts: state.attempts,
1243
+ messageId: notice.id,
1244
+ })
1245
+ } catch {
1246
+ /* a failed notice must never take the turn down */
1247
+ }
1248
+ }
1249
+ writePluginLog(options, ctx, {
1250
+ ...base,
1251
+ priorNudges: state.count,
1252
+ attempts: state.attempts,
1253
+ nudge: false,
1254
+ reason: 'max-nudges-reached',
1255
+ })
1256
+ return
1257
+ }
1258
+
1259
+ const fingerprint = fingerprintOf(folded.final)
1260
+ if (fingerprint !== undefined && state.fingerprints.has(fingerprint)) {
1261
+ writePluginLog(options, ctx, { ...base, nudge: false, reason: 'duplicate-fingerprint' })
1262
+ return
1263
+ }
1264
+
1265
+ if (options.dryRun) {
1266
+ writePluginLog(options, ctx, { ...base, nudge: false, reason: 'dry-run', wouldNudge: true })
1267
+ return
1268
+ }
1269
+
1270
+ // R4: never fire two pushes back to back. A one-second upstream hiccup must not eat the
1271
+ // whole budget, so the gap is measured from the last push we actually made.
1272
+ const lastPushAt = state.lastPushAt ?? folded.lastPushTime
1273
+ const sinceLast = typeof lastPushAt === 'number' ? Date.now() - lastPushAt : Number.POSITIVE_INFINITY
1274
+ const waitMs = Math.max(options.delayMs, options.nudgeMinIntervalMs - sinceLast, 0)
1275
+ if (waitMs > 0) {
1276
+ await sleep(waitMs)
1277
+ if (signal?.aborted === true) {
1278
+ writePluginLog(options, ctx, { ...base, nudge: false, reason: 'turn-aborted-after-delay' })
1279
+ return
1280
+ }
1281
+ }
1282
+
1283
+ const message = buildNudgeMessage({
1284
+ turn,
1285
+ rule: decision.rule,
1286
+ text:
1287
+ options.completionProtocol && decision.reason === 'protocol-missing'
1288
+ ? options.protocolNudgeText
1289
+ : options.nudgeText,
1290
+ })
1291
+ agent.steer(message)
1292
+
1293
+ state.count += 1
1294
+ state.attempts += 1
1295
+ state.lastPushAt = Date.now()
1296
+ if (fingerprint !== undefined) state.fingerprints.add(fingerprint)
1297
+ writePluginLog(options, ctx, {
1298
+ ...base,
1299
+ event: 'nudge',
1300
+ nudge: true,
1301
+ priorNudges: state.count - 1,
1302
+ nudgeIndex: state.attempts,
1303
+ attempts: state.attempts,
1304
+ maxNudges: options.maxNudges,
1305
+ messageId: message.id,
1306
+ })
1307
+ }
1308
+
1309
+ /**
1310
+ * Install the plugin.
1311
+ *
1312
+ * With `enabled: false` or `maxNudges: 0` nothing is installed and the plugin is
1313
+ * behaviourally identical to not being installed.
1314
+ * @param {object} ctx - the plugin context.
1315
+ * @param {unknown} config - the Loader row's config.
1316
+ * @returns {void}
1317
+ */
1318
+ export function apply(ctx, config) {
1319
+ const options = resolveConfig(config)
1320
+ if (options.invalidPatterns.length > 0) {
1321
+ writePluginLog(options, ctx, {
1322
+ event: 'inert',
1323
+ message: `ignored ${options.invalidPatterns.length} invalid pattern(s)`,
1324
+ })
1325
+ }
1326
+ if (!options.enabled || options.maxNudges <= 0) {
1327
+ writePluginLog(options, ctx, {
1328
+ event: 'inert',
1329
+ reason: options.enabled ? 'maxNudges=0' : 'enabled=false',
1330
+ })
1331
+ return
1332
+ }
1333
+
1334
+ /** @type {Map<string, { turn: number, count: number, fingerprints: Set<string> }>} */
1335
+ const states = new Map()
1336
+ /** @type {Map<string, { used: number }>} */
1337
+ const guardBudgets = new Map()
1338
+ /** @type {Map<string, number>} */
1339
+ const guardRetries = new Map()
1340
+
1341
+ // ---- completion protocol: our own system-prompt section -------------------
1342
+ // The service key is `systemPrompt` (camelCase); `system-prompt` is the event name.
1343
+ // The returned disposer MUST be registered: without it every reload appends another
1344
+ // section to live sessions.
1345
+ if (options.completionProtocol) {
1346
+ try {
1347
+ const systemPrompt = ctx.get('systemPrompt')
1348
+ if (systemPrompt === undefined) {
1349
+ writePluginLog(options, ctx, {
1350
+ event: 'error',
1351
+ message: 'completionProtocol requested but no systemPrompt service is mounted',
1352
+ })
1353
+ } else {
1354
+ ctx.effect(() =>
1355
+ systemPrompt.section({
1356
+ name: 'auto-continue-protocol',
1357
+ order: PROTOCOL_SECTION_ORDER,
1358
+ text: buildProtocolInstruction(options),
1359
+ }),
1360
+ )
1361
+ writePluginLog(options, ctx, {
1362
+ event: 'inert',
1363
+ message: `completion protocol section installed (${options.markerStyle})`,
1364
+ })
1365
+ }
1366
+ } catch (error) {
1367
+ try {
1368
+ writePluginLog(options, ctx, {
1369
+ event: 'error',
1370
+ message: error instanceof Error ? error.message : String(error),
1371
+ stage: 'protocol-section',
1372
+ })
1373
+ } catch {
1374
+ /* activation must not fail over a prompt section */
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ ctx.on('agent/disposed', (payload) => {
1380
+ try {
1381
+ const agentId = payload?.agent?.id
1382
+ if (agentId === undefined) {
1383
+ states.clear()
1384
+ guardBudgets.clear()
1385
+ guardRetries.clear()
1386
+ return
1387
+ }
1388
+ const key = String(agentId)
1389
+ states.delete(key)
1390
+ guardBudgets.delete(key)
1391
+ const prefix = `${key}|`
1392
+ for (const retryKey of guardRetries.keys()) {
1393
+ if (retryKey.startsWith(prefix)) guardRetries.delete(retryKey)
1394
+ }
1395
+ } catch {
1396
+ /* bookkeeping only */
1397
+ }
1398
+ })
1399
+
1400
+ ctx.on('agent/turn-stopping', async (payload) => {
1401
+ try {
1402
+ await onTurnStopping(ctx, options, states, payload)
1403
+ } catch (error) {
1404
+ try {
1405
+ writePluginLog(options, ctx, {
1406
+ event: 'error',
1407
+ message: error instanceof Error ? error.message : String(error),
1408
+ turn: payload?.turn ?? null,
1409
+ })
1410
+ } catch {
1411
+ /* the innermost failure path still must not throw */
1412
+ }
1413
+ }
1414
+ })
1415
+
1416
+ // ---- provider-side guard --------------------------------------------------
1417
+ if (options.streamGuard) {
1418
+ const logGuard = (record) => {
1419
+ try {
1420
+ writePluginLog(options, ctx, record)
1421
+ } catch {
1422
+ /* diagnostics must never affect the stream */
1423
+ }
1424
+ }
1425
+
1426
+ ctx.on('llm/stream', (requestOptions, next) => {
1427
+ const route = `${String(requestOptions?.provider)}/${String(requestOptions?.model)}`
1428
+ if (requestOptions?.purpose !== undefined) return next()
1429
+ if (options.allowModels.length > 0 && !options.allowModels.includes(route)) return next()
1430
+
1431
+ const sessionId = requestOptions?.sessionId
1432
+ const key = typeof sessionId === 'string' ? sessionId : 'anonymous'
1433
+ let budget = guardBudgets.get(key)
1434
+ if (budget === undefined) {
1435
+ budget = { used: 0 }
1436
+ guardBudgets.set(key, budget)
1437
+ }
1438
+ const theBudget = budget
1439
+ const consumeTruncationBudget = () => {
1440
+ if (theBudget.used >= options.maxGuardRetries) return false
1441
+ theBudget.used += 1
1442
+ return true
1443
+ }
1444
+
1445
+ const wrapped = guardStream(next(), { options, consumeTruncationBudget, log: logGuard })
1446
+
1447
+ // Only a clean, settled finish gives the budget back; an unsettled stop that the
1448
+ // guard merely delegated must not reset it, or a route that truncates every time
1449
+ // would be intercepted again and again instead of staying capped.
1450
+ return (async function* () {
1451
+ let text = ''
1452
+ for await (const chunk of wrapped) {
1453
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
1454
+ if (chunk?.type === 'finish' && chunk.reason?.kind === 'stop') {
1455
+ if (isSettled(stripProtocolMarkers(text, options))) theBudget.used = 0
1456
+ }
1457
+ yield chunk
1458
+ }
1459
+ })()
1460
+ })
1461
+
1462
+ ctx.on('agent/request-error', async (payload, next) => {
1463
+ try {
1464
+ const code = payload?.failure?.code
1465
+ if (!GUARD_CODES.includes(code)) return next()
1466
+ const agentId = String(payload?.agent?.id ?? 'unknown')
1467
+ const retryKey = `${agentId}|${payload?.turn}|${payload?.step}|${code}`
1468
+ const taken = guardRetries.get(retryKey) ?? 0
1469
+ if (taken >= options.maxGuardRetries) {
1470
+ logGuard({ event: 'guard', damage: code, action: 'delegate', retriesTaken: taken })
1471
+ return next()
1472
+ }
1473
+ guardRetries.set(retryKey, taken + 1)
1474
+ logGuard({
1475
+ event: 'guard',
1476
+ damage: code,
1477
+ action: 'retry',
1478
+ retryIndex: taken + 1,
1479
+ turn: payload?.turn ?? null,
1480
+ step: payload?.step ?? null,
1481
+ })
1482
+ return { kind: 'retry' }
1483
+ } catch (error) {
1484
+ try {
1485
+ logGuard({
1486
+ event: 'error',
1487
+ message: error instanceof Error ? error.message : String(error),
1488
+ stage: 'guard-retry',
1489
+ })
1490
+ } catch {
1491
+ /* never let guard logging break recovery */
1492
+ }
1493
+ return next()
1494
+ }
1495
+ })
1496
+ }
1497
+
1498
+ ctx.effect(() => () => {
1499
+ states.clear()
1500
+ guardBudgets.clear()
1501
+ guardRetries.clear()
1502
+ })
1503
+ }