dsh-autorestart 0.2.1 → 0.3.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
@@ -25,18 +25,38 @@ import path from 'node:path'
25
25
  import {
26
26
  beginBoot,
27
27
  cancelRestart,
28
+ disarmedRecord,
29
+ entryFor,
30
+ handoffRecord,
28
31
  markIdle,
29
32
  markPendingResume,
30
33
  markRunning,
34
+ parkedRecord,
31
35
  pruneSessions,
32
36
  readState,
33
37
  recoverableSessions,
34
38
  requestRestart,
39
+ setContextOverflow,
40
+ setDisarmedGoal,
35
41
  setGoal,
42
+ setHandoff,
43
+ setRecoveryOutcome,
36
44
  setPhase,
45
+ stampResumedGoal,
37
46
  writeState,
38
47
  } from './lib/checkpoint.js'
39
- import { bootPlan, continuationPrompt, describeState, restartDecision, restartGate } from './lib/lifecycle.js'
48
+ import {
49
+ bootPlan,
50
+ composeHandoff,
51
+ contextOverflow,
52
+ continuationPrompt,
53
+ describeState,
54
+ GOAL_BLOCK_REASONS,
55
+ HANDOFF_DEFAULT_MAX_CHARS,
56
+ restartDecision,
57
+ restartGate,
58
+ selectGoalDisarms,
59
+ } from './lib/lifecycle.js'
40
60
  import {
41
61
  buildCustomCommand,
42
62
  detectEnvironment,
@@ -130,11 +150,13 @@ function delay(ms) {
130
150
  }
131
151
 
132
152
  /**
133
- * Hard dependencies. `sessionController` is read optionally (`ctx.get`) so the plugin
134
- * also loads in compositions without the web session controller; it simply cannot
135
- * resume agents there.
153
+ * Hard dependencies. `sessionController` is declared as a dependency because a
154
+ * session that is NOT live in this process can only be resumed through it
155
+ * (`resolveAgent`) or, as a fallback, through `prompt` (which resumes the cold
156
+ * session and admits the message in one step). It is still looked up
157
+ * defensively so a composition that provides it late still resumes.
136
158
  */
137
- export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt']
159
+ export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt', 'sessionController']
138
160
 
139
161
  /**
140
162
  * Validated loader row config. Exported so the Plugin Manager / config editor can show
@@ -234,6 +256,193 @@ function resolveHealthPath(config) {
234
256
  : '/__dsh_autorestart/health'
235
257
  }
236
258
 
259
+ /**
260
+ * Resolve the park-route path: a sibling of the health route, under the same
261
+ * prefix (the health path without its last segment).
262
+ *
263
+ * @param {object} config
264
+ * @returns {string}
265
+ */
266
+ function resolveParkPath(config) {
267
+ const healthPath = resolveHealthPath(config)
268
+ const index = healthPath.lastIndexOf('/')
269
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
270
+ return `${prefix}/park`
271
+ }
272
+
273
+ /**
274
+ * Resolve the recovery-route path: a sibling of the health route, under the same
275
+ * prefix (`POST /recover` retries the boot recovery for the pending sessions).
276
+ *
277
+ * @param {object} config
278
+ * @returns {string}
279
+ */
280
+ function resolveRecoverPath(config) {
281
+ const healthPath = resolveHealthPath(config)
282
+ const index = healthPath.lastIndexOf('/')
283
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
284
+ return `${prefix}/recover`
285
+ }
286
+
287
+ /**
288
+ * Resolve the handoff-route path: a sibling of the health route, under the same
289
+ * prefix (`POST /handoff` hands an over-limit session off to a fresh session).
290
+ *
291
+ * @param {object} config
292
+ * @returns {string}
293
+ */
294
+ function resolveHandoffPath(config) {
295
+ const healthPath = resolveHealthPath(config)
296
+ const index = healthPath.lastIndexOf('/')
297
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
298
+ return `${prefix}/handoff`
299
+ }
300
+
301
+ /**
302
+ * The blocking sessions with the reason each one blocks: a session that is
303
+ * held by an armed goal reports `goal-active` (that goal can be disarmed), a
304
+ * goal whose round budget is spent reports `goal-exhausted`, and the original
305
+ * busy reason is kept as `busyReason`.
306
+ *
307
+ * Never throws: a goal lookup must never break a status report.
308
+ *
309
+ * @param {object} runtime
310
+ * @param {any} blockedBy
311
+ * @returns {any[]}
312
+ */
313
+ function goalAwareBlockingDetails(runtime, blockedBy) {
314
+ const list = Array.isArray(blockedBy) ? blockedBy : []
315
+ const reasons = new Map()
316
+ try {
317
+ const { disarms, reported } = selectGoalDisarms(list, {
318
+ getGoal: (sessionId) => runtime.goalFor(sessionId),
319
+ disarmedGoals: runtime.disarmedGoalRecords(),
320
+ parkedGoals: runtime.parkedGoalRecords(),
321
+ })
322
+ for (const item of disarms) reasons.set(item.sessionId, GOAL_BLOCK_REASONS.ACTIVE)
323
+ for (const item of reported) reasons.set(item.sessionId, item.reason)
324
+ } catch {
325
+ // A goal lookup must never break the status report.
326
+ }
327
+ return list.map((entry) => {
328
+ if (!entry || typeof entry !== 'object') return entry
329
+ const reason = reasons.get(entry.sessionId)
330
+ if (!reason) return entry
331
+ return { ...entry, busyReason: entry.reason, reason }
332
+ })
333
+ }
334
+
335
+ /**
336
+ * The goal records this plugin keeps, in both shapes: the current disarmed-goal
337
+ * records (`goalId` / `disarmedAt` / `resumedAt`, written by
338
+ * `ctx.goals.disarm`) and the LEGACY parked-goal records written by the
339
+ * previous pause-based build (`goalId` / `parkedAt` / `resumedAt`).
340
+ *
341
+ * The status field is still called `parkedGoals` so an older client keeps
342
+ * working; `kind` tells the two shapes apart.
343
+ *
344
+ * @param {object} runtime
345
+ * @returns {{sessionId: string, kind: 'disarmed'|'parked', goalId: string|null, disarmedAt: number|null, parkedAt: number|null, resumedAt: number|null, resumed: boolean}[]}
346
+ */
347
+ function parkedGoalList(runtime) {
348
+ const out = []
349
+ const collect = (records, kind, atKey) => {
350
+ for (const [sessionId, record] of Object.entries(records || {})) {
351
+ if (!record || typeof record !== 'object') continue
352
+ out.push({
353
+ sessionId,
354
+ kind,
355
+ goalId: record.goalId != null ? String(record.goalId) : null,
356
+ disarmedAt: kind === 'disarmed' && Number.isFinite(record.disarmedAt) ? record.disarmedAt : null,
357
+ parkedAt: kind === 'parked' && Number.isFinite(record[atKey]) ? record[atKey] : null,
358
+ resumedAt: record.resumedAt != null ? record.resumedAt : null,
359
+ resumed: record.resumedAt != null,
360
+ })
361
+ }
362
+ }
363
+ collect(runtime.disarmedGoalRecords(), 'disarmed', 'disarmedAt')
364
+ collect(runtime.parkedGoalRecords(), 'parked', 'parkedAt')
365
+ return out
366
+ }
367
+
368
+ /**
369
+ * The self-reported outcome of the last recovery attempt, per session, plus the
370
+ * sessions that can still be recovered. Used by `continuum_status` and by the
371
+ * `?state=1` route so a stuck recovery is visible without reading the log.
372
+ *
373
+ * @param {object} state
374
+ * @returns {{sessionId: string, action: string|null, detail: string|null, pending: boolean, attempts: number, error: string|null}[]}
375
+ */
376
+ function recoveryList(state) {
377
+ const out = []
378
+ try {
379
+ const sessions = state && typeof state === 'object' && state.sessions && typeof state.sessions === 'object'
380
+ ? state.sessions
381
+ : {}
382
+ for (const [sessionId, entry] of Object.entries(sessions)) {
383
+ if (!entry || typeof entry !== 'object') continue
384
+ if (entry.lastRecoveryAction == null && entry.pendingResume !== true) continue
385
+ out.push({
386
+ sessionId: typeof entry.sessionId === 'string' && entry.sessionId ? entry.sessionId : sessionId,
387
+ action: entry.lastRecoveryAction != null ? String(entry.lastRecoveryAction) : null,
388
+ detail: entry.lastRecoveryDetail != null ? String(entry.lastRecoveryDetail) : null,
389
+ pending: entry.pendingResume === true,
390
+ attempts: Number.isFinite(entry.resumeAttempts) ? entry.resumeAttempts : 0,
391
+ error: entry.lastResumeError != null ? String(entry.lastResumeError) : null,
392
+ })
393
+ }
394
+ } catch {}
395
+ return out
396
+ }
397
+
398
+ /**
399
+ * The per-session handoff / context-overflow diagnostics of the checkpoint.
400
+ *
401
+ * Both records are the durable "this session outgrew the model window" markers:
402
+ * a handed-off session was replaced by a fresh one, an over-limit session is
403
+ * refused by the automatic resume path. `recoverableSessions` skips both, so the
404
+ * status has to report them from the raw entries or they would be invisible.
405
+ *
406
+ * @param {object} state
407
+ * @param {{ limit?: number }} [options]
408
+ * @returns {{sessionId: string, handedOffTo: string|null, handoffAt: number|null, carriedChars: number|null, handedOff: boolean, overContextLimit: boolean, contextOverflow: object|null, lastRecoveryAction: string|null, lastRecoveryDetail: string|null}[]}
409
+ */
410
+ function diagnosticSessions(state, { limit = 50 } = {}) {
411
+ const out = []
412
+ try {
413
+ const sessions = state && typeof state === 'object' && state.sessions && typeof state.sessions === 'object'
414
+ ? state.sessions
415
+ : {}
416
+ for (const [sessionId, entry] of Object.entries(sessions)) {
417
+ if (!entry || typeof entry !== 'object') continue
418
+ const handoff = entry.handoff && typeof entry.handoff === 'object' ? entry.handoff : null
419
+ const overflow = entry.contextOverflow && typeof entry.contextOverflow === 'object'
420
+ && entry.contextOverflow.over === true
421
+ ? entry.contextOverflow
422
+ : null
423
+ if (handoff === null && overflow === null) continue
424
+ out.push({
425
+ sessionId: typeof entry.sessionId === 'string' && entry.sessionId ? entry.sessionId : sessionId,
426
+ handedOff: handoff !== null,
427
+ handedOffTo: handoff && typeof handoff.handedOffTo === 'string' ? handoff.handedOffTo : null,
428
+ handoffAt: handoff && Number.isFinite(handoff.handoffAt) ? handoff.handoffAt : null,
429
+ carriedChars: handoff && Number.isFinite(handoff.carriedChars) ? handoff.carriedChars : null,
430
+ overContextLimit: overflow !== null,
431
+ contextOverflow: overflow === null ? null : {
432
+ inputTokens: Number.isFinite(overflow.inputTokens) ? overflow.inputTokens : null,
433
+ contextWindow: Number.isFinite(overflow.contextWindow) ? overflow.contextWindow : null,
434
+ detail: typeof overflow.detail === 'string' ? overflow.detail : null,
435
+ detectedAt: Number.isFinite(overflow.detectedAt) ? overflow.detectedAt : null,
436
+ },
437
+ lastRecoveryAction: entry.lastRecoveryAction != null ? String(entry.lastRecoveryAction) : null,
438
+ lastRecoveryDetail: entry.lastRecoveryDetail != null ? String(entry.lastRecoveryDetail) : null,
439
+ })
440
+ if (out.length >= limit) break
441
+ }
442
+ } catch {}
443
+ return out
444
+ }
445
+
237
446
  /** Resolve the durable checkpoint path. */
238
447
  function resolveStateFile(config) {
239
448
  if (typeof config.stateFile === 'string' && config.stateFile.trim() !== '') {
@@ -309,6 +518,11 @@ function createRuntime(ctx, config) {
309
518
  let recoverTimer
310
519
  let disposed = false
311
520
  let lastPersistError
521
+ /**
522
+ * The tool registrations that failed. A failed registration is recorded and
523
+ * logged, never thrown: the plugin still loads and the other tools still work.
524
+ */
525
+ const toolErrors = []
312
526
 
313
527
  const persist = () => {
314
528
  try {
@@ -387,6 +601,331 @@ function createRuntime(ctx, config) {
387
601
  return out
388
602
  }
389
603
 
604
+ /**
605
+ * The disarmed-goal records of the checkpoint: one entry per session whose
606
+ * goal this plugin disarmed and has not re-armed yet.
607
+ */
608
+ const disarmedGoalRecords = () => (
609
+ state.disarmedGoals && typeof state.disarmedGoals === 'object' ? state.disarmedGoals : {}
610
+ )
611
+
612
+ /**
613
+ * The LEGACY (pause-based) parked-goal records of the checkpoint. They are
614
+ * still read so a goal the previous build paused can be resumed once.
615
+ */
616
+ const parkedGoalRecords = () => (
617
+ state.parkedGoals && typeof state.parkedGoals === 'object' ? state.parkedGoals : {}
618
+ )
619
+
620
+ /** The live agent of a session, or undefined when it is not attached. */
621
+ const liveAgent = (sessionId) => {
622
+ if (!sessionId) return undefined
623
+ try {
624
+ if (ctx.agents && typeof ctx.agents.get === 'function') {
625
+ const agent = ctx.agents.get(sessionId)
626
+ if (agent) return agent
627
+ }
628
+ } catch {}
629
+ return undefined
630
+ }
631
+
632
+ /** The live goal of a session, or undefined when it has none. */
633
+ const goalFor = (sessionId, agent) => {
634
+ const target = agent || liveAgent(sessionId)
635
+ if (!target) return undefined
636
+ try {
637
+ return ctx.goals.get(target)
638
+ } catch {
639
+ return undefined
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Disarm the goal of one session so a pending restart can proceed.
645
+ *
646
+ * `ctx.goals.disarm(agent)` removes the process-local continuation authority
647
+ * WITHOUT touching the durable goal: the goal stays `active`, the round that
648
+ * is running right now still finishes normally (no turn is cancelled and
649
+ * nothing is lost), but the goal round driver will not start another round.
650
+ * That is what lets the session go idle so the restart can proceed; after the
651
+ * boot the goal is re-armed through `ctx.goals.resume(...)`.
652
+ *
653
+ * Never throws: a failed disarm is reported instead of propagated, so it can
654
+ * never break the restart gate.
655
+ */
656
+ const disarmGoal = (sessionId, agent) => {
657
+ const target = agent || liveAgent(sessionId)
658
+ if (!target) {
659
+ return { sessionId, disarmed: false, reason: GOAL_BLOCK_REASONS.NO_AGENT, error: 'session-not-live' }
660
+ }
661
+ const goal = goalFor(sessionId, target)
662
+ if (!goal || typeof goal !== 'object') return { sessionId, disarmed: false, reason: 'no-goal' }
663
+ const goalId = goal.id == null ? null : String(goal.id)
664
+ const { disarms, reported } = selectGoalDisarms([{ sessionId }], {
665
+ getGoal: () => goal,
666
+ disarmedGoals: disarmedGoalRecords(),
667
+ parkedGoals: parkedGoalRecords(),
668
+ })
669
+ if (disarms.length === 0) {
670
+ const reason = reported.length > 0 ? reported[0].reason : 'goal-not-disarmable'
671
+ return { sessionId, disarmed: false, goalId, reason }
672
+ }
673
+ const goals = ctx.goals
674
+ if (!goals || typeof goals.disarm !== 'function') {
675
+ return { sessionId, disarmed: false, goalId, reason: 'disarm-unavailable', error: 'goals.disarm is not available' }
676
+ }
677
+ let outcome
678
+ try {
679
+ outcome = goals.disarm(target)
680
+ } catch (error) {
681
+ return { sessionId, disarmed: false, goalId, reason: 'disarm-failed', error: describeError(error) }
682
+ }
683
+ const onDisarmed = () => {
684
+ setDisarmedGoal(state, sessionId, { goalId, disarmedAt: Date.now() })
685
+ persist()
686
+ log.info(`disarmed goal ${goalId} of session ${sessionId}; the running round still finishes and the goal is re-armed after the restart`)
687
+ }
688
+ if (outcome && typeof outcome.then === 'function') {
689
+ outcome.then(onDisarmed, (error) => {
690
+ log.warn(`could not disarm goal ${goalId} of session ${sessionId}:`, describeError(error))
691
+ })
692
+ return { sessionId, disarmed: true, goalId, reason: GOAL_BLOCK_REASONS.ACTIVE, pending: true }
693
+ }
694
+ onDisarmed()
695
+ return { sessionId, disarmed: true, goalId, reason: GOAL_BLOCK_REASONS.ACTIVE }
696
+ }
697
+
698
+ /**
699
+ * The live SESSION of a session id: the readable conversation surface
700
+ * (`deriveMessages` / `requestHeader` / `requestContext` / `snapshotEvents`).
701
+ * Falls back to the live agent's session when the sessions service is absent.
702
+ */
703
+ const liveSession = (sessionId) => {
704
+ if (!sessionId) return undefined
705
+ try {
706
+ const sessions = ctx.get('sessions')
707
+ if (sessions && typeof sessions.get === 'function') {
708
+ const session = sessions.get(String(sessionId))
709
+ if (session) return session
710
+ }
711
+ } catch {}
712
+ const agent = liveAgent(sessionId)
713
+ return agent && agent.session ? agent.session : undefined
714
+ }
715
+
716
+ /** Read the readable surface of one live session. Never throws. */
717
+ const readSession = (sessionId) => {
718
+ const session = liveSession(sessionId)
719
+ if (!session) return { session: undefined, messages: undefined, header: undefined, requestContext: undefined, events: undefined }
720
+ const read = (fn) => {
721
+ try {
722
+ return typeof session[fn] === 'function' ? session[fn]() : undefined
723
+ } catch {
724
+ return undefined
725
+ }
726
+ }
727
+ return {
728
+ session,
729
+ messages: read('deriveMessages'),
730
+ header: read('requestHeader'),
731
+ requestContext: read('requestContext'),
732
+ events: read('snapshotEvents'),
733
+ }
734
+ }
735
+
736
+ /**
737
+ * The context-overflow verdict of a session, from its LIVE surface only.
738
+ *
739
+ * Evidence: the newest assistant message `usage.inputTokens` versus the
740
+ * session's `contextWindow`, or a recent turn / event that failed with a
741
+ * context-window error. Unknown numbers never mean overflow, so an unknown
742
+ * session keeps the normal resume path. Never throws.
743
+ */
744
+ const overflowFor = (sessionId) => {
745
+ const unknown = { over: false, inputTokens: null, contextWindow: null, detail: null }
746
+ try {
747
+ const read = readSession(sessionId)
748
+ if (!read.session) return unknown
749
+ const info = read.requestContext
750
+ const contextWindow = info && typeof info === 'object' && Number.isFinite(info.contextWindow) ? info.contextWindow : null
751
+ return contextOverflow({ messages: read.messages, contextWindow, events: read.events })
752
+ } catch {
753
+ return unknown
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Hand an over-limit session off to a fresh, usable session.
759
+ *
760
+ * A session whose conversation outgrew the model window can neither continue
761
+ * nor be compacted: every request fails with "context window exceeds limit",
762
+ * and the compaction request needs the same oversized context. The handoff
763
+ * carries what still fits — the goal objective, the checkpoint reasons and a
764
+ * bounded tail of the conversation — into a NEW live agent/session, then marks
765
+ * the old session and disarms its goal so it stops issuing doomed requests.
766
+ *
767
+ * Nothing is created when the source cannot be read.
768
+ *
769
+ * @param {{ sessionId?: string, maxChars?: number, callerAgent?: object }} [options]
770
+ * @returns {Promise<object>} `{ ok, sessionId, carriedChars, instruction }`
771
+ */
772
+ const handoff = async ({ sessionId, maxChars, callerAgent } = {}) => {
773
+ const requested = typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : null
774
+ const sourceId = requested !== null ? requested : (callerAgent && callerAgent.id ? String(callerAgent.id) : null)
775
+ if (sourceId === null) {
776
+ return { ok: false, error: 'no-session', instruction: 'No source session: pass sessionId, or call this tool from the session that should be handed off.' }
777
+ }
778
+ const read = readSession(sourceId)
779
+ if (!read.session) {
780
+ return {
781
+ ok: false,
782
+ error: 'source-session-not-live',
783
+ sourceSessionId: sourceId,
784
+ instruction: `Session ${sourceId} is not live in this process, so its conversation cannot be read and no handoff was created.`,
785
+ }
786
+ }
787
+ if (read.messages === undefined) {
788
+ return {
789
+ ok: false,
790
+ error: 'source-unreadable',
791
+ sourceSessionId: sourceId,
792
+ instruction: `Session ${sourceId} did not expose its messages, so no handoff was created.`,
793
+ }
794
+ }
795
+ const header = read.header
796
+ const cwd = header && typeof header.cwd === 'string' && path.isAbsolute(header.cwd) ? header.cwd : null
797
+ if (cwd === null) {
798
+ return {
799
+ ok: false,
800
+ error: 'source-cwd-unknown',
801
+ sourceSessionId: sourceId,
802
+ instruction: `Session ${sourceId} has no absolute working directory in its header, so a new session could not be created for the handoff.`,
803
+ }
804
+ }
805
+
806
+ const goal = goalFor(sourceId)
807
+ const objective = goal && typeof goal === 'object'
808
+ ? [goal.objective, goal.title, goal.description].find((value) => typeof value === 'string' && value.trim() !== '') ?? null
809
+ : null
810
+ const entry = state.sessions && state.sessions[sourceId] ? state.sessions[sourceId] : null
811
+ const goalLabel = entry && entry.goal && entry.goal.goalId != null
812
+ ? String(entry.goal.goalId)
813
+ : (goal && goal.id != null ? String(goal.id) : null)
814
+ const budget = Number.isFinite(maxChars) && maxChars > 0 ? Math.trunc(maxChars) : HANDOFF_DEFAULT_MAX_CHARS
815
+ const composed = composeHandoff({
816
+ sessionId: sourceId,
817
+ objective,
818
+ goalLabel,
819
+ reasons: entry && Array.isArray(entry.reasons) ? entry.reasons : [],
820
+ messages: read.messages,
821
+ maxChars: budget,
822
+ })
823
+
824
+ const newSessionId = 'session-' + crypto.randomUUID()
825
+ let created
826
+ try {
827
+ created = await ctx.agents.create({
828
+ sessionId: newSessionId,
829
+ meta: { cwd, parentSession: sourceId, createdAt: Date.now() },
830
+ })
831
+ } catch (error) {
832
+ const detail = describeError(error)
833
+ log.warn(`could not create the handoff session for ${sourceId}:`, detail)
834
+ return {
835
+ ok: false,
836
+ error: 'create-failed',
837
+ sourceSessionId: sourceId,
838
+ detail,
839
+ instruction: `The new session could not be created (${detail}); session ${sourceId} is unchanged.`,
840
+ }
841
+ }
842
+ let newAgent = created && typeof created === 'object' ? (created.agent ?? created.handle ?? created) : undefined
843
+ if (!newAgent || typeof newAgent.followup !== 'function' || newAgent.id === undefined) newAgent = liveAgent(newSessionId)
844
+ if (!newAgent || typeof newAgent.followup !== 'function') {
845
+ return {
846
+ ok: false,
847
+ error: 'handoff-agent-unavailable',
848
+ sourceSessionId: sourceId,
849
+ sessionId: newSessionId,
850
+ instruction: `The new session ${newSessionId} exposes no agent to deliver the handoff text to; session ${sourceId} is unchanged.`,
851
+ }
852
+ }
853
+ const continueId = newAgent.id === undefined || newAgent.id === null ? newSessionId : String(newAgent.id)
854
+ try {
855
+ newAgent.followup(createContinuationMessage(composed.text, {
856
+ summary: 'handoff of a session that exceeded the model context window',
857
+ }))
858
+ } catch (error) {
859
+ const detail = describeError(error)
860
+ log.warn(`the handoff text was rejected for ${continueId}:`, detail)
861
+ return {
862
+ ok: false,
863
+ error: 'deliver-failed',
864
+ sourceSessionId: sourceId,
865
+ sessionId: continueId,
866
+ detail,
867
+ instruction: `The handoff text could not be delivered to ${continueId} (${detail}); session ${sourceId} is unchanged.`,
868
+ }
869
+ }
870
+
871
+ const at = Date.now()
872
+ setHandoff(state, sourceId, { handedOffTo: continueId, handoffAt: at, carriedChars: composed.carriedChars })
873
+ setRecoveryOutcome(state, sourceId, { action: 'handed-off', detail: `handed off to ${continueId}`, now: at })
874
+ persist()
875
+
876
+ // (e) the over-limit session must stop issuing doomed requests.
877
+ let disarmedGoal = false
878
+ try {
879
+ const result = disarmGoal(sourceId, requested === null ? callerAgent : undefined)
880
+ disarmedGoal = result.disarmed === true
881
+ } catch (error) {
882
+ log.warn(`could not disarm the goal of the handed-off session ${sourceId}:`, describeError(error))
883
+ }
884
+ log.info(`handed session ${sourceId} off to ${continueId} (${composed.carriedChars} characters carried)`)
885
+ return {
886
+ ok: true,
887
+ sourceSessionId: sourceId,
888
+ sessionId: continueId,
889
+ carriedChars: composed.carriedChars,
890
+ carriedTailChars: composed.tailChars,
891
+ disarmedGoal,
892
+ instruction: `Continue the work in the new session ${continueId}. The old session ${sourceId} is kept and marked as handed off: its goal is disarmed and it is never auto-resumed again.`,
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Disarm the goals of the sessions that block the pending restart.
898
+ *
899
+ * A goal-driven session keeps its own agent busy (the goal round driver
900
+ * starts the next round within milliseconds), so the restart gate would never
901
+ * open. Disarming removes that authority: the CURRENT round finishes
902
+ * normally, no new round starts, the session goes idle and the next poll sees
903
+ * a quiet host. Goals are never paused and no turn is ever cancelled.
904
+ */
905
+ const disarmBlockingGoals = (busy) => {
906
+ const restart = state.restart
907
+ if (!restart) return { disarmed: [], reported: [] }
908
+ if (restart.parkGoals === false) return { disarmed: [], reported: [], skipped: 'park-goals-disabled' }
909
+ const list = Array.isArray(busy) ? busy : []
910
+ if (list.length === 0) return { disarmed: [], reported: [] }
911
+ const { disarms, reported } = selectGoalDisarms(list, {
912
+ getGoal: (sessionId) => goalFor(sessionId),
913
+ disarmedGoals: disarmedGoalRecords(),
914
+ parkedGoals: parkedGoalRecords(),
915
+ })
916
+ const disarmed = []
917
+ for (const item of disarms) {
918
+ try {
919
+ const result = disarmGoal(item.sessionId)
920
+ if (result.disarmed) disarmed.push(result)
921
+ else log.warn(`could not disarm the goal of session ${item.sessionId}: ${result.reason}${result.error ? ` (${result.error})` : ''}`)
922
+ } catch (error) {
923
+ log.warn(`disarming the goal of session ${item.sessionId} failed:`, describeError(error))
924
+ }
925
+ }
926
+ return { disarmed, reported }
927
+ }
928
+
390
929
  const anyAgentRunning = () => busyAgents().length > 0
391
930
 
392
931
  /** The two-layer restart gate: no busy conversation AND a quiet settle window. */
@@ -578,6 +1117,13 @@ function createRuntime(ctx, config) {
578
1117
  // This re-check also protects the ungated fallback (>20 gate aborts) and the
579
1118
  // forceRestartWhenBusy path: even when the detached helper cannot re-check the
580
1119
  // gate itself, a busy conversation is never killed here.
1120
+ // Last chance to park a goal-driven session: without it the detached
1121
+ // helper would refuse to kill the process and the restart would wait.
1122
+ try {
1123
+ disarmBlockingGoals(busyAgents())
1124
+ } catch (error) {
1125
+ log.warn('could not disarm the goals of the blocking sessions:', String(error))
1126
+ }
581
1127
  const verdict = gate()
582
1128
  if (!verdict.allow) {
583
1129
  log.info('restart deferred: a conversation is still active or the host is settling', {
@@ -639,6 +1185,15 @@ function createRuntime(ctx, config) {
639
1185
  }
640
1186
  const now = Date.now()
641
1187
  const busy = busyAgents()
1188
+ // A goal-driven session blocks the restart forever: park its goal BEFORE the
1189
+ // gate decides that it is blocked, so the next poll sees an idle session.
1190
+ if (busy.length > 0) {
1191
+ try {
1192
+ disarmBlockingGoals(busy)
1193
+ } catch (error) {
1194
+ log.warn('could not disarm the goals of the blocking sessions:', String(error))
1195
+ }
1196
+ }
642
1197
  const decision = restartDecision(state, {
643
1198
  anyAgentRunning: busy.length > 0,
644
1199
  busyAgents: busy,
@@ -693,6 +1248,7 @@ function createRuntime(ctx, config) {
693
1248
  stateFile,
694
1249
  log,
695
1250
  persist,
1251
+ toolErrors,
696
1252
  startPolling,
697
1253
  stopPolling,
698
1254
  tick,
@@ -707,7 +1263,70 @@ function createRuntime(ctx, config) {
707
1263
  busyAgents,
708
1264
  gate,
709
1265
  sessionTitle,
1266
+ disarmGoal,
1267
+ disarmBlockingGoals,
1268
+ disarmedGoalRecords,
1269
+ parkedGoalRecords,
1270
+ goalFor,
1271
+ handoffRecord: (sessionId) => handoffRecord(state, sessionId),
1272
+ overflowFor,
1273
+ readSession,
1274
+ handoff,
710
1275
  boot,
1276
+ /**
1277
+ * Re-run the boot recovery for the still-pending sessions, right now.
1278
+ *
1279
+ * This is the retry path behind `continuum_recover_now` and the POST
1280
+ * `/recover` route: a session whose agent was not live yet, or whose
1281
+ * `goals.resume` failed, can be retried without waiting for the next boot.
1282
+ * Every attempt reports its own action/detail, exactly like the boot pass.
1283
+ *
1284
+ * @param {string} [sessionId] one session, or every pending session
1285
+ * @param {{ force?: boolean }} [options] `force` re-opens one explicit session
1286
+ * (clearing a stale over-limit record and the attempt counter) before retrying
1287
+ * @returns {Promise<object[]>} one outcome per attempted session
1288
+ */
1289
+ async recoverNow(sessionId, { force = false } = {}) {
1290
+ const target = typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : null
1291
+ if (force === true && target !== null) {
1292
+ const entry = state.sessions && state.sessions[target] ? state.sessions[target] : null
1293
+ if (entry) {
1294
+ // An explicit retry re-checks the live evidence, so a stale record must
1295
+ // not keep the session out of recovery, and the attempt counter resets.
1296
+ entry.pendingResume = true
1297
+ entry.interrupted = true
1298
+ entry.pendingSince = Date.now()
1299
+ entry.resumeAttempts = 0
1300
+ entry.lastResumeError = null
1301
+ setContextOverflow(state, target, { over: false })
1302
+ // Clear the durable "never resume again" marker directly: calling the
1303
+ // handoff writer here would CREATE one (and clear pendingResume again).
1304
+ entry.handoff = null
1305
+ runtime.persist()
1306
+ log.info(`forced recovery for ${target}: cleared stale over-limit/handoff marks and the attempt counter`)
1307
+ }
1308
+ }
1309
+ const pending = recoverableSessions(state, {}).filter((entry) => (
1310
+ target === null ? true : String(entry.sessionId) === target
1311
+ ))
1312
+ if (pending.length === 0 && target !== null) {
1313
+ return [{ sessionId: target, action: 'nothing', detail: 'no-pending-recovery' }]
1314
+ }
1315
+ const results = []
1316
+ for (const entry of pending) {
1317
+ try {
1318
+ results.push(await recoverSession(ctx, runtime, entry, config))
1319
+ } catch (error) {
1320
+ const detail = describeError(error)
1321
+ setRecoveryOutcome(state, entry.sessionId, { action: 'prompt-failed', detail })
1322
+ results.push({ sessionId: entry.sessionId, action: 'prompt-failed', detail })
1323
+ }
1324
+ }
1325
+ try {
1326
+ runtime.persist()
1327
+ } catch {}
1328
+ return results
1329
+ },
711
1330
  }
712
1331
  return runtime
713
1332
  }
@@ -721,9 +1340,10 @@ function createRuntime(ctx, config) {
721
1340
  * message: it is NOT human authority.
722
1341
  *
723
1342
  * @param {string} text
1343
+ * @param {{ summary?: string }} [options] one-line label of what this notice is
724
1344
  * @returns {object} a frozen user message
725
1345
  */
726
- function createContinuationMessage(text) {
1346
+ function createContinuationMessage(text, { summary } = {}) {
727
1347
  return Object.freeze({
728
1348
  id: crypto.randomUUID(),
729
1349
  role: 'user',
@@ -731,7 +1351,9 @@ function createContinuationMessage(text) {
731
1351
  source: Object.freeze({
732
1352
  kind: 'continuum',
733
1353
  form: 'notice',
734
- summary: 'automatic continuation after a DeepSeek Harness restart',
1354
+ summary: typeof summary === 'string' && summary.trim() !== ''
1355
+ ? summary.trim()
1356
+ : 'automatic continuation after a DeepSeek Harness restart',
735
1357
  }),
736
1358
  })
737
1359
  }
@@ -759,6 +1381,24 @@ function describeError(value) {
759
1381
  }
760
1382
  }
761
1383
 
1384
+ /**
1385
+ * Record the self-reported outcome of one recovery attempt for a session.
1386
+ *
1387
+ * Every return path of `recoverSession` calls this: a recovery that did nothing
1388
+ * must say WHY it did nothing, otherwise a stuck session is invisible in
1389
+ * `continuum_status` and in the `?state=1` route.
1390
+ *
1391
+ * @param {object} state
1392
+ * @param {string} sessionId
1393
+ * @param {string} action short action label (`resume-disarmed-goal`, …)
1394
+ * @param {string|null} [detail] short human-readable reason
1395
+ */
1396
+ function noteRecovery(state, sessionId, action, detail = null) {
1397
+ try {
1398
+ setRecoveryOutcome(state, sessionId, { action, detail: detail == null ? null : String(detail) })
1399
+ } catch {}
1400
+ }
1401
+
762
1402
  /**
763
1403
  * Decide what a session needs after a restart and do exactly that.
764
1404
  * At-most-once per boot: the resume attempt is persisted before any side effect.
@@ -775,9 +1415,77 @@ async function recoverSession(ctx, runtime, entry, config) {
775
1415
  entry.pendingResume = false
776
1416
  entry.interrupted = false
777
1417
  entry.lastResumeError = entry.lastResumeError || 'resume attempts exhausted'
1418
+ noteRecovery(state, sessionId, 'nothing', 'resume-attempts-exhausted')
778
1419
  runtime.persist()
779
1420
  log.info(`giving up on session ${sessionId}: resume attempts exhausted`, { lastResumeError: entry.lastResumeError })
780
- return { sessionId, action: 'skip', error: entry.lastResumeError }
1421
+ return { sessionId, action: 'nothing', detail: 'resume-attempts-exhausted', error: entry.lastResumeError }
1422
+ }
1423
+
1424
+ // CONTEXT-OVERFLOW GUARD. A session whose conversation outgrew the model window
1425
+ // can neither continue nor be compacted (the compaction request needs the same
1426
+ // oversized context): every request to it fails with "context window exceeds
1427
+ // limit". Re-arming its goal or queueing a continuation message into it can
1428
+ // therefore only fail, so the guard runs BEFORE the goal re-arm and before the
1429
+ // disarm decision. Evidence: a verdict an earlier pass already recorded, or the
1430
+ // LIVE session (the newest assistant message's `usage.inputTokens` versus the
1431
+ // session's `contextWindow`, or a recent context-window failure). Unknown
1432
+ // numbers never block the normal path.
1433
+ const recordedOverflow = entry.contextOverflow && entry.contextOverflow.over === true
1434
+ ? entry.contextOverflow
1435
+ : null
1436
+ // The LIVE session is authoritative. A record is only a fallback for a session
1437
+ // this process cannot read at all; a record whose evidence no longer holds (for
1438
+ // example a false positive from an older detector) is cleared instead of
1439
+ // keeping the session out of recovery forever.
1440
+ let liveReadable = false
1441
+ try {
1442
+ const read = runtime.readSession(sessionId)
1443
+ liveReadable = Boolean(read && read.session)
1444
+ } catch {
1445
+ liveReadable = false
1446
+ }
1447
+ let overflow = null
1448
+ if (liveReadable) {
1449
+ try {
1450
+ overflow = runtime.overflowFor(sessionId)
1451
+ } catch {
1452
+ overflow = null
1453
+ }
1454
+ if ((!overflow || overflow.over !== true) && recordedOverflow !== null) {
1455
+ setContextOverflow(state, sessionId, { over: false })
1456
+ runtime.persist()
1457
+ log.info(`cleared a stale over-limit record for ${sessionId}: the live session is not over the window`)
1458
+ }
1459
+ } else if (recordedOverflow !== null) {
1460
+ overflow = {
1461
+ over: true,
1462
+ inputTokens: Number.isFinite(recordedOverflow.inputTokens) ? recordedOverflow.inputTokens : null,
1463
+ contextWindow: Number.isFinite(recordedOverflow.contextWindow) ? recordedOverflow.contextWindow : null,
1464
+ detail: typeof recordedOverflow.detail === 'string' && recordedOverflow.detail !== ''
1465
+ ? recordedOverflow.detail
1466
+ : 'the session is over the model context window (recorded by an earlier recovery)',
1467
+ }
1468
+ }
1469
+ if (overflow && overflow.over === true) {
1470
+ const detail = typeof overflow.detail === 'string' && overflow.detail !== ''
1471
+ ? overflow.detail
1472
+ : 'the session is over the model context window'
1473
+ setContextOverflow(state, sessionId, {
1474
+ over: true,
1475
+ inputTokens: overflow.inputTokens,
1476
+ contextWindow: overflow.contextWindow,
1477
+ detail,
1478
+ detectedAt: Date.now(),
1479
+ })
1480
+ // Out of the automatic resume path for good: the goal is NOT re-armed, no
1481
+ // continuation message is queued, and `recoverableSessions` skips an
1482
+ // over-limit entry from now on. The recovery attempt is still reported.
1483
+ entry.pendingResume = false
1484
+ entry.interrupted = false
1485
+ noteRecovery(state, sessionId, 'context-overflow', detail)
1486
+ runtime.persist()
1487
+ log.warn(`session ${sessionId} is over the model context window and is NOT re-armed: hand it off with continuum_handoff`, { detail })
1488
+ return { sessionId, action: 'context-overflow', detail, overContextLimit: true }
781
1489
  }
782
1490
 
783
1491
  // Persist the attempt BEFORE any side effect, so a crash cannot double-resume and
@@ -794,10 +1502,67 @@ async function recoverSession(ctx, runtime, entry, config) {
794
1502
  }
795
1503
 
796
1504
  if (agent === undefined) {
797
- const controller = ctx.get('sessionController')
798
- if (!controller || typeof controller.resolveAgent !== 'function') {
799
- log.warn('sessionController is unavailable; cannot resume', sessionId)
800
- return { sessionId, action: 'unavailable' }
1505
+ // Resolve the controller defensively: the declared dependency first, then the
1506
+ // optional lookup (a late-provided service still resumes this way).
1507
+ let controller = null
1508
+ let controllerPath = 'none'
1509
+ try {
1510
+ if (ctx.sessionController && typeof ctx.sessionController === 'object') {
1511
+ controller = ctx.sessionController
1512
+ controllerPath = 'inject'
1513
+ }
1514
+ } catch {}
1515
+ if (controller === null) {
1516
+ try {
1517
+ const viaGet = ctx.get('sessionController')
1518
+ if (viaGet && typeof viaGet === 'object') {
1519
+ controller = viaGet
1520
+ controllerPath = 'get'
1521
+ }
1522
+ } catch {}
1523
+ }
1524
+ const canResolve = Boolean(controller) && typeof controller.resolveAgent === 'function'
1525
+ const canPrompt = Boolean(controller) && typeof controller.prompt === 'function'
1526
+
1527
+ // Fallback: the controller prompt RESUMES a cold session and admits the
1528
+ // message in one step, so it can replace resolveAgent + followup entirely.
1529
+ if (!canResolve) {
1530
+ if (canPrompt) {
1531
+ const text = continuationPrompt(entry, {
1532
+ reason: (Array.isArray(entry.reasons) && entry.reasons[entry.reasons.length - 1]) || 'host restart',
1533
+ override: config.continuationText,
1534
+ })
1535
+ try {
1536
+ await controller.prompt({
1537
+ requestId: crypto.randomUUID(),
1538
+ sessionId,
1539
+ mode: 'queue',
1540
+ content: [{ type: 'text', text }],
1541
+ })
1542
+ entry.pendingResume = false
1543
+ entry.running = false
1544
+ entry.interrupted = false
1545
+ entry.lastResumeError = null
1546
+ noteRecovery(state, sessionId, 'resume-via-prompt', `sessionController.prompt (${controllerPath})`)
1547
+ runtime.persist()
1548
+ log.info(`resumed ${sessionId} through sessionController.prompt (${controllerPath})`)
1549
+ return { sessionId, action: 'resume-via-prompt', detail: `sessionController.prompt (${controllerPath})`, controllerPath }
1550
+ } catch (error) {
1551
+ const detail = describeError(error)
1552
+ entry.lastResumeError = detail
1553
+ noteRecovery(state, sessionId, 'prompt-failed', detail)
1554
+ runtime.persist()
1555
+ log.warn(`sessionController.prompt failed for ${sessionId}:`, detail)
1556
+ return { sessionId, action: 'prompt-failed', detail, error: detail, controllerPath }
1557
+ }
1558
+ }
1559
+ const detail = controller === null
1560
+ ? 'the session controller is not reachable (neither the declared inject nor ctx.get returned it)'
1561
+ : 'the session controller exposes neither resolveAgent nor prompt'
1562
+ log.warn('sessionController is unavailable; cannot resume', sessionId, detail)
1563
+ noteRecovery(state, sessionId, 'session-controller-unavailable', detail)
1564
+ runtime.persist()
1565
+ return { sessionId, action: 'session-controller-unavailable', detail, controllerPath }
801
1566
  }
802
1567
  // A freshly reloaded profile can expose the live agent a moment after the
803
1568
  // state file was written, so a THROWN error is retried inside the same boot
@@ -827,51 +1592,116 @@ async function recoverSession(ctx, runtime, entry, config) {
827
1592
  }
828
1593
  }
829
1594
  if (lastError) {
830
- // Keep pendingResume: the session stays recoverable on a later boot, but the
831
- // failure is now visible in continuum_status instead of vanishing.
1595
+ // Keep pendingResume: the session stays recoverable on a later boot (or a
1596
+ // later continuum_recover_now retry), but the failure is visible instead of
1597
+ // vanishing.
832
1598
  entry.lastResumeError = lastError
1599
+ noteRecovery(state, sessionId, 'agent-not-live', lastError)
833
1600
  runtime.persist()
834
1601
  log.warn(`could not resume ${sessionId}:`, lastError)
835
- return { sessionId, action: 'failed', error: lastError }
1602
+ return { sessionId, action: 'agent-not-live', detail: lastError, error: lastError }
836
1603
  }
837
1604
  agent = result && result.agent
1605
+ if (agent) {
1606
+ noteRecovery(state, sessionId, 'resume-via-resolve-agent', `sessionController.resolveAgent (${controllerPath})`)
1607
+ runtime.persist()
1608
+ }
838
1609
  }
839
1610
  if (!agent) {
840
1611
  entry.lastResumeError = 'resolveAgent returned no agent'
1612
+ noteRecovery(state, sessionId, 'agent-not-live', entry.lastResumeError)
841
1613
  runtime.persist()
842
- return { sessionId, action: 'failed', error: entry.lastResumeError }
1614
+ return { sessionId, action: 'agent-not-live', detail: entry.lastResumeError, error: entry.lastResumeError }
843
1615
  }
844
1616
 
845
1617
  let goal
1618
+ let goalLookupError = null
846
1619
  try {
847
1620
  goal = ctx.goals.get(agent)
848
1621
  } catch (error) {
849
- log.warn(`goals.get failed for ${sessionId}:`, String(error))
1622
+ goalLookupError = describeError(error)
1623
+ log.warn(`goals.get failed for ${sessionId}:`, goalLookupError)
1624
+ }
1625
+ if (!goal || typeof goal !== 'object') {
1626
+ goal = undefined
1627
+ if (goalLookupError === null) goalLookupError = 'goals.get returned no goal'
1628
+ }
1629
+
1630
+ const disarmed = disarmedRecord(state, sessionId)
1631
+ const legacyParked = parkedRecord(state, sessionId)
1632
+ let resumeError = null
1633
+ let resumeAction = null
1634
+
1635
+ // (1) The goal THIS plugin disarmed before the restart: it is still durably
1636
+ // `active` and only its activation is `disarmed`, so re-arming it with its
1637
+ // current revision lets the goal round driver continue the work.
1638
+ if (goal && disarmed && goal.phase === 'active' && goal.activation === 'disarmed' && disarmed.goalId === goal.id) {
1639
+ resumeAction = 'resume-disarmed-goal'
1640
+ }
1641
+
1642
+ // (2) The goal the PREVIOUS (pause-based) build PAUSED: we paused it, so we may
1643
+ // resume it. A goal the USER paused has no record and must stay paused.
1644
+ if (resumeAction === null && goal && legacyParked && goal.phase === 'paused' && legacyParked.goalId === goal.id) {
1645
+ resumeAction = 'resume-paused-goal'
850
1646
  }
851
1647
 
1648
+ // (3) The already proven boot path: an active goal whose continuation authority
1649
+ // is gone is re-armed (the host may have disarmed it on shutdown).
852
1650
  if (
853
- goal
1651
+ resumeAction === null
1652
+ && goal
854
1653
  && goal.phase === 'active'
855
1654
  && goal.activation === 'disarmed'
856
1655
  && goal.roundsStarted < goal.maxGoalRounds
857
1656
  ) {
1657
+ resumeAction = 'rearm-goal'
1658
+ }
1659
+
1660
+ if (resumeAction !== null) {
858
1661
  try {
859
1662
  ctx.goals.resume(agent, { id: goal.id, revision: goal.revision })
1663
+ // The goal is armed again: stamp the record(s) of this session with the
1664
+ // resume timestamp, so the status shows the goal as resumed (the record is
1665
+ // reporting only — it never decides whether a goal may be disarmed).
1666
+ stampResumedGoal(state, sessionId, { resumedAt: Date.now() })
860
1667
  entry.pendingResume = false
861
1668
  entry.running = false
862
1669
  entry.interrupted = false
863
1670
  entry.lastResumeError = null
864
1671
  entry.goal = { goalId: goal.id, goalRevision: goal.revision + 1, phase: 'active' }
1672
+ noteRecovery(state, sessionId, resumeAction, `goal ${goal.id} is armed again`)
865
1673
  runtime.persist()
866
- log.info(`re-armed goal ${goal.id} for session ${sessionId}; the goal round driver continues the work`)
867
- return { sessionId, action: 'rearm-goal', goalId: goal.id }
1674
+ log.info(`${resumeAction}: goal ${goal.id} of session ${sessionId} is armed again; the goal round driver continues the work`)
1675
+ return { sessionId, action: resumeAction, goalId: goal.id, detail: `goal ${goal.id} is armed again` }
868
1676
  } catch (error) {
869
- log.warn(`goals.resume failed for ${sessionId}:`, String(error))
1677
+ // Stale revision or an exhausted round budget: fall back to the
1678
+ // continuation message and KEEP the record, so a later boot (or a manual
1679
+ // continuum_recover_now retry) can try to resume the goal again.
1680
+ resumeError = describeError(error)
1681
+ entry.lastResumeError = resumeError
1682
+ log.warn(`could not resume goal ${goal.id} of ${sessionId} (${resumeAction}):`, resumeError)
870
1683
  }
871
1684
  }
872
1685
 
873
1686
  if (!entry.pendingResume && !entry.interrupted) {
874
- return { sessionId, action: 'nothing' }
1687
+ if (resumeError) {
1688
+ entry.lastResumeError = resumeError
1689
+ noteRecovery(state, sessionId, resumeAction || 'nothing', `resume failed: ${resumeError}`)
1690
+ runtime.persist()
1691
+ return { sessionId, action: resumeAction || 'nothing', detail: `resume failed: ${resumeError}`, error: resumeError }
1692
+ }
1693
+ noteRecovery(state, sessionId, resumeAction || 'nothing', resumeAction ? 'goal handled, session not pending' : 'session-not-pending')
1694
+ runtime.persist()
1695
+ return { sessionId, action: resumeAction || 'nothing', detail: 'session-not-pending' }
1696
+ }
1697
+
1698
+ // A failed goal lookup is not a reason to skip the session: the continuation
1699
+ // message still makes progress, and the reason is recorded for the operator.
1700
+ let pendingAction = 'prompt-continuation'
1701
+ let pendingDetail = resumeError ? `goal resume failed: ${resumeError}` : 'continuation message queued'
1702
+ if (goal === undefined) {
1703
+ pendingAction = 'goal-lookup-failed'
1704
+ pendingDetail = `${goalLookupError || 'goals.get returned no goal'}; continuation message queued`
875
1705
  }
876
1706
 
877
1707
  // Inject exactly one continuation message through the live agent's inbox. The
@@ -879,7 +1709,9 @@ async function recoverSession(ctx, runtime, entry, config) {
879
1709
  // signature is `prompt(request, signal)` and it hardcodes a human `user` source.
880
1710
  if (typeof agent.followup !== 'function') {
881
1711
  log.warn('the resumed agent exposes no followup(); cannot inject the continuation prompt')
882
- return { sessionId, action: 'unavailable' }
1712
+ noteRecovery(state, sessionId, 'prompt-failed', 'the agent exposes no followup()')
1713
+ runtime.persist()
1714
+ return { sessionId, action: 'prompt-failed', detail: 'the agent exposes no followup()' }
883
1715
  }
884
1716
  const text = continuationPrompt(entry, {
885
1717
  reason: (Array.isArray(entry.reasons) && entry.reasons[entry.reasons.length - 1]) || 'host restart',
@@ -888,16 +1720,20 @@ async function recoverSession(ctx, runtime, entry, config) {
888
1720
  try {
889
1721
  agent.followup(createContinuationMessage(text))
890
1722
  } catch (error) {
891
- log.warn(`continuation message rejected for ${sessionId}:`, String(error))
892
- return { sessionId, action: 'prompt-failed' }
1723
+ const detail = describeError(error)
1724
+ log.warn(`continuation message rejected for ${sessionId}:`, detail)
1725
+ noteRecovery(state, sessionId, 'prompt-failed', detail)
1726
+ runtime.persist()
1727
+ return { sessionId, action: 'prompt-failed', detail }
893
1728
  }
894
1729
  entry.pendingResume = false
895
1730
  entry.running = false
896
1731
  entry.interrupted = false
897
- entry.lastResumeError = null
1732
+ entry.lastResumeError = resumeError
1733
+ noteRecovery(state, sessionId, pendingAction, pendingDetail)
898
1734
  runtime.persist()
899
1735
  log.info(`queued a continuation message for session ${sessionId}`)
900
- return { sessionId, action: 'prompt-continuation' }
1736
+ return { sessionId, action: pendingAction, detail: pendingDetail }
901
1737
  }
902
1738
 
903
1739
  /** The boot recovery pass. Runs once per host boot, bounded and idempotent. */
@@ -997,16 +1833,46 @@ function persistSafely(runtime) {
997
1833
  function registerTools(ctx, runtime, config) {
998
1834
  const log = runtime.log
999
1835
 
1836
+ /**
1837
+ * Register one tool.
1838
+ *
1839
+ * The disposer returned by `ctx.tools.register` is handed to `ctx.effect`, so
1840
+ * the HOST owns it: when the plugin row is disabled (or the plugin is
1841
+ * hot-reloaded) the fiber disposes the effect and the tool is unregistered
1842
+ * together with the HTTP routes. Registering it bare would leave the tools of
1843
+ * the OLD instance registered (and the new instance's registration would fail
1844
+ * as a duplicate), so the host could serve the tools of an instance whose
1845
+ * routes are already gone.
1846
+ *
1847
+ * A failed registration is recorded in `runtime.toolErrors` and logged; it is
1848
+ * never thrown, so one broken tool can never stop the plugin from loading.
1849
+ */
1000
1850
  const tool = (definition) => {
1851
+ const name = definition && definition.name ? String(definition.name) : null
1001
1852
  try {
1002
- ctx.tools.register(definition)
1853
+ ctx.effect(
1854
+ () => ctx.tools.register(definition),
1855
+ `dsh-autorestart: tool ${name ?? 'unknown'}`,
1856
+ )
1003
1857
  } catch (error) {
1004
- log.warn(`could not register tool ${definition?.name}:`, String(error))
1858
+ runtime.toolErrors.push({ tool: name, error: describeError(error) })
1859
+ log.warn(`could not register tool ${name}:`, String(error))
1005
1860
  }
1006
1861
  }
1007
1862
 
1008
1863
  const textOutput = (schema, render) => ({ schema, render })
1009
1864
 
1865
+ /**
1866
+ * The blocking sessions with the reason each one blocks: a session that is
1867
+ * held by an armed goal reports `goal-active`, a goal whose round budget is
1868
+ * used up reports `goal-exhausted`, and the original busy reason is kept as
1869
+ * `busyReason`.
1870
+ */
1871
+ const blockingDetails = (blockedBy) => goalAwareBlockingDetails(runtime, blockedBy)
1872
+
1873
+ /** The goals this plugin parked, with the outcome of the resume. */
1874
+ const parkedGoalDetails = () => parkedGoalList(runtime)
1875
+
1010
1876
  tool({
1011
1877
  name: 'continuum_restart',
1012
1878
  description:
@@ -1016,7 +1882,12 @@ function registerTools(ctx, runtime, config) {
1016
1882
  + 'forceRestartWhenBusy is enabled, otherwise the restart keeps waiting for the other conversations. '
1017
1883
  + 'Other conversations are never killed mid-turn: the detached helper re-checks this idle gate '
1018
1884
  + 'over loopback immediately before it kills the process. After the restart this '
1019
- + 'plugin resumes this session and continues the work automatically. Write your final message '
1885
+ + 'plugin resumes this session and continues the work automatically. A goal-driven session is '
1886
+ + 'DISARMED before the restart (pass parkGoals:false to turn that off): disarming only removes the '
1887
+ + 'process-local continuation authority, so the round that is running right now still finishes normally '
1888
+ + '(no turn is cancelled and nothing is lost), no new round starts, and after the restart the goal is '
1889
+ + 're-armed and continues. A goal whose round budget is spent cannot be continued and is reported '
1890
+ + 'instead. Write your final message '
1020
1891
  + 'in this turn, then stop calling tools — the restart fires as soon as the turn goes idle. '
1021
1892
  + 'Use it only when a restart is genuinely required (for example to load an installed plugin update).',
1022
1893
  parameters: {
@@ -1026,6 +1897,7 @@ function registerTools(ctx, runtime, config) {
1026
1897
  reason: { type: 'string', description: 'Why the restart is needed; recorded in the checkpoint and shown in the log.' },
1027
1898
  resume: { type: 'boolean', description: 'Resume this session after the restart (default true).' },
1028
1899
  defer_seconds: { type: 'number', description: 'Optional extra seconds to wait before the first idle check.' },
1900
+ parkGoals: { type: 'boolean', description: 'Disarm the goal of a goal-driven session before the restart (default true). Disarming keeps the durable goal active and lets the current round finish; after the restart the goal is re-armed and continues. With false no goal is disarmed, so a goal-driven session may keep the restart waiting.' },
1029
1901
  },
1030
1902
  },
1031
1903
  output: textOutput(
@@ -1049,10 +1921,12 @@ function registerTools(ctx, runtime, config) {
1049
1921
  const agent = exec && exec.agent
1050
1922
  const sessionId = agent && agent.id ? String(agent.id) : undefined
1051
1923
  const resume = args.resume !== false
1924
+ const parkGoals = args.parkGoals !== false
1052
1925
  requestRestart(state, {
1053
1926
  reason: typeof args.reason === 'string' && args.reason.trim() !== '' ? args.reason.trim() : 'agent requested restart',
1054
1927
  bySessionId: sessionId,
1055
1928
  resume,
1929
+ parkGoals,
1056
1930
  now,
1057
1931
  })
1058
1932
  if (sessionId && resume) {
@@ -1068,13 +1942,15 @@ function registerTools(ctx, runtime, config) {
1068
1942
  }
1069
1943
  runtime.persist()
1070
1944
  runtime.startPolling()
1071
- log.info('restart requested', { generation: state.generation, sessionId, resume })
1945
+ log.info('restart requested', { generation: state.generation, sessionId, resume, parkGoals })
1072
1946
  return {
1073
1947
  accepted: true,
1074
1948
  generation: state.generation,
1075
1949
  phase: state.phase,
1076
1950
  willResume: resume && Boolean(sessionId),
1077
- note: 'The restart runs at the next safe point (all agents idle). Keep this reply short; the session resumes afterwards.',
1951
+ note: parkGoals
1952
+ ? 'The restart runs at the next safe point (all agents idle). Keep this reply short; the session resumes afterwards.'
1953
+ : 'The restart runs at the next safe point (all agents idle), but no goal is parked: a goal-driven session keeps its own agent busy and may therefore keep the restart waiting.',
1078
1954
  }
1079
1955
  },
1080
1956
  })
@@ -1083,8 +1959,12 @@ function registerTools(ctx, runtime, config) {
1083
1959
  name: 'continuum_status',
1084
1960
  description:
1085
1961
  'Report the dsh-autorestart lifecycle: whether a restart is pending, which sessions were '
1086
- + 'checkpointed as interrupted or continuation-pending, the resolved state-file path, and the '
1087
- + 'detected restart environment. Read-only.',
1962
+ + 'checkpointed as interrupted or continuation-pending, why each blocking session blocks '
1963
+ + '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
1964
+ + 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
1965
+ + 'whether a session was handed off to a fresh session (target session id and time) or is over '
1966
+ + 'the model context window (such a session is never auto-resumed), the '
1967
+ + 'resolved state-file path, and the detected restart environment. Read-only.',
1088
1968
  parameters: {
1089
1969
  type: 'object',
1090
1970
  additionalProperties: false,
@@ -1107,6 +1987,9 @@ function registerTools(ctx, runtime, config) {
1107
1987
  gateAllow: { type: 'boolean' },
1108
1988
  quietForMs: { type: 'number' },
1109
1989
  blockedBy: { type: 'array', items: { type: 'object' } },
1990
+ parkedGoals: { type: 'array', items: { type: 'object' } },
1991
+ recovery: { type: 'array', items: { type: 'object' } },
1992
+ handoffs: { type: 'array', items: { type: 'object' } },
1110
1993
  environment: { type: 'object' },
1111
1994
  sessions: { type: 'array', items: { type: 'object' } },
1112
1995
  },
@@ -1126,7 +2009,10 @@ function registerTools(ctx, runtime, config) {
1126
2009
  pendingRestart: state.restart !== null,
1127
2010
  gateAllow: verdict.allow,
1128
2011
  quietForMs: Math.round(verdict.quietForMs),
1129
- blockedBy: verdict.blockedBy,
2012
+ blockedBy: blockingDetails(verdict.blockedBy),
2013
+ parkedGoals: parkedGoalDetails(),
2014
+ recovery: recoveryList(state),
2015
+ handoffs: diagnosticSessions(state),
1130
2016
  environment: {
1131
2017
  platform: runtime.env.platform,
1132
2018
  isDesktopApp: runtime.env.isDesktopApp,
@@ -1137,17 +2023,169 @@ function registerTools(ctx, runtime, config) {
1137
2023
  },
1138
2024
  ...(includeSessions
1139
2025
  ? {
1140
- sessions: recoverableSessions(state, { limit: 50 }).map((entry) => ({
1141
- ...entry,
1142
- // Diagnostics: the last resolveAgent failure for this session (null when healthy).
1143
- lastResumeError: entry.lastResumeError ?? null,
1144
- })),
2026
+ sessions: [
2027
+ ...recoverableSessions(state, { limit: 50 }).map((entry) => ({
2028
+ ...entry,
2029
+ // Diagnostics: the last resolveAgent failure for this session (null when healthy).
2030
+ lastResumeError: entry.lastResumeError ?? null,
2031
+ handedOff: false,
2032
+ overContextLimit: false,
2033
+ })),
2034
+ // A handed-off or over-limit session is out of the automatic resume
2035
+ // path, so it is listed here instead of vanishing from the report.
2036
+ ...diagnosticSessions(state, { limit: 50 }),
2037
+ ],
1145
2038
  }
1146
2039
  : {}),
1147
2040
  }
1148
2041
  },
1149
2042
  })
1150
2043
 
2044
+ tool({
2045
+ name: 'continuum_park_goal',
2046
+ description:
2047
+ 'Disarm the durable goal of a session so a pending restart can proceed: a goal-driven '
2048
+ + 'session keeps its own agent busy forever (the goal round driver starts the next round '
2049
+ + 'immediately), so the restart would wait forever behind it. Disarming only removes the '
2050
+ + 'process-local continuation authority: the goal stays active, the round that is running '
2051
+ + 'right now still finishes normally (no turn is cancelled and nothing is lost), and no new '
2052
+ + 'round starts. After the next restart the goal is re-armed automatically and continues. '
2053
+ + 'Without a sessionId the goal of the calling session is disarmed. A goal whose round budget '
2054
+ + 'is spent can never be continued, so it is reported instead of disarmed.',
2055
+ parameters: {
2056
+ type: 'object',
2057
+ additionalProperties: false,
2058
+ properties: {
2059
+ sessionId: { type: 'string', description: 'The session whose goal should be disarmed (default: the calling session).' },
2060
+ },
2061
+ },
2062
+ output: textOutput(
2063
+ {
2064
+ type: 'object',
2065
+ additionalProperties: false,
2066
+ required: ['ok', 'disarmed'],
2067
+ properties: {
2068
+ ok: { type: 'boolean' },
2069
+ disarmed: { type: 'boolean' },
2070
+ sessionId: { type: 'string' },
2071
+ goalId: { type: 'string' },
2072
+ reason: { type: 'string' },
2073
+ error: { type: 'string' },
2074
+ },
2075
+ },
2076
+ (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
2077
+ ),
2078
+ execute(args = {}, exec) {
2079
+ const agent = exec && exec.agent
2080
+ const callerId = agent && agent.id ? String(agent.id) : undefined
2081
+ const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
2082
+ const sessionId = requested !== '' ? requested : callerId
2083
+ if (!sessionId) {
2084
+ return { ok: false, disarmed: false, reason: 'no-session' }
2085
+ }
2086
+ const result = runtime.disarmGoal(sessionId, sessionId === callerId ? agent : undefined)
2087
+ const payload = {
2088
+ ok: true,
2089
+ disarmed: result.disarmed === true,
2090
+ sessionId,
2091
+ reason: result.reason,
2092
+ }
2093
+ if (result.goalId !== undefined && result.goalId !== null) payload.goalId = String(result.goalId)
2094
+ if (result.error) payload.error = String(result.error)
2095
+ return payload
2096
+ },
2097
+ })
2098
+
2099
+ tool({
2100
+ name: 'continuum_recover_now',
2101
+ description:
2102
+ 'Retry the post-restart recovery RIGHT NOW instead of waiting for the next restart. Use it when '
2103
+ + 'a session is still stuck after a restart: its goal has not been re-armed yet, or its agent was '
2104
+ + 'not live when the boot recovery ran. Optional sessionId retries one session; without one every '
2105
+ + 'pending session is retried. Returns, per session, the action that was taken (for example '
2106
+ + '"resume-disarmed-goal", "resume-paused-goal", "rearm-goal", "prompt-continuation", '
2107
+ + '"agent-not-live", "goal-lookup-failed", "nothing") and a short detail.',
2108
+ parameters: {
2109
+ type: 'object',
2110
+ additionalProperties: false,
2111
+ properties: {
2112
+ sessionId: { type: 'string', description: 'Retry only this session (default: every pending session).' },
2113
+ force: {
2114
+ type: 'boolean',
2115
+ description: 'Re-open this explicit session before retrying: clears a stale over-limit / handoff mark and resets the attempt counter.',
2116
+ },
2117
+ },
2118
+ },
2119
+ output: textOutput(
2120
+ {
2121
+ type: 'object',
2122
+ additionalProperties: false,
2123
+ required: ['ok', 'results'],
2124
+ properties: {
2125
+ ok: { type: 'boolean' },
2126
+ results: { type: 'array', items: { type: 'object' } },
2127
+ },
2128
+ },
2129
+ (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
2130
+ ),
2131
+ async execute(args = {}) {
2132
+ const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
2133
+ const results = await runtime.recoverNow(requested !== '' ? requested : undefined, { force: args.force === true })
2134
+ return { ok: true, results }
2135
+ },
2136
+ })
2137
+
2138
+ tool({
2139
+ name: 'continuum_handoff',
2140
+ description:
2141
+ 'Hand a session whose conversation exceeded the model context window off to a FRESH session. '
2142
+ + 'Such a session is dead: every request to it fails with "context window exceeds limit", and '
2143
+ + 'compacting it needs the same oversized context. The handoff carries the goal objective, the '
2144
+ + 'recorded checkpoint reasons and a bounded tail of the conversation (default 20000 characters, '
2145
+ + '`maxChars`) into a new live session in the same working directory, marks the old session as '
2146
+ + 'handed off (so it is never auto-resumed again) and disarms its goal. The old session and its '
2147
+ + 'conversation are kept, untouched. Without `sessionId` the calling session is handed off. It '
2148
+ + 'reports the new session id, how many characters were carried, and what the human should do.',
2149
+ parameters: {
2150
+ type: 'object',
2151
+ additionalProperties: false,
2152
+ properties: {
2153
+ sessionId: { type: 'string', description: 'The over-limit session to hand off (default: the calling session).' },
2154
+ maxChars: { type: 'number', description: 'Character budget of the carried conversation tail (default 20000).' },
2155
+ },
2156
+ },
2157
+ output: textOutput(
2158
+ {
2159
+ type: 'object',
2160
+ additionalProperties: false,
2161
+ required: ['ok'],
2162
+ properties: {
2163
+ ok: { type: 'boolean' },
2164
+ sessionId: { type: 'string' },
2165
+ sourceSessionId: { type: 'string' },
2166
+ carriedChars: { type: 'number' },
2167
+ carriedTailChars: { type: 'number' },
2168
+ disarmedGoal: { type: 'boolean' },
2169
+ error: { type: 'string' },
2170
+ detail: { type: 'string' },
2171
+ instruction: { type: 'string' },
2172
+ },
2173
+ },
2174
+ (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
2175
+ ),
2176
+ async execute(args = {}, exec) {
2177
+ const agent = exec && exec.agent
2178
+ const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
2179
+ return runtime.handoff({
2180
+ sessionId: requested !== '' ? requested : undefined,
2181
+ maxChars: typeof args.maxChars === 'number' && Number.isFinite(args.maxChars) && args.maxChars > 0
2182
+ ? args.maxChars
2183
+ : undefined,
2184
+ callerAgent: agent,
2185
+ })
2186
+ },
2187
+ })
2188
+
1151
2189
  tool({
1152
2190
  name: 'continuum_cancel_restart',
1153
2191
  description: 'Cancel a restart previously requested through continuum_restart. Does nothing when no restart is pending.',
@@ -1219,9 +2257,11 @@ function injectPageHeartbeat(html, bootId, routePath) {
1219
2257
  const AUTORESTART_PROMPT = [
1220
2258
  '## Restart policy (dsh-autorestart)',
1221
2259
  '- When DeepSeek Harness itself must restart (for example to load an installed or updated plugin bundle), do NOT kill or relaunch the app yourself with pwsh/taskkill/Stop-Process/npm scripts. A hard guard blocks such commands anyway (denials mention `blockManualRestart`).',
1222
- '- Call the `continuum_restart` tool instead (optional arguments: `reason`, `resume`, `defer_seconds`). It waits until every conversation is idle (no running turn, no queued inbox work) AND the host has been quiet for `settleMs` (default 2000 ms); the detached helper re-checks that gate immediately before killing, so other conversations are never killed mid-turn. It then relaunches the app, and after boot this plugin resumes the session and continues the work automatically.',
2260
+ '- Call the `continuum_restart` tool instead (optional arguments: `reason`, `resume`, `defer_seconds`, `parkGoals`). It waits until every conversation is idle (no running turn, no queued inbox work) AND the host has been quiet for `settleMs` (default 2000 ms); the detached helper re-checks that gate immediately before killing, so other conversations are never killed mid-turn. It then relaunches the app, and after boot this plugin resumes the session and continues the work automatically.',
2261
+ '- A goal-driven session is DISARMED automatically before the restart (its current round still finishes, nothing is lost), because such a session would otherwise keep the restart waiting forever: disarming only removes the process-local continuation authority, so no new round starts and the session goes idle. After the restart the goal is re-armed and continues. A goal whose round budget is spent cannot be continued and is reported instead. You can disarm your own goal on demand with `continuum_park_goal`, and retry a stuck recovery with `continuum_recover_now`.',
1223
2262
  '- Persist anything the next turn needs before restarting (workspace files, `todo_write`, goal state); do not rely on in-memory state.',
1224
2263
  '- Inspect the lifecycle with `continuum_status`; cancel a pending restart with `continuum_cancel_restart`.',
2264
+ '- A session whose context exceeds the model window can be handed off to a fresh session with `continuum_handoff` (it carries the goal objective, the recorded reasons and a bounded tail of the conversation); this plugin refuses to auto-resume such a session, so hand it off instead of retrying it.',
1225
2265
  '- For multi-turn objectives prefer a durable goal (`create_goal`): after a restart the plugin re-arms it and the goal round driver keeps producing rounds. No goal is required for restart continuation — without one the plugin injects a single continuation message that continues the work in a fresh turn.',
1226
2266
  ].join('\n')
1227
2267
 
@@ -1354,13 +2394,18 @@ function registerManualRestartGuard(ctx, runtime, config) {
1354
2394
  return
1355
2395
  }
1356
2396
  try {
1357
- tools.guard((exec) => {
1358
- const hit = detectManualRestart(exec, { webPort: runtime.webPort ?? 0 })
1359
- if (hit === null) return undefined
1360
- runtime.manualRestartBlocks += 1
1361
- runtime.log.warn(`blocked a manual DSH restart attempt (${hit}) through tool "${exec.name}"; steering the agent to continuum_restart`)
1362
- return MANUAL_RESTART_DENIAL
1363
- })
2397
+ // The disposer is owned by the fiber (like the tools and the routes), so a
2398
+ // re-applied plugin never leaves a guard of an old instance behind.
2399
+ ctx.effect(
2400
+ () => tools.guard((exec) => {
2401
+ const hit = detectManualRestart(exec, { webPort: runtime.webPort ?? 0 })
2402
+ if (hit === null) return undefined
2403
+ runtime.manualRestartBlocks += 1
2404
+ runtime.log.warn(`blocked a manual DSH restart attempt (${hit}) through tool "${exec.name}"; steering the agent to continuum_restart`)
2405
+ return MANUAL_RESTART_DENIAL
2406
+ }),
2407
+ 'dsh-autorestart: manual-restart guard',
2408
+ )
1364
2409
  } catch (error) {
1365
2410
  runtime.log.warn('could not register the manual-restart guard:', String(error))
1366
2411
  }
@@ -1404,6 +2449,273 @@ function admittedByConnection(ctx, req) {
1404
2449
  }
1405
2450
  }
1406
2451
 
2452
+ /**
2453
+ * Read a small JSON request body. Never throws: an unreadable or malformed
2454
+ * body simply becomes an empty object.
2455
+ *
2456
+ * @param {any} req
2457
+ * @param {number} [maxBytes]
2458
+ * @returns {Promise<object>}
2459
+ */
2460
+ function readJsonBody(req, maxBytes = 64 * 1024) {
2461
+ return new Promise((resolve) => {
2462
+ if (!req || typeof req.on !== 'function') {
2463
+ resolve({})
2464
+ return
2465
+ }
2466
+ let size = 0
2467
+ let settled = false
2468
+ const chunks = []
2469
+ const finish = (text) => {
2470
+ if (settled) return
2471
+ settled = true
2472
+ try {
2473
+ const raw = String(text || '').trim()
2474
+ if (raw === '') {
2475
+ resolve({})
2476
+ return
2477
+ }
2478
+ const parsed = JSON.parse(raw)
2479
+ resolve(parsed && typeof parsed === 'object' ? parsed : {})
2480
+ } catch {
2481
+ resolve({})
2482
+ }
2483
+ }
2484
+ req.on('data', (chunk) => {
2485
+ size += typeof chunk === 'string' ? chunk.length : (chunk && chunk.length ? chunk.length : 0)
2486
+ if (size > maxBytes) {
2487
+ finish('')
2488
+ return
2489
+ }
2490
+ chunks.push(typeof chunk === 'string' ? chunk : String(chunk))
2491
+ })
2492
+ req.on('end', () => finish(chunks.join('')))
2493
+ req.on('error', () => finish(''))
2494
+ })
2495
+ }
2496
+
2497
+ /**
2498
+ * The ids of the sessions that currently block a pending restart.
2499
+ *
2500
+ * @param {object} runtime
2501
+ * @returns {string[]}
2502
+ */
2503
+ function blockingSessionIds(runtime) {
2504
+ const ids = []
2505
+ try {
2506
+ const verdict = runtime.gate()
2507
+ const blockedBy = Array.isArray(verdict.blockedBy) ? verdict.blockedBy : []
2508
+ for (const entry of blockedBy) {
2509
+ if (!entry || typeof entry !== 'object') continue
2510
+ const id = entry.sessionId == null ? '' : String(entry.sessionId)
2511
+ if (id && !ids.includes(id)) ids.push(id)
2512
+ }
2513
+ } catch {}
2514
+ return ids
2515
+ }
2516
+
2517
+ /**
2518
+ * Build the POST route that disarms the goal of a session.
2519
+ *
2520
+ * It uses the same authentication rule as the health route (loopback OR a
2521
+ * request the connection service admits) and answers JSON only.
2522
+ */
2523
+ function buildParkRoute(ctx, runtime, config) {
2524
+ const routePath = resolveParkPath(config)
2525
+ return {
2526
+ kind: 'exact',
2527
+ path: routePath,
2528
+ handler: (req, res) => {
2529
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
2530
+ const loopback = remote === undefined
2531
+ || remote === '127.0.0.1'
2532
+ || remote === '::1'
2533
+ || remote === '::ffff:127.0.0.1'
2534
+ if (!loopback && !admittedByConnection(ctx, req)) {
2535
+ res.statusCode = 403
2536
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2537
+ res.end('{"ok":false,"error":"loopback-only"}')
2538
+ return
2539
+ }
2540
+ if (!req || req.method !== 'POST') {
2541
+ res.statusCode = 405
2542
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2543
+ res.setHeader('allow', 'POST')
2544
+ res.end('{"ok":false,"error":"method-not-allowed"}')
2545
+ return
2546
+ }
2547
+ void readJsonBody(req).then((payload) => {
2548
+ const body = payload && typeof payload === 'object' ? payload : {}
2549
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2550
+ const restart = body.restart === true
2551
+ // Without a session id every session that blocks a pending restart is
2552
+ // parked, so the restart can proceed.
2553
+ const targets = sessionId !== '' ? [sessionId] : blockingSessionIds(runtime)
2554
+ const disarmed = []
2555
+ const reported = []
2556
+ for (const target of targets) {
2557
+ try {
2558
+ const result = runtime.disarmGoal(target)
2559
+ if (result.disarmed) disarmed.push(result)
2560
+ else reported.push(result)
2561
+ } catch (error) {
2562
+ reported.push({ sessionId: target, disarmed: false, reason: 'disarm-failed', error: describeError(error) })
2563
+ }
2564
+ }
2565
+ if (restart && sessionId !== '') {
2566
+ // The session becomes a requester of the pending restart: the restart
2567
+ // proceeds and the session continues afterwards.
2568
+ requestRestart(runtime.state, {
2569
+ reason: 'disarm route requested a restart',
2570
+ bySessionId: sessionId,
2571
+ resume: true,
2572
+ now: Date.now(),
2573
+ })
2574
+ runtime.persist()
2575
+ runtime.startPolling()
2576
+ }
2577
+ res.statusCode = 200
2578
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2579
+ res.setHeader('cache-control', 'no-store')
2580
+ res.end(JSON.stringify({
2581
+ ok: true,
2582
+ disarmed,
2583
+ reported,
2584
+ pendingRestart: runtime.state.restart !== null,
2585
+ restartRequested: restart === true && sessionId !== '',
2586
+ }))
2587
+ }).catch((error) => {
2588
+ res.statusCode = 400
2589
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2590
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
2591
+ })
2592
+ },
2593
+ }
2594
+ }
2595
+
2596
+ /**
2597
+ * Build the POST `/recover` route: retry the post-restart recovery for the
2598
+ * still-pending sessions right now.
2599
+ *
2600
+ * It is the sibling of the `/park` route next to the health route and uses the
2601
+ * same authentication rule (loopback OR a request the connection service
2602
+ * admits). Body: `{ "sessionId"?: string }`; without one every pending session
2603
+ * is retried. The answer reports, per session, the recovery action and detail.
2604
+ */
2605
+ function buildRecoverRoute(ctx, runtime, config) {
2606
+ const routePath = resolveRecoverPath(config)
2607
+ return {
2608
+ kind: 'exact',
2609
+ path: routePath,
2610
+ handler: (req, res) => {
2611
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
2612
+ const loopback = remote === undefined
2613
+ || remote === '127.0.0.1'
2614
+ || remote === '::1'
2615
+ || remote === '::ffff:127.0.0.1'
2616
+ if (!loopback && !admittedByConnection(ctx, req)) {
2617
+ res.statusCode = 403
2618
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2619
+ res.end('{"ok":false,"error":"loopback-only"}')
2620
+ return
2621
+ }
2622
+ if (!req || req.method !== 'POST') {
2623
+ res.statusCode = 405
2624
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2625
+ res.setHeader('allow', 'POST')
2626
+ res.end('{"ok":false,"error":"method-not-allowed"}')
2627
+ return
2628
+ }
2629
+ void readJsonBody(req).then(async (payload) => {
2630
+ const body = payload && typeof payload === 'object' ? payload : {}
2631
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2632
+ const results = await runtime.recoverNow(sessionId !== '' ? sessionId : undefined, { force: body.force === true })
2633
+ res.statusCode = 200
2634
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2635
+ res.setHeader('cache-control', 'no-store')
2636
+ res.end(JSON.stringify({ ok: true, results }))
2637
+ }).catch((error) => {
2638
+ res.statusCode = 400
2639
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2640
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
2641
+ })
2642
+ },
2643
+ }
2644
+ }
2645
+
2646
+ /**
2647
+ * Build the POST `/handoff` route: hand an over-limit session off to a fresh
2648
+ * session (the same operation as the `continuum_handoff` tool).
2649
+ *
2650
+ * It sits next to the health route and uses the same authentication rule
2651
+ * (loopback OR a request the connection service admits). Body:
2652
+ * `{ "sessionId"?: string, "maxChars"?: number }`; without a session id the
2653
+ * request is refused, because a route call has no calling session to default to.
2654
+ * The answer reports the new session id and the number of carried characters.
2655
+ */
2656
+ function buildHandoffRoute(ctx, runtime, config) {
2657
+ const routePath = resolveHandoffPath(config)
2658
+ return {
2659
+ kind: 'exact',
2660
+ path: routePath,
2661
+ handler: (req, res) => {
2662
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
2663
+ const loopback = remote === undefined
2664
+ || remote === '127.0.0.1'
2665
+ || remote === '::1'
2666
+ || remote === '::ffff:127.0.0.1'
2667
+ if (!loopback && !admittedByConnection(ctx, req)) {
2668
+ res.statusCode = 403
2669
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2670
+ res.end('{"ok":false,"error":"loopback-only"}')
2671
+ return
2672
+ }
2673
+ if (!req || req.method !== 'POST') {
2674
+ res.statusCode = 405
2675
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2676
+ res.setHeader('allow', 'POST')
2677
+ res.end('{"ok":false,"error":"method-not-allowed"}')
2678
+ return
2679
+ }
2680
+ void readJsonBody(req).then(async (payload) => {
2681
+ const body = payload && typeof payload === 'object' ? payload : {}
2682
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2683
+ if (sessionId === '') {
2684
+ res.statusCode = 400
2685
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2686
+ res.end(JSON.stringify({
2687
+ ok: false,
2688
+ error: 'no-session',
2689
+ instruction: 'Pass { "sessionId": "<id>" }: this route has no calling session to default to.',
2690
+ }))
2691
+ return
2692
+ }
2693
+ const result = await runtime.handoff({
2694
+ sessionId,
2695
+ maxChars: typeof body.maxChars === 'number' && Number.isFinite(body.maxChars) && body.maxChars > 0
2696
+ ? body.maxChars
2697
+ : undefined,
2698
+ })
2699
+ res.statusCode = result && result.ok === true ? 200 : 400
2700
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2701
+ res.setHeader('cache-control', 'no-store')
2702
+ res.end(JSON.stringify({
2703
+ ok: result && result.ok === true,
2704
+ sessionId: result ? result.sessionId : undefined,
2705
+ sourceSessionId: result ? result.sourceSessionId : sessionId,
2706
+ carriedChars: result ? result.carriedChars : undefined,
2707
+ error: result ? result.error : 'handoff-failed',
2708
+ instruction: result ? result.instruction : undefined,
2709
+ }))
2710
+ }).catch((error) => {
2711
+ res.statusCode = 400
2712
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2713
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
2714
+ })
2715
+ },
2716
+ }
2717
+ }
2718
+
1407
2719
  /** Expose a tiny read-only health route for humans and verification scripts. */
1408
2720
  function registerHealthRoute(ctx, runtime, config) {
1409
2721
  const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
@@ -1490,8 +2802,10 @@ function registerHealthRoute(ctx, runtime, config) {
1490
2802
  const verdict = runtime.gate()
1491
2803
  // Best effort: label the blocking sessions with their titles when the
1492
2804
  // session controller knows them. The title is optional, so an entry
1493
- // without one is sent exactly as the gate produced it.
1494
- const blockedBy = (Array.isArray(verdict.blockedBy) ? verdict.blockedBy : []).map((entry) => {
2805
+ // without one is sent exactly as the gate produced it. The goal
2806
+ // reason (`goal-active` / `goal-exhausted`) is attached as well, so
2807
+ // the client can tell a goal-driven blocker from a busy session.
2808
+ const blockedBy = goalAwareBlockingDetails(runtime, verdict.blockedBy).map((entry) => {
1495
2809
  if (!entry || typeof entry !== 'object') return entry
1496
2810
  const title = runtime.sessionTitle(entry.sessionId)
1497
2811
  return title ? { ...entry, title } : entry
@@ -1507,6 +2821,13 @@ function registerHealthRoute(ctx, runtime, config) {
1507
2821
  quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
1508
2822
  settleMs: config.settleMs,
1509
2823
  updatedAt: live.updatedAt,
2824
+ // The park route, so the client never has to guess the prefix, and
2825
+ // the goals this plugin already disarmed (they are re-armed after
2826
+ // the restart).
2827
+ parkUrl: resolveParkPath(config),
2828
+ recoverUrl: resolveRecoverPath(config),
2829
+ parkedGoals: parkedGoalList(runtime),
2830
+ recovery: recoveryList(live),
1510
2831
  })
1511
2832
  } catch {
1512
2833
  // The banner must never break the health route: fall back to "no restart".
@@ -1517,6 +2838,10 @@ function registerHealthRoute(ctx, runtime, config) {
1517
2838
  blockedBy: [],
1518
2839
  allowRestart: false,
1519
2840
  quietForMs: 0,
2841
+ parkUrl: resolveParkPath(config),
2842
+ recoverUrl: resolveRecoverPath(config),
2843
+ parkedGoals: [],
2844
+ recovery: [],
1520
2845
  })
1521
2846
  }
1522
2847
  res.statusCode = 200
@@ -1568,6 +2893,9 @@ function registerHealthRoute(ctx, runtime, config) {
1568
2893
  return false
1569
2894
  }
1570
2895
  if (!webServer || typeof webServer.register !== 'function') return false
2896
+ const parkRoute = buildParkRoute(ctx, runtime, config)
2897
+ const recoverRoute = buildRecoverRoute(ctx, runtime, config)
2898
+ const handoffRoute = buildHandoffRoute(ctx, runtime, config)
1571
2899
  try {
1572
2900
  const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
1573
2901
  const portMatch = /:(\d+)\/?$/.exec(origin)
@@ -1579,6 +2907,21 @@ function registerHealthRoute(ctx, runtime, config) {
1579
2907
  // A duplicate registration means another load of this plugin already owns it.
1580
2908
  runtime.log.warn('could not register the health route:', String(error))
1581
2909
  }
2910
+ try {
2911
+ target.effect(() => webServer.register(parkRoute), 'dsh-autorestart: park route')
2912
+ } catch (error) {
2913
+ runtime.log.warn('could not register the park route:', String(error))
2914
+ }
2915
+ try {
2916
+ target.effect(() => webServer.register(recoverRoute), 'dsh-autorestart: recover route')
2917
+ } catch (error) {
2918
+ runtime.log.warn('could not register the recover route:', String(error))
2919
+ }
2920
+ try {
2921
+ target.effect(() => webServer.register(handoffRoute), 'dsh-autorestart: handoff route')
2922
+ } catch (error) {
2923
+ runtime.log.warn('could not register the handoff route:', String(error))
2924
+ }
1582
2925
  if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
1583
2926
  try {
1584
2927
  target.effect(