@adhdev/daemon-core 0.9.82-rc.360 → 0.9.82-rc.362

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 (38) hide show
  1. package/dist/commands/cli-manager.d.ts +13 -0
  2. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  3. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  4. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  5. package/dist/commands/low-family/index.d.ts +3 -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/refine-config.d.ts +2 -0
  10. package/dist/commands/low-family/session-host.d.ts +2 -0
  11. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  12. package/dist/commands/low-family/status-meta.d.ts +2 -0
  13. package/dist/commands/low-family/types.d.ts +33 -0
  14. package/dist/commands/router.d.ts +0 -1
  15. package/dist/index.js +5314 -5204
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +4833 -4723
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/mesh/mesh-events-utils.d.ts +0 -2
  20. package/package.json +2 -2
  21. package/src/commands/cli-manager.ts +69 -4
  22. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  23. package/src/commands/low-family/daemon-lifecycle.ts +107 -0
  24. package/src/commands/low-family/diagnostics.ts +57 -0
  25. package/src/commands/low-family/index.ts +37 -0
  26. package/src/commands/low-family/mesh-ledger.ts +62 -0
  27. package/src/commands/low-family/mesh-node-logs.ts +81 -0
  28. package/src/commands/low-family/notification.ts +116 -0
  29. package/src/commands/low-family/refine-config.ts +106 -0
  30. package/src/commands/low-family/session-host.ts +274 -0
  31. package/src/commands/low-family/spec-providerdev.ts +217 -0
  32. package/src/commands/low-family/status-meta.ts +112 -0
  33. package/src/commands/low-family/types.ts +39 -0
  34. package/src/commands/router.ts +22 -1076
  35. package/src/mesh/contracts.ts +12 -3
  36. package/src/mesh/mesh-events-coordinator.ts +1 -1
  37. package/src/mesh/mesh-events-utils.ts +0 -20
  38. package/src/providers/cli-provider-instance.ts +16 -3
@@ -1,8 +1,6 @@
1
1
  import type { SessionRecoveryContext } from './mesh-ledger.js';
2
2
  export declare function readNonEmptyString(value: unknown): string;
3
3
  export declare function readRecord(value: unknown): Record<string, unknown> | undefined;
4
- export declare function canonicalDaemonId(value: unknown): string;
5
- export declare function sameDaemonId(a: unknown, b: unknown): boolean;
6
4
  /**
7
5
  * The relay-safety metadata a worker session must carry so that its completion /
8
6
  * generating events route back to the coordinator proactively (without waiting for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.360",
3
+ "version": "0.9.82-rc.362",
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.360",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.362",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -148,6 +148,18 @@ function hasCompletedStartingLaunch(adapter: any): boolean {
148
148
  return hasFinalAssistantMessage(parsedStatus?.messages);
149
149
  }
150
150
 
151
+ /**
152
+ * WTCLAIM (B): compare two workspace paths for node scoping. Normalizes separator
153
+ * style, trailing slashes, and case (Windows paths are case-insensitive; the
154
+ * coordinator-supplied node.workspace and the adapter's launch workingDir can
155
+ * differ only in those) so a base node and a worktree clone are still told apart
156
+ * by their distinct workspace roots.
157
+ */
158
+ function normalizeDirForCompare(dir?: string): string {
159
+ if (typeof dir !== 'string') return '';
160
+ return dir.trim().replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
161
+ }
162
+
151
163
  function shouldSuppressStaleParsedBusyStatus(adapterStatus: string, parsedStatus: any, adapter: any): boolean {
152
164
  const parsedRawStatus = normalizeAgentStatus(parsedStatus?.status);
153
165
  if (!BUSY_AGENT_STATUSES.has(parsedRawStatus)) return false;
@@ -1142,6 +1154,40 @@ export class DaemonCliManager {
1142
1154
  return adapter ? { adapter, key: ik } : null;
1143
1155
  }
1144
1156
 
1157
+ /**
1158
+ * WTCLAIM (B): resolve the adapter for a mesh dispatch that named a node
1159
+ * (meshContext.nodeId) but carried no explicit session. Matches by the
1160
+ * instance's bound mesh node id (settings.meshNodeId, falling back to the
1161
+ * sticky meshLastNodeId) first, then by the node workspace (workingDir).
1162
+ *
1163
+ * Unlike findAdapter's step-2 fuzzy fallback, this NEVER degrades to a
1164
+ * provider-only first-match: on a daemon hosting BOTH a base node and a
1165
+ * cloned worktree node (same daemonId), that fuzzy match could land a
1166
+ * worktree-targeted task on the base session. Returns null when no session
1167
+ * is bound to this node so the caller fails closed.
1168
+ */
1169
+ private findMeshNodeAdapter(agentType: string, nodeId: string, dir?: string): { adapter: CliAdapter; key: string } | null {
1170
+ const instanceManager = this.deps.getInstanceManager();
1171
+ const targetDir = normalizeDirForCompare(dir);
1172
+ let workspaceMatch: { adapter: CliAdapter; key: string } | null = null;
1173
+ for (const [k, a] of this.adapters) {
1174
+ if (a.cliType !== agentType) continue;
1175
+ const settings = (instanceManager?.getInstance(k) as any)?.getState?.()?.settings as Record<string, unknown> | undefined;
1176
+ const boundNodeId = (typeof settings?.meshNodeId === 'string' && settings.meshNodeId.trim())
1177
+ ? settings.meshNodeId.trim()
1178
+ : (typeof settings?.meshLastNodeId === 'string' ? settings.meshLastNodeId.trim() : '');
1179
+ // Exact node binding wins immediately (most precise).
1180
+ if (boundNodeId && boundNodeId === nodeId) return { adapter: a, key: k };
1181
+ // Workspace identity is the secondary signal for a session not (yet)
1182
+ // stamped with a node id — a base node and a worktree clone always have
1183
+ // distinct workspaces, so this still separates them.
1184
+ if (!workspaceMatch && targetDir && normalizeDirForCompare(a.workingDir) === targetDir) {
1185
+ workspaceMatch = { adapter: a, key: k };
1186
+ }
1187
+ }
1188
+ return workspaceMatch;
1189
+ }
1190
+
1145
1191
  // ─── CLI command handling ────────────────────────────
1146
1192
 
1147
1193
  async handleCliCommand(cmd: string, args: any): Promise<CommandResult | null> {
@@ -1343,10 +1389,29 @@ export class DaemonCliManager {
1343
1389
  const action = args?.action;
1344
1390
  if (!agentType || !action) throw new Error('agentType and action required');
1345
1391
 
1346
- const found = this.findAdapter(agentType, {
1347
- dir: args?.dir,
1348
- instanceKey: args?.targetSessionId,
1349
- });
1392
+ // WTCLAIM (B): a mesh dispatch that named a node (meshContext.nodeId)
1393
+ // but resolved no explicit session must be scoped to THAT node's
1394
+ // session — never routed by findAdapter's provider-only fuzzy fallback,
1395
+ // which on a daemon hosting both a base node and a cloned worktree node
1396
+ // (same daemonId) could land a worktree task on the base session. Fail
1397
+ // closed when no session is bound to the node so the coordinator
1398
+ // launches/retries instead of mis-landing the work.
1399
+ const meshScopeNodeId = (() => {
1400
+ const mc = (args as any)?.meshContext;
1401
+ return mc && typeof mc === 'object' && typeof mc.nodeId === 'string' ? mc.nodeId.trim() : '';
1402
+ })();
1403
+ let found: { adapter: CliAdapter; key: string } | null;
1404
+ if (meshScopeNodeId && !args?.targetSessionId) {
1405
+ found = this.findMeshNodeAdapter(agentType, meshScopeNodeId, args?.dir);
1406
+ if (!found) {
1407
+ throw new Error(`No mesh worker session bound to node '${meshScopeNodeId}' for agent '${agentType}' on this daemon; refusing provider-only fuzzy match to avoid cross-node dispatch`);
1408
+ }
1409
+ } else {
1410
+ found = this.findAdapter(agentType, {
1411
+ dir: args?.dir,
1412
+ instanceKey: args?.targetSessionId,
1413
+ });
1414
+ }
1350
1415
  if (!found) throw new Error(`CLI agent not running: ${agentType}`);
1351
1416
  const { adapter, key } = found;
1352
1417
 
@@ -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
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * RF-ROUTER LOW family — command registry.
3
+ *
4
+ * Aggregates the low-coupling command handlers extracted from
5
+ * DaemonCommandRouter.executeDaemonCommand into a single cmd → handler map.
6
+ * The router consults this registry before its switch (registry hit → return the
7
+ * handler result; miss → fall through to the remaining switch / CommandHandler
8
+ * delegation), so the facade and dispatch semantics are unchanged.
9
+ */
10
+ import { sessionHostHandlers } from './session-host.js';
11
+ import { specProviderDevHandlers } from './spec-providerdev.js';
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';
20
+ import type { LowFamilyRegistry } from './types.js';
21
+
22
+ export type { LowFamilyContext, LowFamilyHandler, LowFamilyRegistry } from './types.js';
23
+
24
+ export const lowFamilyRegistry: LowFamilyRegistry = new Map(
25
+ Object.entries({
26
+ ...sessionHostHandlers,
27
+ ...specProviderDevHandlers,
28
+ ...refineConfigHandlers,
29
+ ...diagnosticsHandlers,
30
+ ...statusMetaHandlers,
31
+ ...coordinatorPromptHandlers,
32
+ ...notificationHandlers,
33
+ ...daemonLifecycleHandlers,
34
+ ...meshLedgerHandlers,
35
+ ...meshNodeLogsHandlers,
36
+ }),
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
+ };