dsh-autorestart 0.2.0 → 0.3.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 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,
@@ -234,6 +254,193 @@ function resolveHealthPath(config) {
234
254
  : '/__dsh_autorestart/health'
235
255
  }
236
256
 
257
+ /**
258
+ * Resolve the park-route path: a sibling of the health route, under the same
259
+ * prefix (the health path without its last segment).
260
+ *
261
+ * @param {object} config
262
+ * @returns {string}
263
+ */
264
+ function resolveParkPath(config) {
265
+ const healthPath = resolveHealthPath(config)
266
+ const index = healthPath.lastIndexOf('/')
267
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
268
+ return `${prefix}/park`
269
+ }
270
+
271
+ /**
272
+ * Resolve the recovery-route path: a sibling of the health route, under the same
273
+ * prefix (`POST /recover` retries the boot recovery for the pending sessions).
274
+ *
275
+ * @param {object} config
276
+ * @returns {string}
277
+ */
278
+ function resolveRecoverPath(config) {
279
+ const healthPath = resolveHealthPath(config)
280
+ const index = healthPath.lastIndexOf('/')
281
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
282
+ return `${prefix}/recover`
283
+ }
284
+
285
+ /**
286
+ * Resolve the handoff-route path: a sibling of the health route, under the same
287
+ * prefix (`POST /handoff` hands an over-limit session off to a fresh session).
288
+ *
289
+ * @param {object} config
290
+ * @returns {string}
291
+ */
292
+ function resolveHandoffPath(config) {
293
+ const healthPath = resolveHealthPath(config)
294
+ const index = healthPath.lastIndexOf('/')
295
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
296
+ return `${prefix}/handoff`
297
+ }
298
+
299
+ /**
300
+ * The blocking sessions with the reason each one blocks: a session that is
301
+ * held by an armed goal reports `goal-active` (that goal can be disarmed), a
302
+ * goal whose round budget is spent reports `goal-exhausted`, and the original
303
+ * busy reason is kept as `busyReason`.
304
+ *
305
+ * Never throws: a goal lookup must never break a status report.
306
+ *
307
+ * @param {object} runtime
308
+ * @param {any} blockedBy
309
+ * @returns {any[]}
310
+ */
311
+ function goalAwareBlockingDetails(runtime, blockedBy) {
312
+ const list = Array.isArray(blockedBy) ? blockedBy : []
313
+ const reasons = new Map()
314
+ try {
315
+ const { disarms, reported } = selectGoalDisarms(list, {
316
+ getGoal: (sessionId) => runtime.goalFor(sessionId),
317
+ disarmedGoals: runtime.disarmedGoalRecords(),
318
+ parkedGoals: runtime.parkedGoalRecords(),
319
+ })
320
+ for (const item of disarms) reasons.set(item.sessionId, GOAL_BLOCK_REASONS.ACTIVE)
321
+ for (const item of reported) reasons.set(item.sessionId, item.reason)
322
+ } catch {
323
+ // A goal lookup must never break the status report.
324
+ }
325
+ return list.map((entry) => {
326
+ if (!entry || typeof entry !== 'object') return entry
327
+ const reason = reasons.get(entry.sessionId)
328
+ if (!reason) return entry
329
+ return { ...entry, busyReason: entry.reason, reason }
330
+ })
331
+ }
332
+
333
+ /**
334
+ * The goal records this plugin keeps, in both shapes: the current disarmed-goal
335
+ * records (`goalId` / `disarmedAt` / `resumedAt`, written by
336
+ * `ctx.goals.disarm`) and the LEGACY parked-goal records written by the
337
+ * previous pause-based build (`goalId` / `parkedAt` / `resumedAt`).
338
+ *
339
+ * The status field is still called `parkedGoals` so an older client keeps
340
+ * working; `kind` tells the two shapes apart.
341
+ *
342
+ * @param {object} runtime
343
+ * @returns {{sessionId: string, kind: 'disarmed'|'parked', goalId: string|null, disarmedAt: number|null, parkedAt: number|null, resumedAt: number|null, resumed: boolean}[]}
344
+ */
345
+ function parkedGoalList(runtime) {
346
+ const out = []
347
+ const collect = (records, kind, atKey) => {
348
+ for (const [sessionId, record] of Object.entries(records || {})) {
349
+ if (!record || typeof record !== 'object') continue
350
+ out.push({
351
+ sessionId,
352
+ kind,
353
+ goalId: record.goalId != null ? String(record.goalId) : null,
354
+ disarmedAt: kind === 'disarmed' && Number.isFinite(record.disarmedAt) ? record.disarmedAt : null,
355
+ parkedAt: kind === 'parked' && Number.isFinite(record[atKey]) ? record[atKey] : null,
356
+ resumedAt: record.resumedAt != null ? record.resumedAt : null,
357
+ resumed: record.resumedAt != null,
358
+ })
359
+ }
360
+ }
361
+ collect(runtime.disarmedGoalRecords(), 'disarmed', 'disarmedAt')
362
+ collect(runtime.parkedGoalRecords(), 'parked', 'parkedAt')
363
+ return out
364
+ }
365
+
366
+ /**
367
+ * The self-reported outcome of the last recovery attempt, per session, plus the
368
+ * sessions that can still be recovered. Used by `continuum_status` and by the
369
+ * `?state=1` route so a stuck recovery is visible without reading the log.
370
+ *
371
+ * @param {object} state
372
+ * @returns {{sessionId: string, action: string|null, detail: string|null, pending: boolean, attempts: number, error: string|null}[]}
373
+ */
374
+ function recoveryList(state) {
375
+ const out = []
376
+ try {
377
+ const sessions = state && typeof state === 'object' && state.sessions && typeof state.sessions === 'object'
378
+ ? state.sessions
379
+ : {}
380
+ for (const [sessionId, entry] of Object.entries(sessions)) {
381
+ if (!entry || typeof entry !== 'object') continue
382
+ if (entry.lastRecoveryAction == null && entry.pendingResume !== true) continue
383
+ out.push({
384
+ sessionId: typeof entry.sessionId === 'string' && entry.sessionId ? entry.sessionId : sessionId,
385
+ action: entry.lastRecoveryAction != null ? String(entry.lastRecoveryAction) : null,
386
+ detail: entry.lastRecoveryDetail != null ? String(entry.lastRecoveryDetail) : null,
387
+ pending: entry.pendingResume === true,
388
+ attempts: Number.isFinite(entry.resumeAttempts) ? entry.resumeAttempts : 0,
389
+ error: entry.lastResumeError != null ? String(entry.lastResumeError) : null,
390
+ })
391
+ }
392
+ } catch {}
393
+ return out
394
+ }
395
+
396
+ /**
397
+ * The per-session handoff / context-overflow diagnostics of the checkpoint.
398
+ *
399
+ * Both records are the durable "this session outgrew the model window" markers:
400
+ * a handed-off session was replaced by a fresh one, an over-limit session is
401
+ * refused by the automatic resume path. `recoverableSessions` skips both, so the
402
+ * status has to report them from the raw entries or they would be invisible.
403
+ *
404
+ * @param {object} state
405
+ * @param {{ limit?: number }} [options]
406
+ * @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}[]}
407
+ */
408
+ function diagnosticSessions(state, { limit = 50 } = {}) {
409
+ const out = []
410
+ try {
411
+ const sessions = state && typeof state === 'object' && state.sessions && typeof state.sessions === 'object'
412
+ ? state.sessions
413
+ : {}
414
+ for (const [sessionId, entry] of Object.entries(sessions)) {
415
+ if (!entry || typeof entry !== 'object') continue
416
+ const handoff = entry.handoff && typeof entry.handoff === 'object' ? entry.handoff : null
417
+ const overflow = entry.contextOverflow && typeof entry.contextOverflow === 'object'
418
+ && entry.contextOverflow.over === true
419
+ ? entry.contextOverflow
420
+ : null
421
+ if (handoff === null && overflow === null) continue
422
+ out.push({
423
+ sessionId: typeof entry.sessionId === 'string' && entry.sessionId ? entry.sessionId : sessionId,
424
+ handedOff: handoff !== null,
425
+ handedOffTo: handoff && typeof handoff.handedOffTo === 'string' ? handoff.handedOffTo : null,
426
+ handoffAt: handoff && Number.isFinite(handoff.handoffAt) ? handoff.handoffAt : null,
427
+ carriedChars: handoff && Number.isFinite(handoff.carriedChars) ? handoff.carriedChars : null,
428
+ overContextLimit: overflow !== null,
429
+ contextOverflow: overflow === null ? null : {
430
+ inputTokens: Number.isFinite(overflow.inputTokens) ? overflow.inputTokens : null,
431
+ contextWindow: Number.isFinite(overflow.contextWindow) ? overflow.contextWindow : null,
432
+ detail: typeof overflow.detail === 'string' ? overflow.detail : null,
433
+ detectedAt: Number.isFinite(overflow.detectedAt) ? overflow.detectedAt : null,
434
+ },
435
+ lastRecoveryAction: entry.lastRecoveryAction != null ? String(entry.lastRecoveryAction) : null,
436
+ lastRecoveryDetail: entry.lastRecoveryDetail != null ? String(entry.lastRecoveryDetail) : null,
437
+ })
438
+ if (out.length >= limit) break
439
+ }
440
+ } catch {}
441
+ return out
442
+ }
443
+
237
444
  /** Resolve the durable checkpoint path. */
238
445
  function resolveStateFile(config) {
239
446
  if (typeof config.stateFile === 'string' && config.stateFile.trim() !== '') {
@@ -309,6 +516,11 @@ function createRuntime(ctx, config) {
309
516
  let recoverTimer
310
517
  let disposed = false
311
518
  let lastPersistError
519
+ /**
520
+ * The tool registrations that failed. A failed registration is recorded and
521
+ * logged, never thrown: the plugin still loads and the other tools still work.
522
+ */
523
+ const toolErrors = []
312
524
 
313
525
  const persist = () => {
314
526
  try {
@@ -387,6 +599,331 @@ function createRuntime(ctx, config) {
387
599
  return out
388
600
  }
389
601
 
602
+ /**
603
+ * The disarmed-goal records of the checkpoint: one entry per session whose
604
+ * goal this plugin disarmed and has not re-armed yet.
605
+ */
606
+ const disarmedGoalRecords = () => (
607
+ state.disarmedGoals && typeof state.disarmedGoals === 'object' ? state.disarmedGoals : {}
608
+ )
609
+
610
+ /**
611
+ * The LEGACY (pause-based) parked-goal records of the checkpoint. They are
612
+ * still read so a goal the previous build paused can be resumed once.
613
+ */
614
+ const parkedGoalRecords = () => (
615
+ state.parkedGoals && typeof state.parkedGoals === 'object' ? state.parkedGoals : {}
616
+ )
617
+
618
+ /** The live agent of a session, or undefined when it is not attached. */
619
+ const liveAgent = (sessionId) => {
620
+ if (!sessionId) return undefined
621
+ try {
622
+ if (ctx.agents && typeof ctx.agents.get === 'function') {
623
+ const agent = ctx.agents.get(sessionId)
624
+ if (agent) return agent
625
+ }
626
+ } catch {}
627
+ return undefined
628
+ }
629
+
630
+ /** The live goal of a session, or undefined when it has none. */
631
+ const goalFor = (sessionId, agent) => {
632
+ const target = agent || liveAgent(sessionId)
633
+ if (!target) return undefined
634
+ try {
635
+ return ctx.goals.get(target)
636
+ } catch {
637
+ return undefined
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Disarm the goal of one session so a pending restart can proceed.
643
+ *
644
+ * `ctx.goals.disarm(agent)` removes the process-local continuation authority
645
+ * WITHOUT touching the durable goal: the goal stays `active`, the round that
646
+ * is running right now still finishes normally (no turn is cancelled and
647
+ * nothing is lost), but the goal round driver will not start another round.
648
+ * That is what lets the session go idle so the restart can proceed; after the
649
+ * boot the goal is re-armed through `ctx.goals.resume(...)`.
650
+ *
651
+ * Never throws: a failed disarm is reported instead of propagated, so it can
652
+ * never break the restart gate.
653
+ */
654
+ const disarmGoal = (sessionId, agent) => {
655
+ const target = agent || liveAgent(sessionId)
656
+ if (!target) {
657
+ return { sessionId, disarmed: false, reason: GOAL_BLOCK_REASONS.NO_AGENT, error: 'session-not-live' }
658
+ }
659
+ const goal = goalFor(sessionId, target)
660
+ if (!goal || typeof goal !== 'object') return { sessionId, disarmed: false, reason: 'no-goal' }
661
+ const goalId = goal.id == null ? null : String(goal.id)
662
+ const { disarms, reported } = selectGoalDisarms([{ sessionId }], {
663
+ getGoal: () => goal,
664
+ disarmedGoals: disarmedGoalRecords(),
665
+ parkedGoals: parkedGoalRecords(),
666
+ })
667
+ if (disarms.length === 0) {
668
+ const reason = reported.length > 0 ? reported[0].reason : 'goal-not-disarmable'
669
+ return { sessionId, disarmed: false, goalId, reason }
670
+ }
671
+ const goals = ctx.goals
672
+ if (!goals || typeof goals.disarm !== 'function') {
673
+ return { sessionId, disarmed: false, goalId, reason: 'disarm-unavailable', error: 'goals.disarm is not available' }
674
+ }
675
+ let outcome
676
+ try {
677
+ outcome = goals.disarm(target)
678
+ } catch (error) {
679
+ return { sessionId, disarmed: false, goalId, reason: 'disarm-failed', error: describeError(error) }
680
+ }
681
+ const onDisarmed = () => {
682
+ setDisarmedGoal(state, sessionId, { goalId, disarmedAt: Date.now() })
683
+ persist()
684
+ log.info(`disarmed goal ${goalId} of session ${sessionId}; the running round still finishes and the goal is re-armed after the restart`)
685
+ }
686
+ if (outcome && typeof outcome.then === 'function') {
687
+ outcome.then(onDisarmed, (error) => {
688
+ log.warn(`could not disarm goal ${goalId} of session ${sessionId}:`, describeError(error))
689
+ })
690
+ return { sessionId, disarmed: true, goalId, reason: GOAL_BLOCK_REASONS.ACTIVE, pending: true }
691
+ }
692
+ onDisarmed()
693
+ return { sessionId, disarmed: true, goalId, reason: GOAL_BLOCK_REASONS.ACTIVE }
694
+ }
695
+
696
+ /**
697
+ * The live SESSION of a session id: the readable conversation surface
698
+ * (`deriveMessages` / `requestHeader` / `requestContext` / `snapshotEvents`).
699
+ * Falls back to the live agent's session when the sessions service is absent.
700
+ */
701
+ const liveSession = (sessionId) => {
702
+ if (!sessionId) return undefined
703
+ try {
704
+ const sessions = ctx.get('sessions')
705
+ if (sessions && typeof sessions.get === 'function') {
706
+ const session = sessions.get(String(sessionId))
707
+ if (session) return session
708
+ }
709
+ } catch {}
710
+ const agent = liveAgent(sessionId)
711
+ return agent && agent.session ? agent.session : undefined
712
+ }
713
+
714
+ /** Read the readable surface of one live session. Never throws. */
715
+ const readSession = (sessionId) => {
716
+ const session = liveSession(sessionId)
717
+ if (!session) return { session: undefined, messages: undefined, header: undefined, requestContext: undefined, events: undefined }
718
+ const read = (fn) => {
719
+ try {
720
+ return typeof session[fn] === 'function' ? session[fn]() : undefined
721
+ } catch {
722
+ return undefined
723
+ }
724
+ }
725
+ return {
726
+ session,
727
+ messages: read('deriveMessages'),
728
+ header: read('requestHeader'),
729
+ requestContext: read('requestContext'),
730
+ events: read('snapshotEvents'),
731
+ }
732
+ }
733
+
734
+ /**
735
+ * The context-overflow verdict of a session, from its LIVE surface only.
736
+ *
737
+ * Evidence: the newest assistant message `usage.inputTokens` versus the
738
+ * session's `contextWindow`, or a recent turn / event that failed with a
739
+ * context-window error. Unknown numbers never mean overflow, so an unknown
740
+ * session keeps the normal resume path. Never throws.
741
+ */
742
+ const overflowFor = (sessionId) => {
743
+ const unknown = { over: false, inputTokens: null, contextWindow: null, detail: null }
744
+ try {
745
+ const read = readSession(sessionId)
746
+ if (!read.session) return unknown
747
+ const info = read.requestContext
748
+ const contextWindow = info && typeof info === 'object' && Number.isFinite(info.contextWindow) ? info.contextWindow : null
749
+ return contextOverflow({ messages: read.messages, contextWindow, events: read.events })
750
+ } catch {
751
+ return unknown
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Hand an over-limit session off to a fresh, usable session.
757
+ *
758
+ * A session whose conversation outgrew the model window can neither continue
759
+ * nor be compacted: every request fails with "context window exceeds limit",
760
+ * and the compaction request needs the same oversized context. The handoff
761
+ * carries what still fits — the goal objective, the checkpoint reasons and a
762
+ * bounded tail of the conversation — into a NEW live agent/session, then marks
763
+ * the old session and disarms its goal so it stops issuing doomed requests.
764
+ *
765
+ * Nothing is created when the source cannot be read.
766
+ *
767
+ * @param {{ sessionId?: string, maxChars?: number, callerAgent?: object }} [options]
768
+ * @returns {Promise<object>} `{ ok, sessionId, carriedChars, instruction }`
769
+ */
770
+ const handoff = async ({ sessionId, maxChars, callerAgent } = {}) => {
771
+ const requested = typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : null
772
+ const sourceId = requested !== null ? requested : (callerAgent && callerAgent.id ? String(callerAgent.id) : null)
773
+ if (sourceId === null) {
774
+ return { ok: false, error: 'no-session', instruction: 'No source session: pass sessionId, or call this tool from the session that should be handed off.' }
775
+ }
776
+ const read = readSession(sourceId)
777
+ if (!read.session) {
778
+ return {
779
+ ok: false,
780
+ error: 'source-session-not-live',
781
+ sourceSessionId: sourceId,
782
+ instruction: `Session ${sourceId} is not live in this process, so its conversation cannot be read and no handoff was created.`,
783
+ }
784
+ }
785
+ if (read.messages === undefined) {
786
+ return {
787
+ ok: false,
788
+ error: 'source-unreadable',
789
+ sourceSessionId: sourceId,
790
+ instruction: `Session ${sourceId} did not expose its messages, so no handoff was created.`,
791
+ }
792
+ }
793
+ const header = read.header
794
+ const cwd = header && typeof header.cwd === 'string' && path.isAbsolute(header.cwd) ? header.cwd : null
795
+ if (cwd === null) {
796
+ return {
797
+ ok: false,
798
+ error: 'source-cwd-unknown',
799
+ sourceSessionId: sourceId,
800
+ instruction: `Session ${sourceId} has no absolute working directory in its header, so a new session could not be created for the handoff.`,
801
+ }
802
+ }
803
+
804
+ const goal = goalFor(sourceId)
805
+ const objective = goal && typeof goal === 'object'
806
+ ? [goal.objective, goal.title, goal.description].find((value) => typeof value === 'string' && value.trim() !== '') ?? null
807
+ : null
808
+ const entry = state.sessions && state.sessions[sourceId] ? state.sessions[sourceId] : null
809
+ const goalLabel = entry && entry.goal && entry.goal.goalId != null
810
+ ? String(entry.goal.goalId)
811
+ : (goal && goal.id != null ? String(goal.id) : null)
812
+ const budget = Number.isFinite(maxChars) && maxChars > 0 ? Math.trunc(maxChars) : HANDOFF_DEFAULT_MAX_CHARS
813
+ const composed = composeHandoff({
814
+ sessionId: sourceId,
815
+ objective,
816
+ goalLabel,
817
+ reasons: entry && Array.isArray(entry.reasons) ? entry.reasons : [],
818
+ messages: read.messages,
819
+ maxChars: budget,
820
+ })
821
+
822
+ const newSessionId = 'session-' + crypto.randomUUID()
823
+ let created
824
+ try {
825
+ created = await ctx.agents.create({
826
+ sessionId: newSessionId,
827
+ meta: { cwd, parentSession: sourceId, createdAt: Date.now() },
828
+ })
829
+ } catch (error) {
830
+ const detail = describeError(error)
831
+ log.warn(`could not create the handoff session for ${sourceId}:`, detail)
832
+ return {
833
+ ok: false,
834
+ error: 'create-failed',
835
+ sourceSessionId: sourceId,
836
+ detail,
837
+ instruction: `The new session could not be created (${detail}); session ${sourceId} is unchanged.`,
838
+ }
839
+ }
840
+ let newAgent = created && typeof created === 'object' ? (created.agent ?? created.handle ?? created) : undefined
841
+ if (!newAgent || typeof newAgent.followup !== 'function' || newAgent.id === undefined) newAgent = liveAgent(newSessionId)
842
+ if (!newAgent || typeof newAgent.followup !== 'function') {
843
+ return {
844
+ ok: false,
845
+ error: 'handoff-agent-unavailable',
846
+ sourceSessionId: sourceId,
847
+ sessionId: newSessionId,
848
+ instruction: `The new session ${newSessionId} exposes no agent to deliver the handoff text to; session ${sourceId} is unchanged.`,
849
+ }
850
+ }
851
+ const continueId = newAgent.id === undefined || newAgent.id === null ? newSessionId : String(newAgent.id)
852
+ try {
853
+ newAgent.followup(createContinuationMessage(composed.text, {
854
+ summary: 'handoff of a session that exceeded the model context window',
855
+ }))
856
+ } catch (error) {
857
+ const detail = describeError(error)
858
+ log.warn(`the handoff text was rejected for ${continueId}:`, detail)
859
+ return {
860
+ ok: false,
861
+ error: 'deliver-failed',
862
+ sourceSessionId: sourceId,
863
+ sessionId: continueId,
864
+ detail,
865
+ instruction: `The handoff text could not be delivered to ${continueId} (${detail}); session ${sourceId} is unchanged.`,
866
+ }
867
+ }
868
+
869
+ const at = Date.now()
870
+ setHandoff(state, sourceId, { handedOffTo: continueId, handoffAt: at, carriedChars: composed.carriedChars })
871
+ setRecoveryOutcome(state, sourceId, { action: 'handed-off', detail: `handed off to ${continueId}`, now: at })
872
+ persist()
873
+
874
+ // (e) the over-limit session must stop issuing doomed requests.
875
+ let disarmedGoal = false
876
+ try {
877
+ const result = disarmGoal(sourceId, requested === null ? callerAgent : undefined)
878
+ disarmedGoal = result.disarmed === true
879
+ } catch (error) {
880
+ log.warn(`could not disarm the goal of the handed-off session ${sourceId}:`, describeError(error))
881
+ }
882
+ log.info(`handed session ${sourceId} off to ${continueId} (${composed.carriedChars} characters carried)`)
883
+ return {
884
+ ok: true,
885
+ sourceSessionId: sourceId,
886
+ sessionId: continueId,
887
+ carriedChars: composed.carriedChars,
888
+ carriedTailChars: composed.tailChars,
889
+ disarmedGoal,
890
+ 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.`,
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Disarm the goals of the sessions that block the pending restart.
896
+ *
897
+ * A goal-driven session keeps its own agent busy (the goal round driver
898
+ * starts the next round within milliseconds), so the restart gate would never
899
+ * open. Disarming removes that authority: the CURRENT round finishes
900
+ * normally, no new round starts, the session goes idle and the next poll sees
901
+ * a quiet host. Goals are never paused and no turn is ever cancelled.
902
+ */
903
+ const disarmBlockingGoals = (busy) => {
904
+ const restart = state.restart
905
+ if (!restart) return { disarmed: [], reported: [] }
906
+ if (restart.parkGoals === false) return { disarmed: [], reported: [], skipped: 'park-goals-disabled' }
907
+ const list = Array.isArray(busy) ? busy : []
908
+ if (list.length === 0) return { disarmed: [], reported: [] }
909
+ const { disarms, reported } = selectGoalDisarms(list, {
910
+ getGoal: (sessionId) => goalFor(sessionId),
911
+ disarmedGoals: disarmedGoalRecords(),
912
+ parkedGoals: parkedGoalRecords(),
913
+ })
914
+ const disarmed = []
915
+ for (const item of disarms) {
916
+ try {
917
+ const result = disarmGoal(item.sessionId)
918
+ if (result.disarmed) disarmed.push(result)
919
+ else log.warn(`could not disarm the goal of session ${item.sessionId}: ${result.reason}${result.error ? ` (${result.error})` : ''}`)
920
+ } catch (error) {
921
+ log.warn(`disarming the goal of session ${item.sessionId} failed:`, describeError(error))
922
+ }
923
+ }
924
+ return { disarmed, reported }
925
+ }
926
+
390
927
  const anyAgentRunning = () => busyAgents().length > 0
391
928
 
392
929
  /** The two-layer restart gate: no busy conversation AND a quiet settle window. */
@@ -578,6 +1115,13 @@ function createRuntime(ctx, config) {
578
1115
  // This re-check also protects the ungated fallback (>20 gate aborts) and the
579
1116
  // forceRestartWhenBusy path: even when the detached helper cannot re-check the
580
1117
  // gate itself, a busy conversation is never killed here.
1118
+ // Last chance to park a goal-driven session: without it the detached
1119
+ // helper would refuse to kill the process and the restart would wait.
1120
+ try {
1121
+ disarmBlockingGoals(busyAgents())
1122
+ } catch (error) {
1123
+ log.warn('could not disarm the goals of the blocking sessions:', String(error))
1124
+ }
581
1125
  const verdict = gate()
582
1126
  if (!verdict.allow) {
583
1127
  log.info('restart deferred: a conversation is still active or the host is settling', {
@@ -639,6 +1183,15 @@ function createRuntime(ctx, config) {
639
1183
  }
640
1184
  const now = Date.now()
641
1185
  const busy = busyAgents()
1186
+ // A goal-driven session blocks the restart forever: park its goal BEFORE the
1187
+ // gate decides that it is blocked, so the next poll sees an idle session.
1188
+ if (busy.length > 0) {
1189
+ try {
1190
+ disarmBlockingGoals(busy)
1191
+ } catch (error) {
1192
+ log.warn('could not disarm the goals of the blocking sessions:', String(error))
1193
+ }
1194
+ }
642
1195
  const decision = restartDecision(state, {
643
1196
  anyAgentRunning: busy.length > 0,
644
1197
  busyAgents: busy,
@@ -693,6 +1246,7 @@ function createRuntime(ctx, config) {
693
1246
  stateFile,
694
1247
  log,
695
1248
  persist,
1249
+ toolErrors,
696
1250
  startPolling,
697
1251
  stopPolling,
698
1252
  tick,
@@ -707,7 +1261,49 @@ function createRuntime(ctx, config) {
707
1261
  busyAgents,
708
1262
  gate,
709
1263
  sessionTitle,
1264
+ disarmGoal,
1265
+ disarmBlockingGoals,
1266
+ disarmedGoalRecords,
1267
+ parkedGoalRecords,
1268
+ goalFor,
1269
+ handoffRecord: (sessionId) => handoffRecord(state, sessionId),
1270
+ overflowFor,
1271
+ handoff,
710
1272
  boot,
1273
+ /**
1274
+ * Re-run the boot recovery for the still-pending sessions, right now.
1275
+ *
1276
+ * This is the retry path behind `continuum_recover_now` and the POST
1277
+ * `/recover` route: a session whose agent was not live yet, or whose
1278
+ * `goals.resume` failed, can be retried without waiting for the next boot.
1279
+ * Every attempt reports its own action/detail, exactly like the boot pass.
1280
+ *
1281
+ * @param {string} [sessionId] one session, or every pending session
1282
+ * @returns {Promise<object[]>} one outcome per attempted session
1283
+ */
1284
+ async recoverNow(sessionId) {
1285
+ const target = typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : null
1286
+ const pending = recoverableSessions(state, {}).filter((entry) => (
1287
+ target === null ? true : String(entry.sessionId) === target
1288
+ ))
1289
+ if (pending.length === 0 && target !== null) {
1290
+ return [{ sessionId: target, action: 'nothing', detail: 'no-pending-recovery' }]
1291
+ }
1292
+ const results = []
1293
+ for (const entry of pending) {
1294
+ try {
1295
+ results.push(await recoverSession(ctx, runtime, entry, config))
1296
+ } catch (error) {
1297
+ const detail = describeError(error)
1298
+ setRecoveryOutcome(state, entry.sessionId, { action: 'prompt-failed', detail })
1299
+ results.push({ sessionId: entry.sessionId, action: 'prompt-failed', detail })
1300
+ }
1301
+ }
1302
+ try {
1303
+ runtime.persist()
1304
+ } catch {}
1305
+ return results
1306
+ },
711
1307
  }
712
1308
  return runtime
713
1309
  }
@@ -721,9 +1317,10 @@ function createRuntime(ctx, config) {
721
1317
  * message: it is NOT human authority.
722
1318
  *
723
1319
  * @param {string} text
1320
+ * @param {{ summary?: string }} [options] one-line label of what this notice is
724
1321
  * @returns {object} a frozen user message
725
1322
  */
726
- function createContinuationMessage(text) {
1323
+ function createContinuationMessage(text, { summary } = {}) {
727
1324
  return Object.freeze({
728
1325
  id: crypto.randomUUID(),
729
1326
  role: 'user',
@@ -731,7 +1328,9 @@ function createContinuationMessage(text) {
731
1328
  source: Object.freeze({
732
1329
  kind: 'continuum',
733
1330
  form: 'notice',
734
- summary: 'automatic continuation after a DeepSeek Harness restart',
1331
+ summary: typeof summary === 'string' && summary.trim() !== ''
1332
+ ? summary.trim()
1333
+ : 'automatic continuation after a DeepSeek Harness restart',
735
1334
  }),
736
1335
  })
737
1336
  }
@@ -759,6 +1358,24 @@ function describeError(value) {
759
1358
  }
760
1359
  }
761
1360
 
1361
+ /**
1362
+ * Record the self-reported outcome of one recovery attempt for a session.
1363
+ *
1364
+ * Every return path of `recoverSession` calls this: a recovery that did nothing
1365
+ * must say WHY it did nothing, otherwise a stuck session is invisible in
1366
+ * `continuum_status` and in the `?state=1` route.
1367
+ *
1368
+ * @param {object} state
1369
+ * @param {string} sessionId
1370
+ * @param {string} action short action label (`resume-disarmed-goal`, …)
1371
+ * @param {string|null} [detail] short human-readable reason
1372
+ */
1373
+ function noteRecovery(state, sessionId, action, detail = null) {
1374
+ try {
1375
+ setRecoveryOutcome(state, sessionId, { action, detail: detail == null ? null : String(detail) })
1376
+ } catch {}
1377
+ }
1378
+
762
1379
  /**
763
1380
  * Decide what a session needs after a restart and do exactly that.
764
1381
  * At-most-once per boot: the resume attempt is persisted before any side effect.
@@ -775,9 +1392,61 @@ async function recoverSession(ctx, runtime, entry, config) {
775
1392
  entry.pendingResume = false
776
1393
  entry.interrupted = false
777
1394
  entry.lastResumeError = entry.lastResumeError || 'resume attempts exhausted'
1395
+ noteRecovery(state, sessionId, 'nothing', 'resume-attempts-exhausted')
778
1396
  runtime.persist()
779
1397
  log.info(`giving up on session ${sessionId}: resume attempts exhausted`, { lastResumeError: entry.lastResumeError })
780
- return { sessionId, action: 'skip', error: entry.lastResumeError }
1398
+ return { sessionId, action: 'nothing', detail: 'resume-attempts-exhausted', error: entry.lastResumeError }
1399
+ }
1400
+
1401
+ // CONTEXT-OVERFLOW GUARD. A session whose conversation outgrew the model window
1402
+ // can neither continue nor be compacted (the compaction request needs the same
1403
+ // oversized context): every request to it fails with "context window exceeds
1404
+ // limit". Re-arming its goal or queueing a continuation message into it can
1405
+ // therefore only fail, so the guard runs BEFORE the goal re-arm and before the
1406
+ // disarm decision. Evidence: a verdict an earlier pass already recorded, or the
1407
+ // LIVE session (the newest assistant message's `usage.inputTokens` versus the
1408
+ // session's `contextWindow`, or a recent context-window failure). Unknown
1409
+ // numbers never block the normal path.
1410
+ const recordedOverflow = entry.contextOverflow && entry.contextOverflow.over === true
1411
+ ? entry.contextOverflow
1412
+ : null
1413
+ let overflow = null
1414
+ if (recordedOverflow !== null) {
1415
+ overflow = {
1416
+ over: true,
1417
+ inputTokens: Number.isFinite(recordedOverflow.inputTokens) ? recordedOverflow.inputTokens : null,
1418
+ contextWindow: Number.isFinite(recordedOverflow.contextWindow) ? recordedOverflow.contextWindow : null,
1419
+ detail: typeof recordedOverflow.detail === 'string' && recordedOverflow.detail !== ''
1420
+ ? recordedOverflow.detail
1421
+ : 'the session is over the model context window (recorded by an earlier recovery)',
1422
+ }
1423
+ } else {
1424
+ try {
1425
+ overflow = runtime.overflowFor(sessionId)
1426
+ } catch {
1427
+ overflow = null
1428
+ }
1429
+ }
1430
+ if (overflow && overflow.over === true) {
1431
+ const detail = typeof overflow.detail === 'string' && overflow.detail !== ''
1432
+ ? overflow.detail
1433
+ : 'the session is over the model context window'
1434
+ setContextOverflow(state, sessionId, {
1435
+ over: true,
1436
+ inputTokens: overflow.inputTokens,
1437
+ contextWindow: overflow.contextWindow,
1438
+ detail,
1439
+ detectedAt: Date.now(),
1440
+ })
1441
+ // Out of the automatic resume path for good: the goal is NOT re-armed, no
1442
+ // continuation message is queued, and `recoverableSessions` skips an
1443
+ // over-limit entry from now on. The recovery attempt is still reported.
1444
+ entry.pendingResume = false
1445
+ entry.interrupted = false
1446
+ noteRecovery(state, sessionId, 'context-overflow', detail)
1447
+ runtime.persist()
1448
+ log.warn(`session ${sessionId} is over the model context window and is NOT re-armed: hand it off with continuum_handoff`, { detail })
1449
+ return { sessionId, action: 'context-overflow', detail, overContextLimit: true }
781
1450
  }
782
1451
 
783
1452
  // Persist the attempt BEFORE any side effect, so a crash cannot double-resume and
@@ -797,7 +1466,9 @@ async function recoverSession(ctx, runtime, entry, config) {
797
1466
  const controller = ctx.get('sessionController')
798
1467
  if (!controller || typeof controller.resolveAgent !== 'function') {
799
1468
  log.warn('sessionController is unavailable; cannot resume', sessionId)
800
- return { sessionId, action: 'unavailable' }
1469
+ noteRecovery(state, sessionId, 'session-controller-unavailable', 'sessionController.resolveAgent is unavailable')
1470
+ runtime.persist()
1471
+ return { sessionId, action: 'session-controller-unavailable', detail: 'sessionController.resolveAgent is unavailable' }
801
1472
  }
802
1473
  // A freshly reloaded profile can expose the live agent a moment after the
803
1474
  // state file was written, so a THROWN error is retried inside the same boot
@@ -827,51 +1498,112 @@ async function recoverSession(ctx, runtime, entry, config) {
827
1498
  }
828
1499
  }
829
1500
  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.
1501
+ // Keep pendingResume: the session stays recoverable on a later boot (or a
1502
+ // later continuum_recover_now retry), but the failure is visible instead of
1503
+ // vanishing.
832
1504
  entry.lastResumeError = lastError
1505
+ noteRecovery(state, sessionId, 'agent-not-live', lastError)
833
1506
  runtime.persist()
834
1507
  log.warn(`could not resume ${sessionId}:`, lastError)
835
- return { sessionId, action: 'failed', error: lastError }
1508
+ return { sessionId, action: 'agent-not-live', detail: lastError, error: lastError }
836
1509
  }
837
1510
  agent = result && result.agent
838
1511
  }
839
1512
  if (!agent) {
840
1513
  entry.lastResumeError = 'resolveAgent returned no agent'
1514
+ noteRecovery(state, sessionId, 'agent-not-live', entry.lastResumeError)
841
1515
  runtime.persist()
842
- return { sessionId, action: 'failed', error: entry.lastResumeError }
1516
+ return { sessionId, action: 'agent-not-live', detail: entry.lastResumeError, error: entry.lastResumeError }
843
1517
  }
844
1518
 
845
1519
  let goal
1520
+ let goalLookupError = null
846
1521
  try {
847
1522
  goal = ctx.goals.get(agent)
848
1523
  } catch (error) {
849
- log.warn(`goals.get failed for ${sessionId}:`, String(error))
1524
+ goalLookupError = describeError(error)
1525
+ log.warn(`goals.get failed for ${sessionId}:`, goalLookupError)
850
1526
  }
1527
+ if (!goal || typeof goal !== 'object') {
1528
+ goal = undefined
1529
+ if (goalLookupError === null) goalLookupError = 'goals.get returned no goal'
1530
+ }
1531
+
1532
+ const disarmed = disarmedRecord(state, sessionId)
1533
+ const legacyParked = parkedRecord(state, sessionId)
1534
+ let resumeError = null
1535
+ let resumeAction = null
851
1536
 
1537
+ // (1) The goal THIS plugin disarmed before the restart: it is still durably
1538
+ // `active` and only its activation is `disarmed`, so re-arming it with its
1539
+ // current revision lets the goal round driver continue the work.
1540
+ if (goal && disarmed && goal.phase === 'active' && goal.activation === 'disarmed' && disarmed.goalId === goal.id) {
1541
+ resumeAction = 'resume-disarmed-goal'
1542
+ }
1543
+
1544
+ // (2) The goal the PREVIOUS (pause-based) build PAUSED: we paused it, so we may
1545
+ // resume it. A goal the USER paused has no record and must stay paused.
1546
+ if (resumeAction === null && goal && legacyParked && goal.phase === 'paused' && legacyParked.goalId === goal.id) {
1547
+ resumeAction = 'resume-paused-goal'
1548
+ }
1549
+
1550
+ // (3) The already proven boot path: an active goal whose continuation authority
1551
+ // is gone is re-armed (the host may have disarmed it on shutdown).
852
1552
  if (
853
- goal
1553
+ resumeAction === null
1554
+ && goal
854
1555
  && goal.phase === 'active'
855
1556
  && goal.activation === 'disarmed'
856
1557
  && goal.roundsStarted < goal.maxGoalRounds
857
1558
  ) {
1559
+ resumeAction = 'rearm-goal'
1560
+ }
1561
+
1562
+ if (resumeAction !== null) {
858
1563
  try {
859
1564
  ctx.goals.resume(agent, { id: goal.id, revision: goal.revision })
1565
+ // The goal is armed again: stamp the record(s) of this session with the
1566
+ // resume timestamp, so the status shows the goal as resumed (the record is
1567
+ // reporting only — it never decides whether a goal may be disarmed).
1568
+ stampResumedGoal(state, sessionId, { resumedAt: Date.now() })
860
1569
  entry.pendingResume = false
861
1570
  entry.running = false
862
1571
  entry.interrupted = false
863
1572
  entry.lastResumeError = null
864
1573
  entry.goal = { goalId: goal.id, goalRevision: goal.revision + 1, phase: 'active' }
1574
+ noteRecovery(state, sessionId, resumeAction, `goal ${goal.id} is armed again`)
865
1575
  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 }
1576
+ log.info(`${resumeAction}: goal ${goal.id} of session ${sessionId} is armed again; the goal round driver continues the work`)
1577
+ return { sessionId, action: resumeAction, goalId: goal.id, detail: `goal ${goal.id} is armed again` }
868
1578
  } catch (error) {
869
- log.warn(`goals.resume failed for ${sessionId}:`, String(error))
1579
+ // Stale revision or an exhausted round budget: fall back to the
1580
+ // continuation message and KEEP the record, so a later boot (or a manual
1581
+ // continuum_recover_now retry) can try to resume the goal again.
1582
+ resumeError = describeError(error)
1583
+ entry.lastResumeError = resumeError
1584
+ log.warn(`could not resume goal ${goal.id} of ${sessionId} (${resumeAction}):`, resumeError)
870
1585
  }
871
1586
  }
872
1587
 
873
1588
  if (!entry.pendingResume && !entry.interrupted) {
874
- return { sessionId, action: 'nothing' }
1589
+ if (resumeError) {
1590
+ entry.lastResumeError = resumeError
1591
+ noteRecovery(state, sessionId, resumeAction || 'nothing', `resume failed: ${resumeError}`)
1592
+ runtime.persist()
1593
+ return { sessionId, action: resumeAction || 'nothing', detail: `resume failed: ${resumeError}`, error: resumeError }
1594
+ }
1595
+ noteRecovery(state, sessionId, resumeAction || 'nothing', resumeAction ? 'goal handled, session not pending' : 'session-not-pending')
1596
+ runtime.persist()
1597
+ return { sessionId, action: resumeAction || 'nothing', detail: 'session-not-pending' }
1598
+ }
1599
+
1600
+ // A failed goal lookup is not a reason to skip the session: the continuation
1601
+ // message still makes progress, and the reason is recorded for the operator.
1602
+ let pendingAction = 'prompt-continuation'
1603
+ let pendingDetail = resumeError ? `goal resume failed: ${resumeError}` : 'continuation message queued'
1604
+ if (goal === undefined) {
1605
+ pendingAction = 'goal-lookup-failed'
1606
+ pendingDetail = `${goalLookupError || 'goals.get returned no goal'}; continuation message queued`
875
1607
  }
876
1608
 
877
1609
  // Inject exactly one continuation message through the live agent's inbox. The
@@ -879,7 +1611,9 @@ async function recoverSession(ctx, runtime, entry, config) {
879
1611
  // signature is `prompt(request, signal)` and it hardcodes a human `user` source.
880
1612
  if (typeof agent.followup !== 'function') {
881
1613
  log.warn('the resumed agent exposes no followup(); cannot inject the continuation prompt')
882
- return { sessionId, action: 'unavailable' }
1614
+ noteRecovery(state, sessionId, 'prompt-failed', 'the agent exposes no followup()')
1615
+ runtime.persist()
1616
+ return { sessionId, action: 'prompt-failed', detail: 'the agent exposes no followup()' }
883
1617
  }
884
1618
  const text = continuationPrompt(entry, {
885
1619
  reason: (Array.isArray(entry.reasons) && entry.reasons[entry.reasons.length - 1]) || 'host restart',
@@ -888,16 +1622,20 @@ async function recoverSession(ctx, runtime, entry, config) {
888
1622
  try {
889
1623
  agent.followup(createContinuationMessage(text))
890
1624
  } catch (error) {
891
- log.warn(`continuation message rejected for ${sessionId}:`, String(error))
892
- return { sessionId, action: 'prompt-failed' }
1625
+ const detail = describeError(error)
1626
+ log.warn(`continuation message rejected for ${sessionId}:`, detail)
1627
+ noteRecovery(state, sessionId, 'prompt-failed', detail)
1628
+ runtime.persist()
1629
+ return { sessionId, action: 'prompt-failed', detail }
893
1630
  }
894
1631
  entry.pendingResume = false
895
1632
  entry.running = false
896
1633
  entry.interrupted = false
897
- entry.lastResumeError = null
1634
+ entry.lastResumeError = resumeError
1635
+ noteRecovery(state, sessionId, pendingAction, pendingDetail)
898
1636
  runtime.persist()
899
1637
  log.info(`queued a continuation message for session ${sessionId}`)
900
- return { sessionId, action: 'prompt-continuation' }
1638
+ return { sessionId, action: pendingAction, detail: pendingDetail }
901
1639
  }
902
1640
 
903
1641
  /** The boot recovery pass. Runs once per host boot, bounded and idempotent. */
@@ -997,16 +1735,46 @@ function persistSafely(runtime) {
997
1735
  function registerTools(ctx, runtime, config) {
998
1736
  const log = runtime.log
999
1737
 
1738
+ /**
1739
+ * Register one tool.
1740
+ *
1741
+ * The disposer returned by `ctx.tools.register` is handed to `ctx.effect`, so
1742
+ * the HOST owns it: when the plugin row is disabled (or the plugin is
1743
+ * hot-reloaded) the fiber disposes the effect and the tool is unregistered
1744
+ * together with the HTTP routes. Registering it bare would leave the tools of
1745
+ * the OLD instance registered (and the new instance's registration would fail
1746
+ * as a duplicate), so the host could serve the tools of an instance whose
1747
+ * routes are already gone.
1748
+ *
1749
+ * A failed registration is recorded in `runtime.toolErrors` and logged; it is
1750
+ * never thrown, so one broken tool can never stop the plugin from loading.
1751
+ */
1000
1752
  const tool = (definition) => {
1753
+ const name = definition && definition.name ? String(definition.name) : null
1001
1754
  try {
1002
- ctx.tools.register(definition)
1755
+ ctx.effect(
1756
+ () => ctx.tools.register(definition),
1757
+ `dsh-autorestart: tool ${name ?? 'unknown'}`,
1758
+ )
1003
1759
  } catch (error) {
1004
- log.warn(`could not register tool ${definition?.name}:`, String(error))
1760
+ runtime.toolErrors.push({ tool: name, error: describeError(error) })
1761
+ log.warn(`could not register tool ${name}:`, String(error))
1005
1762
  }
1006
1763
  }
1007
1764
 
1008
1765
  const textOutput = (schema, render) => ({ schema, render })
1009
1766
 
1767
+ /**
1768
+ * The blocking sessions with the reason each one blocks: a session that is
1769
+ * held by an armed goal reports `goal-active`, a goal whose round budget is
1770
+ * used up reports `goal-exhausted`, and the original busy reason is kept as
1771
+ * `busyReason`.
1772
+ */
1773
+ const blockingDetails = (blockedBy) => goalAwareBlockingDetails(runtime, blockedBy)
1774
+
1775
+ /** The goals this plugin parked, with the outcome of the resume. */
1776
+ const parkedGoalDetails = () => parkedGoalList(runtime)
1777
+
1010
1778
  tool({
1011
1779
  name: 'continuum_restart',
1012
1780
  description:
@@ -1016,7 +1784,12 @@ function registerTools(ctx, runtime, config) {
1016
1784
  + 'forceRestartWhenBusy is enabled, otherwise the restart keeps waiting for the other conversations. '
1017
1785
  + 'Other conversations are never killed mid-turn: the detached helper re-checks this idle gate '
1018
1786
  + '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 '
1787
+ + 'plugin resumes this session and continues the work automatically. A goal-driven session is '
1788
+ + 'DISARMED before the restart (pass parkGoals:false to turn that off): disarming only removes the '
1789
+ + 'process-local continuation authority, so the round that is running right now still finishes normally '
1790
+ + '(no turn is cancelled and nothing is lost), no new round starts, and after the restart the goal is '
1791
+ + 're-armed and continues. A goal whose round budget is spent cannot be continued and is reported '
1792
+ + 'instead. Write your final message '
1020
1793
  + 'in this turn, then stop calling tools — the restart fires as soon as the turn goes idle. '
1021
1794
  + 'Use it only when a restart is genuinely required (for example to load an installed plugin update).',
1022
1795
  parameters: {
@@ -1026,6 +1799,7 @@ function registerTools(ctx, runtime, config) {
1026
1799
  reason: { type: 'string', description: 'Why the restart is needed; recorded in the checkpoint and shown in the log.' },
1027
1800
  resume: { type: 'boolean', description: 'Resume this session after the restart (default true).' },
1028
1801
  defer_seconds: { type: 'number', description: 'Optional extra seconds to wait before the first idle check.' },
1802
+ 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
1803
  },
1030
1804
  },
1031
1805
  output: textOutput(
@@ -1049,10 +1823,12 @@ function registerTools(ctx, runtime, config) {
1049
1823
  const agent = exec && exec.agent
1050
1824
  const sessionId = agent && agent.id ? String(agent.id) : undefined
1051
1825
  const resume = args.resume !== false
1826
+ const parkGoals = args.parkGoals !== false
1052
1827
  requestRestart(state, {
1053
1828
  reason: typeof args.reason === 'string' && args.reason.trim() !== '' ? args.reason.trim() : 'agent requested restart',
1054
1829
  bySessionId: sessionId,
1055
1830
  resume,
1831
+ parkGoals,
1056
1832
  now,
1057
1833
  })
1058
1834
  if (sessionId && resume) {
@@ -1068,13 +1844,15 @@ function registerTools(ctx, runtime, config) {
1068
1844
  }
1069
1845
  runtime.persist()
1070
1846
  runtime.startPolling()
1071
- log.info('restart requested', { generation: state.generation, sessionId, resume })
1847
+ log.info('restart requested', { generation: state.generation, sessionId, resume, parkGoals })
1072
1848
  return {
1073
1849
  accepted: true,
1074
1850
  generation: state.generation,
1075
1851
  phase: state.phase,
1076
1852
  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.',
1853
+ note: parkGoals
1854
+ ? 'The restart runs at the next safe point (all agents idle). Keep this reply short; the session resumes afterwards.'
1855
+ : '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
1856
  }
1079
1857
  },
1080
1858
  })
@@ -1083,8 +1861,12 @@ function registerTools(ctx, runtime, config) {
1083
1861
  name: 'continuum_status',
1084
1862
  description:
1085
1863
  '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.',
1864
+ + 'checkpointed as interrupted or continuation-pending, why each blocking session blocks '
1865
+ + '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
1866
+ + 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
1867
+ + 'whether a session was handed off to a fresh session (target session id and time) or is over '
1868
+ + 'the model context window (such a session is never auto-resumed), the '
1869
+ + 'resolved state-file path, and the detected restart environment. Read-only.',
1088
1870
  parameters: {
1089
1871
  type: 'object',
1090
1872
  additionalProperties: false,
@@ -1107,6 +1889,9 @@ function registerTools(ctx, runtime, config) {
1107
1889
  gateAllow: { type: 'boolean' },
1108
1890
  quietForMs: { type: 'number' },
1109
1891
  blockedBy: { type: 'array', items: { type: 'object' } },
1892
+ parkedGoals: { type: 'array', items: { type: 'object' } },
1893
+ recovery: { type: 'array', items: { type: 'object' } },
1894
+ handoffs: { type: 'array', items: { type: 'object' } },
1110
1895
  environment: { type: 'object' },
1111
1896
  sessions: { type: 'array', items: { type: 'object' } },
1112
1897
  },
@@ -1126,7 +1911,10 @@ function registerTools(ctx, runtime, config) {
1126
1911
  pendingRestart: state.restart !== null,
1127
1912
  gateAllow: verdict.allow,
1128
1913
  quietForMs: Math.round(verdict.quietForMs),
1129
- blockedBy: verdict.blockedBy,
1914
+ blockedBy: blockingDetails(verdict.blockedBy),
1915
+ parkedGoals: parkedGoalDetails(),
1916
+ recovery: recoveryList(state),
1917
+ handoffs: diagnosticSessions(state),
1130
1918
  environment: {
1131
1919
  platform: runtime.env.platform,
1132
1920
  isDesktopApp: runtime.env.isDesktopApp,
@@ -1137,17 +1925,165 @@ function registerTools(ctx, runtime, config) {
1137
1925
  },
1138
1926
  ...(includeSessions
1139
1927
  ? {
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
- })),
1928
+ sessions: [
1929
+ ...recoverableSessions(state, { limit: 50 }).map((entry) => ({
1930
+ ...entry,
1931
+ // Diagnostics: the last resolveAgent failure for this session (null when healthy).
1932
+ lastResumeError: entry.lastResumeError ?? null,
1933
+ handedOff: false,
1934
+ overContextLimit: false,
1935
+ })),
1936
+ // A handed-off or over-limit session is out of the automatic resume
1937
+ // path, so it is listed here instead of vanishing from the report.
1938
+ ...diagnosticSessions(state, { limit: 50 }),
1939
+ ],
1145
1940
  }
1146
1941
  : {}),
1147
1942
  }
1148
1943
  },
1149
1944
  })
1150
1945
 
1946
+ tool({
1947
+ name: 'continuum_park_goal',
1948
+ description:
1949
+ 'Disarm the durable goal of a session so a pending restart can proceed: a goal-driven '
1950
+ + 'session keeps its own agent busy forever (the goal round driver starts the next round '
1951
+ + 'immediately), so the restart would wait forever behind it. Disarming only removes the '
1952
+ + 'process-local continuation authority: the goal stays active, the round that is running '
1953
+ + 'right now still finishes normally (no turn is cancelled and nothing is lost), and no new '
1954
+ + 'round starts. After the next restart the goal is re-armed automatically and continues. '
1955
+ + 'Without a sessionId the goal of the calling session is disarmed. A goal whose round budget '
1956
+ + 'is spent can never be continued, so it is reported instead of disarmed.',
1957
+ parameters: {
1958
+ type: 'object',
1959
+ additionalProperties: false,
1960
+ properties: {
1961
+ sessionId: { type: 'string', description: 'The session whose goal should be disarmed (default: the calling session).' },
1962
+ },
1963
+ },
1964
+ output: textOutput(
1965
+ {
1966
+ type: 'object',
1967
+ additionalProperties: false,
1968
+ required: ['ok', 'disarmed'],
1969
+ properties: {
1970
+ ok: { type: 'boolean' },
1971
+ disarmed: { type: 'boolean' },
1972
+ sessionId: { type: 'string' },
1973
+ goalId: { type: 'string' },
1974
+ reason: { type: 'string' },
1975
+ error: { type: 'string' },
1976
+ },
1977
+ },
1978
+ (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
1979
+ ),
1980
+ execute(args = {}, exec) {
1981
+ const agent = exec && exec.agent
1982
+ const callerId = agent && agent.id ? String(agent.id) : undefined
1983
+ const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
1984
+ const sessionId = requested !== '' ? requested : callerId
1985
+ if (!sessionId) {
1986
+ return { ok: false, disarmed: false, reason: 'no-session' }
1987
+ }
1988
+ const result = runtime.disarmGoal(sessionId, sessionId === callerId ? agent : undefined)
1989
+ const payload = {
1990
+ ok: true,
1991
+ disarmed: result.disarmed === true,
1992
+ sessionId,
1993
+ reason: result.reason,
1994
+ }
1995
+ if (result.goalId !== undefined && result.goalId !== null) payload.goalId = String(result.goalId)
1996
+ if (result.error) payload.error = String(result.error)
1997
+ return payload
1998
+ },
1999
+ })
2000
+
2001
+ tool({
2002
+ name: 'continuum_recover_now',
2003
+ description:
2004
+ 'Retry the post-restart recovery RIGHT NOW instead of waiting for the next restart. Use it when '
2005
+ + 'a session is still stuck after a restart: its goal has not been re-armed yet, or its agent was '
2006
+ + 'not live when the boot recovery ran. Optional sessionId retries one session; without one every '
2007
+ + 'pending session is retried. Returns, per session, the action that was taken (for example '
2008
+ + '"resume-disarmed-goal", "resume-paused-goal", "rearm-goal", "prompt-continuation", '
2009
+ + '"agent-not-live", "goal-lookup-failed", "nothing") and a short detail.',
2010
+ parameters: {
2011
+ type: 'object',
2012
+ additionalProperties: false,
2013
+ properties: {
2014
+ sessionId: { type: 'string', description: 'Retry only this session (default: every pending session).' },
2015
+ },
2016
+ },
2017
+ output: textOutput(
2018
+ {
2019
+ type: 'object',
2020
+ additionalProperties: false,
2021
+ required: ['ok', 'results'],
2022
+ properties: {
2023
+ ok: { type: 'boolean' },
2024
+ results: { type: 'array', items: { type: 'object' } },
2025
+ },
2026
+ },
2027
+ (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
2028
+ ),
2029
+ async execute(args = {}) {
2030
+ const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
2031
+ const results = await runtime.recoverNow(requested !== '' ? requested : undefined)
2032
+ return { ok: true, results }
2033
+ },
2034
+ })
2035
+
2036
+ tool({
2037
+ name: 'continuum_handoff',
2038
+ description:
2039
+ 'Hand a session whose conversation exceeded the model context window off to a FRESH session. '
2040
+ + 'Such a session is dead: every request to it fails with "context window exceeds limit", and '
2041
+ + 'compacting it needs the same oversized context. The handoff carries the goal objective, the '
2042
+ + 'recorded checkpoint reasons and a bounded tail of the conversation (default 20000 characters, '
2043
+ + '`maxChars`) into a new live session in the same working directory, marks the old session as '
2044
+ + 'handed off (so it is never auto-resumed again) and disarms its goal. The old session and its '
2045
+ + 'conversation are kept, untouched. Without `sessionId` the calling session is handed off. It '
2046
+ + 'reports the new session id, how many characters were carried, and what the human should do.',
2047
+ parameters: {
2048
+ type: 'object',
2049
+ additionalProperties: false,
2050
+ properties: {
2051
+ sessionId: { type: 'string', description: 'The over-limit session to hand off (default: the calling session).' },
2052
+ maxChars: { type: 'number', description: 'Character budget of the carried conversation tail (default 20000).' },
2053
+ },
2054
+ },
2055
+ output: textOutput(
2056
+ {
2057
+ type: 'object',
2058
+ additionalProperties: false,
2059
+ required: ['ok'],
2060
+ properties: {
2061
+ ok: { type: 'boolean' },
2062
+ sessionId: { type: 'string' },
2063
+ sourceSessionId: { type: 'string' },
2064
+ carriedChars: { type: 'number' },
2065
+ carriedTailChars: { type: 'number' },
2066
+ disarmedGoal: { type: 'boolean' },
2067
+ error: { type: 'string' },
2068
+ detail: { type: 'string' },
2069
+ instruction: { type: 'string' },
2070
+ },
2071
+ },
2072
+ (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
2073
+ ),
2074
+ async execute(args = {}, exec) {
2075
+ const agent = exec && exec.agent
2076
+ const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
2077
+ return runtime.handoff({
2078
+ sessionId: requested !== '' ? requested : undefined,
2079
+ maxChars: typeof args.maxChars === 'number' && Number.isFinite(args.maxChars) && args.maxChars > 0
2080
+ ? args.maxChars
2081
+ : undefined,
2082
+ callerAgent: agent,
2083
+ })
2084
+ },
2085
+ })
2086
+
1151
2087
  tool({
1152
2088
  name: 'continuum_cancel_restart',
1153
2089
  description: 'Cancel a restart previously requested through continuum_restart. Does nothing when no restart is pending.',
@@ -1219,9 +2155,11 @@ function injectPageHeartbeat(html, bootId, routePath) {
1219
2155
  const AUTORESTART_PROMPT = [
1220
2156
  '## Restart policy (dsh-autorestart)',
1221
2157
  '- 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.',
2158
+ '- 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.',
2159
+ '- 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
2160
  '- Persist anything the next turn needs before restarting (workspace files, `todo_write`, goal state); do not rely on in-memory state.',
1224
2161
  '- Inspect the lifecycle with `continuum_status`; cancel a pending restart with `continuum_cancel_restart`.',
2162
+ '- 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
2163
  '- 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
2164
  ].join('\n')
1227
2165
 
@@ -1354,13 +2292,18 @@ function registerManualRestartGuard(ctx, runtime, config) {
1354
2292
  return
1355
2293
  }
1356
2294
  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
- })
2295
+ // The disposer is owned by the fiber (like the tools and the routes), so a
2296
+ // re-applied plugin never leaves a guard of an old instance behind.
2297
+ ctx.effect(
2298
+ () => tools.guard((exec) => {
2299
+ const hit = detectManualRestart(exec, { webPort: runtime.webPort ?? 0 })
2300
+ if (hit === null) return undefined
2301
+ runtime.manualRestartBlocks += 1
2302
+ runtime.log.warn(`blocked a manual DSH restart attempt (${hit}) through tool "${exec.name}"; steering the agent to continuum_restart`)
2303
+ return MANUAL_RESTART_DENIAL
2304
+ }),
2305
+ 'dsh-autorestart: manual-restart guard',
2306
+ )
1364
2307
  } catch (error) {
1365
2308
  runtime.log.warn('could not register the manual-restart guard:', String(error))
1366
2309
  }
@@ -1404,6 +2347,273 @@ function admittedByConnection(ctx, req) {
1404
2347
  }
1405
2348
  }
1406
2349
 
2350
+ /**
2351
+ * Read a small JSON request body. Never throws: an unreadable or malformed
2352
+ * body simply becomes an empty object.
2353
+ *
2354
+ * @param {any} req
2355
+ * @param {number} [maxBytes]
2356
+ * @returns {Promise<object>}
2357
+ */
2358
+ function readJsonBody(req, maxBytes = 64 * 1024) {
2359
+ return new Promise((resolve) => {
2360
+ if (!req || typeof req.on !== 'function') {
2361
+ resolve({})
2362
+ return
2363
+ }
2364
+ let size = 0
2365
+ let settled = false
2366
+ const chunks = []
2367
+ const finish = (text) => {
2368
+ if (settled) return
2369
+ settled = true
2370
+ try {
2371
+ const raw = String(text || '').trim()
2372
+ if (raw === '') {
2373
+ resolve({})
2374
+ return
2375
+ }
2376
+ const parsed = JSON.parse(raw)
2377
+ resolve(parsed && typeof parsed === 'object' ? parsed : {})
2378
+ } catch {
2379
+ resolve({})
2380
+ }
2381
+ }
2382
+ req.on('data', (chunk) => {
2383
+ size += typeof chunk === 'string' ? chunk.length : (chunk && chunk.length ? chunk.length : 0)
2384
+ if (size > maxBytes) {
2385
+ finish('')
2386
+ return
2387
+ }
2388
+ chunks.push(typeof chunk === 'string' ? chunk : String(chunk))
2389
+ })
2390
+ req.on('end', () => finish(chunks.join('')))
2391
+ req.on('error', () => finish(''))
2392
+ })
2393
+ }
2394
+
2395
+ /**
2396
+ * The ids of the sessions that currently block a pending restart.
2397
+ *
2398
+ * @param {object} runtime
2399
+ * @returns {string[]}
2400
+ */
2401
+ function blockingSessionIds(runtime) {
2402
+ const ids = []
2403
+ try {
2404
+ const verdict = runtime.gate()
2405
+ const blockedBy = Array.isArray(verdict.blockedBy) ? verdict.blockedBy : []
2406
+ for (const entry of blockedBy) {
2407
+ if (!entry || typeof entry !== 'object') continue
2408
+ const id = entry.sessionId == null ? '' : String(entry.sessionId)
2409
+ if (id && !ids.includes(id)) ids.push(id)
2410
+ }
2411
+ } catch {}
2412
+ return ids
2413
+ }
2414
+
2415
+ /**
2416
+ * Build the POST route that disarms the goal of a session.
2417
+ *
2418
+ * It uses the same authentication rule as the health route (loopback OR a
2419
+ * request the connection service admits) and answers JSON only.
2420
+ */
2421
+ function buildParkRoute(ctx, runtime, config) {
2422
+ const routePath = resolveParkPath(config)
2423
+ return {
2424
+ kind: 'exact',
2425
+ path: routePath,
2426
+ handler: (req, res) => {
2427
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
2428
+ const loopback = remote === undefined
2429
+ || remote === '127.0.0.1'
2430
+ || remote === '::1'
2431
+ || remote === '::ffff:127.0.0.1'
2432
+ if (!loopback && !admittedByConnection(ctx, req)) {
2433
+ res.statusCode = 403
2434
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2435
+ res.end('{"ok":false,"error":"loopback-only"}')
2436
+ return
2437
+ }
2438
+ if (!req || req.method !== 'POST') {
2439
+ res.statusCode = 405
2440
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2441
+ res.setHeader('allow', 'POST')
2442
+ res.end('{"ok":false,"error":"method-not-allowed"}')
2443
+ return
2444
+ }
2445
+ void readJsonBody(req).then((payload) => {
2446
+ const body = payload && typeof payload === 'object' ? payload : {}
2447
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2448
+ const restart = body.restart === true
2449
+ // Without a session id every session that blocks a pending restart is
2450
+ // parked, so the restart can proceed.
2451
+ const targets = sessionId !== '' ? [sessionId] : blockingSessionIds(runtime)
2452
+ const disarmed = []
2453
+ const reported = []
2454
+ for (const target of targets) {
2455
+ try {
2456
+ const result = runtime.disarmGoal(target)
2457
+ if (result.disarmed) disarmed.push(result)
2458
+ else reported.push(result)
2459
+ } catch (error) {
2460
+ reported.push({ sessionId: target, disarmed: false, reason: 'disarm-failed', error: describeError(error) })
2461
+ }
2462
+ }
2463
+ if (restart && sessionId !== '') {
2464
+ // The session becomes a requester of the pending restart: the restart
2465
+ // proceeds and the session continues afterwards.
2466
+ requestRestart(runtime.state, {
2467
+ reason: 'disarm route requested a restart',
2468
+ bySessionId: sessionId,
2469
+ resume: true,
2470
+ now: Date.now(),
2471
+ })
2472
+ runtime.persist()
2473
+ runtime.startPolling()
2474
+ }
2475
+ res.statusCode = 200
2476
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2477
+ res.setHeader('cache-control', 'no-store')
2478
+ res.end(JSON.stringify({
2479
+ ok: true,
2480
+ disarmed,
2481
+ reported,
2482
+ pendingRestart: runtime.state.restart !== null,
2483
+ restartRequested: restart === true && sessionId !== '',
2484
+ }))
2485
+ }).catch((error) => {
2486
+ res.statusCode = 400
2487
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2488
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
2489
+ })
2490
+ },
2491
+ }
2492
+ }
2493
+
2494
+ /**
2495
+ * Build the POST `/recover` route: retry the post-restart recovery for the
2496
+ * still-pending sessions right now.
2497
+ *
2498
+ * It is the sibling of the `/park` route next to the health route and uses the
2499
+ * same authentication rule (loopback OR a request the connection service
2500
+ * admits). Body: `{ "sessionId"?: string }`; without one every pending session
2501
+ * is retried. The answer reports, per session, the recovery action and detail.
2502
+ */
2503
+ function buildRecoverRoute(ctx, runtime, config) {
2504
+ const routePath = resolveRecoverPath(config)
2505
+ return {
2506
+ kind: 'exact',
2507
+ path: routePath,
2508
+ handler: (req, res) => {
2509
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
2510
+ const loopback = remote === undefined
2511
+ || remote === '127.0.0.1'
2512
+ || remote === '::1'
2513
+ || remote === '::ffff:127.0.0.1'
2514
+ if (!loopback && !admittedByConnection(ctx, req)) {
2515
+ res.statusCode = 403
2516
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2517
+ res.end('{"ok":false,"error":"loopback-only"}')
2518
+ return
2519
+ }
2520
+ if (!req || req.method !== 'POST') {
2521
+ res.statusCode = 405
2522
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2523
+ res.setHeader('allow', 'POST')
2524
+ res.end('{"ok":false,"error":"method-not-allowed"}')
2525
+ return
2526
+ }
2527
+ void readJsonBody(req).then(async (payload) => {
2528
+ const body = payload && typeof payload === 'object' ? payload : {}
2529
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2530
+ const results = await runtime.recoverNow(sessionId !== '' ? sessionId : undefined)
2531
+ res.statusCode = 200
2532
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2533
+ res.setHeader('cache-control', 'no-store')
2534
+ res.end(JSON.stringify({ ok: true, results }))
2535
+ }).catch((error) => {
2536
+ res.statusCode = 400
2537
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2538
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
2539
+ })
2540
+ },
2541
+ }
2542
+ }
2543
+
2544
+ /**
2545
+ * Build the POST `/handoff` route: hand an over-limit session off to a fresh
2546
+ * session (the same operation as the `continuum_handoff` tool).
2547
+ *
2548
+ * It sits next to the health route and uses the same authentication rule
2549
+ * (loopback OR a request the connection service admits). Body:
2550
+ * `{ "sessionId"?: string, "maxChars"?: number }`; without a session id the
2551
+ * request is refused, because a route call has no calling session to default to.
2552
+ * The answer reports the new session id and the number of carried characters.
2553
+ */
2554
+ function buildHandoffRoute(ctx, runtime, config) {
2555
+ const routePath = resolveHandoffPath(config)
2556
+ return {
2557
+ kind: 'exact',
2558
+ path: routePath,
2559
+ handler: (req, res) => {
2560
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
2561
+ const loopback = remote === undefined
2562
+ || remote === '127.0.0.1'
2563
+ || remote === '::1'
2564
+ || remote === '::ffff:127.0.0.1'
2565
+ if (!loopback && !admittedByConnection(ctx, req)) {
2566
+ res.statusCode = 403
2567
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2568
+ res.end('{"ok":false,"error":"loopback-only"}')
2569
+ return
2570
+ }
2571
+ if (!req || req.method !== 'POST') {
2572
+ res.statusCode = 405
2573
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2574
+ res.setHeader('allow', 'POST')
2575
+ res.end('{"ok":false,"error":"method-not-allowed"}')
2576
+ return
2577
+ }
2578
+ void readJsonBody(req).then(async (payload) => {
2579
+ const body = payload && typeof payload === 'object' ? payload : {}
2580
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2581
+ if (sessionId === '') {
2582
+ res.statusCode = 400
2583
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2584
+ res.end(JSON.stringify({
2585
+ ok: false,
2586
+ error: 'no-session',
2587
+ instruction: 'Pass { "sessionId": "<id>" }: this route has no calling session to default to.',
2588
+ }))
2589
+ return
2590
+ }
2591
+ const result = await runtime.handoff({
2592
+ sessionId,
2593
+ maxChars: typeof body.maxChars === 'number' && Number.isFinite(body.maxChars) && body.maxChars > 0
2594
+ ? body.maxChars
2595
+ : undefined,
2596
+ })
2597
+ res.statusCode = result && result.ok === true ? 200 : 400
2598
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2599
+ res.setHeader('cache-control', 'no-store')
2600
+ res.end(JSON.stringify({
2601
+ ok: result && result.ok === true,
2602
+ sessionId: result ? result.sessionId : undefined,
2603
+ sourceSessionId: result ? result.sourceSessionId : sessionId,
2604
+ carriedChars: result ? result.carriedChars : undefined,
2605
+ error: result ? result.error : 'handoff-failed',
2606
+ instruction: result ? result.instruction : undefined,
2607
+ }))
2608
+ }).catch((error) => {
2609
+ res.statusCode = 400
2610
+ res.setHeader('content-type', 'application/json; charset=utf-8')
2611
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
2612
+ })
2613
+ },
2614
+ }
2615
+ }
2616
+
1407
2617
  /** Expose a tiny read-only health route for humans and verification scripts. */
1408
2618
  function registerHealthRoute(ctx, runtime, config) {
1409
2619
  const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
@@ -1490,8 +2700,10 @@ function registerHealthRoute(ctx, runtime, config) {
1490
2700
  const verdict = runtime.gate()
1491
2701
  // Best effort: label the blocking sessions with their titles when the
1492
2702
  // 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) => {
2703
+ // without one is sent exactly as the gate produced it. The goal
2704
+ // reason (`goal-active` / `goal-exhausted`) is attached as well, so
2705
+ // the client can tell a goal-driven blocker from a busy session.
2706
+ const blockedBy = goalAwareBlockingDetails(runtime, verdict.blockedBy).map((entry) => {
1495
2707
  if (!entry || typeof entry !== 'object') return entry
1496
2708
  const title = runtime.sessionTitle(entry.sessionId)
1497
2709
  return title ? { ...entry, title } : entry
@@ -1507,6 +2719,13 @@ function registerHealthRoute(ctx, runtime, config) {
1507
2719
  quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
1508
2720
  settleMs: config.settleMs,
1509
2721
  updatedAt: live.updatedAt,
2722
+ // The park route, so the client never has to guess the prefix, and
2723
+ // the goals this plugin already disarmed (they are re-armed after
2724
+ // the restart).
2725
+ parkUrl: resolveParkPath(config),
2726
+ recoverUrl: resolveRecoverPath(config),
2727
+ parkedGoals: parkedGoalList(runtime),
2728
+ recovery: recoveryList(live),
1510
2729
  })
1511
2730
  } catch {
1512
2731
  // The banner must never break the health route: fall back to "no restart".
@@ -1517,6 +2736,10 @@ function registerHealthRoute(ctx, runtime, config) {
1517
2736
  blockedBy: [],
1518
2737
  allowRestart: false,
1519
2738
  quietForMs: 0,
2739
+ parkUrl: resolveParkPath(config),
2740
+ recoverUrl: resolveRecoverPath(config),
2741
+ parkedGoals: [],
2742
+ recovery: [],
1520
2743
  })
1521
2744
  }
1522
2745
  res.statusCode = 200
@@ -1568,6 +2791,9 @@ function registerHealthRoute(ctx, runtime, config) {
1568
2791
  return false
1569
2792
  }
1570
2793
  if (!webServer || typeof webServer.register !== 'function') return false
2794
+ const parkRoute = buildParkRoute(ctx, runtime, config)
2795
+ const recoverRoute = buildRecoverRoute(ctx, runtime, config)
2796
+ const handoffRoute = buildHandoffRoute(ctx, runtime, config)
1571
2797
  try {
1572
2798
  const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
1573
2799
  const portMatch = /:(\d+)\/?$/.exec(origin)
@@ -1579,6 +2805,21 @@ function registerHealthRoute(ctx, runtime, config) {
1579
2805
  // A duplicate registration means another load of this plugin already owns it.
1580
2806
  runtime.log.warn('could not register the health route:', String(error))
1581
2807
  }
2808
+ try {
2809
+ target.effect(() => webServer.register(parkRoute), 'dsh-autorestart: park route')
2810
+ } catch (error) {
2811
+ runtime.log.warn('could not register the park route:', String(error))
2812
+ }
2813
+ try {
2814
+ target.effect(() => webServer.register(recoverRoute), 'dsh-autorestart: recover route')
2815
+ } catch (error) {
2816
+ runtime.log.warn('could not register the recover route:', String(error))
2817
+ }
2818
+ try {
2819
+ target.effect(() => webServer.register(handoffRoute), 'dsh-autorestart: handoff route')
2820
+ } catch (error) {
2821
+ runtime.log.warn('could not register the handoff route:', String(error))
2822
+ }
1582
2823
  if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
1583
2824
  try {
1584
2825
  target.effect(