tailii-host 0.4.0 → 0.6.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.
Files changed (114) hide show
  1. package/dist/approval/hook.js +11 -1
  2. package/dist/approval/hook.js.map +1 -1
  3. package/dist/backend/herdr.d.ts +30 -1
  4. package/dist/backend/herdr.js +103 -20
  5. package/dist/backend/herdr.js.map +1 -1
  6. package/dist/backend/sessionBackend.d.ts +12 -0
  7. package/dist/backend/sessionBackend.js +6 -0
  8. package/dist/backend/sessionBackend.js.map +1 -1
  9. package/dist/backend/tmux.d.ts +153 -3
  10. package/dist/backend/tmux.js +488 -14
  11. package/dist/backend/tmux.js.map +1 -1
  12. package/dist/chat/chatTailController.d.ts +3 -1
  13. package/dist/chat/chatTailController.js +3 -0
  14. package/dist/chat/chatTailController.js.map +1 -1
  15. package/dist/chat/subagentTailer.d.ts +51 -0
  16. package/dist/chat/subagentTailer.js +491 -47
  17. package/dist/chat/subagentTailer.js.map +1 -1
  18. package/dist/chat/subagentTranscript.js +51 -5
  19. package/dist/chat/subagentTranscript.js.map +1 -1
  20. package/dist/chat/transcriptTailer.d.ts +37 -1
  21. package/dist/chat/transcriptTailer.js +170 -3
  22. package/dist/chat/transcriptTailer.js.map +1 -1
  23. package/dist/codex/codexAppServer.js +20 -0
  24. package/dist/codex/codexAppServer.js.map +1 -1
  25. package/dist/codex/codexNativeTurnController.js +20 -4
  26. package/dist/codex/codexNativeTurnController.js.map +1 -1
  27. package/dist/codex/codexRolloutTailer.d.ts +1 -0
  28. package/dist/codex/codexRolloutTailer.js +7 -0
  29. package/dist/codex/codexRolloutTailer.js.map +1 -1
  30. package/dist/codex/codexSystemNotice.d.ts +16 -0
  31. package/dist/codex/codexSystemNotice.js +195 -0
  32. package/dist/codex/codexSystemNotice.js.map +1 -0
  33. package/dist/commands/doctor.d.ts +2 -6
  34. package/dist/commands/doctor.js +2 -29
  35. package/dist/commands/doctor.js.map +1 -1
  36. package/dist/commands/launch.d.ts +2 -0
  37. package/dist/commands/launch.js +5 -0
  38. package/dist/commands/launch.js.map +1 -1
  39. package/dist/engine/engine.js +32 -22
  40. package/dist/engine/engine.js.map +1 -1
  41. package/dist/engine/handlers/accountUsage.d.ts +17 -0
  42. package/dist/engine/handlers/accountUsage.js +40 -6
  43. package/dist/engine/handlers/accountUsage.js.map +1 -1
  44. package/dist/engine/handlers/mode.js +42 -1
  45. package/dist/engine/handlers/mode.js.map +1 -1
  46. package/dist/engine/handlers/session.js +5 -3
  47. package/dist/engine/handlers/session.js.map +1 -1
  48. package/dist/engine/handlers/usage.js +4 -1
  49. package/dist/engine/handlers/usage.js.map +1 -1
  50. package/dist/engine/handlers/workspace.js +38 -1
  51. package/dist/engine/handlers/workspace.js.map +1 -1
  52. package/dist/hub/effortDialog.d.ts +20 -0
  53. package/dist/hub/effortDialog.js +40 -0
  54. package/dist/hub/effortDialog.js.map +1 -0
  55. package/dist/hub/engineRelaySocket.d.ts +3 -0
  56. package/dist/hub/engineRelaySocket.js +2 -1
  57. package/dist/hub/engineRelaySocket.js.map +1 -1
  58. package/dist/hub/hubDaemon.js +20 -3
  59. package/dist/hub/hubDaemon.js.map +1 -1
  60. package/dist/hub/hubProtocol.js +8 -2
  61. package/dist/hub/hubProtocol.js.map +1 -1
  62. package/dist/hub/panePreviewPump.d.ts +27 -0
  63. package/dist/hub/panePreviewPump.js +62 -1
  64. package/dist/hub/panePreviewPump.js.map +1 -1
  65. package/dist/hub/questionInjection.js +2 -1
  66. package/dist/hub/questionInjection.js.map +1 -1
  67. package/dist/hub/reaper.d.ts +5 -0
  68. package/dist/hub/reaper.js +25 -7
  69. package/dist/hub/reaper.js.map +1 -1
  70. package/dist/hub/sessionHub.d.ts +48 -1
  71. package/dist/hub/sessionHub.js +248 -25
  72. package/dist/hub/sessionHub.js.map +1 -1
  73. package/dist/protocol/decode.js +45 -0
  74. package/dist/protocol/decode.js.map +1 -1
  75. package/dist/protocol/encode.js +2 -0
  76. package/dist/protocol/encode.js.map +1 -1
  77. package/dist/protocol/messages.d.ts +65 -1
  78. package/dist/services/accountIdentity.js +3 -1
  79. package/dist/services/accountIdentity.js.map +1 -1
  80. package/dist/services/fileService.d.ts +19 -1
  81. package/dist/services/fileService.js +126 -0
  82. package/dist/services/fileService.js.map +1 -1
  83. package/dist/services/keychainMirror.d.ts +9 -0
  84. package/dist/services/keychainMirror.js +64 -0
  85. package/dist/services/keychainMirror.js.map +1 -0
  86. package/dist/services/officialApps.d.ts +12 -0
  87. package/dist/services/officialApps.js +84 -30
  88. package/dist/services/officialApps.js.map +1 -1
  89. package/dist/services/planUsageFetcher.d.ts +53 -5
  90. package/dist/services/planUsageFetcher.js +109 -22
  91. package/dist/services/planUsageFetcher.js.map +1 -1
  92. package/dist/sessions/claudeSessionStore.js +30 -0
  93. package/dist/sessions/claudeSessionStore.js.map +1 -1
  94. package/dist/sessions/heartbeat.d.ts +7 -1
  95. package/dist/sessions/heartbeat.js +3 -1
  96. package/dist/sessions/heartbeat.js.map +1 -1
  97. package/dist/sessions/sessionSearch.js +23 -5
  98. package/dist/sessions/sessionSearch.js.map +1 -1
  99. package/dist/shared/cliVersions.d.ts +14 -0
  100. package/dist/shared/cliVersions.js +38 -0
  101. package/dist/shared/cliVersions.js.map +1 -0
  102. package/dist/shared/crossSession.d.ts +15 -0
  103. package/dist/shared/crossSession.js +75 -0
  104. package/dist/shared/crossSession.js.map +1 -0
  105. package/dist/shared/harnessReminder.d.ts +32 -0
  106. package/dist/shared/harnessReminder.js +124 -0
  107. package/dist/shared/harnessReminder.js.map +1 -0
  108. package/dist/shared/lineWriter.d.ts +7 -0
  109. package/dist/shared/lineWriter.js +26 -0
  110. package/dist/shared/lineWriter.js.map +1 -1
  111. package/dist/shared/systemNotice.d.ts +30 -0
  112. package/dist/shared/systemNotice.js +209 -0
  113. package/dist/shared/systemNotice.js.map +1 -0
  114. package/package.json +3 -3
@@ -7,6 +7,7 @@ import { execFile } from "node:child_process";
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { PROTOCOL_V2 } from "../protocol.js";
10
+ import { injectedReminderBodies } from "../shared/harnessReminder.js";
10
11
  import { abortableSleep } from "../shared/sleep.js";
11
12
  import { extractToolActivities, TranscriptTailer } from "./transcriptTailer.js";
12
13
  /** サブエージェント meta/jsonl と親 transcript の tool_result を監視する。 */
@@ -15,7 +16,11 @@ export class SubagentTailer {
15
16
  tailIndefinitely;
16
17
  orphanGraceMs;
17
18
  orphanProbeIntervalMs;
19
+ agentOrphanGraceMs;
20
+ tentativeFinalGraceMs;
18
21
  probeOutputOpen;
22
+ probeSessionAlive;
23
+ sessionStaleMs;
19
24
  jsonlPaths = new Map();
20
25
  outputPaths = new Map();
21
26
  constructor(options = {}) {
@@ -23,7 +28,12 @@ export class SubagentTailer {
23
28
  this.tailIndefinitely = options.tailIndefinitely ?? true;
24
29
  this.orphanGraceMs = options.orphanGraceMs ?? 60_000;
25
30
  this.orphanProbeIntervalMs = options.orphanProbeIntervalMs ?? 30_000;
31
+ this.agentOrphanGraceMs = options.agentOrphanGraceMs ?? 120_000;
32
+ this.tentativeFinalGraceMs = Math.max(this.agentOrphanGraceMs, options.tentativeFinalGraceMs ?? 600_000);
26
33
  this.probeOutputOpen = options.probeOutputOpen ?? defaultProbeOutputOpen;
34
+ this.sessionStaleMs = options.sessionStaleMs ?? 6 * 60 * 60 * 1000;
35
+ this.probeSessionAlive = options.probeSessionAlive
36
+ ?? ((sessionId, lastActivityMs) => defaultProbeSessionAlive(sessionId, lastActivityMs, this.sessionStaleMs));
27
37
  }
28
38
  /** 現在 tail 中の nodeId から transcript 実体を引く。 */
29
39
  jsonlPath(nodeId) {
@@ -65,10 +75,25 @@ export class SubagentTailer {
65
75
  // 通知が握り潰された背景コマンドの孤児判定(taskId → 終了扱いにした ts)。
66
76
  const bgOrphanTsByTaskId = new Map();
67
77
  const bgFirstSeenMsByTaskId = new Map();
78
+ // 通知が配達されえない背景エージェントの孤児判定(nodeId → settle 扱いにした ts/status)。
79
+ const agentOrphanByNodeId = new Map();
80
+ // 死亡セッションの一括 sweep マーク。生存へ転じたら全破棄して回復できるよう、
81
+ // 通常の孤児 settle とは別に持つ。
82
+ const deadSweepByNodeId = new Map();
83
+ // セッション transcript 群の最終更新(生存プローブの補助情報)。
84
+ let lastActivityTsMs = null;
85
+ // セッション(claude プロセス)の生存。false のみが「死亡確定」。null は生存扱い(安全側)。
86
+ let sessionAlive = null;
87
+ let lastSessionProbeMs = 0;
68
88
  let lastOrphanProbeMs = 0;
69
89
  let aggregateDirty = true;
90
+ let lastMetaRefreshMs = 0;
70
91
  while (!signal?.aborted) {
71
- if (discoverMetaFiles(subagentsDir, tracked))
92
+ // meta の stat/再読込は毎 tick やると重い(ノード数×50ms)。1秒ごとに間引く。
93
+ const refreshMetas = Date.now() - lastMetaRefreshMs >= 1_000;
94
+ if (refreshMetas)
95
+ lastMetaRefreshMs = Date.now();
96
+ if (discoverMetaFiles(subagentsDir, tracked, refreshMetas))
72
97
  aggregateDirty = true;
73
98
  for (const node of tracked.values()) {
74
99
  if (node.jsonlPath !== null)
@@ -83,6 +108,9 @@ export class SubagentTailer {
83
108
  if (node !== null) {
84
109
  node.currentActivity = null;
85
110
  node.lastJsonlTimestampMs = null;
111
+ node.finalReportMarker = null;
112
+ node.finalDefinite = false;
113
+ node.apiErrorMarker = false;
86
114
  }
87
115
  }
88
116
  if (node !== null)
@@ -91,6 +119,9 @@ export class SubagentTailer {
91
119
  aggregateDirty = true;
92
120
  for (const line of read.lines) {
93
121
  const lineTs = timestampMs(line);
122
+ if (lineTs !== null && (lastActivityTsMs === null || lineTs > lastActivityTsMs)) {
123
+ lastActivityTsMs = lineTs;
124
+ }
94
125
  if (read.state.firstTimestampMs === null && lineTs !== null) {
95
126
  read.state.firstTimestampMs = lineTs;
96
127
  }
@@ -129,18 +160,42 @@ export class SubagentTailer {
129
160
  status: "completed",
130
161
  exitCode: null,
131
162
  ts: hit.ts ?? lineTs,
163
+ stopAck: true,
132
164
  });
133
165
  }
134
166
  }
135
- const notification = extractTaskNotification(line);
136
- if (notification !== null) {
167
+ for (const notification of extractTaskNotifications(line)) {
168
+ // 同じ task-id の通知は enqueue ログと配達行(user / attachment / assistant 注入形)の
169
+ // 複数行に現れ、再開→再停止で複数回起こる。ファイル横断の集約と同じく最新 ts の
170
+ // 記録を採る(= 最後に起きた完了イベント)。TaskStop の ack は明示停止として常に優先。
171
+ const existing = read.state.notificationByTaskId.get(notification.taskId);
172
+ if (existing?.stopAck === true)
173
+ continue;
174
+ const ts = notification.ts ?? lineTs;
175
+ if (existing !== undefined && (existing.ts ?? 0) > (ts ?? 0))
176
+ continue;
177
+ // enqueue(完了時点の正確な ts)を、同じ完了イベントの遅い配達 ts で前進させない。
178
+ // 配達は停止境界までずれ込むため、その間の再開(自 transcript の追記)を「通知より
179
+ // 古い」と誤認して running のエージェントを completed に塗ってしまう。再完了は新しい
180
+ // enqueue が先に書かれるので latest-wins のまま拾える。
181
+ if (existing !== undefined && existing.enqueue === true && !notification.enqueue
182
+ && existing.status === notification.status && existing.exitCode === notification.exitCode) {
183
+ continue;
184
+ }
137
185
  read.state.notificationByTaskId.set(notification.taskId, {
138
186
  status: notification.status,
139
187
  exitCode: notification.exitCode,
140
- ts: notification.ts ?? lineTs,
188
+ ts,
189
+ ...(notification.enqueue ? { enqueue: true } : {}),
141
190
  });
142
191
  }
143
192
  if (node !== null) {
193
+ const shape = messageShape(line);
194
+ if (shape !== null) {
195
+ node.finalReportMarker = shape.final;
196
+ node.finalDefinite = shape.definite;
197
+ node.apiErrorMarker = shape.apiError;
198
+ }
144
199
  const activity = latestActivitySummary(line);
145
200
  if (activity !== null)
146
201
  node.currentActivity = activity;
@@ -162,7 +217,17 @@ export class SubagentTailer {
162
217
  resultByToolUseId.set(id, result);
163
218
  for (const [id, notification] of state.notificationByTaskId) {
164
219
  const existing = notificationByTaskId.get(id);
165
- if (existing === undefined || (notification.ts ?? 0) >= (existing.ts ?? 0)) {
220
+ if (existing?.stopAck === true && notification.stopAck !== true)
221
+ continue;
222
+ // per-file と同じ規則: enqueue の正確な ts を、同じ完了イベントの遅い配達 ts で
223
+ // 前進させない。
224
+ if (existing?.enqueue === true && notification.enqueue !== true
225
+ && notification.stopAck !== true
226
+ && existing.status === notification.status && existing.exitCode === notification.exitCode) {
227
+ continue;
228
+ }
229
+ if (existing === undefined || notification.stopAck === true
230
+ || (notification.ts ?? 0) >= (existing.ts ?? 0)) {
166
231
  notificationByTaskId.set(id, notification);
167
232
  }
168
233
  }
@@ -171,34 +236,218 @@ export class SubagentTailer {
171
236
  }
172
237
  aggregateDirty = false;
173
238
  }
174
- for (const node of tracked.values()) {
175
- const result = resultByToolUseId.get(node.meta.toolUseId) ?? null;
176
- let status;
177
- let ts;
239
+ const ownerOf = (node) => {
240
+ // meta の直接リンクを優先(forked-skill の子は transcript 相関では解決できない)。
241
+ const pid = node.meta.parentAgentId;
242
+ if (pid !== null && tracked.has(pid))
243
+ return pid;
244
+ const correlated = node.meta.toolUseId === null
245
+ ? undefined
246
+ : ownerByToolUseId.get(node.meta.toolUseId);
247
+ // 深さ1は起動元が main(root)と確定できる(forked-skill 親もここに落ちる)。
248
+ return correlated ?? (node.meta.spawnDepth <= 1 ? "root" : undefined);
249
+ };
250
+ const resolveAgentStatus = (node) => {
251
+ const result = node.meta.toolUseId === null
252
+ ? null
253
+ : resultByToolUseId.get(node.meta.toolUseId) ?? null;
178
254
  if (result !== null && !result.asyncLaunch) {
179
255
  // 同期実行: 親 transcript の tool_result が終了信号。
180
- status = result.isError ? "error" : "completed";
181
- ts = result.ts ?? node.firstJsonlTimestampMs ?? mtimeMs(node.metaPath);
256
+ return {
257
+ status: result.isError ? "error" : "completed",
258
+ ts: result.ts ?? node.firstJsonlTimestampMs ?? mtimeMs(node.metaPath),
259
+ };
182
260
  }
183
- else {
184
- // バックグラウンド実行(または結果未着): task-notification が終了信号。
185
- // 通知後に自分の transcript が伸びたら resume とみなし running へ戻す。
186
- const notification = notificationByTaskId.get(node.nodeId) ?? null;
187
- const resumedAfter = notification !== null && notification.ts !== null
188
- && (node.lastJsonlTimestampMs ?? 0) > notification.ts;
189
- if (notification !== null && !resumedAfter) {
190
- status = notification.status === "completed" ? "completed" : "error";
191
- ts = notification.ts ?? node.lastJsonlTimestampMs ?? mtimeMs(node.metaPath);
261
+ // バックグラウンド実行(または結果未着): task-notification が終了信号。
262
+ // 通知後に自分の transcript が伸びたら resume とみなし running へ戻す
263
+ // (再通知か、通知済み継続の settle(下記 notifiedContinuation)で再び終端する)。
264
+ const notification = notificationByTaskId.get(node.nodeId) ?? null;
265
+ const resumedAfter = notification !== null && notification.ts !== null
266
+ && (node.lastJsonlTimestampMs ?? 0) > notification.ts;
267
+ if (notification !== null && !resumedAfter) {
268
+ return {
269
+ status: notification.status === "completed" ? "completed" : "error",
270
+ ts: notification.ts ?? node.lastJsonlTimestampMs ?? mtimeMs(node.metaPath),
271
+ };
272
+ }
273
+ // ユーザー停止済み(TaskStop 等)。通知は残らないため meta の刻印が終了信号。
274
+ if (node.meta.stoppedByUser) {
275
+ return { status: "completed", ts: node.lastJsonlTimestampMs ?? mtimeMs(node.metaPath) };
276
+ }
277
+ // 通知が配達されえず孤児 settle 済みなら終端。settle 後に transcript が
278
+ // 伸びたら resume とみなしマークを破棄して running へ戻す(孤児検知で再評価される)。
279
+ const orphan = agentOrphanByNodeId.get(node.nodeId);
280
+ if (orphan !== undefined) {
281
+ if ((node.lastJsonlTimestampMs ?? 0) > orphan.ts) {
282
+ agentOrphanByNodeId.delete(node.nodeId);
283
+ }
284
+ else {
285
+ return { status: orphan.status, ts: orphan.ts };
286
+ }
287
+ }
288
+ // 死亡セッションの sweep マーク。transcript が伸びたら破棄(誤判定からの回復)。
289
+ const sweep = deadSweepByNodeId.get(node.nodeId);
290
+ if (sweep !== undefined) {
291
+ if ((node.lastJsonlTimestampMs ?? 0) > sweep.ts) {
292
+ deadSweepByNodeId.delete(node.nodeId);
192
293
  }
193
294
  else {
194
- status = "running";
195
- ts = node.firstJsonlTimestampMs ?? mtimeMs(node.metaPath);
295
+ return { status: sweep.status, ts: sweep.ts };
196
296
  }
197
297
  }
198
- const parentNodeId = ownerByToolUseId.get(node.meta.toolUseId) ?? fallbackParent(node.meta.spawnDepth);
298
+ return { status: "running", ts: node.firstJsonlTimestampMs ?? mtimeMs(node.metaPath) };
299
+ };
300
+ const nowMs = Date.now();
301
+ if (nowMs - lastSessionProbeMs >= this.orphanProbeIntervalMs) {
302
+ lastSessionProbeMs = nowMs;
303
+ sessionAlive = await this.probeSessionAlive(sessionId, lastActivityTsMs);
304
+ }
305
+ // 孤児判定(エージェント): 背景エージェントの task-notification は「起動元の transcript に、
306
+ // 起動元が次に動くとき」に投函されるため、起動元が子より先に終端した・セッションが死んだ等で
307
+ // 配達が途絶えると二度と届かない。判定はセッション生存で二分する:
308
+ //
309
+ // [死亡確定 (sessionAlive === false)] このセッションの作業は何も動いていない。
310
+ // 静止時間が猶予を超えた running ノードを最終レポートの有無を問わず一括 settle する
311
+ // (中断で tool_use のまま止まった木・同期起動の孤児もここで解消する)。
312
+ //
313
+ // [生存または不明] root(main)起動のノードは settle しない — 生きている main への
314
+ // 通知配達は途絶えないし、SendMessage 待機中の生存エージェントを「完了」に
315
+ // してはならない。settle するのは「実信号(通知/同期結果/停止刻印/過去の settle)で
316
+ // 終端した起動元」に根ざした木だけ。条件:
317
+ // - 背景起動(forked-skill、または async 起動 ack 済み)だが有効な完了信号がない
318
+ // - 最後の message 行が「text のみの assistant」(最終レポート済み。再開注入の
319
+ // user 行や生成途中の thinking 行でマーカーが倒れるため、再開直後の長考中・
320
+ // 長い最終レポートのストリーミング中は候補にならない)
321
+ // - transcript が agentOrphanGraceMs 以上静止(transcript 時刻起点なので
322
+ // tail 張り直し直後でも古い孤児は即 settle できる)
323
+ // - 直下に running かつ非候補の子(エージェント/背景コマンド)がいない(fixpoint。
324
+ // 子の通知で再開されうるため)
325
+ // - 起動元が終端済み、または「終端済みに根ざして承認された候補」(承認は終端
326
+ // アンカーからの伝播のみ。候補同士の相互承認では settle しないので、壊れた
327
+ // meta の循環参照でも誤 settle しない)
328
+ // - 例外=「通知済み継続」: 完了通知が一度出た後に自 transcript が伸びたノードは、
329
+ // 起動元が root でも settle する。ハーネスは 1 回の走行につき最初の停止でしか
330
+ // 通知しないため、停止境界で自分の背景タスク通知([SYSTEM NOTIFICATION])を
331
+ // 受け取って続行した走行の 2 度目の停止には通知が来ない(実測: 通知後 3 分続行し
332
+ // 最終レポートで停止 → 20 時間「実行中」残留)。通知済み = 起動元へ完了が届いた
333
+ // 後なので、SendMessage 待機中の「未通知の生存エージェント」保護とは衝突しない。
334
+ // coordinator 再開(SendMessage)なら再通知が数秒で届き、そちらの ts が優先される。
335
+ const agentStatusById = new Map();
336
+ for (const node of tracked.values())
337
+ agentStatusById.set(node.nodeId, resolveAgentStatus(node).status);
338
+ const lastActivityMs = (node) => node.lastJsonlTimestampMs ?? mtimeMs(node.jsonlPath ?? node.metaPath);
339
+ // 最終レポートの形で止まっていれば完了、API エラーで途絶えていれば error。
340
+ const settledStatus = (node) => node.finalReportMarker === true && !node.apiErrorMarker ? "completed" : "error";
341
+ if (sessionAlive === false) {
342
+ for (const node of tracked.values()) {
343
+ if (agentStatusById.get(node.nodeId) !== "running")
344
+ continue;
345
+ if (deadSweepByNodeId.has(node.nodeId))
346
+ continue;
347
+ const lastTs = lastActivityMs(node);
348
+ if (lastTs <= 0 || nowMs - lastTs < this.agentOrphanGraceMs)
349
+ continue;
350
+ // tool_use のまま止まっていれば中断(error)。
351
+ deadSweepByNodeId.set(node.nodeId, { ts: lastTs, status: settledStatus(node) });
352
+ }
353
+ }
354
+ else {
355
+ // 生存(または不明)へ転じたら sweep 由来のマークは全破棄する。
356
+ // 死亡判定が誤りだった場合の回復経路(通常の孤児 settle は保持する)。
357
+ if (deadSweepByNodeId.size > 0)
358
+ deadSweepByNodeId.clear();
359
+ const settleCandidates = new Set();
360
+ const notifiedContinuations = new Set();
361
+ for (const node of tracked.values()) {
362
+ if (agentStatusById.get(node.nodeId) !== "running")
363
+ continue;
364
+ const result = node.meta.toolUseId === null
365
+ ? null
366
+ : resultByToolUseId.get(node.meta.toolUseId) ?? null;
367
+ const backgroundLaunch = node.meta.toolUseId === null || (result !== null && result.asyncLaunch);
368
+ if (!backgroundLaunch)
369
+ continue;
370
+ if (node.finalReportMarker !== true)
371
+ continue;
372
+ if (node.lastJsonlTimestampMs === null)
373
+ continue;
374
+ // stop_reason 無しの暫定的な最終行は「続きの tool_use を生成中」でもあり得るので長い猶予。
375
+ const graceMs = node.finalDefinite ? this.agentOrphanGraceMs : this.tentativeFinalGraceMs;
376
+ if (nowMs - node.lastJsonlTimestampMs < graceMs)
377
+ continue;
378
+ settleCandidates.add(node.nodeId);
379
+ const notification = notificationByTaskId.get(node.nodeId) ?? null;
380
+ if (notification !== null && notification.ts !== null && node.lastJsonlTimestampMs > notification.ts) {
381
+ notifiedContinuations.add(node.nodeId);
382
+ }
383
+ }
384
+ for (;;) {
385
+ const blockedOwners = new Set();
386
+ for (const node of tracked.values()) {
387
+ if (agentStatusById.get(node.nodeId) !== "running")
388
+ continue;
389
+ if (settleCandidates.has(node.nodeId))
390
+ continue;
391
+ const owner = ownerOf(node);
392
+ if (owner !== undefined)
393
+ blockedOwners.add(owner);
394
+ }
395
+ for (const command of bgCommandByTaskId.values()) {
396
+ if (notificationByTaskId.has(command.taskId) || bgOrphanTsByTaskId.has(command.taskId))
397
+ continue;
398
+ blockedOwners.add(command.owner);
399
+ }
400
+ let changed = false;
401
+ for (const id of settleCandidates) {
402
+ if (!blockedOwners.has(id))
403
+ continue;
404
+ settleCandidates.delete(id);
405
+ changed = true;
406
+ }
407
+ if (!changed)
408
+ break;
409
+ }
410
+ const approved = new Set();
411
+ for (;;) {
412
+ let changed = false;
413
+ for (const id of settleCandidates) {
414
+ if (approved.has(id))
415
+ continue;
416
+ const node = tracked.get(id);
417
+ if (node === undefined)
418
+ continue;
419
+ // 通知済み継続は起動元を問わず承認(起動元へは完了が届いている)。
420
+ if (notifiedContinuations.has(id)) {
421
+ approved.add(id);
422
+ changed = true;
423
+ continue;
424
+ }
425
+ const ownerId = ownerOf(node);
426
+ if (ownerId === undefined || ownerId === "root")
427
+ continue;
428
+ const ownerStatus = agentStatusById.get(ownerId);
429
+ if (ownerStatus !== "completed" && ownerStatus !== "error" && !approved.has(ownerId))
430
+ continue;
431
+ approved.add(id);
432
+ changed = true;
433
+ }
434
+ if (!changed)
435
+ break;
436
+ }
437
+ for (const id of approved) {
438
+ const node = tracked.get(id);
439
+ if (node === undefined)
440
+ continue;
441
+ agentOrphanByNodeId.set(id, { ts: lastActivityMs(node), status: settledStatus(node) });
442
+ }
443
+ }
444
+ for (const node of tracked.values()) {
445
+ const { status, ts } = resolveAgentStatus(node);
446
+ const parentNodeId = ownerOf(node) ?? fallbackParent(node.meta.spawnDepth);
199
447
  const messageNode = {
200
448
  nodeId: node.nodeId,
201
- toolUseId: node.meta.toolUseId,
449
+ // forked-skill エージェントに tool_use はない。ワイヤ上は必須 string のため空で送る。
450
+ toolUseId: node.meta.toolUseId ?? "",
202
451
  parentNodeId,
203
452
  agentType: node.meta.agentType,
204
453
  label: node.meta.description,
@@ -213,11 +462,10 @@ export class SubagentTailer {
213
462
  node.lastKey = key;
214
463
  yield { type: "subagent_node", v: PROTOCOL_V2, node: messageNode };
215
464
  }
216
- // 孤児判定: task-notification は起動元エージェントが先に終了すると届かないまま
217
- // 消えることがある(完了信号の消失)。output ファイルを開いているプロセスが
465
+ // 孤児判定(背景コマンド): task-notification は起動元エージェントが先に終了すると
466
+ // 届かないまま消えることがある(完了信号の消失)。output ファイルを開いているプロセスが
218
467
  // いなければ「終了済みなのに通知が来ない孤児」として完了へ落とす。
219
468
  // 実行中のコマンドはシェルが output の fd を保持し続けるため誤爆しない。
220
- const nowMs = Date.now();
221
469
  if (nowMs - lastOrphanProbeMs >= this.orphanProbeIntervalMs) {
222
470
  lastOrphanProbeMs = nowMs;
223
471
  for (const command of bgCommandByTaskId.values()) {
@@ -277,7 +525,7 @@ export class SubagentTailer {
277
525
  }
278
526
  }
279
527
  }
280
- function discoverMetaFiles(dir, tracked) {
528
+ function discoverMetaFiles(dir, tracked, refreshMetas) {
281
529
  let files;
282
530
  try {
283
531
  files = fs.readdirSync(dir);
@@ -298,6 +546,18 @@ function discoverMetaFiles(dir, tracked) {
298
546
  existing.jsonlPath = jsonlPath;
299
547
  changed = true;
300
548
  }
549
+ // stoppedByUser は停止時に後から書き込まれるため、mtime 変化で meta を読み直す。
550
+ if (refreshMetas) {
551
+ const metaMtime = mtimeMs(existing.metaPath);
552
+ if (metaMtime !== existing.metaMtimeMs) {
553
+ existing.metaMtimeMs = metaMtime;
554
+ const meta = readMeta(existing.metaPath);
555
+ if (meta !== null) {
556
+ existing.meta = meta;
557
+ changed = true;
558
+ }
559
+ }
560
+ }
301
561
  continue;
302
562
  }
303
563
  const metaPath = path.join(dir, file);
@@ -308,9 +568,13 @@ function discoverMetaFiles(dir, tracked) {
308
568
  nodeId,
309
569
  meta,
310
570
  metaPath,
571
+ metaMtimeMs: mtimeMs(metaPath),
311
572
  jsonlPath: siblingJsonl(metaPath),
312
573
  firstJsonlTimestampMs: null,
313
574
  lastJsonlTimestampMs: null,
575
+ finalReportMarker: null,
576
+ finalDefinite: false,
577
+ apiErrorMarker: false,
314
578
  currentActivity: null,
315
579
  lastKey: null,
316
580
  });
@@ -323,15 +587,16 @@ function readMeta(metaPath) {
323
587
  const parsed = JSON.parse(fs.readFileSync(metaPath, "utf8"));
324
588
  if (typeof parsed["agentType"] !== "string" ||
325
589
  typeof parsed["description"] !== "string" ||
326
- typeof parsed["toolUseId"] !== "string" ||
327
590
  typeof parsed["spawnDepth"] !== "number") {
328
591
  return null;
329
592
  }
330
593
  return {
331
594
  agentType: parsed["agentType"],
332
595
  description: parsed["description"],
333
- toolUseId: parsed["toolUseId"],
596
+ toolUseId: typeof parsed["toolUseId"] === "string" ? parsed["toolUseId"] : null,
334
597
  spawnDepth: parsed["spawnDepth"],
598
+ parentAgentId: typeof parsed["parentAgentId"] === "string" ? parsed["parentAgentId"] : null,
599
+ stoppedByUser: parsed["stoppedByUser"] === true,
335
600
  };
336
601
  }
337
602
  catch {
@@ -524,26 +789,163 @@ function extractBackgroundSpawns(line) {
524
789
  return out;
525
790
  }
526
791
  /**
527
- * 親 transcript の `<task-notification>` 行(user メッセージの文字列 content)を解析する。
528
- * バックグラウンドのエージェント/コマンド共通の完了信号で、task-id はエージェントの
529
- * nodeId またはコマンドの taskId に一致する。
792
+ * `<task-notification>` ブロックを運ぶ行の本文と出所(enqueue = 完了時点の正確な ts を
793
+ * 持つ)を返す。配達形は 4 つ:
794
+ * - user 行(文字列 content): 起動元がターン境界で受け取った通知(ts ≈ 完了時刻)
795
+ * - attachment(queued_command) 行: 起動元がターン中だったとき、次の tool_result 境界で
796
+ * 配達された通知(prompt に本文。user 行としては残らない。ts は enqueue 時刻)
797
+ * - queue-operation 行(content): ハーネスのキュー操作ログ。`enqueue` は完了時点で
798
+ * 配達より先に書かれる最速の信号。`remove` は配達後(実測で最大 268s 後)に書かれる
799
+ * ログなので読まない — その ts を通知時刻に採ると、配達遅延の間に起きた再開
800
+ * (SendMessage)を「通知より古い行」と誤認して completed に塗ってしまう。
801
+ * - assistant 行の text ブロック末尾(`<system-reminder>` 包み。Claude Code 2.1.251〜の
802
+ * 停止境界注入): 通知が user 行に残らない配達形。行頭形ブロックの中身だけを返す
803
+ * (引用・fence 内には反応しない。ts は assistant 行 ≈ 配達時刻)。
530
804
  */
531
- function extractTaskNotification(line) {
532
- const content = messageContent(line);
533
- if (typeof content !== "string" || !content.includes("<task-notification>"))
805
+ function notificationText(line) {
806
+ try {
807
+ const obj = JSON.parse(line);
808
+ if (obj["type"] === "attachment") {
809
+ const attachment = typeof obj["attachment"] === "object" && obj["attachment"] !== null
810
+ ? obj["attachment"]
811
+ : null;
812
+ if (attachment?.["type"] !== "queued_command")
813
+ return null;
814
+ return typeof attachment["prompt"] === "string" ? { text: attachment["prompt"], enqueue: false } : null;
815
+ }
816
+ if (obj["type"] === "queue-operation") {
817
+ if (obj["operation"] !== "enqueue")
818
+ return null;
819
+ return typeof obj["content"] === "string" ? { text: obj["content"], enqueue: true } : null;
820
+ }
821
+ const message = obj["message"];
822
+ if (typeof message === "object" && message !== null) {
823
+ const content = message["content"];
824
+ if (typeof content === "string")
825
+ return { text: content, enqueue: false };
826
+ // 停止境界の背景通知注入(Claude Code 2.1.251〜): 通知が user 行に残らず、直前の
827
+ // assistant text ブロック末尾へ <system-reminder> として追記される。行頭形ブロックの
828
+ // 中身だけを通知本文として読む(表示側の除去と同じ門番なので、モデルが本文や fence
829
+ // 内で引用した <task-notification> には反応しない)。
830
+ if (obj["type"] === "assistant" && Array.isArray(content)) {
831
+ const joined = content
832
+ .flatMap((block) => {
833
+ if (typeof block !== "object" || block === null)
834
+ return [];
835
+ const rec = block;
836
+ return rec["type"] === "text" && typeof rec["text"] === "string" ? [rec["text"]] : [];
837
+ })
838
+ .join("\n");
839
+ if (!joined.includes("<task-notification>"))
840
+ return null;
841
+ const bodies = injectedReminderBodies(joined);
842
+ return bodies.length > 0 ? { text: bodies.join("\n"), enqueue: false } : null;
843
+ }
844
+ return null;
845
+ }
846
+ return typeof obj["content"] === "string" ? { text: obj["content"], enqueue: false } : null;
847
+ }
848
+ catch {
534
849
  return null;
535
- const taskId = /<task-id>([^<]+)<\/task-id>/.exec(content)?.[1];
536
- if (taskId === undefined)
850
+ }
851
+ }
852
+ /**
853
+ * 行に含まれる `<task-notification>` を全て解析する(1 行に複数ブロックが束ねられる:
854
+ * 同時に終わった背景コマンド 2 件が 1 つの [SYSTEM NOTIFICATION] で届く等)。
855
+ * バックグラウンドのエージェント/コマンド共通の完了信号で、task-id はエージェントの
856
+ * nodeId またはコマンドの taskId に一致する。同じ封筒は完了以外にも使われる
857
+ * (Monitor の進捗 `<event>`、背景コマンドの「対話入力待ちらしい」ヒント)。それらは
858
+ * `<status>` を持たないので、`<status>` のないブロックは完了信号として扱わない。
859
+ */
860
+ function extractTaskNotifications(line) {
861
+ const carried = notificationText(line);
862
+ if (carried === null || !carried.text.includes("<task-notification>"))
863
+ return [];
864
+ const content = carried.text;
865
+ const ts = timestampMs(line);
866
+ const out = [];
867
+ const blockPattern = /<task-notification>([\s\S]*?)(?:<\/task-notification>|$)/g;
868
+ for (const match of content.matchAll(blockPattern)) {
869
+ const block = match[1] ?? "";
870
+ const taskId = /<task-id>([^<]+)<\/task-id>/.exec(block)?.[1];
871
+ if (taskId === undefined)
872
+ continue;
873
+ const status = /<status>([^<]+)<\/status>/.exec(block)?.[1];
874
+ if (status === undefined)
875
+ continue;
876
+ const summary = /<summary>([^<]*)<\/summary>/.exec(block)?.[1] ?? "";
877
+ const exitCode = /exit code (\d+)/.exec(summary)?.[1];
878
+ out.push({
879
+ taskId,
880
+ status,
881
+ exitCode: exitCode === undefined ? null : Number(exitCode),
882
+ ts,
883
+ enqueue: carried.enqueue,
884
+ });
885
+ }
886
+ return out;
887
+ }
888
+ /** message の生成完了を示す stop_reason(これ以外は「続きがある」か「不明」)。 */
889
+ const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens", "refusal"]);
890
+ /** 続きのブロック/ターンが確定している stop_reason(text のみ行でも最終レポートではない)。 */
891
+ const CONTINUING_STOP_REASONS = new Set(["tool_use", "pause_turn"]);
892
+ /**
893
+ * message 行の「最終レポートらしさ」を返す:
894
+ * final=true = text のみの assistant 行(サブエージェントはこの形で停止する)
895
+ * final=false = それ以外の message 行(user 行・tool_use を含む assistant 行・
896
+ * 生成途中の thinking のみの assistant 行・stop_reason が「続きあり」の text 行)
897
+ * null = message を持たない行(summary 等。判定を変えない)
898
+ * final のまま transcript が静止していればエージェントは停止済み。再開注入
899
+ * (task-notification / SendMessage)は user 行として届き false へ倒れるため、
900
+ * 再開直後の長考中を「最終レポート済み」と誤認しない。
901
+ * claude は content ブロックを 1 行ずつ書く(実データ: サブエージェント側 assistant 2.4 万行中
902
+ * 複数ブロックは 1 行、main 側 4.0 万行中 0)。
903
+ * thinking ブロックの行は後続の text/tool_use をまだ生成中の印(最終行になるのは中断時だけ)
904
+ * なので final=false。text 行の stop_reason は:
905
+ * - 終端(end_turn 等)→ definite(生成完了確定。確定形の猶予)
906
+ * - tool_use / pause_turn → 続きの tool_use/ターンが確定 → final=false(message 途中の text 行を
907
+ * main 側 writer はこの形で書く: main transcript で 3,685 行、サブエージェント側は 10 行。
908
+ * 99.9% が同一 message の tool_use 行に続かれ、最終行になった例は両側とも 0)
909
+ * - null / 欠落 / 未知 → 暫定(サブエージェント側 writer は message 途中の text 行を null で
910
+ * 書く(無音の最長は実測 135s、>120s は 0.4%)。一方で null のまま書かれた最終レポートも
911
+ * 版を問わず 6〜30%(全体 11.7%)あり判別不能)→ 呼び出し側が長い猶予で扱う
912
+ */
913
+ function messageShape(line) {
914
+ try {
915
+ const obj = JSON.parse(line);
916
+ const message = obj["message"];
917
+ if (typeof message !== "object" || message === null)
918
+ return null;
919
+ const rec = message;
920
+ const content = rec["content"];
921
+ if (content === undefined || content === null)
922
+ return null;
923
+ const notFinal = { final: false, definite: false, apiError: false };
924
+ if (rec["role"] !== "assistant")
925
+ return notFinal;
926
+ const apiError = obj["isApiErrorMessage"] === true;
927
+ const stopReason = typeof rec["stop_reason"] === "string" ? rec["stop_reason"] : null;
928
+ if (stopReason !== null && CONTINUING_STOP_REASONS.has(stopReason))
929
+ return notFinal;
930
+ const definite = stopReason !== null && TERMINAL_STOP_REASONS.has(stopReason);
931
+ if (typeof content === "string")
932
+ return { final: true, definite, apiError };
933
+ if (!Array.isArray(content))
934
+ return null;
935
+ const types = content.map((block) => typeof block === "object" && block !== null
936
+ ? block["type"]
937
+ : undefined);
938
+ if (types.includes("tool_use"))
939
+ return notFinal;
940
+ const thinkingOnly = types.length > 0
941
+ && types.every((type) => type === "thinking" || type === "redacted_thinking");
942
+ if (thinkingOnly)
943
+ return notFinal;
944
+ return { final: true, definite, apiError };
945
+ }
946
+ catch {
537
947
  return null;
538
- const status = /<status>([^<]+)<\/status>/.exec(content)?.[1] ?? "completed";
539
- const summary = /<summary>([^<]*)<\/summary>/.exec(content)?.[1] ?? "";
540
- const exitCode = /exit code (\d+)/.exec(summary)?.[1];
541
- return {
542
- taskId,
543
- status,
544
- exitCode: exitCode === undefined ? null : Number(exitCode),
545
- ts: timestampMs(line),
546
- };
948
+ }
547
949
  }
548
950
  function latestActivitySummary(line) {
549
951
  const activities = extractToolActivities(messageContent(line));
@@ -649,6 +1051,48 @@ function probeViaLsof(filePath) {
649
1051
  });
650
1052
  });
651
1053
  }
1054
+ const defaultProbeExec = (cmd, args, callback) => {
1055
+ execFile(cmd, args, { timeout: 10_000 }, (error, stdout) => {
1056
+ callback(error, stdout ?? "");
1057
+ });
1058
+ };
1059
+ /**
1060
+ * セッションの claude プロセス生存を判定する。
1061
+ * 1) argv にセッション id を持つプロセスがいれば生存確定(Tailii が張る engine は
1062
+ * `--session-id`/`--resume` で id を運ぶ)。
1063
+ * 2) argv 不一致でも bare CLI(argv に id なし)の可能性が残るため、transcript が
1064
+ * staleMs 以内に更新されていれば「不明」(生存扱い)。
1065
+ * 3) argv 不一致 かつ transcript が staleMs 以上静止しているときだけ「死亡確定」。
1066
+ * 生きているのに静かなだけのセッションを誤判定しても、transcript が伸びれば
1067
+ * sweep マークは破棄され running へ回復する。
1068
+ * プロセス名の照合(`lsof -c claude` 等)は使わない — macOS では CLI の comm が
1069
+ * バージョン文字列(例 "2.1.226")になるなど環境・バージョンで壊れるため。
1070
+ * プローブ不能(pgrep 不在・タイムアウト等)は常に「不明」= 生存扱いへ倒す。
1071
+ */
1072
+ export async function defaultProbeSessionAlive(sessionId, lastActivityMs, staleMs, exec = defaultProbeExec) {
1073
+ const byArgv = await probeArgvAlive(sessionId, exec);
1074
+ if (byArgv === true)
1075
+ return true;
1076
+ if (byArgv === null)
1077
+ return null;
1078
+ if (lastActivityMs === null)
1079
+ return null;
1080
+ return Date.now() - lastActivityMs >= staleMs ? false : null;
1081
+ }
1082
+ /** argv にセッション id を持つプロセスの有無。true / false / null(判定不能)。 */
1083
+ function probeArgvAlive(sessionId, exec) {
1084
+ return new Promise((resolve) => {
1085
+ exec("pgrep", ["-f", sessionId], (error, stdout) => {
1086
+ if (error === null) {
1087
+ resolve(stdout.trim().length > 0);
1088
+ return;
1089
+ }
1090
+ // exit 1 = 一致なし。それ以外(pgrep 不在・タイムアウト等)は判定不能。
1091
+ const code = error.code;
1092
+ resolve(code === 1 ? false : null);
1093
+ });
1094
+ });
1095
+ }
652
1096
  /** lsof が無い Linux 向けフォールバック。/proc/<pid>/fd を走査する。 */
653
1097
  function probeViaProcFd(filePath) {
654
1098
  let target;