dsh-autorestart 0.2.1 → 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/lib/lifecycle.js CHANGED
@@ -28,6 +28,11 @@ function isFiniteNumber(value) {
28
28
  function isDoneEntry(entry) {
29
29
  if (!isObject(entry)) return true;
30
30
  if (entry.done === true) return true;
31
+ // A handed-off session and an over-limit session are BOTH out of the
32
+ // automatic resume path for good: replaying that conversation can only fail
33
+ // with the same "context window exceeds limit" error.
34
+ if (isObject(entry.handoff)) return true;
35
+ if (isObject(entry.contextOverflow) && entry.contextOverflow.over === true) return true;
31
36
  const phase = isObject(entry.goal) && typeof entry.goal.phase === 'string' ? entry.goal.phase : null;
32
37
  return phase === 'done' || phase === 'complete' || phase === 'completed';
33
38
  }
@@ -120,6 +125,122 @@ export function restartGate(state, { busyAgents = [], lastActivityAt = 0, now =
120
125
  return { allow: true, reason: 'quiet', blockedBy: [], quietForMs };
121
126
  }
122
127
 
128
+ /**
129
+ * Why a session still keeps the restart waiting. `goal-active` is the only
130
+ * reason that can be cleared by DISARMING the goal (`ctx.goals.disarm`, which
131
+ * leaves the durable goal `active` but stops the round driver); every other
132
+ * reason is only REPORTED, because disarming would be wrong or useless:
133
+ * - `goal-paused` — somebody paused it (the model may not resume it),
134
+ * - `goal-blocked` / `goal-complete` / `goal-not-active` — not running work,
135
+ * - `goal-disarmed` — no continuation authority left to take away,
136
+ * - `goal-exhausted` — the round budget is spent, so it can never be resumed,
137
+ * - `goal-no-agent` — the session has no live agent to disarm through.
138
+ * - `goal-parked` — LEGACY: the old "this plugin already handled it" reason.
139
+ * It is never produced any more: the live goal state decides, not a record.
140
+ */
141
+ export const GOAL_BLOCK_REASONS = {
142
+ ACTIVE: 'goal-active',
143
+ PARKED: 'goal-parked',
144
+ EXHAUSTED: 'goal-exhausted',
145
+ PAUSED: 'goal-paused',
146
+ BLOCKED: 'goal-blocked',
147
+ COMPLETE: 'goal-complete',
148
+ DISARMED: 'goal-disarmed',
149
+ NOT_ACTIVE: 'goal-not-active',
150
+ NO_AGENT: 'goal-no-agent',
151
+ };
152
+
153
+ /**
154
+ * Decide which busy sessions must have their goal DISARMED before a pending
155
+ * restart can proceed.
156
+ *
157
+ * The decision is taken on the LIVE goal view and on nothing else. A goal may
158
+ * be disarmed ONLY when ALL of these hold:
159
+ * - the goal exists,
160
+ * - its phase is `active`,
161
+ * - its activation is `armed`,
162
+ * - `roundsStarted < maxGoalRounds` (a goal whose round budget is spent can
163
+ * never be resumed, so disarming it would strand the work).
164
+ *
165
+ * The checkpoint records (`disarmedGoals` / `parkedGoals`) are REPORTING ONLY:
166
+ * they never veto the decision. A goal that was resumed — and is therefore
167
+ * armed again — must be disarmable a second time, otherwise the restart waits
168
+ * for a goal nobody can disarm any more (the exact deadlock this feature
169
+ * exists to remove). The `disarmedGoals` / `parkedGoals` options are still
170
+ * accepted for backwards compatibility, but they are not consulted.
171
+ *
172
+ * Disarming is safe because it only removes process-local continuation
173
+ * authority: the durable goal stays `active`, the round that is running right
174
+ * now still finishes normally, and `ctx.goals.resume(...)` re-arms it after the
175
+ * restart.
176
+ *
177
+ * @param {{ sessionId: string, reason?: string }[]} busyAgents
178
+ * @param {{
179
+ * getGoal?: (sessionId: string) => object|undefined,
180
+ * disarmedGoals?: object,
181
+ * parkedGoals?: object,
182
+ * }} [options]
183
+ * @returns {{
184
+ * disarms: { sessionId: string, goalId: string|null, revision: number|null, reason: string }[],
185
+ * reported: { sessionId: string, goalId: string|null, reason: string }[],
186
+ * }}
187
+ */
188
+ export function selectGoalDisarms(busyAgents, { getGoal, disarmedGoals, parkedGoals } = {}) {
189
+ const disarms = [];
190
+ const reported = [];
191
+ const list = Array.isArray(busyAgents) ? busyAgents : [];
192
+ // The records are reporting material only: they must never veto a disarm.
193
+ void disarmedGoals;
194
+ void parkedGoals;
195
+ for (const entry of list) {
196
+ if (!isObject(entry)) continue;
197
+ const sessionId = entry.sessionId == null ? '' : String(entry.sessionId);
198
+ if (!sessionId) continue;
199
+ let goal;
200
+ try {
201
+ goal = typeof getGoal === 'function' ? getGoal(sessionId) : undefined;
202
+ } catch {
203
+ goal = undefined;
204
+ }
205
+ if (!isObject(goal)) continue;
206
+ const goalId = goal.id == null ? null : String(goal.id);
207
+ // The LIVE goal decides. A checkpoint record (this plugin disarmed the goal
208
+ // earlier, or the previous pause-based build parked it) is reporting material
209
+ // only: it must never veto a disarm, or a goal that was resumed (and is armed
210
+ // again) could never be disarmed a second time and the restart would wait for
211
+ // it forever.
212
+ const phase = typeof goal.phase === 'string' ? goal.phase : '';
213
+ if (phase !== 'active') {
214
+ const reason = phase === 'paused'
215
+ ? GOAL_BLOCK_REASONS.PAUSED
216
+ : phase === 'blocked'
217
+ ? GOAL_BLOCK_REASONS.BLOCKED
218
+ : phase === 'complete'
219
+ ? GOAL_BLOCK_REASONS.COMPLETE
220
+ : GOAL_BLOCK_REASONS.NOT_ACTIVE;
221
+ reported.push({ sessionId, goalId, reason });
222
+ continue;
223
+ }
224
+ if (goal.activation !== 'armed') {
225
+ reported.push({ sessionId, goalId, reason: GOAL_BLOCK_REASONS.DISARMED });
226
+ continue;
227
+ }
228
+ const roundsStarted = isFiniteNumber(goal.roundsStarted) ? goal.roundsStarted : 0;
229
+ const maxGoalRounds = isFiniteNumber(goal.maxGoalRounds) ? goal.maxGoalRounds : Infinity;
230
+ if (roundsStarted >= maxGoalRounds) {
231
+ reported.push({ sessionId, goalId, reason: GOAL_BLOCK_REASONS.EXHAUSTED });
232
+ continue;
233
+ }
234
+ disarms.push({
235
+ sessionId,
236
+ goalId,
237
+ revision: isFiniteNumber(goal.revision) ? goal.revision : null,
238
+ reason: GOAL_BLOCK_REASONS.ACTIVE,
239
+ });
240
+ }
241
+ return { disarms, reported };
242
+ }
243
+
123
244
  /**
124
245
  * The idle-aware restart policy. Call this on a poll timer (e.g. `deferPollMs`)
125
246
  * while `state.restart` exists.
@@ -301,6 +422,272 @@ export function continuationPrompt(
301
422
  ].join('\n');
302
423
  }
303
424
 
425
+ /**
426
+ * Default character budget of the conversation tail a handoff carries.
427
+ */
428
+ export const HANDOFF_DEFAULT_MAX_CHARS = 20000;
429
+
430
+ /** Tokens/characters of a message body, flattened to one plain-text line. */
431
+ function flattenText(value) {
432
+ if (typeof value !== 'string') return '';
433
+ return value.replace(/\s+/g, ' ').trim();
434
+ }
435
+
436
+ /** The tool name of one content block, or a generic label. */
437
+ function toolNameOf(block) {
438
+ const candidates = [block.name, block.toolName, block.tool, block.id];
439
+ if (isObject(block.data)) candidates.push(block.data.name, block.data.toolName);
440
+ for (const candidate of candidates) {
441
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim();
442
+ }
443
+ return 'unknown-tool';
444
+ }
445
+
446
+ /**
447
+ * Render one conversation message as plain-text lines. A text block becomes
448
+ * `role: text`; a tool call / tool result becomes one short line with the tool
449
+ * name only (tool payloads are never carried).
450
+ *
451
+ * @param {unknown} message
452
+ * @returns {string[]}
453
+ */
454
+ export function renderMessageLines(message) {
455
+ if (!isObject(message)) return [];
456
+ const role = typeof message.role === 'string' && message.role !== '' ? message.role : 'message';
457
+ const out = [];
458
+ const pushText = (text) => {
459
+ const line = flattenText(text);
460
+ if (line !== '') out.push(`${role}: ${line}`);
461
+ };
462
+ const content = message.content;
463
+ if (typeof content === 'string') {
464
+ pushText(content);
465
+ return out;
466
+ }
467
+ if (!Array.isArray(content)) return out;
468
+ for (const block of content) {
469
+ if (typeof block === 'string') {
470
+ pushText(block);
471
+ continue;
472
+ }
473
+ if (!isObject(block)) continue;
474
+ const type = typeof block.type === 'string' ? block.type : '';
475
+ if (type.includes('tool')) {
476
+ const label = type.includes('result') ? 'tool result' : 'tool call';
477
+ out.push(`${label}: ${toolNameOf(block)}`);
478
+ continue;
479
+ }
480
+ if (typeof block.text === 'string') pushText(block.text);
481
+ }
482
+ return out;
483
+ }
484
+
485
+ /**
486
+ * The tail of a conversation, newest message first, bounded by a character
487
+ * budget. Rendering stops as soon as adding the next line would exceed the
488
+ * budget; one oversized single line is truncated so a handoff always carries
489
+ * something.
490
+ *
491
+ * @param {unknown} messages
492
+ * @param {{ budget?: number }} [options]
493
+ * @returns {{ lines: string[], chars: number }} `lines` are oldest first
494
+ */
495
+ export function handoffTail(messages, { budget = HANDOFF_DEFAULT_MAX_CHARS } = {}) {
496
+ const list = Array.isArray(messages) ? messages : [];
497
+ const limit = isFiniteNumber(budget) && budget > 0 ? Math.trunc(budget) : HANDOFF_DEFAULT_MAX_CHARS;
498
+ const picked = [];
499
+ let chars = 0;
500
+ for (let i = list.length - 1; i >= 0; i -= 1) {
501
+ const lines = renderMessageLines(list[i]);
502
+ for (let j = lines.length - 1; j >= 0; j -= 1) {
503
+ const line = lines[j];
504
+ const cost = line.length + 1;
505
+ if (chars + cost > limit) {
506
+ if (chars === 0 && limit > 16) {
507
+ const truncated = `${line.slice(0, limit - 1)}…`;
508
+ picked.push(truncated);
509
+ chars = truncated.length;
510
+ }
511
+ return { lines: picked.reverse(), chars };
512
+ }
513
+ picked.push(line);
514
+ chars += cost;
515
+ }
516
+ }
517
+ return { lines: picked.reverse(), chars };
518
+ }
519
+
520
+ /**
521
+ * Compose the text a handoff carries into the fresh session, in this order:
522
+ * (i) the goal objective, (ii) the recorded handoff context / reasons (most
523
+ * recent first), (iii) the bounded tail of the earlier conversation (oldest
524
+ * first), after one line saying this is a trimmed continuation and one line
525
+ * naming the old session id.
526
+ *
527
+ * @param {{
528
+ * sessionId?: string|null,
529
+ * objective?: string|null,
530
+ * goalLabel?: string|null,
531
+ * reasons?: string[]|null,
532
+ * messages?: unknown,
533
+ * maxChars?: number,
534
+ * }} [options]
535
+ * @returns {{ text: string, carriedChars: number, tailChars: number, tailLines: number }}
536
+ */
537
+ export function composeHandoff({
538
+ sessionId = null,
539
+ objective = null,
540
+ goalLabel = null,
541
+ reasons = null,
542
+ messages = [],
543
+ maxChars = HANDOFF_DEFAULT_MAX_CHARS,
544
+ } = {}) {
545
+ const budget = isFiniteNumber(maxChars) && maxChars > 0 ? Math.trunc(maxChars) : HANDOFF_DEFAULT_MAX_CHARS;
546
+ const oldId = sessionId == null || sessionId === '' ? '(unknown)' : String(sessionId);
547
+ const tail = handoffTail(messages, { budget });
548
+ const lines = [
549
+ '<continuum_handoff>',
550
+ 'This is a trimmed continuation of an earlier session whose earlier content could not be carried because it exceeded the model context window.',
551
+ `Old session id: ${oldId}`,
552
+ ];
553
+
554
+ // (i) the goal objective
555
+ const goalText = flattenText(objective);
556
+ if (goalText !== '') lines.push(`Goal objective: ${goalText}`);
557
+ else if (flattenText(goalLabel) !== '') lines.push(`Goal objective: (not recorded) — recorded goal ${flattenText(goalLabel)}`);
558
+ else lines.push('Goal objective: (no goal recorded)');
559
+
560
+ // (ii) the recorded handoff context of that session, most recent first
561
+ const recorded = Array.isArray(reasons)
562
+ ? reasons.filter((reason) => typeof reason === 'string' && flattenText(reason) !== '').map(flattenText)
563
+ : [];
564
+ if (recorded.length > 0) {
565
+ lines.push('Recorded checkpoint context of that session (most recent first):');
566
+ for (const reason of recorded.slice().reverse()) lines.push(`- ${reason}`);
567
+ } else {
568
+ lines.push('Recorded checkpoint context of that session: (none recorded)');
569
+ }
570
+
571
+ // (iii) the bounded tail of the conversation, oldest first
572
+ if (tail.lines.length > 0) {
573
+ lines.push(`Trimmed tail of the earlier conversation (${tail.lines.length} line(s), ${tail.chars} character(s), oldest first):`);
574
+ for (const line of tail.lines) lines.push(line);
575
+ } else {
576
+ lines.push('Trimmed tail of the earlier conversation: (not available)');
577
+ }
578
+
579
+ lines.push('Continue the work from here: this handoff is the only context carried over from the earlier session.');
580
+ lines.push('</continuum_handoff>');
581
+ const text = lines.join('\n');
582
+ return { text, carriedChars: text.length, tailChars: tail.chars, tailLines: tail.lines.length };
583
+ }
584
+
585
+ /** A phrase that identifies a failure caused by an oversized context. */
586
+ const CONTEXT_WINDOW_PATTERN = /context[ _-]?(?:window|length)|context_length_exceeded|maximum context/i;
587
+
588
+ /** A phrase that identifies an exceeded limit next to {@link CONTEXT_WINDOW_PATTERN}. */
589
+ const EXCEED_PATTERN = /exceed|overflow|too (?:long|large|many)|over the limit|maximum/i;
590
+
591
+ /** The joined text of one message (used for overflow evidence). */
592
+ function messageTextOf(message) {
593
+ if (!isObject(message)) return '';
594
+ const parts = [];
595
+ if (typeof message.content === 'string') parts.push(message.content);
596
+ else if (Array.isArray(message.content)) {
597
+ for (const block of message.content) {
598
+ if (typeof block === 'string') parts.push(block);
599
+ else if (isObject(block) && typeof block.text === 'string') parts.push(block.text);
600
+ }
601
+ }
602
+ if (typeof message.text === 'string') parts.push(message.text);
603
+ return parts.join(' ');
604
+ }
605
+
606
+ /** The failure text of one event-ish object (shallow, never throws). */
607
+ function eventTextOf(event) {
608
+ if (!isObject(event)) return '';
609
+ const parts = [];
610
+ for (const key of ['message', 'error', 'reason', 'text']) {
611
+ const value = event[key];
612
+ if (typeof value === 'string') parts.push(value);
613
+ else if (isObject(value) && typeof value.message === 'string') parts.push(value.message);
614
+ }
615
+ if (isObject(event.data)) {
616
+ for (const key of ['message', 'error', 'text']) {
617
+ const value = event.data[key];
618
+ if (typeof value === 'string') parts.push(value);
619
+ else if (isObject(value) && typeof value.message === 'string') parts.push(value.message);
620
+ }
621
+ }
622
+ return parts.join(' ');
623
+ }
624
+
625
+ /**
626
+ * Decide whether a session is over the model context window.
627
+ *
628
+ * Evidence, in this order:
629
+ * 1. the newest assistant message's `usage.inputTokens` versus the session's
630
+ * `contextWindow`: overflow when the used tokens EXCEED the window;
631
+ * 2. a recent message / event whose text says the context window was exceeded.
632
+ *
633
+ * When the numbers are unknown, the verdict is NOT overflow: an unknown window
634
+ * must never block the normal resume path.
635
+ *
636
+ * @param {{ messages?: unknown, contextWindow?: number|null, events?: unknown }} [options]
637
+ * @returns {{ over: boolean, inputTokens: number|null, contextWindow: number|null, detail: string|null }}
638
+ */
639
+ export function contextOverflow({ messages = [], contextWindow = null, events = [] } = {}) {
640
+ const list = Array.isArray(messages) ? messages : [];
641
+ let inputTokens = null;
642
+ for (let i = list.length - 1; i >= 0; i -= 1) {
643
+ const message = list[i];
644
+ if (!isObject(message)) continue;
645
+ const usage = message.usage;
646
+ if (!isObject(usage)) continue;
647
+ for (const key of ['inputTokens', 'promptTokens', 'input_tokens']) {
648
+ if (isFiniteNumber(usage[key]) && usage[key] >= 0) {
649
+ inputTokens = usage[key];
650
+ break;
651
+ }
652
+ }
653
+ if (inputTokens !== null) break;
654
+ }
655
+ const window = isFiniteNumber(contextWindow) && contextWindow > 0 ? contextWindow : null;
656
+
657
+ let failure = '';
658
+ const scan = (text) => {
659
+ if (failure !== '' || typeof text !== 'string' || text === '') return;
660
+ if (CONTEXT_WINDOW_PATTERN.test(text) && EXCEED_PATTERN.test(text)) failure = flattenText(text).slice(0, 200);
661
+ };
662
+ for (let i = list.length - 1; i >= 0 && failure === ''; i -= 1) scan(messageTextOf(list[i]));
663
+ const eventList = Array.isArray(events) ? events.slice(-20) : [];
664
+ for (let i = eventList.length - 1; i >= 0 && failure === ''; i -= 1) scan(eventTextOf(eventList[i]));
665
+
666
+ const numbers = inputTokens !== null && window !== null
667
+ ? ` (used ${inputTokens} of ${window} tokens)`
668
+ : inputTokens !== null
669
+ ? ` (used ${inputTokens} tokens; window unknown)`
670
+ : '';
671
+
672
+ if (failure !== '') {
673
+ return { over: true, inputTokens, contextWindow: window, detail: `a recent turn failed with a context-window error${numbers}: ${failure}` };
674
+ }
675
+ if (inputTokens !== null && window !== null && inputTokens > window) {
676
+ return {
677
+ over: true,
678
+ inputTokens,
679
+ contextWindow: window,
680
+ detail: `the last request used ${inputTokens} tokens, which exceeds the model context window of ${window}`,
681
+ };
682
+ }
683
+ return {
684
+ over: false,
685
+ inputTokens,
686
+ contextWindow: window,
687
+ detail: inputTokens !== null && window !== null ? `used ${inputTokens} of ${window} tokens` : null,
688
+ };
689
+ }
690
+
304
691
  /**
305
692
  * One-line human-readable summary for logs and the health endpoint.
306
693
  *
package/lib/restart.js CHANGED
File without changes
package/locale/en.json CHANGED
File without changes
package/locale/zh.json CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-autorestart",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "DeepSeek Harness plugin: restart without losing work. Waits until every conversation is idle, restarts the host, then automatically resumes the interrupted sessions. AI-generated, not human-reviewed: use with caution.",
6
6
  "license": "MIT",
@@ -34,7 +34,10 @@
34
34
  "cordis.patch.yml",
35
35
  "locale/*.json",
36
36
  "icon.svg",
37
- "README.md"
37
+ "assets/*.png",
38
+ "screenshots.json",
39
+ "README.md",
40
+ "README.zh.md"
38
41
  ],
39
42
  "dsh": {
40
43
  "bundle": {
@@ -0,0 +1,5 @@
1
+ [
2
+ "assets/screenshot-1.png",
3
+ "assets/screenshot-2.png",
4
+ "assets/screenshot-3.png"
5
+ ]