@adhdev/daemon-core 0.9.82-rc.361 → 0.9.82-rc.363

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 (33) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +1 -1
  2. package/dist/cli-adapters/provider-cli-shared.d.ts +10 -0
  3. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  4. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  5. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  6. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  7. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  8. package/dist/commands/low-family/notification.d.ts +2 -0
  9. package/dist/commands/low-family/status-meta.d.ts +2 -0
  10. package/dist/commands/low-family/types.d.ts +17 -0
  11. package/dist/index.js +2290 -2177
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +2296 -2183
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/mesh/mesh-events-utils.d.ts +1 -0
  16. package/dist/providers/cli-provider-instance.d.ts +2 -0
  17. package/package.json +2 -2
  18. package/src/cli-adapters/cli-state-engine.ts +7 -1
  19. package/src/cli-adapters/provider-cli-adapter.ts +1 -0
  20. package/src/cli-adapters/provider-cli-shared.ts +10 -0
  21. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  22. package/src/commands/low-family/daemon-lifecycle.ts +107 -0
  23. package/src/commands/low-family/diagnostics.ts +57 -0
  24. package/src/commands/low-family/index.ts +14 -0
  25. package/src/commands/low-family/mesh-ledger.ts +62 -0
  26. package/src/commands/low-family/mesh-node-logs.ts +81 -0
  27. package/src/commands/low-family/notification.ts +116 -0
  28. package/src/commands/low-family/status-meta.ts +112 -0
  29. package/src/commands/low-family/types.ts +20 -0
  30. package/src/commands/router.ts +8 -522
  31. package/src/mesh/mesh-events-coordinator.ts +37 -0
  32. package/src/mesh/mesh-events-utils.ts +28 -2
  33. package/src/providers/cli-provider-instance.ts +46 -0
@@ -52,6 +52,7 @@ export declare function resolveMeshSurfacedSessionPreview(metadataEvent: Record<
52
52
  role: 'assistant';
53
53
  receivedAt: number;
54
54
  } | undefined;
55
+ export declare function isWeakCompletionMetadata(metadataEvent: Record<string, unknown>): boolean;
55
56
  export declare function buildMeshSystemMessage(args: {
56
57
  event: string;
57
58
  nodeLabel: string;
@@ -183,6 +183,8 @@ export declare class CliProviderInstance implements ProviderInstance {
183
183
  private hasAdapterPendingResponse;
184
184
  private shouldSuppressStaleParsedBusyStatus;
185
185
  private getCompletedFinalizationBlock;
186
+ private hasApprovalResolutionEvidence;
187
+ private approvalResolutionFinalizationBlock;
186
188
  private scheduleCompletedDebounceFlush;
187
189
  private isMeshWorkerSession;
188
190
  private meshTraceCtx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.361",
3
+ "version": "0.9.82-rc.363",
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.361",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.363",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -126,7 +126,13 @@ export class CliStateEngine {
126
126
  * seq) is always a real, distinct approval and must be written.
127
127
  */
128
128
  approvalEntrySeq = 0;
129
- private lastResolvedEntrySeq = -1;
129
+ // Records which approval entry the last resolveModal() handled. Set unconditionally on
130
+ // every resolveModal (auto-approve fire, dashboard/mesh_approve, dev-cli-debug), so
131
+ // `lastResolvedEntrySeq >= approvalEntrySeq` is positive, ADHDev-side proof that the
132
+ // current/latest approval entry was actually resolved. Exposed via getStatus so the
133
+ // completion finalization gate (FALSEIDLE-a) can require resolution evidence before
134
+ // confirming a waiting_approval→idle transition. Public (read-only by convention).
135
+ lastResolvedEntrySeq = -1;
130
136
  /**
131
137
  * When the engine previously held a modal but the latest parse failed
132
138
  * to extract one, we record the timestamp here and only drop the modal
@@ -967,6 +967,7 @@ export class ProviderCliAdapter implements CliAdapter {
967
967
  workingDir: this.workingDir,
968
968
  activeModal: effectiveModal,
969
969
  approvalEntrySeq: this.engine.approvalEntrySeq,
970
+ lastResolvedEntrySeq: this.engine.lastResolvedEntrySeq,
970
971
  pendingOutboundCount: this.pendingOutboundQueue.length,
971
972
  pendingOutboundMessages: this.pendingOutboundQueue.map((message) => ({
972
973
  id: message.id,
@@ -38,6 +38,16 @@ export interface CliSessionStatus {
38
38
  * seq is the only reliable discriminator.
39
39
  */
40
40
  approvalEntrySeq?: number;
41
+ /**
42
+ * The approval entry seq that the engine's last resolveModal() handled. When
43
+ * `lastResolvedEntrySeq >= approvalEntrySeq` (and approvalEntrySeq > 0) it is positive
44
+ * evidence that the current/latest approval entry was resolved through ADHDev (auto-approve
45
+ * fire, dashboard / mesh_approve, or dev-cli-debug — all route through resolveModal). The
46
+ * completion finalization gate uses this to avoid confirming a waiting_approval→idle
47
+ * transition for which no resolution actually occurred (a false idle: the spec's text-based
48
+ * approval→idle rule tripped while the modal is still on screen).
49
+ */
50
+ lastResolvedEntrySeq?: number;
41
51
  activeInteractivePrompt?: InteractivePrompt | null;
42
52
  pendingOutboundCount?: number;
43
53
  pendingOutboundMessages?: Array<{
@@ -0,0 +1,72 @@
1
+ /**
2
+ * RF-ROUTER LOW family — coordinator-prompt override/append file commands.
3
+ *
4
+ * Extracted verbatim from DaemonCommandRouter.executeDaemonCommand. Both handlers
5
+ * read/write only the on-disk ~/.adhdev/coordinator-prompts directory (no router
6
+ * instance state) and return the same CommandRouterResult the inlined cases did.
7
+ */
8
+ import type { LowFamilyContext, LowFamilyHandler } from './types.js';
9
+
10
+ export const coordinatorPromptHandlers: Record<string, LowFamilyHandler> = {
11
+ list_coordinator_prompts: async (_ctx: LowFamilyContext, _args: any) => {
12
+ const fs = await import('node:fs');
13
+ const path = await import('node:path');
14
+ const os = await import('node:os');
15
+ const dir = path.join(os.homedir(), '.adhdev', 'coordinator-prompts');
16
+ const entries: Record<string, { override: string; append: string }> = {};
17
+ try {
18
+ if (fs.existsSync(dir)) {
19
+ for (const name of fs.readdirSync(dir)) {
20
+ // Bucket files into <key>.{md|append.md}; ignore others
21
+ // so a stray README or .DS_Store doesn't show up.
22
+ const matchOverride = name.match(/^([a-zA-Z0-9_.-]+)\.md$/);
23
+ const matchAppend = name.match(/^([a-zA-Z0-9_.-]+)\.append\.md$/);
24
+ // append-pattern wins when both match (file is `.append.md`).
25
+ const m = matchAppend || matchOverride;
26
+ if (!m) continue;
27
+ const isAppend = !!matchAppend;
28
+ const key = m[1];
29
+ const full = path.join(dir, name);
30
+ let content = '';
31
+ try { content = fs.readFileSync(full, 'utf8'); } catch { /* skip */ }
32
+ if (!entries[key]) entries[key] = { override: '', append: '' };
33
+ if (isAppend) entries[key].append = content;
34
+ else entries[key].override = content;
35
+ }
36
+ }
37
+ } catch (error: any) {
38
+ return { success: false, error: error?.message || String(error) };
39
+ }
40
+ return { success: true, dir, entries };
41
+ },
42
+
43
+ write_coordinator_prompt: async (_ctx: LowFamilyContext, args: any) => {
44
+ const fs = await import('node:fs');
45
+ const path = await import('node:path');
46
+ const os = await import('node:os');
47
+ const key = typeof args?.key === 'string' ? args.key.trim() : '';
48
+ const kind = args?.kind === 'append' ? 'append' : 'override';
49
+ const content = typeof args?.content === 'string' ? args.content : '';
50
+ // Whitelist key chars so a malicious caller can't write
51
+ // ../../etc/passwd. Same charset readUserPromptFile accepts.
52
+ if (!key || !/^[a-zA-Z0-9_.-]+$/.test(key)) {
53
+ return { success: false, error: 'key must match [a-zA-Z0-9_.-]+' };
54
+ }
55
+ const dir = path.join(os.homedir(), '.adhdev', 'coordinator-prompts');
56
+ const filename = kind === 'append' ? `${key}.append.md` : `${key}.md`;
57
+ const full = path.join(dir, filename);
58
+ try {
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ if (content.trim()) {
61
+ fs.writeFileSync(full, content, { encoding: 'utf8', mode: 0o600 });
62
+ } else if (fs.existsSync(full)) {
63
+ // Empty content = "reset to default" — delete the file
64
+ // so the daemon's readUserPromptFile path falls through.
65
+ fs.unlinkSync(full);
66
+ }
67
+ return { success: true, path: full, kind, key };
68
+ } catch (error: any) {
69
+ return { success: false, error: error?.message || String(error) };
70
+ }
71
+ },
72
+ };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * RF-ROUTER LOW family — daemon self-upgrade + machine-settings commands.
3
+ *
4
+ * Extracted verbatim from DaemonCommandRouter.executeDaemonCommand. The
5
+ * release-channel helpers (ReleaseChannel / CHANNEL_* / normalizeReleaseChannel /
6
+ * resolveUpgradeChannel) were router module-locals used only by daemon_upgrade, so
7
+ * they are relocated here unchanged. Handlers read only ctx.deps.packageName /
8
+ * ctx.deps.statusVersion plus process-global config + npm/upgrade helpers, and
9
+ * return the same CommandRouterResult the inlined cases did.
10
+ */
11
+ import { loadConfig, updateConfig } from '../../config/config.js';
12
+ import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDaemonUpgradeHelper } from '../upgrade-helper.js';
13
+ import { LOG } from '../../logging/logger.js';
14
+ import type { LowFamilyContext, LowFamilyHandler } from './types.js';
15
+
16
+ type ReleaseChannel = 'stable' | 'preview';
17
+ const CHANNEL_NPM_TAG: Record<ReleaseChannel, 'latest' | 'next'> = { stable: 'latest', preview: 'next' };
18
+ const CHANNEL_SERVER_URL: Record<ReleaseChannel, string> = {
19
+ stable: 'https://api.adhf.dev',
20
+ preview: 'https://api-preview.adhf.dev',
21
+ };
22
+
23
+ function normalizeReleaseChannel(value: unknown): ReleaseChannel | null {
24
+ if (typeof value !== 'string') return null;
25
+ const normalized = value.trim().toLowerCase();
26
+ if (normalized === 'stable' || normalized === 'latest') return 'stable';
27
+ if (normalized === 'preview' || normalized === 'next') return 'preview';
28
+ return null;
29
+ }
30
+
31
+ function resolveUpgradeChannel(args: any): ReleaseChannel {
32
+ return normalizeReleaseChannel(args?.channel)
33
+ || normalizeReleaseChannel(args?.updatePolicy?.channel)
34
+ || normalizeReleaseChannel(args?.npmTag)
35
+ || normalizeReleaseChannel(loadConfig().updateChannel)
36
+ || 'stable';
37
+ }
38
+
39
+ export const daemonLifecycleHandlers: Record<string, LowFamilyHandler> = {
40
+ daemon_upgrade: async (ctx: LowFamilyContext, args: any) => {
41
+ LOG.info('Upgrade', 'Remote upgrade requested from dashboard');
42
+ try {
43
+ // Detect package name for upgrade
44
+ const isStandalone = ctx.deps.packageName === '@adhdev/daemon-standalone'
45
+ || process.argv[1]?.includes('daemon-standalone');
46
+ const pkgName = isStandalone ? '@adhdev/daemon-standalone' : 'adhdev';
47
+ const npmSurface = resolveCurrentGlobalInstallSurface({ packageName: pkgName });
48
+ const channel = resolveUpgradeChannel(args);
49
+ const npmTag = CHANNEL_NPM_TAG[channel];
50
+
51
+ // Check channel-pinned dist-tag and resolve it to a concrete install version.
52
+ const latest = String(execNpmCommandSync(['view', `${pkgName}@${npmTag}`, 'version'], { encoding: 'utf-8', timeout: 10000 }, npmSurface)).trim();
53
+ LOG.info('Upgrade', `Latest ${pkgName}@${npmTag}: v${latest}`);
54
+ updateConfig({ updateChannel: channel, serverUrl: CHANNEL_SERVER_URL[channel] } as any);
55
+ let currentInstalled: string | null = null;
56
+ try {
57
+ const currentJson = String(execNpmCommandSync(['ls', '-g', pkgName, '--depth=0', '--json'], {
58
+ encoding: 'utf-8',
59
+ timeout: 10000,
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ }, npmSurface)).trim();
62
+ const parsed = JSON.parse(currentJson);
63
+ currentInstalled = parsed?.dependencies?.[pkgName]?.version || null;
64
+ } catch {
65
+ // ignore ls failures; upgrade can still proceed
66
+ }
67
+
68
+ const runningVersion = typeof ctx.deps.statusVersion === 'string'
69
+ ? ctx.deps.statusVersion.trim().replace(/^v/, '')
70
+ : null;
71
+ if (currentInstalled === latest && runningVersion === latest) {
72
+ LOG.info('Upgrade', `Already on ${channel} channel version v${latest}; skipping install`);
73
+ return { success: true, upgraded: false, alreadyLatest: true, version: latest, channel, npmTag };
74
+ }
75
+ if (currentInstalled === latest && runningVersion && runningVersion !== latest) {
76
+ LOG.info('Upgrade', `Installed package is v${latest}, but running daemon is v${runningVersion}; scheduling restart`);
77
+ }
78
+
79
+ spawnDetachedDaemonUpgradeHelper({
80
+ packageName: pkgName,
81
+ targetVersion: latest,
82
+ parentPid: process.pid,
83
+ restartArgv: process.argv.slice(1),
84
+ cwd: process.cwd(),
85
+ sessionHostAppName: process.env.ADHDEV_SESSION_HOST_NAME || 'adhdev',
86
+ });
87
+ LOG.info('Upgrade', `Scheduled detached ${channel} upgrade to v${latest}`);
88
+
89
+ // Exit after the command response has been sent so the helper can replace the package cleanly.
90
+ setTimeout(() => {
91
+ LOG.info('Upgrade', 'Exiting daemon so detached upgrader can continue...');
92
+ process.exit(0);
93
+ }, 3000);
94
+
95
+ return { success: true, upgraded: true, version: latest, restarting: true, channel, npmTag };
96
+ } catch (e: any) {
97
+ LOG.error('Upgrade', `Failed: ${e.message}`);
98
+ return { success: false, error: e.message };
99
+ }
100
+ },
101
+
102
+ set_machine_nickname: async (_ctx: LowFamilyContext, args: any) => {
103
+ const nickname = args?.nickname;
104
+ updateConfig({ machineNickname: nickname || null });
105
+ return { success: true };
106
+ },
107
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * RF-ROUTER LOW family — diagnostics read commands.
3
+ *
4
+ * Extracted verbatim from DaemonCommandRouter.executeDaemonCommand. Both handlers
5
+ * read only process-global logging buffers (ring buffer + file fallback + debug
6
+ * trace) — no router instance state — and return the same CommandRouterResult the
7
+ * inlined cases did.
8
+ */
9
+ import * as fs from 'fs';
10
+ import { getRecentLogs, LOG_PATH } from '../../logging/logger.js';
11
+ import { getRecentDebugTrace } from '../../logging/debug-trace.js';
12
+ import type { LowFamilyContext, LowFamilyHandler } from './types.js';
13
+
14
+ export const diagnosticsHandlers: Record<string, LowFamilyHandler> = {
15
+ get_logs: async (_ctx: LowFamilyContext, args: any) => {
16
+ const count = parseInt(args?.count) || parseInt(args?.lines) || 100;
17
+ const minLevel = args?.minLevel || 'info';
18
+ const sinceTs = args?.since || 0;
19
+
20
+ try {
21
+ // Priority 1: ring buffer (fast and structured)
22
+ let logs = getRecentLogs(count, minLevel);
23
+ if (sinceTs > 0) {
24
+ logs = logs.filter((l: any) => l.ts > sinceTs);
25
+ }
26
+ if (logs.length > 0) {
27
+ return { success: true, logs, totalBuffered: logs.length };
28
+ }
29
+ // Incremental polling must not fall back to unfiltered file text: the file
30
+ // format is not timestamp-filterable, and returning its tail makes the UI
31
+ // replace structured logs with old raw fallback lines when nothing new exists.
32
+ if (sinceTs > 0) {
33
+ return { success: true, logs: [], totalBuffered: 0 };
34
+ }
35
+ // Priority 2: file fallback
36
+ if (fs.existsSync(LOG_PATH)) {
37
+ const content = fs.readFileSync(LOG_PATH, 'utf-8');
38
+ const allLines = content.split('\n');
39
+ const recent = allLines.slice(-count).join('\n');
40
+ return { success: true, logs: recent, totalLines: allLines.length };
41
+ }
42
+ return { success: true, logs: [], totalBuffered: 0 };
43
+ } catch (e: any) {
44
+ return { success: false, error: e.message };
45
+ }
46
+ },
47
+
48
+ get_debug_trace: async (_ctx: LowFamilyContext, args: any) => {
49
+ const count = parseInt(args?.count) || parseInt(args?.limit) || 100;
50
+ const sinceTs = Number(args?.since) || 0;
51
+ const interactionId = typeof args?.interactionId === 'string' ? args.interactionId : undefined;
52
+ const category = typeof args?.category === 'string' ? args.category : undefined;
53
+ const trace = getRecentDebugTrace({ interactionId, category, limit: count })
54
+ .filter((entry) => !sinceTs || entry.ts > sinceTs);
55
+ return { success: true, trace, count: trace.length };
56
+ },
57
+ };
@@ -10,6 +10,13 @@
10
10
  import { sessionHostHandlers } from './session-host.js';
11
11
  import { specProviderDevHandlers } from './spec-providerdev.js';
12
12
  import { refineConfigHandlers } from './refine-config.js';
13
+ import { diagnosticsHandlers } from './diagnostics.js';
14
+ import { statusMetaHandlers } from './status-meta.js';
15
+ import { coordinatorPromptHandlers } from './coordinator-prompt.js';
16
+ import { notificationHandlers } from './notification.js';
17
+ import { daemonLifecycleHandlers } from './daemon-lifecycle.js';
18
+ import { meshLedgerHandlers } from './mesh-ledger.js';
19
+ import { meshNodeLogsHandlers } from './mesh-node-logs.js';
13
20
  import type { LowFamilyRegistry } from './types.js';
14
21
 
15
22
  export type { LowFamilyContext, LowFamilyHandler, LowFamilyRegistry } from './types.js';
@@ -19,5 +26,12 @@ export const lowFamilyRegistry: LowFamilyRegistry = new Map(
19
26
  ...sessionHostHandlers,
20
27
  ...specProviderDevHandlers,
21
28
  ...refineConfigHandlers,
29
+ ...diagnosticsHandlers,
30
+ ...statusMetaHandlers,
31
+ ...coordinatorPromptHandlers,
32
+ ...notificationHandlers,
33
+ ...daemonLifecycleHandlers,
34
+ ...meshLedgerHandlers,
35
+ ...meshNodeLogsHandlers,
22
36
  }),
23
37
  );
@@ -0,0 +1,62 @@
1
+ /**
2
+ * RF-ROUTER LOW family — mesh ledger read / slice / import commands.
3
+ *
4
+ * Extracted verbatim from DaemonCommandRouter.executeDaemonCommand. Each handler
5
+ * touches only the on-disk mesh ledger (dynamically imported mesh-ledger module)
6
+ * keyed by meshId — no router instance state — and returns the same
7
+ * CommandRouterResult the inlined cases did.
8
+ */
9
+ import type { LowFamilyContext, LowFamilyHandler } from './types.js';
10
+
11
+ export const meshLedgerHandlers: Record<string, LowFamilyHandler> = {
12
+ get_mesh_ledger: async (_ctx: LowFamilyContext, args: any) => {
13
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
14
+ if (!meshId) return { success: false, error: 'meshId required' };
15
+ try {
16
+ const { readLedgerEntries, getLedgerSummary } = await import('../../mesh/mesh-ledger.js');
17
+ const tail = typeof args?.tail === 'number' ? args.tail : 20;
18
+ const since = typeof args?.since === 'string' ? args.since : undefined;
19
+ const kind = Array.isArray(args?.kind) ? args.kind.filter((k: any) => typeof k === 'string') : undefined;
20
+ const entries = readLedgerEntries(meshId, { tail, since, kind });
21
+ const summary = getLedgerSummary(meshId);
22
+ return { success: true, entries, summary };
23
+ } catch (e: any) {
24
+ return { success: false, error: e.message };
25
+ }
26
+ },
27
+
28
+ get_mesh_ledger_slice: async (_ctx: LowFamilyContext, args: any) => {
29
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
30
+ if (!meshId) return { success: false, error: 'meshId required' };
31
+ try {
32
+ const { readLedgerSlice } = await import('../../mesh/mesh-ledger.js');
33
+ const kind = Array.isArray(args?.kind) ? args.kind.filter((k: any) => typeof k === 'string') : undefined;
34
+ const slice = readLedgerSlice(meshId, {
35
+ afterId: typeof args?.afterId === 'string' ? args.afterId : undefined,
36
+ since: typeof args?.since === 'string' ? args.since : undefined,
37
+ kind,
38
+ limit: typeof args?.limit === 'number' ? args.limit : undefined,
39
+ });
40
+ return { success: true, slice };
41
+ } catch (e: any) {
42
+ return { success: false, error: e.message };
43
+ }
44
+ },
45
+
46
+ import_mesh_ledger_slice: async (_ctx: LowFamilyContext, args: any) => {
47
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
48
+ if (!meshId) return { success: false, error: 'meshId required' };
49
+ try {
50
+ const { appendRemoteLedgerEntries, getLedgerSummary } = await import('../../mesh/mesh-ledger.js');
51
+ const entries = Array.isArray(args?.entries)
52
+ ? args.entries as any[]
53
+ : Array.isArray(args?.slice?.entries)
54
+ ? args.slice.entries as any[]
55
+ : [];
56
+ const result = appendRemoteLedgerEntries(meshId, entries as any);
57
+ return { success: true, result, summary: getLedgerSummary(meshId) };
58
+ } catch (e: any) {
59
+ return { success: false, error: e.message };
60
+ }
61
+ },
62
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * RF-ROUTER LOW family — coordinator-driven remote daemon log fetch.
3
+ *
4
+ * Extracted verbatim from DaemonCommandRouter.executeDaemonCommand. Unlike the
5
+ * other LOW handlers this one resolves a node's owning daemonId from the router's
6
+ * inline-mesh cache, so it consumes the router-bound `ctx.getMeshForCommand`
7
+ * helper (mesh state is router instance state, not reconstructable from deps). The
8
+ * forward/local-read/redact logic is otherwise unchanged; returns the same
9
+ * CommandRouterResult the inlined case did.
10
+ */
11
+ import { daemonIdsEquivalent, meshNodeIdMatches } from '@adhdev/mesh-shared';
12
+ import { readDaemonLogTail, MAX_TAIL_BYTES } from '../../logging/log-tail-reader.js';
13
+ import { redactLogLines } from '../../logging/log-redactor.js';
14
+ import type { CommandRouterResult } from '../router.js';
15
+ import type { LowFamilyContext, LowFamilyHandler } from './types.js';
16
+
17
+ export const meshNodeLogsHandlers: Record<string, LowFamilyHandler> = {
18
+ get_mesh_node_logs: async (ctx: LowFamilyContext, args: any) => {
19
+ // Coordinator-driven remote log fetch: read a (possibly remote)
20
+ // daemon's recent log tail over P2P instead of opening a session
21
+ // and grepping the file by hand. Mirrors fast_forward_mesh_node's
22
+ // forward pattern — resolve the node, forward to its owning daemon
23
+ // when remote, otherwise read locally. The reply tail is HARD
24
+ // byte-bounded and secret-redacted before it leaves the machine.
25
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
26
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
27
+ let nodeDaemonId: string | undefined;
28
+ if (meshId && nodeId && ctx.getMeshForCommand) {
29
+ const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
30
+ const node = meshRecord?.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
31
+ nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
32
+ }
33
+ // _meshDirectDispatch prevents re-forwarding (and P2P self-dial)
34
+ // once the call lands on the owning daemon — that daemon then reads
35
+ // its own logs even if the stored daemonId uses a legacy form.
36
+ const selfDaemonId = ctx.deps.statusInstanceId;
37
+ // daemonIdsEquivalent: a legacy-form daemonId resolving to this machine's core is
38
+ // local — read locally instead of forwarding. Equivalent → local.
39
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
40
+ if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
41
+ const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'get_mesh_node_logs', {
42
+ ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
43
+ _meshDirectDispatch: true,
44
+ });
45
+ return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
46
+ }
47
+
48
+ // Local read on the owning daemon.
49
+ const rawTailBytes = Number(args?.tailBytes);
50
+ const tail = readDaemonLogTail({
51
+ date: typeof args?.date === 'string' ? args.date : undefined,
52
+ tailBytes: Number.isFinite(rawTailBytes) ? Math.min(rawTailBytes, MAX_TAIL_BYTES) : undefined,
53
+ grep: typeof args?.grep === 'string' ? args.grep : undefined,
54
+ sinceMs: Number.isFinite(Number(args?.sinceMs)) ? Number(args?.sinceMs) : undefined,
55
+ });
56
+ if (!tail.success) {
57
+ return {
58
+ success: false,
59
+ error: tail.error || 'failed to read daemon log tail',
60
+ nodeId,
61
+ logPath: tail.logPath,
62
+ platform: tail.platform,
63
+ } as CommandRouterResult;
64
+ }
65
+ // SECURITY: redact secrets from every line before returning over P2P.
66
+ const redactedLines = redactLogLines(tail.lines);
67
+ return {
68
+ success: true,
69
+ nodeId,
70
+ daemonId: selfDaemonId,
71
+ logPath: tail.logPath,
72
+ platform: tail.platform,
73
+ lines: redactedLines,
74
+ lineCount: redactedLines.length,
75
+ truncated: tail.truncated,
76
+ filtered: tail.filtered,
77
+ bytesReturned: tail.bytesReturned,
78
+ ...(tail.grep ? { grep: tail.grep } : {}),
79
+ } as CommandRouterResult;
80
+ },
81
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * RF-ROUTER LOW family — session notification / seen-state commands.
3
+ *
4
+ * Extracted verbatim from DaemonCommandRouter.executeDaemonCommand. Each handler
5
+ * mutates only the persisted recent-activity state store (loadState/saveState) and
6
+ * fires ctx.deps.onStatusChange; session lookup is rebuilt from deps the router
7
+ * already holds. Returns the same CommandRouterResult the inlined cases did.
8
+ */
9
+ import { loadState, saveState } from '../../config/state-store.js';
10
+ import { markSessionSeen, dismissSessionNotification, markSessionNotificationUnread } from '../../config/recent-activity.js';
11
+ import { buildSessionEntries } from '../../status/builders.js';
12
+ import { getSessionCompletionMarker } from '../../status/snapshot.js';
13
+ import { LOG } from '../../logging/logger.js';
14
+ import type { LowFamilyContext, LowFamilyHandler } from './types.js';
15
+
16
+ const READ_DEBUG_ENABLED = process.argv.includes('--dev') || process.env.ADHDEV_READ_DEBUG === '1';
17
+
18
+ export const notificationHandlers: Record<string, LowFamilyHandler> = {
19
+ mark_session_seen: async (ctx: LowFamilyContext, args: any) => {
20
+ const sessionId = args?.sessionId;
21
+ if (!sessionId || typeof sessionId !== 'string') {
22
+ return { success: false, error: 'sessionId is required' };
23
+ }
24
+ const currentState = loadState();
25
+ const prevSeenAt = currentState.sessionReads?.[sessionId] || 0;
26
+ const sessionEntries = buildSessionEntries(
27
+ ctx.deps.instanceManager.collectAllStates(),
28
+ ctx.deps.cdpManagers,
29
+ );
30
+ const targetSession = sessionEntries.find((entry) => entry.id === sessionId);
31
+ const requestedCompletionMarker = typeof args?.completionMarker === 'string'
32
+ ? args.completionMarker.trim()
33
+ : '';
34
+ const completionMarker = requestedCompletionMarker || (targetSession ? getSessionCompletionMarker(targetSession) : '');
35
+ const requestedProviderSessionId = typeof args?.providerSessionId === 'string'
36
+ ? args.providerSessionId.trim()
37
+ : '';
38
+ const providerSessionId = requestedProviderSessionId || targetSession?.providerSessionId;
39
+ const next = markSessionSeen(
40
+ currentState,
41
+ sessionId,
42
+ typeof args?.seenAt === 'number' ? args.seenAt : Date.now(),
43
+ completionMarker,
44
+ providerSessionId,
45
+ );
46
+ if (READ_DEBUG_ENABLED) {
47
+ LOG.info('RecentRead', `mark_session_seen sessionId=${sessionId} seenAt=${String(args?.seenAt || '')} prevSeenAt=${String(prevSeenAt)} nextSeenAt=${String(next.sessionReads?.[sessionId] || 0)} marker=${completionMarker || '-'}`);
48
+ }
49
+ saveState(next);
50
+ ctx.deps.onStatusChange?.();
51
+ return {
52
+ success: true,
53
+ sessionId,
54
+ seenAt: next.sessionReads?.[sessionId] || Date.now(),
55
+ completionMarker,
56
+ };
57
+ },
58
+
59
+ delete_notification: async (ctx: LowFamilyContext, args: any) => {
60
+ const sessionId = args?.sessionId;
61
+ const notificationId = typeof args?.notificationId === 'string' ? args.notificationId.trim() : '';
62
+ if (!sessionId || typeof sessionId !== 'string') {
63
+ return { success: false, error: 'sessionId is required' };
64
+ }
65
+ if (!notificationId) {
66
+ return { success: false, error: 'notificationId is required' };
67
+ }
68
+ const sessionEntries = buildSessionEntries(
69
+ ctx.deps.instanceManager.collectAllStates(),
70
+ ctx.deps.cdpManagers,
71
+ );
72
+ const targetSession = sessionEntries.find((entry) => entry.id === sessionId);
73
+ const next = dismissSessionNotification(
74
+ loadState(),
75
+ sessionId,
76
+ notificationId,
77
+ targetSession?.providerSessionId,
78
+ );
79
+ saveState(next);
80
+ ctx.deps.onStatusChange?.();
81
+ return {
82
+ success: true,
83
+ sessionId,
84
+ notificationId,
85
+ };
86
+ },
87
+
88
+ mark_notification_unread: async (ctx: LowFamilyContext, args: any) => {
89
+ const sessionId = args?.sessionId;
90
+ const notificationId = typeof args?.notificationId === 'string' ? args.notificationId.trim() : '';
91
+ if (!sessionId || typeof sessionId !== 'string') {
92
+ return { success: false, error: 'sessionId is required' };
93
+ }
94
+ if (!notificationId) {
95
+ return { success: false, error: 'notificationId is required' };
96
+ }
97
+ const sessionEntries = buildSessionEntries(
98
+ ctx.deps.instanceManager.collectAllStates(),
99
+ ctx.deps.cdpManagers,
100
+ );
101
+ const targetSession = sessionEntries.find((entry) => entry.id === sessionId);
102
+ const next = markSessionNotificationUnread(
103
+ loadState(),
104
+ sessionId,
105
+ notificationId,
106
+ targetSession?.providerSessionId,
107
+ );
108
+ saveState(next);
109
+ ctx.deps.onStatusChange?.();
110
+ return {
111
+ success: true,
112
+ sessionId,
113
+ notificationId,
114
+ };
115
+ },
116
+ };