@linxin666/dsh-client-ui-task-board 0.2.6 → 0.2.8

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/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 359b35160b1cf7a8c697152b4e3fc60193ed3599
6
- README.zh.md: 535ac2b5c8b4c6fdbff61c08b9cadae3b5679cc7
5
+ README.md: d65b9b3213fd504715bffb3a00be17074703e8c6
6
+ README.zh.md: 09705ff5ff6cfdc3df7896cd0af6489c35fbe174
package/README.md CHANGED
@@ -52,7 +52,7 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-task-board
52
52
  | Key | Default | Behavior |
53
53
  | --- | --- | --- |
54
54
  | `enabled` | `true` | Enables the Host service and browser board. |
55
- | `announceToAgent` | `true` | Adds the task-board guidance section to agent system prompts. |
55
+ | `announceToAgent` | `false` | Opt-in: when true, adds the task-board guidance section to agent system prompts. |
56
56
  | `preventIdleSleep` | `false` | Holds one system idle-sleep assertion while any DSH session runs, any schedule is enabled, or session state is unknown. |
57
57
  | `trustedProxyHosts` | `[]` | Canonical `host[:port]` authorities accepted only through the authenticated loopback reverse-proxy path. |
58
58
  | `proxyTokenEnv` | `DSH_TASK_BOARD_PROXY_TOKEN` | Environment variable containing the reverse-proxy token; the token itself is never stored in plugin config. |
package/README.zh.md CHANGED
@@ -52,7 +52,7 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-task-board
52
52
  | 键 | 默认值 | 行为 |
53
53
  | --- | --- | --- |
54
54
  | `enabled` | `true` | 启用 Host 服务与浏览器看板。 |
55
- | `announceToAgent` | `true` | 向 agent 系统提示加入任务看板说明。 |
55
+ | `announceToAgent` | `false` | 按需开启:开启后向 agent 系统提示加入任务看板说明。 |
56
56
  | `preventIdleSleep` | `false` | 存在运行中的 DSH 会话、已启用计划或未知会话状态时,持有一个系统空闲睡眠断言。 |
57
57
  | `trustedProxyHosts` | `[]` | 仅通过已认证 loopback 反向代理路径接受的规范 `host[:port]` authority 白名单。 |
58
58
  | `proxyTokenEnv` | `DSH_TASK_BOARD_PROXY_TOKEN` | 保存反向代理 token 的环境变量名;token 本身不会写入插件配置。 |
package/lib/index.js CHANGED
@@ -852,8 +852,48 @@ function cloneTasks(tasks) {
852
852
  function hasOpenExecution(task) {
853
853
  return task.executions.some((execution) => execution.endedAt === void 0);
854
854
  }
855
+ /**
856
+ * Process states that are dead but still occupy the PID table: `Z` (zombie)
857
+ * and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
858
+ * alive, so a crash leftover whose child was never reaped would otherwise be
859
+ * mistaken for a live owner and block ledger startup forever.
860
+ */
861
+ const DEAD_STATES = /* @__PURE__ */ new Set(["Z", "X"]);
862
+ /**
863
+ * Best-effort single-letter process state ('R','S','D','Z',...) or undefined
864
+ * when no probe is available on this platform. Linux reads /proc/<pid>/stat
865
+ * directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
866
+ * has no zombie state, so it returns undefined and the kill(0) probe alone
867
+ * is authoritative there.
868
+ */
869
+ function processState(pid) {
870
+ if (process.platform === "linux") try {
871
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
872
+ const end = stat.lastIndexOf(")");
873
+ if (end === -1) return void 0;
874
+ return stat.slice(end + 2).split(" ")[0] || void 0;
875
+ } catch {
876
+ return;
877
+ }
878
+ if (process.platform === "win32") return void 0;
879
+ try {
880
+ const probe = spawnSync("ps", [
881
+ "-o",
882
+ "stat=",
883
+ "-p",
884
+ String(pid)
885
+ ], { timeout: PROCESS_PROBE_TIMEOUT_MS });
886
+ if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
887
+ const state = probe.stdout.toString("utf8").trim();
888
+ return state.length > 0 ? state[0] : void 0;
889
+ } catch {
890
+ return;
891
+ }
892
+ }
855
893
  function processIsAlive(pid) {
856
894
  if (!Number.isSafeInteger(pid) || pid <= 0) return false;
895
+ const state = processState(pid);
896
+ if (state !== void 0 && DEAD_STATES.has(state)) return false;
857
897
  try {
858
898
  process.kill(pid, 0);
859
899
  return true;
@@ -865,6 +905,28 @@ const PROCESS_PROBE_TIMEOUT_MS = 3e3;
865
905
  let ownStartTime;
866
906
  let ownStartTimeResolved = false;
867
907
  /**
908
+ * Exact process start time (Unix epoch ms) on Linux, read straight from
909
+ * /proc (field 22 = start ticks since boot, btime = boot epoch seconds).
910
+ * No subprocess and no rounding, so the recorded `startedAt` from a previous
911
+ * boot compares exactly against the live process identity.
912
+ */
913
+ function linuxStartTimeMs(pid) {
914
+ try {
915
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
916
+ const end = stat.lastIndexOf(")");
917
+ if (end === -1) return void 0;
918
+ const ticks = Number(stat.slice(end + 2).split(" ")[19]);
919
+ if (!Number.isFinite(ticks)) return void 0;
920
+ const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync("/proc/stat", "utf8"));
921
+ if (bootMatch === null) return void 0;
922
+ const btime = Number(bootMatch[1]);
923
+ if (!Number.isFinite(btime)) return void 0;
924
+ return btime * 1e3 + ticks * 1e3 / 100;
925
+ } catch {
926
+ return;
927
+ }
928
+ }
929
+ /**
868
930
  * Best-effort start time (Unix epoch ms) of a live process. Used to prove
869
931
  * whether the ledger lock really belongs to the PID recorded in it, so a
870
932
  * crash leftover whose PID was reused by an unrelated process (issue #786)
@@ -872,6 +934,7 @@ let ownStartTimeResolved = false;
872
934
  * undefined when the platform probe is unavailable; callers fail closed.
873
935
  */
874
936
  function processStartTimeMs(pid) {
937
+ if (process.platform === "linux") return linuxStartTimeMs(pid);
875
938
  if (process.platform === "win32") {
876
939
  const probe = spawnSync("powershell", [
877
940
  "-NoProfile",
@@ -924,6 +987,21 @@ function ownProcessStartTimeMs() {
924
987
  }
925
988
  return ownStartTime;
926
989
  }
990
+ /**
991
+ * Bounded tolerance for legacy lock records. Locks written before the
992
+ * ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second
993
+ * resolution; probing the SAME live process exactly (via /proc) then differs
994
+ * in the sub-second remainder. Treating that as PID reuse would steal a live
995
+ * owner's lock during a rolling upgrade and start a second ledger writer.
996
+ * Records written by the ms-precise probe carry `probe: 'exact'` and are
997
+ * compared strictly; anything else (older locks, second-granularity probes)
998
+ * falls back to this bounded tolerance.
999
+ */
1000
+ const LEGACY_START_TOLERANCE_MS = 2e3;
1001
+ /** Whether the recorded start time proves the recorded PID is another process. */
1002
+ function startTimeMismatch(recorded, actual, exact) {
1003
+ return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS;
1004
+ }
927
1005
  function betterExecution(a, b) {
928
1006
  if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
929
1007
  if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
@@ -1369,10 +1447,13 @@ var HostTaskLedger = class {
1369
1447
  acquireLock() {
1370
1448
  for (let attempt = 0; attempt < 2; attempt += 1) try {
1371
1449
  const fd = openSync(this.lockFile, "wx", 384);
1450
+ const startedAt = ownProcessStartTimeMs();
1451
+ const probe = process.platform === "linux" || process.platform === "win32" ? "exact" : "legacy";
1372
1452
  writeFileSync(fd, JSON.stringify({
1373
1453
  pid: process.pid,
1374
1454
  token: this.lockToken,
1375
- startedAt: ownProcessStartTimeMs()
1455
+ startedAt,
1456
+ probe
1376
1457
  }), { encoding: "utf8" });
1377
1458
  fsyncSync(fd);
1378
1459
  try {
@@ -1383,23 +1464,25 @@ var HostTaskLedger = class {
1383
1464
  if (error.code !== "EEXIST") throw error;
1384
1465
  let pid;
1385
1466
  let ownerStartedAt;
1467
+ let ownerExact = false;
1386
1468
  try {
1387
1469
  const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
1388
1470
  if (typeof owner.pid === "number") pid = owner.pid;
1389
1471
  if (typeof owner.startedAt === "number") ownerStartedAt = owner.startedAt;
1472
+ ownerExact = owner.probe === "exact";
1390
1473
  } catch {
1391
- throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
1474
+ throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`);
1392
1475
  }
1393
1476
  if (pid !== void 0 && processIsAlive(pid)) {
1394
1477
  const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid);
1395
- if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? actualStartedAt !== ownerStartedAt : (() => {
1478
+ if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) : (() => {
1396
1479
  try {
1397
1480
  return statSync(this.lockFile).mtimeMs < actualStartedAt;
1398
1481
  } catch {
1399
1482
  return true;
1400
1483
  }
1401
1484
  })()))) {
1402
- const hint = ownerStartedAt !== void 0 && actualStartedAt === ownerStartedAt ? "" : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`;
1485
+ const hint = ownerStartedAt !== void 0 && actualStartedAt !== void 0 && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) ? "" : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`;
1403
1486
  throw new Error(`task-board ledger is already owned by process ${pid}${hint}`);
1404
1487
  }
1405
1488
  }
@@ -2364,7 +2447,7 @@ const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI
2364
2447
  */
2365
2448
  const TASK_BOARD_SETTINGS_NAMESPACE = settingsNamespace("task-board");
2366
2449
  const Config = z.object({
2367
- announceToAgent: z.boolean().default(true),
2450
+ announceToAgent: z.boolean().default(false),
2368
2451
  enabled: z.boolean().default(true),
2369
2452
  preventIdleSleep: z.boolean().default(false),
2370
2453
  trustedProxyHosts: z.array(z.string()).default([]),
@@ -2384,7 +2467,7 @@ function resolveProxyAccess(config, env = process.env) {
2384
2467
  };
2385
2468
  }
2386
2469
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
2387
- const DEFAULT_ANNOUNCE = true;
2470
+ const DEFAULT_ANNOUNCE = false;
2388
2471
  /**
2389
2472
  * Register the board's announcement section, gated on the composition entry's
2390
2473
  * `announceToAgent` (and the live settings value once the web settings
@@ -9,6 +9,15 @@ export interface OpenedRun {
9
9
  task: TaskRecord;
10
10
  execution: ExecutionRecord;
11
11
  }
12
+ /**
13
+ * Best-effort single-letter process state ('R','S','D','Z',...) or undefined
14
+ * when no probe is available on this platform. Linux reads /proc/<pid>/stat
15
+ * directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
16
+ * has no zombie state, so it returns undefined and the kill(0) probe alone
17
+ * is authoritative there.
18
+ */
19
+ export declare function processState(pid: number): string | undefined;
20
+ export declare function processIsAlive(pid: number): boolean;
12
21
  export declare class HostTaskLedger {
13
22
  private readonly now;
14
23
  private document;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.2.6",
4
+ "version": "0.2.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -22,6 +22,9 @@
22
22
  "./package.json": "./package.json"
23
23
  },
24
24
  "dsh": {
25
+ "engines": {
26
+ "dsh": ">=0.1.1-rc.1"
27
+ },
25
28
  "bundle": {
26
29
  "patch": "./cordis.patch.yml"
27
30
  },
@@ -35,8 +38,8 @@
35
38
  }
36
39
  },
37
40
  "peerDependencies": {
38
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
39
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
41
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
42
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
40
43
  "react": "^18.2.0"
41
44
  },
42
45
  "dependencies": {
@@ -44,17 +47,17 @@
44
47
  },
45
48
  "devDependencies": {
46
49
  "@deepseek-ai/cordis": "^4.0.1",
47
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
48
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
49
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
50
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
51
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
52
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
53
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
54
- "@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.8",
55
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
56
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
57
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
50
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
51
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
52
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
53
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
54
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
55
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
56
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
57
+ "@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
58
+ "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
59
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
60
+ "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
58
61
  "@types/react": "~18.3.1",
59
62
  "@types/react-dom": "^18.3.5",
60
63
  "@types/node": "^22.20.0",
@@ -59,8 +59,47 @@ function hasOpenExecution(task: TaskRecord): boolean {
59
59
  return task.executions.some(execution => execution.endedAt === undefined)
60
60
  }
61
61
 
62
- function processIsAlive(pid: number): boolean {
62
+ /**
63
+ * Process states that are dead but still occupy the PID table: `Z` (zombie)
64
+ * and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
65
+ * alive, so a crash leftover whose child was never reaped would otherwise be
66
+ * mistaken for a live owner and block ledger startup forever.
67
+ */
68
+ const DEAD_STATES = new Set(['Z', 'X'])
69
+
70
+ /**
71
+ * Best-effort single-letter process state ('R','S','D','Z',...) or undefined
72
+ * when no probe is available on this platform. Linux reads /proc/<pid>/stat
73
+ * directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
74
+ * has no zombie state, so it returns undefined and the kill(0) probe alone
75
+ * is authoritative there.
76
+ */
77
+ export function processState(pid: number): string | undefined {
78
+ if (process.platform === 'linux') {
79
+ try {
80
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
81
+ const end = stat.lastIndexOf(')')
82
+ if (end === -1) return undefined
83
+ return stat.slice(end + 2).split(' ')[0] || undefined
84
+ } catch {
85
+ return undefined // no such process (or unreadable)
86
+ }
87
+ }
88
+ if (process.platform === 'win32') return undefined
89
+ try {
90
+ const probe = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS })
91
+ if (probe.status !== 0 || probe.stdout.length === 0) return undefined
92
+ const state = probe.stdout.toString('utf8').trim()
93
+ return state.length > 0 ? state[0] : undefined
94
+ } catch {
95
+ return undefined
96
+ }
97
+ }
98
+
99
+ export function processIsAlive(pid: number): boolean {
63
100
  if (!Number.isSafeInteger(pid) || pid <= 0) return false
101
+ const state = processState(pid)
102
+ if (state !== undefined && DEAD_STATES.has(state)) return false
64
103
  try {
65
104
  process.kill(pid, 0)
66
105
  return true
@@ -74,6 +113,29 @@ const PROCESS_PROBE_TIMEOUT_MS = 3000
74
113
  let ownStartTime: number | undefined
75
114
  let ownStartTimeResolved = false
76
115
 
116
+ /**
117
+ * Exact process start time (Unix epoch ms) on Linux, read straight from
118
+ * /proc (field 22 = start ticks since boot, btime = boot epoch seconds).
119
+ * No subprocess and no rounding, so the recorded `startedAt` from a previous
120
+ * boot compares exactly against the live process identity.
121
+ */
122
+ function linuxStartTimeMs(pid: number): number | undefined {
123
+ try {
124
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
125
+ const end = stat.lastIndexOf(')')
126
+ if (end === -1) return undefined
127
+ const ticks = Number(stat.slice(end + 2).split(' ')[19])
128
+ if (!Number.isFinite(ticks)) return undefined
129
+ const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync('/proc/stat', 'utf8'))
130
+ if (bootMatch === null) return undefined
131
+ const btime = Number(bootMatch[1])
132
+ if (!Number.isFinite(btime)) return undefined
133
+ return btime * 1000 + (ticks * 1000) / 100 // USER_HZ is 100 on Linux
134
+ } catch {
135
+ return undefined
136
+ }
137
+ }
138
+
77
139
  /**
78
140
  * Best-effort start time (Unix epoch ms) of a live process. Used to prove
79
141
  * whether the ledger lock really belongs to the PID recorded in it, so a
@@ -82,6 +144,7 @@ let ownStartTimeResolved = false
82
144
  * undefined when the platform probe is unavailable; callers fail closed.
83
145
  */
84
146
  function processStartTimeMs(pid: number): number | undefined {
147
+ if (process.platform === 'linux') return linuxStartTimeMs(pid)
85
148
  if (process.platform === 'win32') {
86
149
  const probe = spawnSync(
87
150
  'powershell',
@@ -93,8 +156,8 @@ function processStartTimeMs(pid: number): number | undefined {
93
156
  const started = Number(probe.stdout.toString('utf8').trim())
94
157
  return Number.isFinite(started) ? started : undefined
95
158
  }
96
- // POSIX: ps lstart with a forced English locale, falling back to the
97
- // elapsed-seconds column when lstart cannot be parsed.
159
+ // Other POSIX (macOS...): ps lstart with a forced English locale, falling
160
+ // back to the elapsed-seconds column when lstart cannot be parsed.
98
161
  const env = { ...process.env, LC_ALL: 'C' }
99
162
  const probe = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
100
163
  if (probe.status === 0 && probe.stdout.length > 0) {
@@ -116,6 +179,23 @@ function ownProcessStartTimeMs(): number | undefined {
116
179
  return ownStartTime
117
180
  }
118
181
 
182
+ /**
183
+ * Bounded tolerance for legacy lock records. Locks written before the
184
+ * ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second
185
+ * resolution; probing the SAME live process exactly (via /proc) then differs
186
+ * in the sub-second remainder. Treating that as PID reuse would steal a live
187
+ * owner's lock during a rolling upgrade and start a second ledger writer.
188
+ * Records written by the ms-precise probe carry `probe: 'exact'` and are
189
+ * compared strictly; anything else (older locks, second-granularity probes)
190
+ * falls back to this bounded tolerance.
191
+ */
192
+ const LEGACY_START_TOLERANCE_MS = 2000
193
+
194
+ /** Whether the recorded start time proves the recorded PID is another process. */
195
+ function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean {
196
+ return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS
197
+ }
198
+
119
199
  function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord {
120
200
  if (a.endedAt === undefined && b.endedAt !== undefined) return b
121
201
  if (b.endedAt === undefined && a.endedAt !== undefined) return a
@@ -560,7 +640,13 @@ export class HostTaskLedger {
560
640
  for (let attempt = 0; attempt < 2; attempt += 1) {
561
641
  try {
562
642
  const fd = openSync(this.lockFile, 'wx', 0o600)
563
- writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt: ownProcessStartTimeMs() }), { encoding: 'utf8' })
643
+ const startedAt = ownProcessStartTimeMs()
644
+ // Linux /proc and Windows PowerShell probes are ms-precise; locks they
645
+ // write are compared strictly. Other POSIX probes (ps) stay
646
+ // second-granularity, so their records are compared with the bounded
647
+ // legacy tolerance.
648
+ const probe = process.platform === 'linux' || process.platform === 'win32' ? 'exact' : 'legacy'
649
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt, probe }), { encoding: 'utf8' })
564
650
  fsyncSync(fd)
565
651
  try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
566
652
  return fd
@@ -569,29 +655,36 @@ export class HostTaskLedger {
569
655
  if (code !== 'EEXIST') throw error
570
656
  let pid: number | undefined
571
657
  let ownerStartedAt: number | undefined
658
+ let ownerExact = false
572
659
  try {
573
- const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown }
660
+ const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown; probe?: unknown }
574
661
  if (typeof owner.pid === 'number') pid = owner.pid
575
662
  if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
663
+ ownerExact = owner.probe === 'exact'
576
664
  } catch {
577
- throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`)
665
+ // A power-loss mid-write can leave a truncated lock; the same event
666
+ // killed the writer, so fail closed but explain the recovery.
667
+ throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`)
578
668
  }
579
669
  if (pid !== undefined && processIsAlive(pid)) {
580
670
  const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
581
671
  // A reused PID is exposed when the live process identity no longer
582
- // matches the recorded one: either the recorded start time differs,
583
- // or (legacy locks without one) the lock file predates the live
584
- // process and therefore cannot have been written by it. Takeover is
585
- // safe in both cases — the original owner is gone.
672
+ // matches the recorded one: either the recorded start time differs
673
+ // beyond the probe's resolution (strict for ms-precise 'exact'
674
+ // records, a bounded legacy tolerance for old second-granularity
675
+ // records written by ps), or (legacy locks without a start time)
676
+ // the lock file predates the live process and therefore cannot
677
+ // have been written by it. Takeover is safe in both cases — the
678
+ // original owner is gone.
586
679
  const staleReuse = actualStartedAt !== undefined && (
587
680
  ownerStartedAt !== undefined
588
- ? actualStartedAt !== ownerStartedAt
681
+ ? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact)
589
682
  : (() => {
590
683
  try { return statSync(this.lockFile).mtimeMs < actualStartedAt } catch { return true }
591
684
  })()
592
685
  )
593
686
  if (!staleReuse) {
594
- const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt === ownerStartedAt
687
+ const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt !== undefined && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact)
595
688
  const hint = confirmedOwner
596
689
  ? ''
597
690
  : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`
package/src/index.ts CHANGED
@@ -55,7 +55,7 @@ export interface Config {
55
55
  }
56
56
 
57
57
  export const Config: z<Config> = z.object({
58
- announceToAgent: z.boolean().default(true),
58
+ announceToAgent: z.boolean().default(false),
59
59
  enabled: z.boolean().default(true),
60
60
  preventIdleSleep: z.boolean().default(false),
61
61
  trustedProxyHosts: z.array(z.string()).default([]),
@@ -76,7 +76,7 @@ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.Proce
76
76
  }
77
77
 
78
78
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
79
- const DEFAULT_ANNOUNCE = true
79
+ const DEFAULT_ANNOUNCE = false
80
80
 
81
81
  /**
82
82
  * Register the board's announcement section, gated on the composition entry's