@adhdev/daemon-core 1.0.17 → 1.0.18-rc.10

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 (36) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  3. package/dist/commands/process-lifecycle.d.ts +43 -0
  4. package/dist/commands/upgrade-helper.d.ts +7 -0
  5. package/dist/commands/windows-atomic-upgrade.d.ts +63 -0
  6. package/dist/index.js +5818 -4496
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +5853 -4525
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  11. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  12. package/dist/mesh/mesh-ledger.d.ts +28 -1
  13. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  14. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  15. package/dist/providers/cli-provider-instance.d.ts +2 -0
  16. package/package.json +3 -3
  17. package/src/boot/daemon-lifecycle.ts +10 -0
  18. package/src/cli-adapters/cli-state-engine.ts +204 -3
  19. package/src/cli-adapters/provider-cli-adapter.ts +39 -5
  20. package/src/cli-adapters/pty-transport.d.ts +2 -1
  21. package/src/cli-adapters/pty-transport.ts +1 -1
  22. package/src/cli-adapters/session-host-transport.ts +8 -3
  23. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  24. package/src/commands/process-lifecycle.ts +248 -0
  25. package/src/commands/upgrade-helper.ts +146 -79
  26. package/src/commands/windows-atomic-upgrade.ts +631 -0
  27. package/src/config/mesh-json-config.ts +8 -0
  28. package/src/mesh/coordinator-prompt.ts +256 -10
  29. package/src/mesh/mesh-disk-retention.ts +370 -0
  30. package/src/mesh/mesh-ledger.ts +72 -0
  31. package/src/mesh/mesh-queue-assignment.ts +126 -1
  32. package/src/mesh/mesh-reconcile-loop.ts +49 -0
  33. package/src/mesh/mesh-runtime-store.ts +21 -0
  34. package/src/providers/cli-provider-instance-types.ts +25 -0
  35. package/src/providers/cli-provider-instance.ts +116 -0
  36. package/src/session-host/managed-host.ts +34 -0
@@ -707,8 +707,10 @@ export class ProviderCliAdapter implements CliAdapter {
707
707
  }
708
708
  });
709
709
 
710
- this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
711
- LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
710
+ this.ptyProcess.onExit(({ exitCode, signal }: { exitCode: number | null; signal?: number | null }) => {
711
+ // Preserve the unknown case: a null exitCode (signal-terminated or
712
+ // otherwise unreported) is logged as "unknown", never as exit 0.
713
+ LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode === null || exitCode === undefined ? 'unknown' : exitCode}${signal ? ` (signal ${signal})` : ''}`);
712
714
  this.flushPendingOutputParse();
713
715
  this.ptyProcess = null;
714
716
  this.engine.onPtyExit();
@@ -1072,7 +1074,19 @@ export class ProviderCliAdapter implements CliAdapter {
1072
1074
 
1073
1075
  getStatus(options: { allowParse?: boolean } = {}): CliSessionStatus {
1074
1076
  const allowParse = options.allowParse !== false;
1075
- const startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
1077
+ let startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
1078
+ // (fix: kimi startup-gate stale-approval bypass) startupParseGate can
1079
+ // still be open by the time a real approval is requested AND resolved
1080
+ // — this ad-hoc parse of recentOutputBuffer runs independently of the
1081
+ // settled-eval loop and previously had no staleness protection at all,
1082
+ // so it kept re-surfacing the identical already-resolved modal for as
1083
+ // long as its text remained anywhere in the rolling buffer, bypassing
1084
+ // engine.activeModal (which resolveModal() had already correctly
1085
+ // cleared). Reuse the SAME discriminator the settle loop uses instead
1086
+ // of inventing a second one here.
1087
+ if (startupModal && this.engine.isStaleResolvedApproval(startupModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
1088
+ startupModal = null;
1089
+ }
1076
1090
  const startupDetectedStatus = allowParse && this.startupParseGate && !startupModal
1077
1091
  ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
1078
1092
  : null;
@@ -1179,7 +1193,20 @@ export class ProviderCliAdapter implements CliAdapter {
1179
1193
  // and let a later evaluate find the complete modal.
1180
1194
  const buttonsOk = liveModal && Array.isArray(liveModal.buttons)
1181
1195
  && liveModal.buttons.some((b: any) => typeof b === 'string' && b.trim());
1182
- if (liveModal && buttonsOk) {
1196
+ // (fix: kimi live-detect stale-approval bypass) This fallback is
1197
+ // NOT gated by startupParseGate — it runs on every getStatus()
1198
+ // call (including the periodic background status heartbeat)
1199
+ // for as long as isWaitingForResponse stays true, which for a
1200
+ // provider that keeps "thinking" after the approved tool call
1201
+ // can be the whole rest of the turn. Its own unguarded re-parse
1202
+ // of recentOutputBuffer/terminalScreen previously kept
1203
+ // re-writing engine.activeModal directly — bypassing setStatus
1204
+ // (so rawStatus never even flipped) and bypassing
1205
+ // applyWaitingApproval's staleness guard entirely, silently
1206
+ // re-corrupting the engine's own state moments after
1207
+ // resolveModal() had correctly cleared it. Reuse the same
1208
+ // engine-owned discriminator here too.
1209
+ if (liveModal && buttonsOk && !this.engine.isStaleResolvedApproval(liveModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
1183
1210
  effectiveModal = liveModal;
1184
1211
  if (!this.engine.activeModal) this.engine.activeModal = liveModal;
1185
1212
  }
@@ -2474,7 +2501,14 @@ export class ProviderCliAdapter implements CliAdapter {
2474
2501
 
2475
2502
  getDebugState(): Record<string, any> {
2476
2503
  const screenText = sanitizeTerminalText(this.terminalScreen.getText());
2477
- const startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2504
+ let startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2505
+ // (fix: kimi startup-gate stale-approval bypass) See the matching
2506
+ // comment in getStatus() — this ad-hoc startup-gate parse bypassed
2507
+ // engine.activeModal's staleness protection entirely. Reuse the same
2508
+ // engine-owned discriminator rather than duplicating it here.
2509
+ if (startupModal && this.engine.isStaleResolvedApproval(startupModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
2510
+ startupModal = null;
2511
+ }
2478
2512
  const startupDetectedStatus = this.startupParseGate && !startupModal
2479
2513
  ? this.runDetectStatus(this.recentOutputBuffer || screenText)
2480
2514
  : null;
@@ -37,7 +37,8 @@ export interface PtyRuntimeTransport {
37
37
  getMetadata?(): PtyRuntimeMetadata | null;
38
38
  onData(callback: (data: string) => void): void;
39
39
  onExit(callback: (info: {
40
- exitCode: number;
40
+ exitCode: number | null;
41
+ signal?: number | null;
41
42
  }) => void): void;
42
43
  }
43
44
  export interface PtyTransportFactory {
@@ -62,7 +62,7 @@ export interface PtyRuntimeTransport {
62
62
  updateMeta?(meta: Record<string, unknown>, replace?: boolean): void;
63
63
  getMetadata?(): PtyRuntimeMetadata | null;
64
64
  onData(callback: (data: string) => void): void;
65
- onExit(callback: (info: { exitCode: number }) => void): void;
65
+ onExit(callback: (info: { exitCode: number | null; signal?: number | null }) => void): void;
66
66
  }
67
67
 
68
68
  export interface PtyTransportFactory {
@@ -42,7 +42,7 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
42
42
 
43
43
  private readonly client: SessionHostClient;
44
44
  private readonly dataCallbacks = new Set<(data: string) => void>();
45
- private readonly exitCallbacks = new Set<(info: { exitCode: number }) => void>();
45
+ private readonly exitCallbacks = new Set<(info: { exitCode: number | null; signal?: number | null }) => void>();
46
46
  private readonly pendingOutput: string[] = [];
47
47
  private operationChain = Promise.resolve();
48
48
  private unsubscribe: (() => void) | null = null;
@@ -79,7 +79,7 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
79
79
  }
80
80
  }
81
81
 
82
- onExit(callback: (info: { exitCode: number }) => void): void {
82
+ onExit(callback: (info: { exitCode: number | null; signal?: number | null }) => void): void {
83
83
  this.exitCallbacks.add(callback);
84
84
  }
85
85
 
@@ -361,8 +361,13 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
361
361
  return;
362
362
  }
363
363
  if (event.type === 'session_exit') {
364
+ // Preserve the nullable/unknown exitCode and signal exactly as the
365
+ // session host reported them — never collapse null to 0, which would
366
+ // make a signal-terminated process indistinguishable from a clean exit.
367
+ const exitCode = typeof event.exitCode === 'number' ? event.exitCode : null;
368
+ const signal = typeof event.signal === 'number' ? event.signal : null;
364
369
  for (const callback of this.exitCallbacks) {
365
- callback({ exitCode: event.exitCode ?? 0 });
370
+ callback({ exitCode, signal });
366
371
  }
367
372
  void this.closeClient(false);
368
373
  }
@@ -115,8 +115,12 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
115
115
  try {
116
116
  const { readOperatingNotes } = await import('../../mesh/mesh-ledger.js');
117
117
  // readOperatingNotes filters out tombstoned (forgotten) notes so a
118
- // retracted lesson never rides into the prompt. Newest last; tail 20.
119
- const noteEntries = readOperatingNotes(id, { tail: 20 });
118
+ // retracted lesson never rides into the prompt. Newest last.
119
+ // Phase 2 (d): the byte-budget/count cap now bounds the injected list
120
+ // (selectOperatingNotesForPrompt), so read a larger candidate tail than
121
+ // the old fixed 20 and let injection-side ranking + budget do the
122
+ // bounding. Keep a sane store-read ceiling to avoid unbounded arrays.
123
+ const noteEntries = readOperatingNotes(id, { tail: 100 });
120
124
  const notes = noteEntries
121
125
  .map((e) => {
122
126
  const p = (e.payload || {}) as Record<string, unknown>;
@@ -131,6 +135,17 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
131
135
  category,
132
136
  createdAt: typeof p.createdAt === 'string' ? p.createdAt : e.timestamp,
133
137
  sourceCoordinator: typeof p.sourceCoordinator === 'string' ? p.sourceCoordinator : undefined,
138
+ // Operating-notes lifecycle: thread pinned/expiresAt so the
139
+ // injection-side selection can honor them. Legacy notes lack
140
+ // these → pinned defaults false, expiry governed by category TTL.
141
+ pinned: p.pinned === true,
142
+ ...(typeof p.expiresAt === 'string' ? { expiresAt: p.expiresAt } : {}),
143
+ // Phase 2 (b)/(c): thread the ledger id + supersedes/subjectKey
144
+ // so version-supersede targeting and same-class folding work.
145
+ // Legacy notes lack them → no supersede, fold by leading [tag].
146
+ noteId: e.id,
147
+ ...(typeof p.supersedes === 'string' ? { supersedes: p.supersedes } : {}),
148
+ ...(typeof p.subjectKey === 'string' ? { subjectKey: p.subjectKey } : {}),
134
149
  };
135
150
  })
136
151
  .filter((n): n is NonNullable<typeof n> => n !== null);
@@ -0,0 +1,248 @@
1
+ import { execFileSync, type ExecFileSyncOptions } from 'child_process';
2
+ import * as path from 'path';
3
+
4
+ export interface ProcessLifecycleOptions {
5
+ platform?: NodeJS.Platform;
6
+ execFileSync?: typeof import('child_process').execFileSync;
7
+ }
8
+
9
+ export interface OwnedProcessInfo {
10
+ pid: number;
11
+ commandLine: string | null;
12
+ }
13
+
14
+ function defaultExecFileSync(): typeof import('child_process').execFileSync {
15
+ return execFileSync;
16
+ }
17
+
18
+ function getWindowsProcessCommandLine(
19
+ pid: number,
20
+ exec: typeof import('child_process').execFileSync,
21
+ ): string | null {
22
+ const pidFilter = `ProcessId=${pid}`;
23
+
24
+ try {
25
+ const psOut = exec('powershell.exe', [
26
+ '-NoProfile',
27
+ '-NonInteractive',
28
+ '-ExecutionPolicy', 'Bypass',
29
+ '-Command',
30
+ `(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
31
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
32
+ const text = String(psOut).trim();
33
+ if (text) return text;
34
+ } catch {
35
+ // fall through to wmic fallback
36
+ }
37
+
38
+ try {
39
+ const wmicOut = exec('wmic', [
40
+ 'process', 'where', pidFilter, 'get', 'CommandLine',
41
+ ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
42
+ const text = String(wmicOut).trim();
43
+ if (text) return text;
44
+ } catch {
45
+ // noop
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ export function getProcessCommandLine(
52
+ pid: number,
53
+ options: ProcessLifecycleOptions = {},
54
+ ): string | null {
55
+ if (!Number.isFinite(pid) || pid <= 0) return null;
56
+ const exec = options.execFileSync ?? defaultExecFileSync();
57
+ const platform = options.platform ?? process.platform;
58
+
59
+ if (platform === 'win32') {
60
+ return getWindowsProcessCommandLine(pid, exec);
61
+ }
62
+
63
+ try {
64
+ const text = String(exec('ps', ['-o', 'command=', '-p', String(pid)], {
65
+ encoding: 'utf8',
66
+ timeout: 3000,
67
+ stdio: ['ignore', 'pipe', 'ignore'],
68
+ })).trim();
69
+ return text || null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Extract the script argument (the token after the node executable) from a
77
+ * command line. Handles both quoted and unquoted executables/scripts.
78
+ */
79
+ export function parseNodeScriptPath(commandLine: string | null): string | null {
80
+ if (!commandLine) return null;
81
+ let rest = commandLine.trim();
82
+
83
+ // Skip the leading executable token.
84
+ if (rest.startsWith('"')) {
85
+ const end = rest.indexOf('"', 1);
86
+ if (end === -1) return null;
87
+ rest = rest.slice(end + 1).trim();
88
+ } else {
89
+ const idx = rest.search(/\s/);
90
+ if (idx === -1) return null;
91
+ rest = rest.slice(idx + 1).trim();
92
+ }
93
+ if (!rest) return null;
94
+
95
+ if (rest.startsWith('"')) {
96
+ const end = rest.indexOf('"', 1);
97
+ return end === -1 ? rest.slice(1) : rest.slice(1, end);
98
+ }
99
+ const idx = rest.search(/\s/);
100
+ return idx === -1 ? rest : rest.slice(0, idx);
101
+ }
102
+
103
+ function normalizeWindowsPath(value: string): string {
104
+ return value.toLowerCase().replace(/\//g, '\\').replace(/\\+$/, '');
105
+ }
106
+
107
+ function isCommandLineUnderPrefix(commandLine: string | null, prefix: string): boolean {
108
+ if (!commandLine) return false;
109
+ const needle = normalizeWindowsPath(prefix);
110
+ // Command-line paths may quote Windows separators as either single or
111
+ // doubled backslashes; collapse doubles before matching.
112
+ const haystack = commandLine.split('\\\\').join('\\').toLowerCase();
113
+ return haystack.includes(needle);
114
+ }
115
+
116
+ export function killProcess(pid: number, options: ProcessLifecycleOptions = {}): boolean {
117
+ if (!Number.isFinite(pid) || pid <= 0) return false;
118
+ const exec = options.execFileSync ?? defaultExecFileSync();
119
+ const platform = options.platform ?? process.platform;
120
+
121
+ try {
122
+ if (platform === 'win32') {
123
+ exec('taskkill', ['/PID', String(pid), '/T', '/F'], {
124
+ stdio: 'ignore',
125
+ windowsHide: true,
126
+ });
127
+ } else {
128
+ process.kill(pid, 'SIGTERM');
129
+ }
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
137
+ const start = Date.now();
138
+ while (Date.now() - start < timeoutMs) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ await new Promise((resolve) => setTimeout(resolve, 250));
142
+ } catch {
143
+ return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * List Node processes whose command line places them under any of the supplied
151
+ * prefixes. Windows-only — the versioned-prefix lifecycle this supports does not
152
+ * exist on POSIX, so the helper is a no-op there.
153
+ */
154
+ export function listOwnedNodeProcesses(options: {
155
+ prefixes: readonly string[];
156
+ excludePids?: readonly number[];
157
+ markers?: readonly string[];
158
+ } & ProcessLifecycleOptions): OwnedProcessInfo[] {
159
+ const platform = options.platform ?? process.platform;
160
+ if (platform !== 'win32') return [];
161
+
162
+ const exec = options.execFileSync ?? defaultExecFileSync();
163
+ const prefixes = options.prefixes.map((p) => normalizeWindowsPath(p));
164
+ const exclude = new Set((options.excludePids ?? []).filter((n) => Number.isFinite(n) && n > 0));
165
+ // Command-line paths use Windows separators; normalize marker separators the
166
+ // same way so a marker like "dist/cli/index.js" matches "dist\cli\index.js".
167
+ const markers = (options.markers ?? []).map((m) => m.toLowerCase().replace(/\//g, '\\'));
168
+
169
+ let pids: number[] = [];
170
+ try {
171
+ const out = String(exec('powershell.exe', [
172
+ '-NoProfile',
173
+ '-NonInteractive',
174
+ '-ExecutionPolicy', 'Bypass',
175
+ '-Command',
176
+ 'Get-Process node -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id | ConvertTo-Json -Compress',
177
+ ], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })).trim();
178
+ if (out) {
179
+ const parsed = JSON.parse(out);
180
+ pids = Array.isArray(parsed) ? parsed : [parsed];
181
+ }
182
+ } catch {
183
+ return [];
184
+ }
185
+
186
+ const results: OwnedProcessInfo[] = [];
187
+ for (const pid of pids) {
188
+ if (!Number.isFinite(pid) || pid <= 0 || exclude.has(pid)) continue;
189
+ const commandLine = getProcessCommandLine(pid, { platform, execFileSync: exec });
190
+ if (!commandLine) continue;
191
+ const lower = commandLine.toLowerCase();
192
+ const underPrefix = prefixes.some((prefix) => lower.includes(prefix));
193
+ if (!underPrefix) continue;
194
+ if (markers.length > 0 && !markers.some((marker) => lower.includes(marker))) continue;
195
+ results.push({ pid, commandLine });
196
+ }
197
+ return results;
198
+ }
199
+
200
+ export async function stopOwnedProcesses(options: {
201
+ processes: readonly OwnedProcessInfo[];
202
+ waitMs?: number;
203
+ } & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
204
+ const waitMs = options.waitMs ?? 15_000;
205
+ const killed = new Set<number>();
206
+
207
+ for (const p of options.processes) {
208
+ if (killProcess(p.pid, options)) killed.add(p.pid);
209
+ }
210
+
211
+ const survivors: OwnedProcessInfo[] = [];
212
+ for (const p of options.processes) {
213
+ if (!killed.has(p.pid)) {
214
+ survivors.push(p);
215
+ continue;
216
+ }
217
+ const exited = await waitForPidExit(p.pid, waitMs);
218
+ if (!exited) survivors.push(p);
219
+ }
220
+
221
+ // A process counts as stopped only when it left the process table; a failed
222
+ // kill or a post-kill timeout both land it in `survivors`.
223
+ return { stopped: options.processes.length - survivors.length, survivors };
224
+ }
225
+
226
+ export async function stopOwnedProcessesForPrefixes(options: {
227
+ prefixes: readonly string[];
228
+ excludePids?: readonly number[];
229
+ markers?: readonly string[];
230
+ waitMs?: number;
231
+ log?: (message: string) => void;
232
+ } & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
233
+ const processes = listOwnedNodeProcesses(options);
234
+ if (processes.length === 0) return { stopped: 0, survivors: [] };
235
+
236
+ options.log?.(`Stopping ${processes.length} owned process(es) under prefixes: ${options.prefixes.join(', ')}`);
237
+ const result = await stopOwnedProcesses({
238
+ processes,
239
+ waitMs: options.waitMs,
240
+ platform: options.platform,
241
+ execFileSync: options.execFileSync,
242
+ });
243
+
244
+ if (result.survivors.length > 0) {
245
+ options.log?.(`Could not stop ${result.survivors.length} owned process(es): ${result.survivors.map((s) => s.pid).join(', ')}`);
246
+ }
247
+ return result;
248
+ }