neoagent 2.1.4 → 2.1.6

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.
@@ -17,7 +17,7 @@ router.get('/status', async (req, res) => {
17
17
  router.post('/start', async (req, res) => {
18
18
  try {
19
19
  const controller = req.app.locals.androidController;
20
- res.json(await controller.startEmulator(req.body || {}));
20
+ res.json(await controller.requestStartEmulator(req.body || {}));
21
21
  } catch (err) {
22
22
  res.status(500).json({ error: sanitizeError(err) });
23
23
  }
@@ -18,7 +18,7 @@ function generateTitle(task) {
18
18
  return cleaned.slice(0, 90);
19
19
  }
20
20
 
21
- async function getProviderForUser(userId, task = '', isSubagent = false, modelOverride = null) {
21
+ async function getProviderForUser(userId, task = '', isSubagent = false, modelOverride = null, providerConfig = {}) {
22
22
  const { getSupportedModels, createProviderInstance } = require('./models');
23
23
  const models = await getSupportedModels(userId);
24
24
 
@@ -68,7 +68,7 @@ async function getProviderForUser(userId, task = '', isSubagent = false, modelOv
68
68
  if (requested && requested.available !== false && enabledIds.includes(requested.id)) {
69
69
  selectedModelDef = requested;
70
70
  return {
71
- provider: createProviderInstance(selectedModelDef.provider, userId),
71
+ provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
72
72
  model: selectedModelDef.id,
73
73
  providerName: selectedModelDef.provider
74
74
  };
@@ -102,7 +102,7 @@ async function getProviderForUser(userId, task = '', isSubagent = false, modelOv
102
102
  }
103
103
 
104
104
  return {
105
- provider: createProviderInstance(selectedModelDef.provider, userId),
105
+ provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
106
106
  model: selectedModelDef.id,
107
107
  providerName: selectedModelDef.provider
108
108
  };
@@ -271,7 +271,6 @@ class AgentEngine {
271
271
  const triggerType = options.triggerType || 'user';
272
272
  ensureDefaultAiSettings(userId);
273
273
  const aiSettings = getAiSettings(userId);
274
- const { provider, model, providerName } = await getProviderForUser(userId, userMessage, triggerType === 'subagent', _modelOverride);
275
274
 
276
275
  const runId = options.runId || uuidv4();
277
276
  const conversationId = options.conversationId;
@@ -280,6 +279,23 @@ class AgentEngine {
280
279
  const historyWindow = aiSettings.chat_history_window;
281
280
  const toolReplayBudget = aiSettings.tool_replay_budget_chars;
282
281
  const maxIterations = this.getIterationLimit(triggerType, aiSettings);
282
+ const providerStatusConfig = {
283
+ onStatus: (status) => {
284
+ if (!status?.message) return;
285
+ this.emit(userId, 'run:interim', {
286
+ runId,
287
+ message: status.message,
288
+ phase: status.phase
289
+ });
290
+ }
291
+ };
292
+ const { provider, model, providerName } = await getProviderForUser(
293
+ userId,
294
+ userMessage,
295
+ triggerType === 'subagent',
296
+ _modelOverride,
297
+ providerStatusConfig
298
+ );
283
299
 
284
300
  const runTitle = generateTitle(userMessage);
285
301
  db.prepare(`INSERT OR REPLACE INTO agent_runs(id, user_id, title, status, trigger_type, trigger_source, model)
@@ -385,7 +401,13 @@ class AgentEngine {
385
401
  console.error(`[Engine] Model call failed (${model}):`, err.message);
386
402
  if (retryForFallback && aiSettings.fallback_model_id && aiSettings.fallback_model_id !== model) {
387
403
  console.log(`[Engine] Attempting fallback to: ${aiSettings.fallback_model_id}`);
388
- const fallback = await getProviderForUser(userId, userMessage, triggerType === 'subagent', aiSettings.fallback_model_id);
404
+ const fallback = await getProviderForUser(
405
+ userId,
406
+ userMessage,
407
+ triggerType === 'subagent',
408
+ aiSettings.fallback_model_id,
409
+ providerStatusConfig
410
+ );
389
411
  // Update local state for the retry
390
412
  const nextProvider = fallback.provider;
391
413
  const nextModel = fallback.model;
@@ -192,7 +192,7 @@ async function refreshDynamicModels(baseUrl) {
192
192
  }
193
193
  }
194
194
 
195
- function createProviderInstance(providerStr, userId = null) {
195
+ function createProviderInstance(providerStr, userId = null, configOverrides = {}) {
196
196
  const runtime = getProviderRuntimeConfig(userId, providerStr);
197
197
 
198
198
  if (!runtime.enabled) {
@@ -203,15 +203,15 @@ function createProviderInstance(providerStr, userId = null) {
203
203
  }
204
204
 
205
205
  if (providerStr === 'grok') {
206
- return new GrokProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl });
206
+ return new GrokProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...configOverrides });
207
207
  } else if (providerStr === 'openai') {
208
- return new OpenAIProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl });
208
+ return new OpenAIProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...configOverrides });
209
209
  } else if (providerStr === 'anthropic') {
210
- return new AnthropicProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl });
210
+ return new AnthropicProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...configOverrides });
211
211
  } else if (providerStr === 'google') {
212
- return new GoogleProvider({ apiKey: runtime.apiKey });
212
+ return new GoogleProvider({ apiKey: runtime.apiKey, ...configOverrides });
213
213
  } else if (providerStr === 'ollama') {
214
- return new OllamaProvider({ baseUrl: runtime.baseUrl });
214
+ return new OllamaProvider({ baseUrl: runtime.baseUrl, ...configOverrides });
215
215
  }
216
216
  throw new Error(`Unknown provider: ${providerStr}`);
217
217
  }
@@ -3,6 +3,7 @@ class BaseProvider {
3
3
  this.config = config;
4
4
  this.name = 'base';
5
5
  this.models = [];
6
+ this.onStatus = typeof config.onStatus === 'function' ? config.onStatus : null;
6
7
  }
7
8
 
8
9
  getDefaultModel() {
@@ -28,6 +28,13 @@ class OllamaProvider extends BaseProvider {
28
28
  if (found) return true;
29
29
 
30
30
  console.log(`[Ollama] Model '${model}' not found, pulling from registry...`);
31
+ this.onStatus?.({
32
+ kind: 'model_download',
33
+ status: 'started',
34
+ model,
35
+ phase: 'Downloading model',
36
+ message: `Downloading local Ollama model '${model}'. First-time pulls can take a while.`
37
+ });
31
38
  try {
32
39
  const res = await fetch(`${this.baseUrl}/api/pull`, {
33
40
  method: 'POST',
@@ -36,10 +43,24 @@ class OllamaProvider extends BaseProvider {
36
43
  });
37
44
  if (!res.ok) throw new Error(`Pull failed: ${res.statusText}`);
38
45
  console.log(`[Ollama] Model '${model}' pulled successfully.`);
46
+ this.onStatus?.({
47
+ kind: 'model_download',
48
+ status: 'completed',
49
+ model,
50
+ phase: 'Thinking',
51
+ message: `Local Ollama model '${model}' is ready.`
52
+ });
39
53
  // Refresh local model list
40
54
  await this.listModels();
41
55
  return true;
42
56
  } catch (e) {
57
+ this.onStatus?.({
58
+ kind: 'model_download',
59
+ status: 'failed',
60
+ model,
61
+ phase: 'Model download failed',
62
+ message: `Failed to download local Ollama model '${model}': ${e.message}`
63
+ });
43
64
  console.error(`[Ollama] Failed to pull model '${model}':`, e.message);
44
65
  throw e;
45
66
  }
@@ -47,6 +47,18 @@ function sleep(ms) {
47
47
  return new Promise((resolve) => setTimeout(resolve, ms));
48
48
  }
49
49
 
50
+ function tailFile(filePath, maxLines = 40) {
51
+ try {
52
+ const lines = fs.readFileSync(filePath, 'utf8')
53
+ .split('\n')
54
+ .map((line) => line.trim())
55
+ .filter(Boolean);
56
+ return lines.slice(-maxLines);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
50
62
  function commandExists(command) {
51
63
  const probe = spawnSync('bash', ['-lc', `command -v "${command}"`], { encoding: 'utf8' });
52
64
  return probe.status === 0;
@@ -103,6 +115,10 @@ function configuredSystemImagePlatform() {
103
115
  return String(process.env.ANDROID_SYSTEM_IMAGE_PLATFORM || '').trim() || null;
104
116
  }
105
117
 
118
+ function shouldForceSdkRefresh() {
119
+ return String(process.env.ANDROID_FORCE_SDK_REFRESH || '').trim().toLowerCase() === 'true';
120
+ }
121
+
106
122
  function systemImageArchCandidates() {
107
123
  const configured = parseCsvEnv(process.env.ANDROID_SYSTEM_IMAGE_ARCH);
108
124
  if (configured.length > 0) {
@@ -399,6 +415,7 @@ class AndroidController {
399
415
  this.cli = new CLIExecutor();
400
416
  this.avdName = readState().avdName || DEFAULT_AVD_NAME;
401
417
  this.bootstrapPromise = null;
418
+ this.startPromise = null;
402
419
  this.#registerProcessCleanup();
403
420
  }
404
421
 
@@ -465,7 +482,12 @@ class AndroidController {
465
482
  }
466
483
 
467
484
  async ensureBootstrapped() {
468
- if (!(isExecutable(adbBinary()) && isExecutable(sdkManagerBinary()) && isExecutable(emulatorBinary()))) {
485
+ const binariesReady =
486
+ isExecutable(adbBinary()) &&
487
+ isExecutable(sdkManagerBinary()) &&
488
+ isExecutable(emulatorBinary());
489
+
490
+ if (!binariesReady) {
469
491
  if (this.bootstrapPromise) {
470
492
  await this.bootstrapPromise;
471
493
  } else {
@@ -478,16 +500,25 @@ class AndroidController {
478
500
  }
479
501
  }
480
502
 
503
+ const state = readState();
504
+ if (
505
+ state.bootstrapped === true &&
506
+ state.systemImage &&
507
+ !shouldForceSdkRefresh()
508
+ ) {
509
+ return;
510
+ }
511
+
481
512
  appendState({ bootstrapped: true });
482
513
  const sdkmanager = sdkManagerBinary();
483
514
  const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
484
515
  const latestSystemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
485
516
  if (!latestSystemImage) throw new Error(formatSystemImageError(available));
486
517
 
487
- const state = readState();
488
- const currentApiLevel = parseApiLevelFromSystemImage(state.systemImage);
518
+ const refreshedState = readState();
519
+ const currentApiLevel = parseApiLevelFromSystemImage(refreshedState.systemImage);
489
520
  const shouldUpgrade =
490
- state.systemImage !== latestSystemImage.packageName ||
521
+ refreshedState.systemImage !== latestSystemImage.packageName ||
491
522
  currentApiLevel < latestSystemImage.apiLevel;
492
523
 
493
524
  if (shouldUpgrade) {
@@ -583,8 +614,13 @@ class AndroidController {
583
614
  fs.writeFileSync(configPath, content);
584
615
  }
585
616
 
586
- async listDevices() {
587
- await this.ensureBootstrapped();
617
+ async listDevices(options = {}) {
618
+ if (options.ensureBootstrapped !== false) {
619
+ await this.ensureBootstrapped();
620
+ }
621
+ if (!isExecutable(adbBinary())) {
622
+ return [];
623
+ }
588
624
  const out = await this.#run(`${quoteShell(adbBinary())} devices -l`);
589
625
  const lines = out.split('\n').map((line) => line.trim()).filter(Boolean);
590
626
  return lines
@@ -600,9 +636,9 @@ class AndroidController {
600
636
  });
601
637
  }
602
638
 
603
- async getPrimarySerial() {
639
+ async getPrimarySerial(options = {}) {
604
640
  const state = readState();
605
- const devices = await this.listDevices();
641
+ const devices = await this.listDevices(options);
606
642
  const preferred = state.serial ? devices.find((device) => device.serial === state.serial && device.status === 'device') : null;
607
643
  if (preferred) return preferred.serial;
608
644
  const emulator = devices.find((device) => device.emulator && device.status === 'device');
@@ -615,11 +651,30 @@ class AndroidController {
615
651
  return null;
616
652
  }
617
653
 
618
- async startEmulator(options = {}) {
654
+ async #startEmulatorBlocking(options = {}) {
655
+ appendState({
656
+ starting: true,
657
+ startupPhase: 'Preparing Android runtime',
658
+ lastStartError: null,
659
+ startRequestedAt: readState().startRequestedAt || new Date().toISOString(),
660
+ });
661
+ console.log('[Android] Preparing emulator start');
619
662
  await this.ensureAvd();
663
+ appendState({
664
+ starting: true,
665
+ startupPhase: 'Checking for an existing Android device',
666
+ lastStartError: null,
667
+ });
620
668
  this.#normalizeAvdConfig();
621
669
  const serial = await this.getPrimarySerial();
622
670
  if (serial) {
671
+ appendState({
672
+ starting: false,
673
+ startupPhase: null,
674
+ serial,
675
+ lastStartError: null,
676
+ lastLogLine: 'Android device already running.',
677
+ });
623
678
  return {
624
679
  success: true,
625
680
  serial,
@@ -650,12 +705,59 @@ class AndroidController {
650
705
  stdio: ['ignore', out, out],
651
706
  env: sdkEnv(),
652
707
  });
708
+
709
+ console.log(`[Android] Emulator process started (pid ${child.pid})`);
710
+ appendState({
711
+ emulatorPid: child.pid,
712
+ avdName: this.avdName,
713
+ logPath,
714
+ starting: true,
715
+ startupPhase: 'Waiting for Android emulator to boot',
716
+ lastStartError: null,
717
+ lastLogLine: 'Android emulator process started. Waiting for boot completion...',
718
+ });
719
+
720
+ const processExit = new Promise((resolve) => {
721
+ child.once('exit', (code, signal) => {
722
+ resolve({ code, signal });
723
+ });
724
+ child.once('error', (error) => {
725
+ resolve({ code: null, signal: null, error });
726
+ });
727
+ });
728
+
653
729
  child.unref();
654
730
 
655
- appendState({ emulatorPid: child.pid, avdName: this.avdName, logPath });
731
+ const bootResult = await Promise.race([
732
+ this.waitForDevice({ timeoutMs: options.timeoutMs || 240000 }).then((serial) => ({
733
+ serial,
734
+ exited: false,
735
+ })),
736
+ processExit.then((result) => ({
737
+ exited: true,
738
+ ...result,
739
+ })),
740
+ ]);
741
+
742
+ if (bootResult.exited) {
743
+ const recentLogLines = tailFile(logPath, 12);
744
+ const lastLine =
745
+ bootResult.error?.message ||
746
+ recentLogLines[recentLogLines.length - 1] ||
747
+ `Emulator process exited before boot completed (code ${bootResult.code ?? 'unknown'}, signal ${bootResult.signal ?? 'none'}).`;
748
+ throw new Error(lastLine);
749
+ }
656
750
 
657
- const onlineSerial = await this.waitForDevice({ timeoutMs: options.timeoutMs || 240000 });
658
- appendState({ serial: onlineSerial, emulatorPid: child.pid });
751
+ const onlineSerial = bootResult.serial;
752
+ appendState({
753
+ serial: onlineSerial,
754
+ emulatorPid: child.pid,
755
+ starting: false,
756
+ startupPhase: null,
757
+ lastStartError: null,
758
+ lastLogLine: 'Android emulator boot completed.',
759
+ });
760
+ console.log(`[Android] Emulator ready on ${onlineSerial}`);
659
761
 
660
762
  return {
661
763
  success: true,
@@ -665,6 +767,85 @@ class AndroidController {
665
767
  };
666
768
  }
667
769
 
770
+ async startEmulator(options = {}) {
771
+ if (this.startPromise) {
772
+ await this.startPromise;
773
+ const serial = await this.getPrimarySerial();
774
+ if (!serial) {
775
+ throw new Error(readState().lastStartError || 'Android emulator did not finish starting.');
776
+ }
777
+ return {
778
+ success: true,
779
+ serial,
780
+ reused: false,
781
+ bootstrapped: readState().bootstrapped === true,
782
+ };
783
+ }
784
+
785
+ return this.#startEmulatorBlocking(options);
786
+ }
787
+
788
+ async requestStartEmulator(options = {}) {
789
+ const serial = await this.getPrimarySerial({ ensureBootstrapped: false }).catch(() => null);
790
+ if (serial) {
791
+ appendState({
792
+ starting: false,
793
+ startupPhase: null,
794
+ serial,
795
+ lastStartError: null,
796
+ lastLogLine: 'Android device already running.',
797
+ });
798
+ return {
799
+ success: true,
800
+ pending: false,
801
+ serial,
802
+ reused: true,
803
+ bootstrapped: readState().bootstrapped === true,
804
+ };
805
+ }
806
+
807
+ if (!this.startPromise) {
808
+ const requestedAt = new Date().toISOString();
809
+ appendState({
810
+ starting: true,
811
+ startupPhase: 'Preparing Android runtime',
812
+ lastStartError: null,
813
+ startRequestedAt: requestedAt,
814
+ lastLogLine: 'Android start requested.',
815
+ });
816
+ const startPromise = this.#startEmulatorBlocking(options).catch((err) => {
817
+ const state = readState();
818
+ const recentLogLines = state.logPath ? tailFile(state.logPath, 12) : [];
819
+ const detailedMessage = recentLogLines[recentLogLines.length - 1] || err.message;
820
+ appendState({
821
+ starting: false,
822
+ startupPhase: 'Start failed',
823
+ lastStartError: detailedMessage,
824
+ lastLogLine: detailedMessage,
825
+ });
826
+ console.error('[Android] Emulator start failed:', detailedMessage);
827
+ throw new Error(detailedMessage);
828
+ }).finally(() => {
829
+ if (this.startPromise === startPromise) {
830
+ this.startPromise = null;
831
+ }
832
+ });
833
+ this.startPromise = startPromise;
834
+ startPromise.catch(() => {});
835
+ }
836
+
837
+ const state = readState();
838
+ return {
839
+ success: true,
840
+ pending: true,
841
+ bootstrapped: state.bootstrapped === true,
842
+ starting: true,
843
+ startupPhase: state.startupPhase || 'Preparing Android runtime',
844
+ startRequestedAt: state.startRequestedAt || null,
845
+ logPath: state.logPath || null,
846
+ };
847
+ }
848
+
668
849
  async waitForDevice(options = {}) {
669
850
  const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 180000);
670
851
  const deadline = Date.now() + timeoutMs;
@@ -699,7 +880,14 @@ class AndroidController {
699
880
  if (state.emulatorPid) {
700
881
  try { process.kill(state.emulatorPid, 'SIGTERM'); } catch {}
701
882
  }
702
- appendState({ serial: null, emulatorPid: null });
883
+ appendState({
884
+ serial: null,
885
+ emulatorPid: null,
886
+ starting: false,
887
+ startupPhase: null,
888
+ lastStartError: null,
889
+ lastLogLine: 'Android emulator stopped.',
890
+ });
703
891
 
704
892
  const deadline = Date.now() + 30000;
705
893
  while (Date.now() < deadline) {
@@ -980,24 +1168,39 @@ class AndroidController {
980
1168
  }
981
1169
 
982
1170
  async getStatus() {
983
- const devices = isExecutable(adbBinary()) ? await this.listDevices().catch(() => []) : [];
1171
+ const devices = isExecutable(adbBinary())
1172
+ ? await this.listDevices({ ensureBootstrapped: false }).catch(() => [])
1173
+ : [];
984
1174
  const state = readState();
985
- let lastLogLine = null;
1175
+ let lastLogLine =
1176
+ state.lastStartError ||
1177
+ state.lastLogLine ||
1178
+ state.startupPhase ||
1179
+ null;
986
1180
  if (state.logPath && fs.existsSync(state.logPath)) {
987
1181
  try {
988
1182
  const lines = fs.readFileSync(state.logPath, 'utf8')
989
1183
  .split('\n')
990
1184
  .map((line) => line.trim())
991
1185
  .filter(Boolean);
992
- lastLogLine = [...lines].reverse().find((line) =>
1186
+ const emulatorLogLine = [...lines].reverse().find((line) =>
993
1187
  /fatal|error|warning|boot completed|disk space|running avd/i.test(line)
994
1188
  ) || lines[lines.length - 1] || null;
1189
+ lastLogLine = emulatorLogLine || lastLogLine;
995
1190
  } catch {
996
- lastLogLine = null;
1191
+ lastLogLine =
1192
+ state.lastStartError ||
1193
+ state.lastLogLine ||
1194
+ state.startupPhase ||
1195
+ null;
997
1196
  }
998
1197
  }
999
1198
  return {
1000
1199
  bootstrapped: state.bootstrapped === true,
1200
+ starting: state.starting === true || this.startPromise != null,
1201
+ startupPhase: state.startupPhase || null,
1202
+ startRequestedAt: state.startRequestedAt || null,
1203
+ lastStartError: state.lastStartError || null,
1001
1204
  sdkRoot: SDK_ROOT,
1002
1205
  avdHome: AVD_HOME,
1003
1206
  avdName: this.avdName,