@adhdev/daemon-core 0.9.82-rc.324 → 0.9.82-rc.326

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.
@@ -1,6 +1,18 @@
1
1
  import type { ChatMessage } from '../types.js';
2
2
  export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 4000;
3
3
  export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
4
+ /**
5
+ * Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
6
+ * selected final assistant/model message. Completion reconciliation needs the
7
+ * timestamp to prove the transcript was produced AFTER the dispatch — without it
8
+ * reconcileDirectDispatchCompletionFromTranscript rejects non-JSON summaries as
9
+ * "transcript_not_proven_after_dispatch". The summary selection is identical so the
10
+ * timestamp always belongs to the message whose text became the summary.
11
+ */
12
+ export declare function extractFinalAssistantSummaryEvidence(messages: ChatMessage[] | null | undefined, maxChars?: number): {
13
+ finalSummary: string;
14
+ transcriptMessageAt?: string;
15
+ };
4
16
  export declare const BUILTIN_CHAT_MESSAGE_KINDS: readonly ["standard", "thought", "tool", "terminal", "system"];
5
17
  export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
6
18
  export type ChatMessageKind = BuiltinChatMessageKind | (string & {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.324",
3
+ "version": "0.9.82-rc.326",
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.324",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.326",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -952,6 +952,8 @@ export class ProviderCliAdapter implements CliAdapter {
952
952
  errorMessage: this.parseErrorMessage || this.engine.providerErrorMessage || undefined,
953
953
  errorReason: this.parseErrorMessage ? 'parse_error' : (this.engine.providerErrorReason || undefined),
954
954
  providerSessionId: this.providerSessionId || undefined,
955
+ lastOutputAt: this.lastOutputAt,
956
+ lastScreenChangeAt: this.lastScreenChangeAt,
955
957
  ...(bufferState ? { bufferState } : {}),
956
958
  };
957
959
  }
@@ -18,6 +18,8 @@ export interface CliSessionStatus {
18
18
  activeModal: { message: string; buttons: string[] } | null;
19
19
  errorMessage?: string;
20
20
  errorReason?: string;
21
+ lastOutputAt?: number;
22
+ lastScreenChangeAt?: number;
21
23
  }
22
24
  export interface CliScripts {
23
25
  detectStatus?: (input: CliStatusInput) => string | null;
@@ -50,6 +50,21 @@ export interface CliSessionStatus {
50
50
  errorMessage?: string;
51
51
  errorReason?: string;
52
52
  providerSessionId?: string;
53
+ /**
54
+ * Timestamp (ms) of the most recent raw PTY output chunk. Advances on every
55
+ * byte the process emits, including tool/build output that produces no
56
+ * parsed assistant text. Liveness watchdogs use this to distinguish a real
57
+ * stall (no output at all) from an active turn whose assistant buffer is
58
+ * momentarily static while a tool runs.
59
+ */
60
+ lastOutputAt?: number;
61
+ /**
62
+ * Timestamp (ms) of the most recent *visible* terminal screen change.
63
+ * Stricter than lastOutputAt — only advances when the rendered screen
64
+ * content actually differs, so repeated keepalive bytes do not register as
65
+ * progress. Preferred liveness signal for the long-generating watchdog.
66
+ */
67
+ lastScreenChangeAt?: number;
53
68
  bufferState?: {
54
69
  responseBuffer?: { truncated: boolean; droppedChars: number; maxChars: number };
55
70
  recentOutputBuffer?: { truncated: boolean; droppedChars: number; maxChars: number };
@@ -426,6 +426,38 @@ function recordInlineMeshDirectGitTruth(
426
426
  node.lastSeenAt = updatedAt;
427
427
  const repoRoot = readStringValue(nextGit.repoRoot);
428
428
  if (repoRoot && !readStringValue(node.repoRoot)) node.repoRoot = repoRoot;
429
+ // Self-heal per-node platform/arch from the live probe. For a remote member
430
+ // this is the platform the member daemon reported in its git_status envelope
431
+ // (threaded through as reporter*); for the local coordinator's own / worktree
432
+ // nodes the git was computed locally (source 'selected_coordinator_local_git'
433
+ // ⇒ the workspace lives on THIS machine), so process.platform/process.arch is
434
+ // the correct value. Stamp into userOverrides — the exact fields
435
+ // buildMeshNodeCapabilityTags reads — only when absent, so an operator's
436
+ // explicit override is preserved and the value is corrected once per reconnect
437
+ // without any migration.
438
+ const isLocalSource = source === 'selected_coordinator_local_git';
439
+ const reporterPlatform = readStringValue(git.reporterPlatform) ?? (isLocalSource ? process.platform : null);
440
+ const reporterArch = readStringValue(git.reporterArch) ?? (isLocalSource ? process.arch : null);
441
+ stampNodeReporterPlatform(node, reporterPlatform, reporterArch);
442
+ }
443
+
444
+ /**
445
+ * Fill node.userOverrides.platform/arch from a live report, but never overwrite a
446
+ * value that is already present (an operator override or an earlier report). Used
447
+ * by both the remote-member probe path and the local self-stamp path so the
448
+ * coordinator advertises each node's real OS instead of falling back to the
449
+ * coordinator's own process.platform.
450
+ */
451
+ function stampNodeReporterPlatform(node: any, platform: string | null, arch: string | null): void {
452
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
453
+ if (!platform && !arch) return;
454
+ const overrides = (node.userOverrides && typeof node.userOverrides === 'object' && !Array.isArray(node.userOverrides))
455
+ ? node.userOverrides as Record<string, unknown>
456
+ : {};
457
+ let changed = false;
458
+ if (platform && !readStringValue(overrides.platform)) { overrides.platform = platform; changed = true; }
459
+ if (arch && !readStringValue(overrides.arch)) { overrides.arch = arch; changed = true; }
460
+ if (changed) node.userOverrides = overrides;
429
461
  }
430
462
 
431
463
  function buildCachedInlineMeshGitStatus(node: any): Record<string, unknown> | undefined {
@@ -1101,9 +1133,17 @@ async function probeRemoteMeshGitStatus(args: {
1101
1133
  new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), args.timeoutMs)),
1102
1134
  ]) as any;
1103
1135
  const remoteGit = remoteResult?.status ?? remoteResult?.git ?? remoteResult;
1104
- return remoteGit && typeof remoteGit === 'object' && typeof remoteGit.isGitRepo === 'boolean'
1105
- ? remoteGit as Record<string, unknown>
1106
- : null;
1136
+ if (!remoteGit || typeof remoteGit !== 'object' || typeof remoteGit.isGitRepo !== 'boolean') return null;
1137
+ // The member daemon stamps its own platform/arch onto the git_status result
1138
+ // envelope (see git-commands.ts). Reflect them onto the returned git object
1139
+ // under non-colliding reporter* keys so recordInlineMeshDirectGitTruth can
1140
+ // persist them to node.userOverrides without touching the git status shape.
1141
+ const reporterPlatform = readStringValue(remoteResult?.reporterPlatform);
1142
+ const reporterArch = readStringValue(remoteResult?.reporterArch);
1143
+ const git = remoteGit as Record<string, unknown>;
1144
+ if (reporterPlatform) git.reporterPlatform = reporterPlatform;
1145
+ if (reporterArch) git.reporterArch = reporterArch;
1146
+ return git;
1107
1147
  }
1108
1148
 
1109
1149
  /** Number of bounded retries after the initial direct-peer git probe attempt. */
@@ -110,7 +110,10 @@ type GitCommandFailure = {
110
110
  };
111
111
 
112
112
  type GitCommandSuccess =
113
- | { success: true; status: GitRepoStatus }
113
+ // reporterPlatform/reporterArch carry the responding daemon's process.platform/
114
+ // process.arch so a mesh coordinator probing this node over P2P can self-heal the
115
+ // node's userOverrides.platform/arch (the fields capability-tag routing reads).
116
+ | { success: true; status: GitRepoStatus; reporterPlatform?: string; reporterArch?: string }
114
117
  | { success: true; diffSummary: GitDiffSummary }
115
118
  | { success: true; diff: GitFileDiff }
116
119
  | { success: true; snapshot: GitSnapshot }
@@ -304,7 +307,15 @@ export async function handleGitCommand(
304
307
  if (includeSubmodules !== undefined) statusParams.includeSubmodules = includeSubmodules;
305
308
  if (submoduleIgnorePaths && submoduleIgnorePaths.length > 0) statusParams.submoduleIgnorePaths = submoduleIgnorePaths;
306
309
  const status = await runService(() => services.getStatus!(statusParams));
307
- return 'success' in status ? status : { success: true, status };
310
+ if ('success' in status) return status;
311
+ // Carry the responding daemon's real platform/arch alongside the git
312
+ // status. A mesh coordinator dispatches `git_status` to each member over
313
+ // P2P on every explicit graph refresh, so this is the recurring live
314
+ // channel that lets a member self-report its OS to the coordinator — which
315
+ // stamps it onto the node's userOverrides.platform/arch (the fields
316
+ // buildMeshNodeCapabilityTags reads). These are siblings of `status`, not
317
+ // part of GitRepoStatus, so the git payload shape is untouched.
318
+ return { success: true, status, reporterPlatform: process.platform, reporterArch: process.arch };
308
319
  }
309
320
 
310
321
  case 'git_diff_summary': {
@@ -55,6 +55,10 @@ import {
55
55
  expireStaleUnresolvedDelegateForwards,
56
56
  } from './mesh-unresolved-forward-outbox.js';
57
57
  import { readNonEmptyString } from './mesh-events-utils.js';
58
+ import { getActiveDirectDispatches } from './mesh-work-queue.js';
59
+ import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
60
+ import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
61
+ import type { ChatMessage } from '../types.js';
58
62
 
59
63
  // Default reconcile cadence. approval/completion notifications to a live CLI
60
64
  // coordinator land within at most one interval. Overridable via env for tuning.
@@ -281,6 +285,31 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
281
285
  }
282
286
  }
283
287
 
288
+ // ── PHASE 4: synthesize lost completions for unterminated direct dispatches ─
289
+ // Symmetric to PHASE 3 (which recovers a *lost claim* for a newly-idle session)
290
+ // but for the opposite gap: a worker that ALREADY completed, went idle, and
291
+ // whose terminal completion event was never persisted (dropped before reaching
292
+ // the queue/outbox, or its forward was lost). PHASE 1/2/3 can only deliver an
293
+ // event that exists in a queue — they cannot recover a completion that was
294
+ // never recorded, so the coordinator keeps believing the worker is generating.
295
+ //
296
+ // reconcileDirectDispatchCompletionFromTranscript already synthesizes the
297
+ // missing terminal event from the worker's transcript, but until now it ran
298
+ // ONLY when an LLM coordinator polled mesh_status (mcp_mesh_status_transcript_
299
+ // reconciliation). This phase pulls that same correction onto the daemon timer
300
+ // so it no longer depends on the LLM polling. The reconcile is idempotent
301
+ // (hasTerminalLedgerAfterDispatch guards against re-synthesis), so attempting it
302
+ // every tick for the same dispatch is safe — once a terminal exists it no-ops.
303
+ for (const mesh of listMeshes()) {
304
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
305
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
306
+ try {
307
+ await reconcileUnterminatedDirectDispatches(components, mesh, selfIds, localDaemonId);
308
+ } catch (e: any) {
309
+ LOG.warn('MeshReconcile', `Completion reconcile failed for mesh ${mesh.id}: ${e?.message || e}`);
310
+ }
311
+ }
312
+
284
313
  // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
285
314
  const coordinators = findLiveCoordinators(components);
286
315
  if (coordinators.length === 0) {
@@ -445,6 +474,124 @@ async function pullRemoteNodeQueues(
445
474
  }
446
475
  }
447
476
 
477
+ // Pull the read_chat payload out of whatever envelope the transport returned.
478
+ // A local commandHandler.handle() returns the CommandResult directly; a remote
479
+ // dispatchMeshCommand returns it possibly wrapped in { payload } / { result }.
480
+ function unwrapReadChatPayload(raw: unknown): Record<string, unknown> | null {
481
+ let cursor: unknown = raw;
482
+ for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
483
+ const record = cursor as Record<string, unknown>;
484
+ if (Array.isArray(record.messages)) return record;
485
+ if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
486
+ if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
487
+ if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
488
+ break;
489
+ }
490
+ return cursor && typeof cursor === 'object' ? cursor as Record<string, unknown> : null;
491
+ }
492
+
493
+ function readChatPayloadStatus(payload: Record<string, unknown> | null): string {
494
+ return readNonEmptyString(payload?.status).toLowerCase();
495
+ }
496
+
497
+ // PHASE 4 helper. For every active (non-terminal) direct dispatch this daemon
498
+ // hosts, confirm the worker session is idle via a read_chat and — if a final
499
+ // assistant summary is present but no terminal ledger exists for that dispatch —
500
+ // synthesize the missing completion through reconcileDirectDispatchCompletionFromTranscript.
501
+ //
502
+ // read_chat is resolved against the target node: a node on THIS daemon is read
503
+ // through the local commandHandler; a remote node is read over P2P via
504
+ // dispatchMeshCommand. Both yield the same { messages, status, providerSessionId }
505
+ // shape. We only synthesize when the session reports idle AND a final assistant
506
+ // message exists — the same evidence bar the MCP poll path uses — so an actively
507
+ // generating worker is never falsely completed. The reconcile itself is idempotent.
508
+ async function reconcileUnterminatedDirectDispatches(
509
+ components: DaemonComponents,
510
+ mesh: LocalMeshEntry,
511
+ selfIds: string[],
512
+ localDaemonId: string | undefined,
513
+ ): Promise<void> {
514
+ const dispatches = getActiveDirectDispatches(mesh.id);
515
+ if (dispatches.length === 0) return; // cheap exit — nothing dispatched, nothing to reconcile
516
+
517
+ const dispatchMeshCommand = components.dispatchMeshCommand;
518
+ const nodeById = new Map(mesh.nodes.map(n => [n.id, n] as const));
519
+
520
+ for (const dispatch of dispatches) {
521
+ const sessionId = readNonEmptyString(dispatch.sessionId);
522
+ const nodeId = readNonEmptyString(dispatch.nodeId);
523
+ const taskId = readNonEmptyString(dispatch.taskId);
524
+ if (!sessionId || !nodeId || !taskId) continue;
525
+
526
+ const node = nodeById.get(nodeId);
527
+ const nodeDaemonId = readNonEmptyString(node?.daemonId);
528
+ // A node is local when it has no daemonId, names this daemon, or actually
529
+ // has a live instance here. Anything else is reached over P2P.
530
+ const isLocalNode = !nodeDaemonId
531
+ || selfIds.includes(nodeDaemonId)
532
+ || (localDaemonId !== undefined && nodeDaemonId === localDaemonId)
533
+ || !!components.instanceManager.getInstance(sessionId);
534
+
535
+ const providerType = readNonEmptyString(dispatch.providerType);
536
+ const readArgs: Record<string, unknown> = {
537
+ sessionId,
538
+ targetSessionId: sessionId,
539
+ tailLimit: 10,
540
+ ...(node?.workspace ? { workspace: node.workspace } : {}),
541
+ ...(providerType ? { agentType: providerType, providerType } : {}),
542
+ };
543
+
544
+ let payload: Record<string, unknown> | null = null;
545
+ try {
546
+ if (isLocalNode) {
547
+ const result = await components.commandHandler.handle('read_chat', readArgs);
548
+ if (result && (result as { success?: boolean }).success === false) continue;
549
+ payload = unwrapReadChatPayload(result);
550
+ } else if (dispatchMeshCommand) {
551
+ const result = await dispatchMeshCommand(nodeDaemonId, 'read_chat', readArgs);
552
+ payload = unwrapReadChatPayload(result);
553
+ if (payload && (payload as { success?: boolean }).success === false) continue;
554
+ } else {
555
+ continue; // remote node but no P2P transport — can't read; retry next tick
556
+ }
557
+ } catch {
558
+ continue; // best-effort; session may be gone or node offline — retry next tick
559
+ }
560
+ if (!payload) continue;
561
+
562
+ // Only act on a session that has actually settled to idle. A generating /
563
+ // waiting_approval session is mid-turn — synthesizing a completion now would
564
+ // be wrong. (idle is the only status the MCP poll path reconciles too.)
565
+ if (readChatPayloadStatus(payload) !== 'idle') continue;
566
+
567
+ const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
568
+ const evidence = extractFinalAssistantSummaryEvidence(messages);
569
+ if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
570
+
571
+ const providerSessionId = readNonEmptyString(payload.providerSessionId);
572
+ const coordinatorDaemonId = selfIds.find(id => !!id);
573
+ try {
574
+ const result = reconcileDirectDispatchCompletionFromTranscript({
575
+ meshId: mesh.id,
576
+ nodeId,
577
+ sessionId,
578
+ providerType: providerType || undefined,
579
+ providerSessionId: providerSessionId || undefined,
580
+ taskId,
581
+ finalSummary: evidence.finalSummary,
582
+ ...(evidence.transcriptMessageAt ? { transcriptMessageAt: evidence.transcriptMessageAt } : {}),
583
+ ...(coordinatorDaemonId ? { targetCoordinatorDaemonId: coordinatorDaemonId } : {}),
584
+ source: 'daemon_reconcile_transcript_completion',
585
+ });
586
+ if (result.reconciled) {
587
+ LOG.info('MeshReconcile', `Synthesized missing completion (${result.kind}) for task ${taskId} on node ${nodeId} (mesh ${mesh.id})`);
588
+ }
589
+ } catch (e: any) {
590
+ LOG.warn('MeshReconcile', `Transcript completion reconcile threw for task ${taskId}: ${e?.message || e}`);
591
+ }
592
+ }
593
+ }
594
+
448
595
  function extractPendingEvents(raw: unknown): any[] {
449
596
  if (Array.isArray(raw)) return raw;
450
597
  if (raw && typeof raw === 'object') {
@@ -27,6 +27,53 @@ export function extractFinalSummaryFromMessages(
27
27
  return '';
28
28
  }
29
29
 
30
+ function readChatMessageTimestampIso(message: ChatMessage | null | undefined): string | undefined {
31
+ if (!message) return undefined;
32
+ const record = message as ChatMessage & Record<string, unknown>;
33
+ for (const value of [record.timestamp, record.createdAt, record.created_at, record.updatedAt, record.time]) {
34
+ if (typeof value === 'number' && Number.isFinite(value)) {
35
+ // Heuristic seconds-vs-ms detection mirrors the mesh transcript reader.
36
+ const ms = value > 10_000_000_000 ? value : value * 1000;
37
+ return new Date(ms).toISOString();
38
+ }
39
+ if (typeof value === 'string' && value.trim()) {
40
+ const ms = new Date(value.trim()).getTime();
41
+ if (Number.isFinite(ms)) return new Date(ms).toISOString();
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
49
+ * selected final assistant/model message. Completion reconciliation needs the
50
+ * timestamp to prove the transcript was produced AFTER the dispatch — without it
51
+ * reconcileDirectDispatchCompletionFromTranscript rejects non-JSON summaries as
52
+ * "transcript_not_proven_after_dispatch". The summary selection is identical so the
53
+ * timestamp always belongs to the message whose text became the summary.
54
+ */
55
+ export function extractFinalAssistantSummaryEvidence(
56
+ messages: ChatMessage[] | null | undefined,
57
+ maxChars: number = DEFAULT_FINAL_SUMMARY_MAX_CHARS,
58
+ ): { finalSummary: string; transcriptMessageAt?: string } {
59
+ if (!Array.isArray(messages) || messages.length === 0) return { finalSummary: '' };
60
+ for (let i = messages.length - 1; i >= 0; i--) {
61
+ const msg = messages[i];
62
+ if (!msg) continue;
63
+ const classification = classifyChatMessageVisibility(msg);
64
+ if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
65
+ const text = flattenContent(msg.content).trim();
66
+ if (text) {
67
+ return {
68
+ finalSummary: text.slice(0, maxChars),
69
+ transcriptMessageAt: readChatMessageTimestampIso(msg),
70
+ };
71
+ }
72
+ }
73
+ }
74
+ return { finalSummary: '' };
75
+ }
76
+
30
77
  export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
31
78
 
32
79
  export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
@@ -1545,8 +1545,16 @@ export class CliProviderInstance implements ProviderInstance {
1545
1545
  const dirName = workingDirBasename(this.workingDir);
1546
1546
  const chatTitle = `${this.provider.name} · ${dirName}`;
1547
1547
  const partial = this.adapter.getPartialResponse();
1548
+ // Liveness fingerprint for the long-generating watchdog. The parsed
1549
+ // assistant buffer (`partial`) alone goes static while a tool/build runs
1550
+ // — the assistant emits no tokens even though the PTY is actively
1551
+ // printing tool output — which made the watchdog false-fire a "stuck"
1552
+ // alert mid-turn. Fold in the adapter's raw-activity timestamps so any
1553
+ // visible terminal progress (lastScreenChangeAt) or raw PTY byte
1554
+ // (lastOutputAt) keeps the fingerprint moving. The watchdog then only
1555
+ // survives a genuine stall where nothing at all is happening.
1548
1556
  const progressFingerprint = newStatus === 'generating'
1549
- ? `${partial || ''}`.slice(-2000)
1557
+ ? `${`${partial || ''}`.slice(-2000)}::scr=${adapterStatus.lastScreenChangeAt ?? 0}::out=${adapterStatus.lastOutputAt ?? 0}`
1550
1558
  : undefined;
1551
1559
 
1552
1560
  const previousStatus = this.lastStatus;