@adhdev/daemon-core 0.7.42 → 0.7.44

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 (39) hide show
  1. package/dist/cli-adapters/provider-cli-adapter.d.ts +2 -4
  2. package/dist/cli-adapters/pty-transport.d.ts +1 -0
  3. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +4 -0
  4. package/dist/cli-adapters/terminal-backends/types.d.ts +4 -0
  5. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +4 -0
  6. package/dist/cli-adapters/terminal-screen.d.ts +4 -0
  7. package/dist/commands/upgrade-helper.d.ts +10 -0
  8. package/dist/config/chat-history.d.ts +0 -3
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +509 -364
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +497 -353
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/providers/provider-instance.d.ts +0 -1
  15. package/dist/status/normalize.js +0 -7
  16. package/dist/status/normalize.js.map +1 -1
  17. package/dist/status/normalize.mjs +0 -7
  18. package/dist/status/normalize.mjs.map +1 -1
  19. package/node_modules/@adhdev/session-host-core/dist/index.js +2 -2
  20. package/node_modules/@adhdev/session-host-core/dist/index.js.map +1 -1
  21. package/node_modules/@adhdev/session-host-core/dist/index.mjs +2 -2
  22. package/node_modules/@adhdev/session-host-core/dist/index.mjs.map +1 -1
  23. package/node_modules/@adhdev/session-host-core/package.json +1 -1
  24. package/package.json +1 -1
  25. package/src/cli-adapters/provider-cli-adapter.ts +79 -70
  26. package/src/cli-adapters/pty-transport.ts +2 -0
  27. package/src/cli-adapters/session-host-transport.ts +1 -0
  28. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +5 -0
  29. package/src/cli-adapters/terminal-backends/types.ts +1 -0
  30. package/src/cli-adapters/terminal-backends/xterm-backend.ts +10 -0
  31. package/src/cli-adapters/terminal-screen.ts +4 -0
  32. package/src/commands/router.ts +29 -23
  33. package/src/commands/upgrade-helper.ts +214 -0
  34. package/src/config/chat-history.ts +3 -55
  35. package/src/index.ts +1 -0
  36. package/src/providers/cli-provider-instance.ts +1 -11
  37. package/src/providers/provider-instance.d.ts +0 -1
  38. package/src/providers/provider-instance.ts +0 -1
  39. package/src/status/normalize.ts +0 -4
@@ -64,7 +64,6 @@ export interface CliSessionStatus {
64
64
  messages: CliChatMessage[];
65
65
  workingDir: string;
66
66
  activeModal: { message: string; buttons: string[] } | null;
67
- terminalHistory?: string;
68
67
  }
69
68
 
70
69
  /**
@@ -90,7 +89,6 @@ export interface CliScriptInput {
90
89
  rawBuffer: string; // Raw PTY output (with ANSI)
91
90
  recentBuffer: string; // Recent 1000 chars (ANSI-stripped)
92
91
  screenText: string; // Current visible screen snapshot
93
- terminalHistory?: string; // Rolling append-only terminal transcript
94
92
  messages: CliChatMessage[]; // Previously parsed messages
95
93
  partialResponse: string; // Current partial response being generated
96
94
  }
@@ -100,7 +98,6 @@ interface TurnParseScope {
100
98
  startedAt: number;
101
99
  bufferStart: number;
102
100
  rawBufferStart: number;
103
- terminalHistoryStart: number;
104
101
  }
105
102
 
106
103
  export interface CliProviderModule {
@@ -173,6 +170,51 @@ function sanitizeTerminalText(str: string): string {
173
170
  return stripTerminalNoise(stripAnsi(str));
174
171
  }
175
172
 
173
+ function buildCliSpawnEnv(baseEnv: NodeJS.ProcessEnv, overrides?: Record<string, string>): Record<string, string> {
174
+ const env: Record<string, string> = {};
175
+ const source = { ...baseEnv, ...(overrides || {}) } as NodeJS.ProcessEnv;
176
+
177
+ for (const [key, value] of Object.entries(source)) {
178
+ if (typeof value !== 'string') continue;
179
+ env[key] = value;
180
+ }
181
+
182
+ for (const key of Object.keys(env)) {
183
+ if (
184
+ key === 'INIT_CWD'
185
+ || key === 'NO_COLOR'
186
+ || key === 'FORCE_COLOR'
187
+ || key === 'npm_command'
188
+ || key === 'npm_execpath'
189
+ || key === 'npm_node_execpath'
190
+ || key.startsWith('npm_')
191
+ || key.startsWith('npm_config_')
192
+ || key.startsWith('npm_package_')
193
+ || key.startsWith('npm_lifecycle_')
194
+ || key.startsWith('PNPM_')
195
+ || key.startsWith('YARN_')
196
+ || key.startsWith('BUN_')
197
+ ) {
198
+ delete env[key];
199
+ }
200
+ }
201
+
202
+ return env;
203
+ }
204
+
205
+ function computeTerminalQueryTail(buffer: string): string {
206
+ const prefixes = ['\x1b[6n', '\x1b[?6n'];
207
+ const maxLength = prefixes.reduce((n, value) => Math.max(n, value.length), 0) - 1;
208
+ const start = Math.max(0, buffer.length - maxLength);
209
+ for (let i = start; i < buffer.length; i++) {
210
+ const suffix = buffer.slice(i);
211
+ if (prefixes.some((pattern) => suffix.length < pattern.length && pattern.startsWith(suffix))) {
212
+ return suffix;
213
+ }
214
+ }
215
+ return '';
216
+ }
217
+
176
218
  function findBinary(name: string): string {
177
219
  const isWin = os.platform() === 'win32';
178
220
  try {
@@ -284,44 +326,6 @@ function promptLikelyVisible(screenText: string, promptSnippet: string): boolean
284
326
  return matched >= required;
285
327
  }
286
328
 
287
- function splitHistoryLines(text: string): string[] {
288
- return String(text || '')
289
- .split('\n')
290
- .map(line => line.replace(/\s+$/, ''));
291
- }
292
-
293
- function normalizeHistoryLine(line: string): string {
294
- return String(line || '').replace(/\s+/g, ' ').trim();
295
- }
296
-
297
- function mergeTerminalHistory(existing: string, snapshot: string): string {
298
- const next = String(snapshot || '').trim();
299
- if (!next) return existing;
300
- const prev = String(existing || '').trim();
301
- if (!prev) return next;
302
- if (prev === next || prev.endsWith(next)) return prev;
303
-
304
- const prevLines = splitHistoryLines(prev);
305
- const nextLines = splitHistoryLines(next);
306
- const prevNorm = prevLines.map(normalizeHistoryLine);
307
- const nextNorm = nextLines.map(normalizeHistoryLine);
308
-
309
- const maxOverlap = Math.min(prevLines.length, nextLines.length);
310
- for (let overlap = maxOverlap; overlap >= 1; overlap -= 1) {
311
- const prevTail = prevNorm.slice(prevNorm.length - overlap);
312
- const nextHead = nextNorm.slice(0, overlap);
313
- if (prevTail.every((line, index) => line === nextHead[index])) {
314
- return [...prevLines, ...nextLines.slice(overlap)].join('\n').trim();
315
- }
316
- }
317
-
318
- const compactPrev = prevNorm.join('\n');
319
- const compactNext = nextNorm.join('\n');
320
- if (compactPrev.includes(compactNext)) return prev;
321
-
322
- return `${prev}\n${next}`.trim();
323
- }
324
-
325
329
  /**
326
330
  * Normalize provider.json for auto-implement approval detection.
327
331
  * Kept for backward compat with dev-server auto-impl pipeline only.
@@ -390,6 +394,7 @@ export class ProviderCliAdapter implements CliAdapter {
390
394
  private pendingOutputParseTimer: NodeJS.Timeout | null = null;
391
395
  private ptyOutputBuffer = '';
392
396
  private ptyOutputFlushTimer: NodeJS.Timeout | null = null;
397
+ private pendingTerminalQueryTail = '';
393
398
 
394
399
  // Server log forwarding
395
400
  private serverConn: any = null;
@@ -428,9 +433,7 @@ export class ProviderCliAdapter implements CliAdapter {
428
433
  /** Full accumulated raw PTY output (with ANSI) */
429
434
  private accumulatedRawBuffer: string = '';
430
435
  /** Current visible terminal screen snapshot */
431
- private terminalScreen = new TerminalScreen(40, 120);
432
- /** Rolling append-only terminal transcript built from screen snapshots */
433
- private terminalHistory: string = '';
436
+ private terminalScreen = new TerminalScreen(30, 100);
434
437
  /** Max accumulated buffer size (last 50KB) */
435
438
  private static readonly MAX_ACCUMULATED_BUFFER = 50000;
436
439
  private currentTurnScope: TurnParseScope | null = null;
@@ -461,23 +464,17 @@ export class ProviderCliAdapter implements CliAdapter {
461
464
 
462
465
  private buildParseInput(baseMessages: CliChatMessage[], partialResponse: string, scope?: TurnParseScope | null): CliScriptInput {
463
466
  const buffer = scope
464
- ? (this.sliceFromOffset(this.terminalHistory, scope.terminalHistoryStart)
465
- || this.sliceFromOffset(this.accumulatedBuffer, scope.bufferStart)
466
- || this.accumulatedBuffer)
467
+ ? (this.sliceFromOffset(this.accumulatedBuffer, scope.bufferStart) || this.accumulatedBuffer)
467
468
  : this.accumulatedBuffer;
468
469
  const rawBuffer = scope
469
470
  ? (this.sliceFromOffset(this.accumulatedRawBuffer, scope.rawBufferStart) || this.accumulatedRawBuffer)
470
471
  : this.accumulatedRawBuffer;
471
- const terminalHistory = scope
472
- ? (this.sliceFromOffset(this.terminalHistory, scope.terminalHistoryStart) || this.terminalHistory)
473
- : this.terminalHistory;
474
472
 
475
473
  return {
476
474
  buffer,
477
475
  rawBuffer,
478
476
  recentBuffer: buffer.slice(-1000) || this.recentOutputBuffer,
479
477
  screenText: this.terminalScreen.getText(),
480
- terminalHistory,
481
478
  messages: [...baseMessages],
482
479
  partialResponse,
483
480
  };
@@ -623,13 +620,10 @@ export class ProviderCliAdapter implements CliAdapter {
623
620
  }
624
621
 
625
622
  const ptyOpts = {
626
- cols: 120,
627
- rows: 40,
623
+ cols: 100,
624
+ rows: 30,
628
625
  cwd: this.workingDir,
629
- env: {
630
- ...process.env,
631
- ...spawnConfig.env,
632
- } as Record<string, string>,
626
+ env: buildCliSpawnEnv(process.env, spawnConfig.env),
633
627
  };
634
628
 
635
629
  try {
@@ -650,9 +644,8 @@ export class ProviderCliAdapter implements CliAdapter {
650
644
  this.ptyProcess.onData((data: string) => {
651
645
  if (Date.now() < this.resizeSuppressUntil) return;
652
646
 
653
- if (data.includes('\x1b[6n') || data.includes('\x1b[?6n')) {
654
- // Some TUIs probe cursor position during startup; reply quickly even when batching parsing.
655
- this.ptyProcess?.write('\x1b[1;1R');
647
+ if (!this.ptyProcess?.terminalQueriesHandled) {
648
+ this.respondToTerminalQueries(data);
656
649
  }
657
650
 
658
651
  this.pendingOutputParseBuffer += data;
@@ -691,8 +684,8 @@ export class ProviderCliAdapter implements CliAdapter {
691
684
  this.spawnAt = Date.now();
692
685
  this.startupParseGate = true;
693
686
  this.startupBuffer = '';
694
- this.terminalScreen.reset(40, 120);
695
- this.terminalHistory = '';
687
+ this.terminalScreen.reset(30, 100);
688
+ this.pendingTerminalQueryTail = '';
696
689
  this.currentTurnScope = null;
697
690
  this.ready = false;
698
691
  await this.ptyProcess.ready;
@@ -704,7 +697,6 @@ export class ProviderCliAdapter implements CliAdapter {
704
697
 
705
698
  private handleOutput(rawData: string): void {
706
699
  this.terminalScreen.write(rawData);
707
- this.terminalHistory = mergeTerminalHistory(this.terminalHistory, this.terminalScreen.getText());
708
700
  const cleanData = sanitizeTerminalText(rawData);
709
701
 
710
702
  if (this.isWaitingForResponse && cleanData) {
@@ -1013,7 +1005,6 @@ export class ProviderCliAdapter implements CliAdapter {
1013
1005
  messages: [...this.committedMessages],
1014
1006
  workingDir: this.workingDir,
1015
1007
  activeModal: this.activeModal,
1016
- terminalHistory: this.terminalHistory,
1017
1008
  };
1018
1009
  }
1019
1010
 
@@ -1032,7 +1023,6 @@ export class ProviderCliAdapter implements CliAdapter {
1032
1023
  id: parsed.id || 'cli_session',
1033
1024
  status: parsed.status || this.currentStatus,
1034
1025
  title: parsed.title || this.cliName,
1035
- terminalHistory: this.terminalHistory,
1036
1026
  messages: parsed.messages,
1037
1027
  activeModal: parsed.activeModal ?? this.activeModal,
1038
1028
  };
@@ -1043,7 +1033,6 @@ export class ProviderCliAdapter implements CliAdapter {
1043
1033
  id: 'cli_session',
1044
1034
  status: this.currentStatus,
1045
1035
  title: this.cliName,
1046
- terminalHistory: this.terminalHistory,
1047
1036
  messages: messages.slice(-50).map((message, index) => ({
1048
1037
  id: `msg_${index}`,
1049
1038
  role: message.role,
@@ -1114,9 +1103,8 @@ export class ProviderCliAdapter implements CliAdapter {
1114
1103
  startedAt: Date.now(),
1115
1104
  bufferStart: this.accumulatedBuffer.length,
1116
1105
  rawBufferStart: this.accumulatedRawBuffer.length,
1117
- terminalHistoryStart: this.terminalHistory.length,
1118
1106
  };
1119
- LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${this.currentTurnScope.bufferStart} raw=${this.currentTurnScope.rawBufferStart} terminal=${this.currentTurnScope.terminalHistoryStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
1107
+ LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${this.currentTurnScope.bufferStart} raw=${this.currentTurnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
1120
1108
  this.submitRetryUsed = false;
1121
1109
  this.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
1122
1110
  const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet);
@@ -1304,6 +1292,7 @@ export class ProviderCliAdapter implements CliAdapter {
1304
1292
  if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
1305
1293
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
1306
1294
  this.pendingOutputParseBuffer = '';
1295
+ this.pendingTerminalQueryTail = '';
1307
1296
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
1308
1297
  this.ptyOutputBuffer = '';
1309
1298
  if (this.ptyProcess) {
@@ -1326,6 +1315,7 @@ export class ProviderCliAdapter implements CliAdapter {
1326
1315
  if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
1327
1316
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
1328
1317
  this.pendingOutputParseBuffer = '';
1318
+ this.pendingTerminalQueryTail = '';
1329
1319
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
1330
1320
  this.ptyOutputBuffer = '';
1331
1321
  if (this.ptyProcess) {
@@ -1349,12 +1339,12 @@ export class ProviderCliAdapter implements CliAdapter {
1349
1339
  this.syncMessageViews();
1350
1340
  this.accumulatedBuffer = '';
1351
1341
  this.accumulatedRawBuffer = '';
1352
- this.terminalHistory = '';
1353
1342
  this.currentTurnScope = null;
1354
1343
  this.submitRetryUsed = false;
1355
1344
  this.submitRetryPromptSnippet = '';
1356
1345
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
1357
1346
  this.pendingOutputParseBuffer = '';
1347
+ this.pendingTerminalQueryTail = '';
1358
1348
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
1359
1349
  this.ptyOutputBuffer = '';
1360
1350
  this.terminalScreen.reset();
@@ -1413,7 +1403,6 @@ export class ProviderCliAdapter implements CliAdapter {
1413
1403
  structuredMessages: this.structuredMessages.slice(-20),
1414
1404
  messageCount: this.committedMessages.length,
1415
1405
  screenText: sanitizeTerminalText(this.terminalScreen.getText()).slice(-4000),
1416
- terminalHistory: this.terminalHistory.slice(-8000),
1417
1406
  currentTurnScope: this.currentTurnScope,
1418
1407
  startupBuffer: this.startupBuffer.slice(-4000),
1419
1408
  recentOutputBuffer: this.recentOutputBuffer.slice(-500),
@@ -1441,4 +1430,24 @@ export class ProviderCliAdapter implements CliAdapter {
1441
1430
  ptyAlive: !!this.ptyProcess,
1442
1431
  };
1443
1432
  }
1433
+
1434
+ private respondToTerminalQueries(data: string): void {
1435
+ if (!this.ptyProcess || !data) return;
1436
+
1437
+ const combined = this.pendingTerminalQueryTail + data;
1438
+ const regex = /\x1b\[(\?)?6n/g;
1439
+ let match: RegExpExecArray | null;
1440
+
1441
+ while ((match = regex.exec(combined)) !== null) {
1442
+ const cursor = this.terminalScreen.getCursorPosition();
1443
+ const row = Math.max(1, (cursor.row | 0) + 1);
1444
+ const col = Math.max(1, (cursor.col | 0) + 1);
1445
+ const response = match[1]
1446
+ ? `\x1b[?${row};${col}R`
1447
+ : `\x1b[${row};${col}R`;
1448
+ this.ptyProcess.write(response);
1449
+ }
1450
+
1451
+ this.pendingTerminalQueryTail = computeTerminalQueryTail(combined);
1452
+ }
1444
1453
  }
@@ -37,6 +37,7 @@ export interface PtyRuntimeMetadata {
37
37
  export interface PtyRuntimeTransport {
38
38
  readonly pid: number;
39
39
  readonly ready: Promise<void>;
40
+ readonly terminalQueriesHandled?: boolean;
40
41
  write(data: string): void;
41
42
  resize(cols: number, rows: number): void;
42
43
  kill(): void;
@@ -53,6 +54,7 @@ export interface PtyTransportFactory {
53
54
 
54
55
  class NodePtyRuntimeTransport implements PtyRuntimeTransport {
55
56
  readonly ready = Promise.resolve();
57
+ readonly terminalQueriesHandled = false;
56
58
 
57
59
  constructor(private readonly handle: any) {}
58
60
 
@@ -28,6 +28,7 @@ interface SessionHostRuntimeOptions extends SessionHostPtyTransportFactoryOption
28
28
 
29
29
  class SessionHostRuntimeTransport implements PtyRuntimeTransport {
30
30
  readonly ready: Promise<void>;
31
+ readonly terminalQueriesHandled = true;
31
32
 
32
33
  private readonly client: SessionHostClient;
33
34
  private readonly dataCallbacks = new Set<(data: string) => void>();
@@ -8,6 +8,7 @@ type GhosttyVtTerminal = {
8
8
  write(data: string | Uint8Array): void;
9
9
  resize(cols: number, rows: number): void;
10
10
  formatPlainText(options?: { trim?: boolean }): string;
11
+ getCursorPosition(): { col: number; row: number };
11
12
  dispose(): void;
12
13
  };
13
14
 
@@ -123,6 +124,10 @@ export class GhosttyVtTerminalBackend implements TerminalViewportBackend {
123
124
  return this.terminal.formatPlainText({ trim: true }) || '';
124
125
  }
125
126
 
127
+ getCursorPosition(): { col: number; row: number } {
128
+ return this.terminal.getCursorPosition();
129
+ }
130
+
126
131
  dispose(): void {
127
132
  this.terminal.dispose();
128
133
  }
@@ -13,5 +13,6 @@ export interface TerminalViewportBackend {
13
13
  resize(rows: number, cols: number): void;
14
14
  write(data: string): void;
15
15
  getText(): string;
16
+ getCursorPosition(): { col: number; row: number };
16
17
  dispose(): void;
17
18
  }
@@ -7,6 +7,8 @@ type XtermBufferLine = {
7
7
  type XtermBuffer = {
8
8
  length: number;
9
9
  viewportY: number;
10
+ cursorX?: number;
11
+ cursorY?: number;
10
12
  getLine(index: number): XtermBufferLine | undefined;
11
13
  };
12
14
 
@@ -72,6 +74,14 @@ export class XtermTerminalBackend implements TerminalViewportBackend {
72
74
  return lines.slice(first, last).join('\n');
73
75
  }
74
76
 
77
+ getCursorPosition(): { col: number; row: number } {
78
+ const buffer = this.terminal.buffer.active;
79
+ return {
80
+ col: Math.max(0, buffer.cursorX || 0),
81
+ row: Math.max(0, buffer.cursorY || 0),
82
+ };
83
+ }
84
+
75
85
  dispose(): void {
76
86
  this.terminal.dispose();
77
87
  }
@@ -106,6 +106,10 @@ export class TerminalScreen {
106
106
  return this.terminal.getText();
107
107
  }
108
108
 
109
+ getCursorPosition(): { col: number; row: number } {
110
+ return this.terminal.getCursorPosition();
111
+ }
112
+
109
113
  dispose(): void {
110
114
  this.terminal.dispose();
111
115
  }
@@ -26,6 +26,7 @@ import { logCommand } from '../logging/command-log.js';
26
26
  import { getRecentLogs, LOG_PATH } from '../logging/logger.js';
27
27
  import { buildSessionEntries } from '../status/builders.js';
28
28
  import { getSessionCompletionMarker } from '../status/snapshot.js';
29
+ import { spawnDetachedDaemonUpgradeHelper } from './upgrade-helper.js';
29
30
  import * as fs from 'fs';
30
31
 
31
32
  // ─── Types ───
@@ -315,36 +316,41 @@ export class DaemonCommandRouter {
315
316
  // Check latest version
316
317
  const latest = execSync(`npm view ${pkgName} version`, { encoding: 'utf-8', timeout: 10000 }).trim();
317
318
  LOG.info('Upgrade', `Latest ${pkgName}: v${latest}`);
319
+ let currentInstalled: string | null = null;
320
+ try {
321
+ const currentJson = execSync(`npm ls -g ${pkgName} --depth=0 --json`, {
322
+ encoding: 'utf-8',
323
+ timeout: 10000,
324
+ stdio: ['pipe', 'pipe', 'pipe'],
325
+ }).trim();
326
+ const parsed = JSON.parse(currentJson);
327
+ currentInstalled = parsed?.dependencies?.[pkgName]?.version || null;
328
+ } catch {
329
+ // ignore ls failures; upgrade can still proceed
330
+ }
318
331
 
319
- // Install latest (--force ensures native addons are rebuilt cleanly)
320
- execSync(`npm install -g ${pkgName}@latest --force`, {
321
- encoding: 'utf-8',
322
- timeout: 120000,
323
- stdio: ['pipe', 'pipe', 'pipe'],
332
+ if (currentInstalled === latest) {
333
+ LOG.info('Upgrade', `Already on latest version v${latest}; skipping install`);
334
+ return { success: true, upgraded: false, alreadyLatest: true, version: latest };
335
+ }
336
+
337
+ spawnDetachedDaemonUpgradeHelper({
338
+ packageName: pkgName,
339
+ targetVersion: latest,
340
+ parentPid: process.pid,
341
+ restartArgv: process.argv.slice(1),
342
+ cwd: process.cwd(),
343
+ sessionHostAppName: process.env.ADHDEV_SESSION_HOST_NAME || 'adhdev',
324
344
  });
325
- LOG.info('Upgrade', `✅ Upgraded to v${latest}`);
345
+ LOG.info('Upgrade', `Scheduled detached upgrade to v${latest}`);
326
346
 
327
- // Schedule restart after response is sent
347
+ // Exit after the command response has been sent so the helper can replace the package cleanly.
328
348
  setTimeout(() => {
329
- LOG.info('Upgrade', 'Restarting daemon with new version...');
330
- // Remove PID file so the new process doesn't see 'already running'
331
- try {
332
- const path = require('path');
333
- const fs = require('fs');
334
- const pidFile = path.join(process.env.HOME || process.env.USERPROFILE || '', '.adhdev', 'daemon.pid');
335
- if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
336
- } catch { /* ignore */ }
337
- const { spawn } = require('child_process');
338
- const child = spawn(process.execPath, process.argv.slice(1), {
339
- detached: true,
340
- stdio: 'ignore',
341
- env: { ...process.env },
342
- });
343
- child.unref();
349
+ LOG.info('Upgrade', 'Exiting daemon so detached upgrader can continue...');
344
350
  process.exit(0);
345
351
  }, 3000);
346
352
 
347
- return { success: true, upgraded: true, version: latest };
353
+ return { success: true, upgraded: true, version: latest, restarting: true };
348
354
  } catch (e: any) {
349
355
  LOG.error('Upgrade', `Failed: ${e.message}`);
350
356
  return { success: false, error: e.message };
@@ -0,0 +1,214 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { spawn } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+
7
+ const UPGRADE_HELPER_ENV = 'ADHDEV_DAEMON_UPGRADE_HELPER';
8
+
9
+ export interface DaemonUpgradeHelperPayload {
10
+ packageName: string;
11
+ targetVersion: string;
12
+ parentPid: number;
13
+ restartArgv: string[];
14
+ cwd?: string;
15
+ sessionHostAppName?: string;
16
+ }
17
+
18
+ function getUpgradeLogPath(): string {
19
+ const home = os.homedir();
20
+ const dir = path.join(home, '.adhdev');
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ return path.join(dir, 'daemon-upgrade.log');
23
+ }
24
+
25
+ function appendUpgradeLog(message: string): void {
26
+ const line = `[${new Date().toISOString()}] ${message}\n`;
27
+ try {
28
+ fs.appendFileSync(getUpgradeLogPath(), line, 'utf8');
29
+ } catch {
30
+ // noop
31
+ }
32
+ }
33
+
34
+ function getNpmExecutable(): string {
35
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
36
+ }
37
+
38
+ function killPid(pid: number): boolean {
39
+ try {
40
+ if (process.platform === 'win32') {
41
+ execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
42
+ } else {
43
+ process.kill(pid, 'SIGTERM');
44
+ }
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
52
+ const start = Date.now();
53
+ while (Date.now() - start < timeoutMs) {
54
+ try {
55
+ process.kill(pid, 0);
56
+ await new Promise((resolve) => setTimeout(resolve, 250));
57
+ } catch {
58
+ return;
59
+ }
60
+ }
61
+ }
62
+
63
+ function stopSessionHostProcesses(appName: string): void {
64
+ const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
65
+ try {
66
+ if (fs.existsSync(pidFile)) {
67
+ const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
68
+ if (Number.isFinite(pid)) {
69
+ killPid(pid);
70
+ }
71
+ }
72
+ } catch {
73
+ // noop
74
+ } finally {
75
+ try {
76
+ fs.unlinkSync(pidFile);
77
+ } catch {
78
+ // noop
79
+ }
80
+ }
81
+
82
+ if (process.platform !== 'win32') {
83
+ try {
84
+ const raw = execFileSync('pgrep', ['-f', 'session-host-daemon'], { encoding: 'utf8' }).trim();
85
+ for (const line of raw.split('\n')) {
86
+ const pid = Number.parseInt(line.trim(), 10);
87
+ if (Number.isFinite(pid)) {
88
+ killPid(pid);
89
+ }
90
+ }
91
+ } catch {
92
+ // noop
93
+ }
94
+ }
95
+ }
96
+
97
+ function removeDaemonPidFile(): void {
98
+ const pidFile = path.join(os.homedir(), '.adhdev', 'daemon.pid');
99
+ try {
100
+ fs.unlinkSync(pidFile);
101
+ } catch {
102
+ // noop
103
+ }
104
+ }
105
+
106
+ function cleanupStaleGlobalInstallDirs(pkgName: string): void {
107
+ const npmRoot = execFileSync(getNpmExecutable(), ['root', '-g'], { encoding: 'utf8' }).trim();
108
+ if (!npmRoot) return;
109
+ const npmPrefix = execFileSync(getNpmExecutable(), ['prefix', '-g'], { encoding: 'utf8' }).trim();
110
+ const binDir = process.platform === 'win32' ? npmPrefix : path.join(npmPrefix, 'bin');
111
+ const packageBaseName = pkgName.startsWith('@') ? pkgName.split('/')[1] : pkgName;
112
+ const binNames = new Set<string>([packageBaseName]);
113
+ if (pkgName === '@adhdev/daemon-standalone') {
114
+ binNames.add('adhdev-standalone');
115
+ }
116
+
117
+ if (pkgName.startsWith('@')) {
118
+ const [scope, name] = pkgName.split('/');
119
+ const scopeDir = path.join(npmRoot, scope);
120
+ if (!fs.existsSync(scopeDir)) return;
121
+ for (const entry of fs.readdirSync(scopeDir)) {
122
+ if (!entry.startsWith(`.${name}-`)) continue;
123
+ fs.rmSync(path.join(scopeDir, entry), { recursive: true, force: true });
124
+ appendUpgradeLog(`Removed stale scoped staging dir: ${path.join(scopeDir, entry)}`);
125
+ }
126
+ } else {
127
+ for (const entry of fs.readdirSync(npmRoot)) {
128
+ if (!entry.startsWith(`.${pkgName}-`)) continue;
129
+ fs.rmSync(path.join(npmRoot, entry), { recursive: true, force: true });
130
+ appendUpgradeLog(`Removed stale staging dir: ${path.join(npmRoot, entry)}`);
131
+ }
132
+ }
133
+
134
+ if (fs.existsSync(binDir)) {
135
+ for (const entry of fs.readdirSync(binDir)) {
136
+ if (![...binNames].some((name) => entry.startsWith(`.${name}-`))) continue;
137
+ fs.rmSync(path.join(binDir, entry), { recursive: true, force: true });
138
+ appendUpgradeLog(`Removed stale bin staging entry: ${path.join(binDir, entry)}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ export function spawnDetachedDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): void {
144
+ const env = { ...process.env, [UPGRADE_HELPER_ENV]: JSON.stringify(payload) };
145
+ const child = spawn(process.execPath, process.argv.slice(1), {
146
+ detached: true,
147
+ stdio: 'ignore',
148
+ windowsHide: true,
149
+ cwd: payload.cwd || process.cwd(),
150
+ env,
151
+ });
152
+ child.unref();
153
+ }
154
+
155
+ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Promise<void> {
156
+ const restartArgv = Array.isArray(payload.restartArgv) ? payload.restartArgv : [];
157
+ const sessionHostAppName = payload.sessionHostAppName || process.env.ADHDEV_SESSION_HOST_NAME || 'adhdev';
158
+ appendUpgradeLog(`Upgrade helper started for ${payload.packageName}@${payload.targetVersion}`);
159
+
160
+ if (Number.isFinite(payload.parentPid) && payload.parentPid > 0) {
161
+ appendUpgradeLog(`Waiting for parent pid ${payload.parentPid} to exit`);
162
+ await waitForPidExit(payload.parentPid, 15000);
163
+ }
164
+
165
+ stopSessionHostProcesses(sessionHostAppName);
166
+ removeDaemonPidFile();
167
+ cleanupStaleGlobalInstallDirs(payload.packageName);
168
+
169
+ const spec = `${payload.packageName}@${payload.targetVersion || 'latest'}`;
170
+ appendUpgradeLog(`Installing ${spec}`);
171
+ const installOutput = execFileSync(
172
+ getNpmExecutable(),
173
+ ['install', '-g', spec, '--force'],
174
+ {
175
+ encoding: 'utf8',
176
+ stdio: 'pipe',
177
+ maxBuffer: 20 * 1024 * 1024,
178
+ },
179
+ );
180
+ if (installOutput.trim()) {
181
+ appendUpgradeLog(installOutput.trim());
182
+ }
183
+
184
+ if (restartArgv.length > 0) {
185
+ const env = { ...process.env };
186
+ delete env[UPGRADE_HELPER_ENV];
187
+ appendUpgradeLog(`Restarting daemon with args: ${restartArgv.join(' ')}`);
188
+ const child = spawn(process.execPath, restartArgv, {
189
+ detached: true,
190
+ stdio: 'ignore',
191
+ windowsHide: true,
192
+ cwd: payload.cwd || process.cwd(),
193
+ env,
194
+ });
195
+ child.unref();
196
+ } else {
197
+ appendUpgradeLog('No restart argv provided; upgrade completed without restart');
198
+ }
199
+ }
200
+
201
+ export async function maybeRunDaemonUpgradeHelperFromEnv(): Promise<boolean> {
202
+ const raw = process.env[UPGRADE_HELPER_ENV];
203
+ if (!raw) return false;
204
+ delete process.env[UPGRADE_HELPER_ENV];
205
+
206
+ try {
207
+ const payload = JSON.parse(raw) as DaemonUpgradeHelperPayload;
208
+ await runDaemonUpgradeHelper(payload);
209
+ process.exit(0);
210
+ } catch (error: any) {
211
+ appendUpgradeLog(`Upgrade helper failed: ${error?.stack || error?.message || String(error)}`);
212
+ process.exit(1);
213
+ }
214
+ }