dsh-autorestart 0.6.1 → 0.6.2

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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * dsh-autorestart — what counts as REAL activity for the quiet window.
3
+ *
4
+ * THE DEFECT THIS MODULE FIXES
5
+ *
6
+ * The settle window ("the host must stay quiet for N ms before a restart may
7
+ * fire") used to be reset by EVERY session event. The host emits session
8
+ * events continuously while the interface polls and synchronises (measured on
9
+ * the real machine: one every 2-3 seconds), so a window longer than the event
10
+ * interval could never fill: the restart never fired, no matter how long the
11
+ * user waited.
12
+ *
13
+ * THE POLICY
14
+ *
15
+ * A session event counts as activity only when it says that WORK happened: a
16
+ * message was written, a tool ran, a turn started or ended. Everything else
17
+ * (a title, a projection update, a context request, a job that settled, a
18
+ * permission preset) is routine and must NOT reset the window.
19
+ *
20
+ * The rule is deliberately one-sided: an event whose type cannot be read is
21
+ * NOT activity. When in doubt the plugin would rather restart a little early
22
+ * than never restart at all.
23
+ *
24
+ * The hard guarantee does not live here: a conversation that is really
25
+ * running blocks the restart through `busyAgents()` (the `running` reason),
26
+ * and that rule is never relaxed. This module only decides what the QUIET
27
+ * window measures.
28
+ *
29
+ * ESM only, Node builtins only. No DSH / npm imports.
30
+ */
31
+
32
+ /**
33
+ * The session event types that mean "work happened".
34
+ *
35
+ * The vocabulary is the one of the DSH session service (its known event
36
+ * types). Only the events that describe a message, a tool, a turn or a
37
+ * command are listed; the rest (titles, projections, jobs, presets, modes,
38
+ * schedules, telemetry) are routine.
39
+ */
40
+ export const WORK_SESSION_EVENT_TYPES = Object.freeze([
41
+ // A message was written into a conversation.
42
+ 'user/message',
43
+ 'developer/message',
44
+ 'system/message',
45
+ 'assistant/message',
46
+ 'assistant/attempt',
47
+ // A tool ran.
48
+ 'tool/call',
49
+ 'tool/result',
50
+ 'tool/ptc-dispatch',
51
+ 'tool/ptc-dispatch-start',
52
+ // A turn or a step started or ended.
53
+ 'turn/start',
54
+ 'turn/end',
55
+ 'step/start',
56
+ 'step/end',
57
+ // A command ran.
58
+ 'command/run',
59
+ 'command/done',
60
+ // Work was handed around: an inbox splice, a team message, a workflow run.
61
+ 'agent/inbox/spliced',
62
+ 'team/message/queued',
63
+ 'team/message/delivered',
64
+ 'team/task',
65
+ 'team/member',
66
+ 'tool-workflow/run-start',
67
+ 'tool-workflow/run-end',
68
+ 'tool-workflow/agent-start',
69
+ 'tool-workflow/agent-end',
70
+ // A hook ran, a compaction ran, the model retried, a goal changed.
71
+ 'hook/invoked',
72
+ 'hook/result',
73
+ 'compaction/start',
74
+ 'compaction/end',
75
+ 'compaction/prune',
76
+ 'compaction/summary',
77
+ 'llm/retry',
78
+ 'llm/retry-started',
79
+ 'goal/change',
80
+ 'approval/asked',
81
+ ])
82
+
83
+ /** The work types as a Set, for a cheap lookup. */
84
+ const WORK_TYPES = new Set(WORK_SESSION_EVENT_TYPES)
85
+
86
+ /**
87
+ * The type of one session event, or null when it cannot be read.
88
+ *
89
+ * The payload shape differs between the hosts that call this plugin, so the
90
+ * type is looked for in the places it can be: the payload itself, the nested
91
+ * event, or the data envelope.
92
+ *
93
+ * @param {any} payload the event payload the host delivered
94
+ * @returns {string|null}
95
+ */
96
+ export function sessionEventType(payload) {
97
+ if (payload === null || typeof payload !== 'object') return null
98
+ const candidates = [
99
+ payload.type,
100
+ payload.eventType,
101
+ payload.event && typeof payload.event === 'object' ? payload.event.type : undefined,
102
+ payload.data && typeof payload.data === 'object' ? payload.data.type : undefined,
103
+ payload.data && typeof payload.data === 'object' && payload.data.event && typeof payload.data.event === 'object'
104
+ ? payload.data.event.type
105
+ : undefined,
106
+ ]
107
+ for (const candidate of candidates) {
108
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim()
109
+ }
110
+ return null
111
+ }
112
+
113
+ /**
114
+ * Whether one session event means that real work happened.
115
+ *
116
+ * @param {any} payload the event payload the host delivered
117
+ * @returns {boolean} false for a routine event AND for an event whose type
118
+ * cannot be read (the one-sided rule: when in doubt, do not wait)
119
+ */
120
+ export function isWorkSessionEvent(payload) {
121
+ const type = sessionEventType(payload)
122
+ if (type === null) return false
123
+ return WORK_TYPES.has(type)
124
+ }
package/lib/lifecycle.js CHANGED
@@ -262,10 +262,12 @@ export function selectGoalDisarms(busyAgents, { getGoal, disarmedGoals, parkedGo
262
262
  * forceRestartWhenBusy?: boolean,
263
263
  * }} [options]
264
264
  * @returns {{
265
- * action: 'none'|'wait'|'warn'|'restart'|'force-restart'|'give-up',
265
+ * action: 'none'|'wait'|'warn'|'long-wait'|'restart'|'force-restart'|'give-up',
266
266
  * reason: string,
267
267
  * log?: string,
268
268
  * markSoftWarned?: boolean,
269
+ * blockedBy?: { sessionId: string|null, reason: string }[],
270
+ * waitingSince?: number,
269
271
  * }}
270
272
  */
271
273
  export function restartDecision(
@@ -325,11 +327,19 @@ export function restartDecision(
325
327
  }
326
328
 
327
329
  // Hard deadline reached while a conversation is still running.
330
+ //
331
+ // From here on the plugin does NOT pretend that "everything is fine, we are
332
+ // just waiting a little longer": the wait has become a LONG wait, and the
333
+ // caller (index.js) has to make it VISIBLE — in the log and in the state
334
+ // payload — while KEEPING the user's restart request alive. The request is
335
+ // never silently dropped.
328
336
  if (policy === 'wait') {
329
337
  return {
330
- action: 'warn',
338
+ action: 'long-wait',
331
339
  reason: 'hard-deadline-policy-wait',
332
340
  log: 'defer policy wait: still busy after hard deadline',
341
+ blockedBy: busy,
342
+ waitingSince: requestedAt,
333
343
  };
334
344
  }
335
345
  // The hard deadline only forces a restart when the operator explicitly opted in
@@ -340,11 +350,14 @@ export function restartDecision(
340
350
  return { action: 'force-restart', reason: 'hard-deadline-force-restart' };
341
351
  }
342
352
  // forceRestartWhenBusy is disabled (the default): the restart keeps waiting
343
- // until the idle gate is quiet again, and it never gives up.
353
+ // until the idle gate is quiet again, and it never gives up. The result says
354
+ // so explicitly — a long wait, with the sessions that hold it.
344
355
  return {
345
- action: 'warn',
356
+ action: 'long-wait',
346
357
  reason: 'hard-deadline-waiting-for-conversations',
347
358
  log: 'dsh-autorestart: hard deadline reached but conversations are still running; forceRestartWhenBusy is disabled, so the restart keeps waiting',
359
+ blockedBy: busy,
360
+ waitingSince: requestedAt,
348
361
  };
349
362
  }
350
363
 
package/lib/queued.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * dsh-autorestart — the upper bound of a QUEUED inbox message.
3
+ *
4
+ * THE DEFECT THIS MODULE FIXES
5
+ *
6
+ * A queued message (an inbox entry that nobody has claimed yet) used to block
7
+ * the restart FOR EVER. The idle gate counted "this session has a queued
8
+ * message" as "this session is busy", and the hard deadline only WARNED, so a
9
+ * session that never ran its turn (the real case: a fresh, empty conversation
10
+ * in the UI with one unclaimed message in its inbox) kept the pending restart
11
+ * pending until the user gave up.
12
+ *
13
+ * THE POLICY
14
+ *
15
+ * - a message that is claimed inside the grace window keeps blocking the
16
+ * restart (that is the normal case: the agent picks it up in a second);
17
+ * - a message that is still unclaimed after the grace window is STUCK: it
18
+ * stops blocking the restart and is named in the log, so the user can see
19
+ * WHICH session holds the queue;
20
+ * - a message that is being worked on (the session is `running`) always
21
+ * blocks, under EVERY grace setting. This module only ever judges queued
22
+ * work — it never relaxes the rule that a live turn is never interrupted.
23
+ *
24
+ * The tracker knows nothing about the host: it is a map of session id to the
25
+ * moment the session was FIRST seen with a queued message, so it can be unit
26
+ * tested with a fake clock.
27
+ *
28
+ * ESM only, Node builtins only. No DSH / npm imports.
29
+ */
30
+
31
+ /** The default upper bound of a queued message: one minute. */
32
+ export const DEFAULT_QUEUED_GRACE_MS = 60000
33
+
34
+ /** @param {unknown} value */
35
+ function isFiniteNumber(value) {
36
+ return typeof value === 'number' && Number.isFinite(value)
37
+ }
38
+
39
+ /**
40
+ * Normalise a grace window.
41
+ *
42
+ * A negative or non-numeric value falls back to the default; `0` is a LEGAL
43
+ * value — it switches the protection off and restores the old behaviour (a
44
+ * queued message blocks the restart for ever).
45
+ *
46
+ * @param {unknown} value
47
+ * @returns {number} milliseconds, never negative
48
+ */
49
+ export function normalizeQueuedGraceMs(value) {
50
+ if (!isFiniteNumber(value) || value < 0) return DEFAULT_QUEUED_GRACE_MS
51
+ return Math.trunc(value)
52
+ }
53
+
54
+ /**
55
+ * Create the tracker.
56
+ *
57
+ * @param {{ now?: () => number }} [options] the clock (injectable for tests)
58
+ * @returns {{
59
+ * observe: (agentId: string, queued: boolean, graceMs?: number) => { blocking: boolean, firstSeenAt: number|null, ageMs: number, becameStale: boolean },
60
+ * forget: (agentId: string) => boolean,
61
+ * size: () => number,
62
+ * ids: () => string[],
63
+ * }}
64
+ */
65
+ export function createQueuedTracker({ now = () => Date.now() } = {}) {
66
+ /** @type {Map<string, { firstSeenAt: number, stale: boolean }>} */
67
+ const records = new Map()
68
+
69
+ /** The current time of the injected clock (never throws). */
70
+ const clock = () => {
71
+ try {
72
+ const value = now()
73
+ return isFiniteNumber(value) ? value : 0
74
+ } catch {
75
+ return 0
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Observe one session.
81
+ *
82
+ * @param {string} agentId the session id
83
+ * @param {boolean} queued does the session have an unclaimed message?
84
+ * @param {number} [graceMs] the upper bound (0 = no upper bound)
85
+ * @returns {{ blocking: boolean, firstSeenAt: number|null, ageMs: number, becameStale: boolean }}
86
+ */
87
+ const observe = (agentId, queued, graceMs) => {
88
+ const id = agentId == null ? '' : String(agentId)
89
+ if (id === '') return { blocking: false, firstSeenAt: null, ageMs: 0, becameStale: false }
90
+
91
+ // The session has no queued work any more: its record is dropped, so a
92
+ // NEW queue starts counting from zero.
93
+ if (queued !== true) {
94
+ records.delete(id)
95
+ return { blocking: false, firstSeenAt: null, ageMs: 0, becameStale: false }
96
+ }
97
+
98
+ const at = clock()
99
+ let record = records.get(id)
100
+ if (record === undefined) {
101
+ record = { firstSeenAt: at, stale: false }
102
+ records.set(id, record)
103
+ }
104
+ const ageMs = Math.max(0, at - record.firstSeenAt)
105
+ const grace = normalizeQueuedGraceMs(graceMs)
106
+
107
+ // The protection is off (grace 0): a queued message blocks the restart for
108
+ // ever — exactly the behaviour before this module existed.
109
+ if (grace === 0) {
110
+ return { blocking: true, firstSeenAt: record.firstSeenAt, ageMs, becameStale: false }
111
+ }
112
+
113
+ // Still inside the grace window: the agent may claim the message any
114
+ // moment, so the restart keeps waiting for it.
115
+ if (ageMs < grace) {
116
+ return { blocking: true, firstSeenAt: record.firstSeenAt, ageMs, becameStale: false }
117
+ }
118
+
119
+ // The message is STUCK: it stops blocking the restart. `becameStale` is
120
+ // true only on the tick where it turned stale, so the caller can log it
121
+ // exactly once.
122
+ const becameStale = record.stale !== true
123
+ record.stale = true
124
+ return { blocking: false, firstSeenAt: record.firstSeenAt, ageMs, becameStale }
125
+ }
126
+
127
+ /**
128
+ * Drop the record of one session (it is running, or it is gone).
129
+ *
130
+ * @param {string} agentId
131
+ * @returns {boolean} whether a record was dropped
132
+ */
133
+ const forget = (agentId) => {
134
+ const id = agentId == null ? '' : String(agentId)
135
+ if (id === '') return false
136
+ return records.delete(id)
137
+ }
138
+
139
+ /** How many sessions are tracked right now. */
140
+ const size = () => records.size
141
+
142
+ /** The ids of the tracked sessions. */
143
+ const ids = () => Array.from(records.keys())
144
+
145
+ return { observe, forget, size, ids }
146
+ }
package/locale/en.json CHANGED
@@ -18,17 +18,21 @@
18
18
  },
19
19
  "settings": {
20
20
  "nav": "Auto-restart & shut down",
21
+ "groups": {
22
+ "restart": "Restart settings",
23
+ "shutdown": "Shutdown settings"
24
+ },
21
25
  "seconds": {
22
- "label": "Countdown seconds",
26
+ "label": "Shutdown countdown seconds",
23
27
  "immediate": "No countdown (switch off right away)",
24
28
  "value": "{n} seconds",
25
- "hint": "After the button: wait for every conversation to be done, count down this many seconds and switch off. \"No countdown\" switches off as soon as the conversations are done."
29
+ "hint": "Once every conversation is done, this countdown is shown on screen (pick \"Never mind, keep it on\" to cancel); the machine goes off when it reaches zero. Raise it when you want more time to write."
26
30
  },
27
- "grace": {
28
- "label": "Wait a few more seconds after the work is done",
29
- "immediate": "Do not wait (restart/switch off right away)",
31
+ "restartGrace": {
32
+ "label": "Wait before the restart",
33
+ "immediate": "Do not wait (restart right away)",
30
34
  "value": "{n} seconds",
31
- "hint": "After the last conversation finished, wait this long before the host is really restarted or switched off — this plugin only sees the DSH conversations, not the work other plugins queue with a timer. If a conversation starts working again while waiting, the timer starts over."
35
+ "hint": "A restart has no countdown: the host restarts as soon as the conversations are done. Raise this if you rely on a memory or summary plugin that writes its notes after the conversation ended (default 0 seconds)."
32
36
  },
33
37
  "backup": {
34
38
  "label": "Backup before a restart",
package/locale/zh.json CHANGED
@@ -18,17 +18,21 @@
18
18
  },
19
19
  "settings": {
20
20
  "nav": "自动重启与关机",
21
+ "groups": {
22
+ "restart": "重启相关设置",
23
+ "shutdown": "关机相关设置"
24
+ },
21
25
  "seconds": {
22
- "label": "倒计时秒数",
26
+ "label": "关机倒数秒数",
23
27
  "immediate": "不倒数(立即关机)",
24
28
  "value": "{n} 秒",
25
- "hint": "按下按钮后,等所有会话收工,再倒数这么多秒才关机;「不倒数」表示会话一收工就关机。"
29
+ "hint": "所有会话收工后,屏幕上会显示这段倒数(可点「不关了」取消),数完才关机;想要更多落盘时间就把倒数调大。"
26
30
  },
27
- "grace": {
28
- "label": "收工后再等几秒(给记忆/总结类插件留时间)",
29
- "immediate": "不等(立即重启/关机)",
31
+ "restartGrace": {
32
+ "label": "重启前等待",
33
+ "immediate": "不等(立即重启)",
30
34
  "value": "{n} 秒",
31
- "hint": "最后一个会话收工后,再等这么久才真正重启或关机——我们只看得见 DSH 的会话,看不见别的插件用定时器排队的活。等待期间如果又有会话开始干活,会重新计时。"
35
+ "hint": "重启没有倒数环节,会话一收工就会立刻重启;如果你装了“会话结束后才写”的记忆/总结类插件,把这里调大(默认 0 秒)。"
32
36
  },
33
37
  "backup": {
34
38
  "label": "重启前备份",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-autorestart",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
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",