@adhdev/daemon-core 0.9.82-rc.372 → 0.9.82-rc.374

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.
@@ -7,7 +7,21 @@
7
7
  * grep the file by hand. Because the mesh RPC envelope is sent as a single
8
8
  * datachannel message (~256KB SCTP ceiling, no chunking), the returned tail is
9
9
  * HARD-bounded by `tailBytes` (default 64KB, capped at MAX_TAIL_BYTES=128KB) and
10
- * flags `truncated:true` when the file was larger.
10
+ * flags `truncated:true` when more content existed than fit.
11
+ *
12
+ * Two read modes:
13
+ * - No filter (no grep/sinceMs): byte-bounded tail of the active file — read the
14
+ * last `tailBytes` bytes only. Cheap, backward-compatible.
15
+ * - Filtered (grep and/or sinceMs given): FULL-FILE scan. The filter is applied
16
+ * across the ENTIRE file (plus the size-rotation `.1.log` backup) BEFORE the
17
+ * byte cap, then the last `tailBytes` worth of MATCHING lines are returned.
18
+ * This is the fix for "matches hidden behind polling spam": when the recent
19
+ * tail window is saturated with high-frequency lines (e.g. coordinator polling
20
+ * `get_pending_mesh_events`/`read_chat` every few seconds), a grep for a rarer
21
+ * earlier line (dispatch/inject/forward) previously matched 0 because the line
22
+ * had already scrolled out of the tail window before the filter ran. Filtering
23
+ * the whole file first surfaces those matches regardless of how much unrelated
24
+ * spam followed them.
11
25
  *
12
26
  * Boundary-safe: lines are cut on the newline byte (0x0A) only, which never
13
27
  * appears inside a multibyte UTF-8 sequence, so decoding each complete byte
@@ -29,18 +43,39 @@ export interface DaemonLogTailResult {
29
43
  success: boolean;
30
44
  error?: string;
31
45
  lines: string[];
46
+ /**
47
+ * No-filter mode: true when the file was larger than the byte window.
48
+ * Filter mode: true when matching lines were dropped from the FRONT to fit
49
+ * the byte cap (i.e. there are older matches than the ones returned).
50
+ */
32
51
  truncated: boolean;
33
52
  logPath: string;
34
53
  platform: NodeJS.Platform;
35
54
  bytesReturned: number;
36
- /** True when a grep/since filter dropped lines from the raw tail window. */
55
+ /** True when a grep/since filter dropped at least one line. */
37
56
  filtered: boolean;
38
57
  /** The grep source actually applied (echoed back for clarity). */
39
58
  grep?: string;
59
+ /** True when the filtered full-file scan path ran (grep/sinceMs given). */
60
+ fullScan: boolean;
61
+ /** Total bytes read while scanning (filter mode scans the whole file + backup). */
62
+ scannedBytes: number;
63
+ /** Number of lines that matched the filter across the full scan (filter mode). */
64
+ matchedLineCount: number;
65
+ /** Number of scanned lines dropped by the filter ("N lines excluded by filter"). */
66
+ excludedByFilter: number;
40
67
  }
41
68
  /**
42
- * Read the daemon log tail for `date` (default today), bounded to `tailBytes`,
43
- * with optional grep (regex source) and sinceMs filters. Falls back to the
44
- * size-rotation backup (`*.1.log`) when the primary file does not exist.
69
+ * Read the daemon log tail for `date` (default today), bounded to `tailBytes`.
70
+ *
71
+ * - No grep/sinceMs byte-bounded tail of the active file (legacy behaviour).
72
+ * - grep and/or sinceMs given → FULL-FILE scan: the filter is applied across the
73
+ * whole file (plus the `*.1.log` size-rotation backup) BEFORE the byte cap, so
74
+ * matches that have scrolled out of the recent tail window (e.g. behind
75
+ * coordinator polling spam) are still returned. Only the last `tailBytes` worth
76
+ * of matching lines ship over P2P.
77
+ *
78
+ * Falls back to the size-rotation backup (`*.1.log`) when the primary file does
79
+ * not exist.
45
80
  */
46
81
  export declare function readDaemonLogTail(args?: ReadDaemonLogTailArgs): DaemonLogTailResult;
@@ -11,6 +11,18 @@ export declare function findRecentTerminalLedgerEvidence(args: {
11
11
  } | null;
12
12
  export declare function hasDispatchAfterTerminal(meshId: string, sessionId: string, terminalId: string): boolean;
13
13
  export declare function hasUnterminalDirectDispatchLedgerEntry(meshId: string, sessionId: string): boolean;
14
+ export declare function findTerminalLedgerEvidenceForTask(args: {
15
+ meshId: string;
16
+ taskId?: string;
17
+ sessionId?: string;
18
+ nodeId?: string;
19
+ tail?: number;
20
+ }): {
21
+ id: string;
22
+ kind: Extract<MeshLedgerKind, 'task_completed' | 'task_failed' | 'task_stalled'>;
23
+ payload: Record<string, unknown>;
24
+ timestamp: string;
25
+ } | null;
14
26
  export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
15
27
  meshId: string;
16
28
  nodeId?: string;
@@ -1,5 +1,5 @@
1
1
  import type { ChatMessage } from '../types.js';
2
- export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 4000;
2
+ export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 16000;
3
3
  export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
4
4
  /**
5
5
  * Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.372",
3
+ "version": "0.9.82-rc.374",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.372",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.374",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -75,6 +75,12 @@ export const meshNodeLogsHandlers: Record<string, LowFamilyHandler> = {
75
75
  truncated: tail.truncated,
76
76
  filtered: tail.filtered,
77
77
  bytesReturned: tail.bytesReturned,
78
+ // Transparency meta — lets the coordinator see that a full-file grep
79
+ // ran past the recent tail window, and how much was scanned/excluded.
80
+ fullScan: tail.fullScan,
81
+ scannedBytes: tail.scannedBytes,
82
+ matchedLineCount: tail.matchedLineCount,
83
+ excludedByFilter: tail.excludedByFilter,
78
84
  ...(tail.grep ? { grep: tail.grep } : {}),
79
85
  } as CommandRouterResult;
80
86
  },
@@ -289,9 +289,19 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
289
289
  const nodeMachineId = readMeshNodeMachineId(node as Record<string, unknown>) || '';
290
290
  const selfDaemonId = ctx.deps.statusInstanceId || '';
291
291
  const selfMachineId = (() => { try { return loadConfig().machineId || ''; } catch { return ''; } })();
292
+ // Identity match is form-safe: a daemon answers to the same machine
293
+ // under interchangeable id forms (bare `mach_X`, cloud `daemon_mach_X`,
294
+ // standalone `standalone_mach_X`). statusInstanceId/loadConfig().machineId
295
+ // and the node's stored daemonId/machineId frequently hold DIFFERENT forms
296
+ // of the same machine, so a raw `===` would miss the self-match and let the
297
+ // coordinator delete its own live base node (the very accident this guard
298
+ // exists to prevent). daemonIdsEquivalent collapses every form to its
299
+ // machine core before comparing, so a same-machine match is caught
300
+ // regardless of which form each side carries. This only widens matches
301
+ // (every raw-`===` hit still matches) — fail-open → fail-closed.
292
302
  const isCoordinatorBaseNode =
293
- (!!selfDaemonId && (nodeDaemonId === selfDaemonId || nodeMachineId === selfDaemonId))
294
- || (!!selfMachineId && (nodeDaemonId === selfMachineId || nodeMachineId === selfMachineId));
303
+ (!!selfDaemonId && (daemonIdsEquivalent(nodeDaemonId, selfDaemonId) || daemonIdsEquivalent(nodeMachineId, selfDaemonId)))
304
+ || (!!selfMachineId && (daemonIdsEquivalent(nodeDaemonId, selfMachineId) || daemonIdsEquivalent(nodeMachineId, selfMachineId)));
295
305
  if (isCoordinatorBaseNode) {
296
306
  return {
297
307
  success: false,
@@ -1216,6 +1216,51 @@ export function buildMeshNodeDataFreshness(args: {
1216
1216
  };
1217
1217
  }
1218
1218
 
1219
+ /**
1220
+ * Canonical live-probe → freshness adapter. The coordinator-facing mesh_status
1221
+ * (mcp-server `meshStatus`) builds each node entry from a SINGLE fresh git_status
1222
+ * probe that either returns (live truth) or throws (peer unreachable). It used to
1223
+ * hand-reconstruct the freshness INPUT inline — a synthetic `{ git, connection }`
1224
+ * status plus the directTruthUnavailable/liveTruthProbed wiring — which is exactly
1225
+ * how a field added to `buildMeshNodeDataFreshness`'s input contract ends up "wired
1226
+ * on the daemon surface, null on the coordinator surface" (the rc.371
1227
+ * null-everywhere regression). Routing every live-probe surface through this one
1228
+ * adapter keeps the marker derivation canonical: there is a SINGLE place that turns
1229
+ * a probe outcome into freshness args, so the two mesh_status surfaces cannot drift.
1230
+ *
1231
+ * `liveTruthProbed` true → the probe returned (live/self truth); false → it threw,
1232
+ * so a configured peer is unreachable while an unconfigured node (no daemonId) falls
1233
+ * through to the classifier's `unconfigured` branch.
1234
+ */
1235
+ export function buildMeshNodeProbeFreshness(args: {
1236
+ /** The git snapshot this probe stamped on the node entry (entry.git). */
1237
+ git: unknown;
1238
+ /** True when the fresh git_status probe RETURNED (live truth); false when it threw. */
1239
+ liveTruthProbed: boolean;
1240
+ isSelfNode: boolean;
1241
+ /** The node's resolved daemonId; absent → unconfigured node. */
1242
+ daemonId?: string;
1243
+ /** The mesh node record, for held-git fallback when the probe did not return live. */
1244
+ node?: any;
1245
+ now?: () => number;
1246
+ }): Record<string, unknown> {
1247
+ const { git, liveTruthProbed, isSelfNode, daemonId, node, now } = args;
1248
+ const status: Record<string, unknown> = {
1249
+ git,
1250
+ connection: { state: liveTruthProbed ? 'connected' : 'disconnected' },
1251
+ };
1252
+ if (liveTruthProbed) status[MESH_NODE_LIVE_TRUTH_MARKER] = true;
1253
+ return buildMeshNodeDataFreshness({
1254
+ status,
1255
+ node,
1256
+ isSelfNode,
1257
+ daemonId,
1258
+ liveTruthProbed,
1259
+ directTruthUnavailable: !liveTruthProbed && !!daemonId,
1260
+ now,
1261
+ });
1262
+ }
1263
+
1219
1264
  export function finalizeMeshNodeStatus(args: {
1220
1265
  status: Record<string, unknown>;
1221
1266
  node: any;
package/src/index.ts CHANGED
@@ -312,7 +312,7 @@ export type { CdpInitializerConfig } from './cdp/initializer.js';
312
312
  // ── Commands ──
313
313
  export { DaemonCommandHandler } from './commands/handler.js';
314
314
  export type { CommandResult, CommandContext } from './commands/handler.js';
315
- export { DaemonCommandRouter, readCachedInlineMeshActiveSessionDetails, resolveMeshNodeAttribution, buildMeshNodeDataFreshness, MESH_NODE_LIVE_TRUTH_MARKER } from './commands/router.js';
315
+ export { DaemonCommandRouter, readCachedInlineMeshActiveSessionDetails, resolveMeshNodeAttribution, buildMeshNodeDataFreshness, buildMeshNodeProbeFreshness, MESH_NODE_LIVE_TRUTH_MARKER } from './commands/router.js';
316
316
  export type { CommandRouterDeps, CommandRouterResult } from './commands/router.js';
317
317
  export {
318
318
  maybeRunDaemonUpgradeHelperFromEnv,
@@ -7,7 +7,21 @@
7
7
  * grep the file by hand. Because the mesh RPC envelope is sent as a single
8
8
  * datachannel message (~256KB SCTP ceiling, no chunking), the returned tail is
9
9
  * HARD-bounded by `tailBytes` (default 64KB, capped at MAX_TAIL_BYTES=128KB) and
10
- * flags `truncated:true` when the file was larger.
10
+ * flags `truncated:true` when more content existed than fit.
11
+ *
12
+ * Two read modes:
13
+ * - No filter (no grep/sinceMs): byte-bounded tail of the active file — read the
14
+ * last `tailBytes` bytes only. Cheap, backward-compatible.
15
+ * - Filtered (grep and/or sinceMs given): FULL-FILE scan. The filter is applied
16
+ * across the ENTIRE file (plus the size-rotation `.1.log` backup) BEFORE the
17
+ * byte cap, then the last `tailBytes` worth of MATCHING lines are returned.
18
+ * This is the fix for "matches hidden behind polling spam": when the recent
19
+ * tail window is saturated with high-frequency lines (e.g. coordinator polling
20
+ * `get_pending_mesh_events`/`read_chat` every few seconds), a grep for a rarer
21
+ * earlier line (dispatch/inject/forward) previously matched 0 because the line
22
+ * had already scrolled out of the tail window before the filter ran. Filtering
23
+ * the whole file first surfaces those matches regardless of how much unrelated
24
+ * spam followed them.
11
25
  *
12
26
  * Boundary-safe: lines are cut on the newline byte (0x0A) only, which never
13
27
  * appears inside a multibyte UTF-8 sequence, so decoding each complete byte
@@ -36,14 +50,27 @@ export interface DaemonLogTailResult {
36
50
  success: boolean;
37
51
  error?: string;
38
52
  lines: string[];
53
+ /**
54
+ * No-filter mode: true when the file was larger than the byte window.
55
+ * Filter mode: true when matching lines were dropped from the FRONT to fit
56
+ * the byte cap (i.e. there are older matches than the ones returned).
57
+ */
39
58
  truncated: boolean;
40
59
  logPath: string;
41
60
  platform: NodeJS.Platform;
42
61
  bytesReturned: number;
43
- /** True when a grep/since filter dropped lines from the raw tail window. */
62
+ /** True when a grep/since filter dropped at least one line. */
44
63
  filtered: boolean;
45
64
  /** The grep source actually applied (echoed back for clarity). */
46
65
  grep?: string;
66
+ /** True when the filtered full-file scan path ran (grep/sinceMs given). */
67
+ fullScan: boolean;
68
+ /** Total bytes read while scanning (filter mode scans the whole file + backup). */
69
+ scannedBytes: number;
70
+ /** Number of lines that matched the filter across the full scan (filter mode). */
71
+ matchedLineCount: number;
72
+ /** Number of scanned lines dropped by the filter ("N lines excluded by filter"). */
73
+ excludedByFilter: number;
47
74
  }
48
75
 
49
76
  function resolveLogPath(date?: string | Date): string {
@@ -102,6 +129,39 @@ function readByteBoundedTail(filePath: string, limitBytes: number): { text: stri
102
129
  }
103
130
  }
104
131
 
132
+ /** Split decoded text into lines, dropping the trailing empty element a final
133
+ * newline produces. Pure helper shared by both read modes. */
134
+ function splitLogLines(text: string): string[] {
135
+ const lines = text.split('\n');
136
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
137
+ return lines;
138
+ }
139
+
140
+ /**
141
+ * Keep the LAST lines whose cumulative UTF-8 byte size (counting one byte per
142
+ * line for the joining newline) stays within `limitBytes`. Always keeps at least
143
+ * the final line, even if it alone exceeds the cap (matches the no-filter path's
144
+ * "never return nothing when there is content" behaviour). `truncated` is true
145
+ * when earlier lines were dropped to fit.
146
+ */
147
+ function takeLastLinesWithinBytes(lines: string[], limitBytes: number): { kept: string[]; truncated: boolean; bytesReturned: number } {
148
+ if (lines.length === 0) return { kept: [], truncated: false, bytesReturned: 0 };
149
+ let total = 0;
150
+ let firstKept = lines.length;
151
+ for (let i = lines.length - 1; i >= 0; i--) {
152
+ const lineBytes = Buffer.byteLength(lines[i], 'utf-8') + 1; // +1 ≈ joining newline
153
+ // Once at least one line is kept, stop before overflowing the cap.
154
+ if (firstKept !== lines.length && total + lineBytes > limitBytes) break;
155
+ total += lineBytes;
156
+ firstKept = i;
157
+ }
158
+ return {
159
+ kept: lines.slice(firstKept),
160
+ truncated: firstKept > 0,
161
+ bytesReturned: total,
162
+ };
163
+ }
164
+
105
165
  // Parse a leading timestamp from a log line into epoch ms. The unified logger
106
166
  // writes `[HH:MM:SS.mmm]` (local time, today's date) and the startup banner uses
107
167
  // a full timestamp; we best-effort parse `[HH:MM:SS...]` against the file's date.
@@ -122,99 +182,160 @@ function parseLineEpochMs(line: string, fileDate: Date): number | null {
122
182
  return d.getTime();
123
183
  }
124
184
 
185
+ /** Build the case-insensitive line predicate for a grep source, falling back to
186
+ * a literal (lowercased substring) match when the source is not a valid regex. */
187
+ function buildGrepPredicate(grepSource: string): (line: string) => boolean {
188
+ let re: RegExp | null = null;
189
+ try {
190
+ re = new RegExp(grepSource, 'i');
191
+ } catch {
192
+ re = null;
193
+ }
194
+ if (re) {
195
+ const compiled = re;
196
+ return (line: string) => compiled.test(line);
197
+ }
198
+ const needle = grepSource.toLowerCase();
199
+ return (line: string) => line.toLowerCase().includes(needle);
200
+ }
201
+
202
+ function fileDateFor(date?: string | Date): Date {
203
+ if (date instanceof Date) return date;
204
+ if (typeof date === 'string' && date.trim()) return new Date(`${date.trim()}T00:00:00.000Z`);
205
+ return new Date();
206
+ }
207
+
208
+ function errorResult(error: string, logPath: string, platform: NodeJS.Platform): DaemonLogTailResult {
209
+ return {
210
+ success: false,
211
+ error,
212
+ lines: [],
213
+ truncated: false,
214
+ logPath,
215
+ platform,
216
+ bytesReturned: 0,
217
+ filtered: false,
218
+ fullScan: false,
219
+ scannedBytes: 0,
220
+ matchedLineCount: 0,
221
+ excludedByFilter: 0,
222
+ };
223
+ }
224
+
125
225
  /**
126
- * Read the daemon log tail for `date` (default today), bounded to `tailBytes`,
127
- * with optional grep (regex source) and sinceMs filters. Falls back to the
128
- * size-rotation backup (`*.1.log`) when the primary file does not exist.
226
+ * Read the daemon log tail for `date` (default today), bounded to `tailBytes`.
227
+ *
228
+ * - No grep/sinceMs byte-bounded tail of the active file (legacy behaviour).
229
+ * - grep and/or sinceMs given → FULL-FILE scan: the filter is applied across the
230
+ * whole file (plus the `*.1.log` size-rotation backup) BEFORE the byte cap, so
231
+ * matches that have scrolled out of the recent tail window (e.g. behind
232
+ * coordinator polling spam) are still returned. Only the last `tailBytes` worth
233
+ * of matching lines ship over P2P.
234
+ *
235
+ * Falls back to the size-rotation backup (`*.1.log`) when the primary file does
236
+ * not exist.
129
237
  */
130
238
  export function readDaemonLogTail(args: ReadDaemonLogTailArgs = {}): DaemonLogTailResult {
131
239
  const platform = process.platform;
132
240
  const limitBytes = clampTailBytes(args.tailBytes);
133
- let logPath = resolveLogPath(args.date);
134
-
135
- // Fall back to the size-rotation backup if the active file is absent.
136
- if (!fs.existsSync(logPath)) {
137
- const backup = logPath.replace(/\.log$/, '.1.log');
138
- if (fs.existsSync(backup)) {
139
- logPath = backup;
140
- } else {
141
- return {
142
- success: false,
143
- error: `No daemon log file at ${logPath} (dir: ${getDaemonLogDir()})`,
144
- lines: [],
145
- truncated: false,
146
- logPath,
147
- platform,
148
- bytesReturned: 0,
149
- filtered: false,
150
- };
151
- }
241
+ const primaryPath = resolveLogPath(args.date);
242
+ const backupPath = primaryPath.replace(/\.log$/, '.1.log');
243
+ const primaryExists = fs.existsSync(primaryPath);
244
+ const backupExists = fs.existsSync(backupPath);
245
+
246
+ if (!primaryExists && !backupExists) {
247
+ return errorResult(
248
+ `No daemon log file at ${primaryPath} (dir: ${getDaemonLogDir()})`,
249
+ primaryPath,
250
+ platform,
251
+ );
152
252
  }
153
253
 
154
- let raw: { text: string; truncated: boolean; bytesReturned: number };
155
- try {
156
- raw = readByteBoundedTail(logPath, limitBytes);
157
- } catch (e: any) {
254
+ // Reported log path: the active file when present, else the backup.
255
+ const logPath = primaryExists ? primaryPath : backupPath;
256
+
257
+ const hasGrep = typeof args.grep === 'string' && args.grep.trim().length > 0;
258
+ const hasSince = Number.isFinite(args.sinceMs);
259
+ const filterMode = hasGrep || hasSince;
260
+
261
+ // ── No-filter mode: cheap byte-bounded tail of the active file ──────────
262
+ if (!filterMode) {
263
+ let raw: { text: string; truncated: boolean; bytesReturned: number };
264
+ try {
265
+ raw = readByteBoundedTail(logPath, limitBytes);
266
+ } catch (e: any) {
267
+ return errorResult(`Failed to read ${logPath}: ${e?.message ?? String(e)}`, logPath, platform);
268
+ }
269
+ const lines = splitLogLines(raw.text);
158
270
  return {
159
- success: false,
160
- error: `Failed to read ${logPath}: ${e?.message ?? String(e)}`,
161
- lines: [],
162
- truncated: false,
271
+ success: true,
272
+ lines,
273
+ truncated: raw.truncated,
163
274
  logPath,
164
275
  platform,
165
- bytesReturned: 0,
276
+ bytesReturned: raw.bytesReturned,
166
277
  filtered: false,
278
+ fullScan: false,
279
+ scannedBytes: raw.bytesReturned,
280
+ matchedLineCount: lines.length,
281
+ excludedByFilter: 0,
167
282
  };
168
283
  }
169
284
 
170
- let lines = raw.text.split('\n');
171
- // A trailing newline yields a final empty element drop it.
172
- if (lines.length && lines[lines.length - 1] === '') lines.pop();
173
- const rawCount = lines.length;
174
-
175
- // since filter
176
- if (Number.isFinite(args.sinceMs)) {
177
- const fileDate = args.date instanceof Date
178
- ? args.date
179
- : typeof args.date === 'string' && args.date.trim()
180
- ? new Date(`${args.date.trim()}T00:00:00.000Z`)
181
- : new Date();
285
+ // ── Filter mode: scan the WHOLE file (backup then primary, chronological)
286
+ // so matches behind a saturated tail window are still found. ────────────
287
+ let scannedBytes = 0;
288
+ let allLines: string[] = [];
289
+ try {
290
+ for (const p of [backupExists ? backupPath : null, primaryExists ? primaryPath : null]) {
291
+ if (!p) continue;
292
+ const buf = fs.readFileSync(p);
293
+ scannedBytes += buf.length;
294
+ allLines = allLines.concat(splitLogLines(buf.toString('utf-8')));
295
+ }
296
+ } catch (e: any) {
297
+ return errorResult(`Failed to read ${logPath}: ${e?.message ?? String(e)}`, logPath, platform);
298
+ }
299
+
300
+ const scannedLineCount = allLines.length;
301
+ let lines = allLines;
302
+
303
+ // since filter — keep lines at/after the floor (and lines with no timestamp).
304
+ if (hasSince) {
305
+ const fileDate = fileDateFor(args.date);
182
306
  const floor = args.sinceMs as number;
183
307
  lines = lines.filter((line) => {
184
308
  const ts = parseLineEpochMs(line, fileDate);
185
- // Keep lines with no parseable timestamp (continuation/stack lines).
186
309
  return ts === null || ts >= floor;
187
310
  });
188
311
  }
189
312
 
190
313
  // grep filter
191
314
  let appliedGrep: string | undefined;
192
- if (typeof args.grep === 'string' && args.grep.trim()) {
193
- appliedGrep = args.grep.trim();
194
- let re: RegExp | null = null;
195
- try {
196
- re = new RegExp(appliedGrep, 'i');
197
- } catch {
198
- re = null;
199
- }
200
- if (re) {
201
- const compiled = re;
202
- lines = lines.filter((line) => compiled.test(line));
203
- } else {
204
- // Invalid regex → fall back to a literal substring match.
205
- const needle = appliedGrep.toLowerCase();
206
- lines = lines.filter((line) => line.toLowerCase().includes(needle));
207
- }
315
+ if (hasGrep) {
316
+ appliedGrep = (args.grep as string).trim();
317
+ const matches = buildGrepPredicate(appliedGrep);
318
+ lines = lines.filter(matches);
208
319
  }
209
320
 
321
+ const matchedLineCount = lines.length;
322
+ const excludedByFilter = scannedLineCount - matchedLineCount;
323
+
324
+ // Byte cap applies to the MATCHING lines: keep the newest matches that fit.
325
+ const capped = takeLastLinesWithinBytes(lines, limitBytes);
326
+
210
327
  return {
211
328
  success: true,
212
- lines,
213
- truncated: raw.truncated,
329
+ lines: capped.kept,
330
+ truncated: capped.truncated,
214
331
  logPath,
215
332
  platform,
216
- bytesReturned: raw.bytesReturned,
217
- filtered: lines.length !== rawCount,
333
+ bytesReturned: capped.bytesReturned,
334
+ filtered: excludedByFilter > 0,
335
+ fullScan: true,
336
+ scannedBytes,
337
+ matchedLineCount,
338
+ excludedByFilter,
218
339
  ...(appliedGrep ? { grep: appliedGrep } : {}),
219
340
  };
220
341
  }
@@ -23,6 +23,7 @@ import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
23
23
  import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
24
24
  import {
25
25
  findRecentTerminalLedgerEvidence,
26
+ findTerminalLedgerEvidenceForTask,
26
27
  hasDispatchAfterTerminal,
27
28
  hasUnterminalDirectDispatchLedgerEntry,
28
29
  buildNoProgressCompletionReconciliation,
@@ -654,6 +655,24 @@ export function tryAssignQueueTask(
654
655
  return false;
655
656
  }
656
657
 
658
+ const terminal = findTerminalLedgerEvidenceForTask({
659
+ meshId,
660
+ taskId: task.id,
661
+ });
662
+ if (terminal) {
663
+ const status = terminal.kind === 'task_completed' ? 'completed' : 'failed';
664
+ updateTaskStatus(meshId, task.id, status);
665
+ LOG.info('MeshQueue', `Skipped dispatch for terminal task ${task.id} on mesh ${meshId}; ${terminal.kind} ledger evidence already exists`);
666
+ traceMeshEventDrop('dispatch_terminal_ledger', {
667
+ taskId: task.id,
668
+ sessionId,
669
+ nodeId,
670
+ meshId,
671
+ event: 'agent_command',
672
+ }, terminal.kind);
673
+ return false;
674
+ }
675
+
657
676
  LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
658
677
 
659
678
  if (node?.daemonId && components.dispatchMeshCommand) {
@@ -85,6 +85,29 @@ function isWeakCompletionLedgerPayload(payload: Record<string, unknown> | undefi
85
85
  return diag?.finalAssistantPresent === false || diag?.blockReason === 'missing_final_assistant';
86
86
  }
87
87
 
88
+ export function findTerminalLedgerEvidenceForTask(args: {
89
+ meshId: string;
90
+ taskId?: string;
91
+ sessionId?: string;
92
+ nodeId?: string;
93
+ tail?: number;
94
+ }): { id: string; kind: Extract<MeshLedgerKind, 'task_completed' | 'task_failed' | 'task_stalled'>; payload: Record<string, unknown>; timestamp: string } | null {
95
+ const taskId = readNonEmptyString(args.taskId);
96
+ if (!taskId) return null;
97
+ const entries = readLedgerEntries(args.meshId, { tail: args.tail ?? 500 });
98
+ for (let i = entries.length - 1; i >= 0; i--) {
99
+ const entry = entries[i];
100
+ if (entry.kind !== 'task_completed' && entry.kind !== 'task_failed' && entry.kind !== 'task_stalled') continue;
101
+ const terminalTaskId = readNonEmptyString(entry.payload?.taskId);
102
+ if (terminalTaskId !== taskId) continue;
103
+ if (entry.kind === 'task_completed' && isWeakCompletionLedgerPayload(entry.payload)) continue;
104
+ if (args.sessionId && entry.sessionId && entry.sessionId !== args.sessionId) continue;
105
+ if (!args.sessionId && args.nodeId && entry.nodeId && !meshNodeIdMatches(entry as unknown as MeshNodeIdentified, args.nodeId)) continue;
106
+ return { id: entry.id, kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
107
+ }
108
+ return null;
109
+ }
110
+
88
111
  function findDirectDispatchLedgerEntry(args: {
89
112
  meshId: string;
90
113
  taskId: string;
@@ -109,7 +109,7 @@ const MESH_SURFACED_PREVIEW_MAX_CHARS = 512;
109
109
  // coordinator-facing payload that replaces a "go call mesh_read_chat" instruction —
110
110
  // it should carry enough of the worker's result to act on without a second round-trip,
111
111
  // while still bounding what is written into the coordinator PTY.
112
- const MESH_COMPLETION_SURFACE_MAX_CHARS = 4000;
112
+ const MESH_COMPLETION_SURFACE_MAX_CHARS = 16000;
113
113
 
114
114
  /**
115
115
  * The worker's final assistant text carried on a completion event — read from