neoagent 2.1.16-beta.0 → 2.1.16

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.
package/lib/manager.js CHANGED
@@ -25,9 +25,11 @@ const {
25
25
  parseReleaseChannel,
26
26
  getReleaseChannelBranch,
27
27
  getReleaseChannelDistTag,
28
- getReleaseChannelLabel,
29
28
  readConfiguredReleaseChannel,
30
29
  writeReleaseChannelToEnvFile,
30
+ describeReleaseChannelPolicy,
31
+ choosePreferredBranchForChannel,
32
+ choosePreferredNpmTagForChannel,
31
33
  } = require('../runtime/release_channel');
32
34
 
33
35
  const APP_NAME = 'NeoAgent';
@@ -197,8 +199,7 @@ function currentReleaseChannel() {
197
199
  }
198
200
 
199
201
  function releaseChannelSummary(channel) {
200
- const normalized = parseReleaseChannel(channel) || currentReleaseChannel();
201
- return `${getReleaseChannelLabel(normalized)} (branch ${getReleaseChannelBranch(normalized)}, npm ${getReleaseChannelDistTag(normalized)})`;
202
+ return describeReleaseChannelPolicy(parseReleaseChannel(channel) || currentReleaseChannel());
202
203
  }
203
204
 
204
205
  function gitWorkingTreeDirty() {
@@ -214,6 +215,62 @@ function gitRemoteBranchExists(branch) {
214
215
  return runQuiet('git', ['ls-remote', '--exit-code', '--heads', 'origin', branch]).status === 0;
215
216
  }
216
217
 
218
+ function latestGitTagVersion(pattern) {
219
+ const res = runQuiet('git', ['tag', '--list', pattern, '--sort=-v:refname']);
220
+ if (res.status !== 0) return null;
221
+ const tag = res.stdout
222
+ .split('\n')
223
+ .map((value) => value.trim())
224
+ .find(Boolean);
225
+ return tag ? tag.replace(/^v/, '') : null;
226
+ }
227
+
228
+ function resolvePreferredGitBranch(channel) {
229
+ const normalized = parseReleaseChannel(channel) || currentReleaseChannel();
230
+ if (normalized === 'stable') {
231
+ return getReleaseChannelBranch(normalized);
232
+ }
233
+
234
+ const stableVersion = latestGitTagVersion('v[0-9]*.[0-9]*.[0-9]*');
235
+ const betaVersion = latestGitTagVersion('v[0-9]*.[0-9]*.[0-9]*-beta.*');
236
+ const preferred = choosePreferredBranchForChannel(normalized, {
237
+ stable: stableVersion,
238
+ beta: betaVersion,
239
+ });
240
+
241
+ if (preferred === 'beta' && !gitRemoteBranchExists('beta')) {
242
+ return 'main';
243
+ }
244
+ return preferred;
245
+ }
246
+
247
+ function resolvePreferredNpmTag(channel) {
248
+ const normalized = parseReleaseChannel(channel) || currentReleaseChannel();
249
+ if (normalized === 'stable') {
250
+ return getReleaseChannelDistTag(normalized);
251
+ }
252
+
253
+ const distTags = {};
254
+ const tagsRes = runQuiet('npm', ['view', 'neoagent', 'dist-tags', '--json'], {
255
+ env: withInstallEnv(),
256
+ });
257
+ if (tagsRes.status === 0) {
258
+ try {
259
+ const parsed = JSON.parse(tagsRes.stdout || '{}');
260
+ if (parsed && typeof parsed === 'object') {
261
+ Object.assign(distTags, parsed);
262
+ }
263
+ } catch {
264
+ // Ignore parse failures and fall back to the beta tag.
265
+ }
266
+ }
267
+
268
+ return choosePreferredNpmTagForChannel(normalized, {
269
+ latest: distTags.latest,
270
+ beta: distTags.beta,
271
+ });
272
+ }
273
+
217
274
  function ensureGitBranchForReleaseChannel(targetBranch) {
218
275
  const branchRes = runQuiet('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
219
276
  const currentBranch = branchRes.status === 0 ? branchRes.stdout.trim() : '';
@@ -692,15 +749,15 @@ function cmdUpdate(args = []) {
692
749
  process.env.NEOAGENT_RELEASE_CHANNEL = releaseChannel;
693
750
  logOk(`Release channel set to ${releaseChannelSummary(releaseChannel)}`);
694
751
  }
695
- const targetBranch = getReleaseChannelBranch(releaseChannel);
696
- const npmTag = getReleaseChannelDistTag(releaseChannel);
697
752
  const versionBefore = currentInstalledVersionLabel();
698
753
  let versionAfter = versionBefore;
699
754
 
700
755
  if (fs.existsSync(path.join(APP_DIR, '.git')) && commandExists('git')) {
701
756
  const current = runQuiet('git', ['rev-parse', '--short', 'HEAD']);
702
757
 
703
- runOrThrow('git', ['fetch', 'origin', targetBranch]);
758
+ runOrThrow('git', ['fetch', 'origin', '--tags']);
759
+ const targetBranch = resolvePreferredGitBranch(releaseChannel);
760
+ logInfo(`Using git branch ${targetBranch} for the ${releaseChannel} channel.`);
704
761
  ensureGitBranchForReleaseChannel(targetBranch);
705
762
  runOrThrow('git', ['pull', '--rebase', '--autostash', 'origin', targetBranch]);
706
763
 
@@ -714,6 +771,7 @@ function cmdUpdate(args = []) {
714
771
  buildBundledWebClientIfPossible();
715
772
  }
716
773
  } else {
774
+ const npmTag = resolvePreferredNpmTag(releaseChannel);
717
775
  logWarn(`No git repo detected; attempting npm global update from ${npmTag}.`);
718
776
  if (commandExists('npm')) {
719
777
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.1.16-beta.0",
3
+ "version": "2.1.16",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "MIT",
6
6
  "main": "server/index.js",
@@ -14,6 +14,14 @@ const RELEASE_CHANNEL_DIST_TAGS = Object.freeze({
14
14
  stable: 'latest',
15
15
  beta: 'beta',
16
16
  });
17
+ const RELEASE_CHANNEL_BRANCH_POLICIES = Object.freeze({
18
+ stable: 'main only',
19
+ beta: 'newest of beta or main',
20
+ });
21
+ const RELEASE_CHANNEL_NPM_POLICIES = Object.freeze({
22
+ stable: 'latest only',
23
+ beta: 'newest of beta or latest',
24
+ });
17
25
 
18
26
  function parseEnv(raw) {
19
27
  const map = new Map();
@@ -62,6 +70,116 @@ function getReleaseChannelLabel(channel) {
62
70
  return normalizeReleaseChannel(channel) === 'beta' ? 'Beta' : 'Stable';
63
71
  }
64
72
 
73
+ function parseSemver(version) {
74
+ const match = String(version || '')
75
+ .trim()
76
+ .replace(/^v/, '')
77
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
78
+ if (!match) {
79
+ return null;
80
+ }
81
+
82
+ return {
83
+ major: Number(match[1]),
84
+ minor: Number(match[2]),
85
+ patch: Number(match[3]),
86
+ prerelease: match[4] ? match[4].split('.') : [],
87
+ raw: match[0],
88
+ };
89
+ }
90
+
91
+ function comparePrereleasePart(left, right) {
92
+ const leftNumeric = /^\d+$/.test(left);
93
+ const rightNumeric = /^\d+$/.test(right);
94
+
95
+ if (leftNumeric && rightNumeric) {
96
+ return Number(left) - Number(right);
97
+ }
98
+ if (leftNumeric) return -1;
99
+ if (rightNumeric) return 1;
100
+ return left.localeCompare(right);
101
+ }
102
+
103
+ function compareVersions(leftVersion, rightVersion) {
104
+ const left = parseSemver(leftVersion);
105
+ const right = parseSemver(rightVersion);
106
+
107
+ if (!left && !right) return 0;
108
+ if (!left) return -1;
109
+ if (!right) return 1;
110
+
111
+ for (const key of ['major', 'minor', 'patch']) {
112
+ if (left[key] !== right[key]) {
113
+ return left[key] - right[key];
114
+ }
115
+ }
116
+
117
+ const leftPre = left.prerelease;
118
+ const rightPre = right.prerelease;
119
+ if (leftPre.length === 0 && rightPre.length === 0) return 0;
120
+ if (leftPre.length === 0) return 1;
121
+ if (rightPre.length === 0) return -1;
122
+
123
+ const length = Math.max(leftPre.length, rightPre.length);
124
+ for (let i = 0; i < length; i++) {
125
+ const leftPart = leftPre[i];
126
+ const rightPart = rightPre[i];
127
+ if (leftPart == null) return -1;
128
+ if (rightPart == null) return 1;
129
+ const diff = comparePrereleasePart(leftPart, rightPart);
130
+ if (diff !== 0) {
131
+ return diff;
132
+ }
133
+ }
134
+
135
+ return 0;
136
+ }
137
+
138
+ function maxVersion(leftVersion, rightVersion) {
139
+ return compareVersions(leftVersion, rightVersion) >= 0 ? leftVersion : rightVersion;
140
+ }
141
+
142
+ function describeReleaseChannelPolicy(channel) {
143
+ const normalized = normalizeReleaseChannel(channel);
144
+ return `${getReleaseChannelLabel(normalized)} (git ${RELEASE_CHANNEL_BRANCH_POLICIES[normalized]}, npm ${RELEASE_CHANNEL_NPM_POLICIES[normalized]})`;
145
+ }
146
+
147
+ function getReleaseChannelBranchPolicy(channel) {
148
+ return RELEASE_CHANNEL_BRANCH_POLICIES[normalizeReleaseChannel(channel)];
149
+ }
150
+
151
+ function getReleaseChannelNpmPolicy(channel) {
152
+ return RELEASE_CHANNEL_NPM_POLICIES[normalizeReleaseChannel(channel)];
153
+ }
154
+
155
+ function choosePreferredBranchForChannel(channel, versions = {}) {
156
+ const normalized = normalizeReleaseChannel(channel);
157
+ if (normalized === 'stable') {
158
+ return 'main';
159
+ }
160
+
161
+ const stableVersion = versions.stable;
162
+ const betaVersion = versions.beta;
163
+ if (compareVersions(betaVersion, stableVersion) > 0) {
164
+ return 'beta';
165
+ }
166
+ return 'main';
167
+ }
168
+
169
+ function choosePreferredNpmTagForChannel(channel, versions = {}) {
170
+ const normalized = normalizeReleaseChannel(channel);
171
+ if (normalized === 'stable') {
172
+ return 'latest';
173
+ }
174
+
175
+ const stableVersion = versions.latest;
176
+ const betaVersion = versions.beta;
177
+ if (compareVersions(betaVersion, stableVersion) > 0) {
178
+ return 'beta';
179
+ }
180
+ return 'latest';
181
+ }
182
+
65
183
  function readReleaseChannelFromRaw(raw) {
66
184
  const env = parseEnv(raw);
67
185
  return normalizeReleaseChannel(env.get(RELEASE_CHANNEL_ENV_KEY));
@@ -116,6 +234,14 @@ module.exports = {
116
234
  getReleaseChannelBranch,
117
235
  getReleaseChannelDistTag,
118
236
  getReleaseChannelLabel,
237
+ parseSemver,
238
+ compareVersions,
239
+ maxVersion,
240
+ describeReleaseChannelPolicy,
241
+ getReleaseChannelBranchPolicy,
242
+ getReleaseChannelNpmPolicy,
243
+ choosePreferredBranchForChannel,
244
+ choosePreferredNpmTagForChannel,
119
245
  readReleaseChannelFromRaw,
120
246
  readReleaseChannelFromEnvFile,
121
247
  readConfiguredReleaseChannel,
@@ -40,6 +40,7 @@ db.exec(`
40
40
  total_tokens INTEGER DEFAULT 0,
41
41
  prompt_metrics TEXT,
42
42
  error TEXT,
43
+ final_response TEXT,
43
44
  created_at TEXT DEFAULT (datetime('now')),
44
45
  updated_at TEXT DEFAULT (datetime('now')),
45
46
  completed_at TEXT,
@@ -389,6 +390,7 @@ for (const col of [
389
390
  "ALTER TABLE scheduled_tasks ADD COLUMN run_at TEXT",
390
391
  "ALTER TABLE scheduled_tasks ADD COLUMN one_time INTEGER DEFAULT 0",
391
392
  "ALTER TABLE agent_runs ADD COLUMN prompt_metrics TEXT",
393
+ "ALTER TABLE agent_runs ADD COLUMN final_response TEXT",
392
394
  "ALTER TABLE conversations ADD COLUMN summary TEXT",
393
395
  "ALTER TABLE conversations ADD COLUMN summary_message_count INTEGER DEFAULT 0",
394
396
  "ALTER TABLE conversations ADD COLUMN last_summary TEXT",
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"052f31d115eceda8cbff1b3481fcde4330c4ae
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "1956345956" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "4059413374" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -94,11 +94,23 @@ router.get('/:id/steps', (req, res) => {
94
94
  if (!run) return res.status(404).json({ error: 'Run not found' });
95
95
 
96
96
  const steps = db.prepare('SELECT * FROM agent_steps WHERE run_id = ? ORDER BY step_index ASC').all(run.id);
97
- const response = db.prepare(
97
+ const historyResponse = db.prepare(
98
98
  `SELECT content FROM conversation_history WHERE user_id = ? AND agent_run_id = ? AND role = 'assistant' ORDER BY created_at DESC LIMIT 1`
99
99
  ).get(req.session.userId, run.id);
100
-
101
- res.json({ run, steps, response: response?.content || null });
100
+ const sentMessages = db.prepare(
101
+ `SELECT content FROM messages WHERE user_id = ? AND run_id = ? AND role = 'assistant' ORDER BY created_at ASC, id ASC`
102
+ ).all(req.session.userId, run.id);
103
+ const sentResponse = sentMessages
104
+ .map((row) => row?.content?.toString().trim() || '')
105
+ .filter(Boolean)
106
+ .join('\n\n');
107
+ const response =
108
+ sentResponse
109
+ || historyResponse?.content
110
+ || run.final_response
111
+ || null;
112
+
113
+ res.json({ run, steps, response });
102
114
  });
103
115
 
104
116
  // Abort a run
@@ -1,17 +1,19 @@
1
1
  const express = require('express');
2
- const fs = require('fs');
3
- const path = require('path');
4
2
  const router = express.Router();
5
3
  const db = require('../db/database');
6
4
  const { requireAuth } = require('../middleware/auth');
7
5
  const { normalizeWhatsAppWhitelist } = require('../utils/whatsapp');
8
6
  const { getVersionInfo } = require('../utils/version');
9
- const { UPDATE_STATUS_FILE, APP_DIR } = require('../../runtime/paths');
7
+ const { APP_DIR } = require('../../runtime/paths');
8
+ const {
9
+ readUpdateStatus,
10
+ writeUpdateStatusFile: writeUpdateStatus,
11
+ } = require('../utils/update_status');
10
12
  const {
11
13
  parseReleaseChannel,
12
- getReleaseChannelBranch,
13
- getReleaseChannelDistTag,
14
14
  writeReleaseChannelToEnvFile,
15
+ getReleaseChannelBranchPolicy,
16
+ getReleaseChannelNpmPolicy,
15
17
  } = require('../../runtime/release_channel');
16
18
  const {
17
19
  createDefaultAiSettings,
@@ -21,36 +23,6 @@ const {
21
23
 
22
24
  router.use(requireAuth);
23
25
 
24
- function readUpdateStatus() {
25
- try {
26
- return JSON.parse(fs.readFileSync(UPDATE_STATUS_FILE, 'utf8'));
27
- } catch {
28
- return {
29
- state: 'idle',
30
- progress: 0,
31
- phase: 'idle',
32
- message: 'No update running',
33
- startedAt: null,
34
- completedAt: null,
35
- versionBefore: null,
36
- versionAfter: null,
37
- changelog: [],
38
- logs: []
39
- };
40
- }
41
- }
42
-
43
- function writeUpdateStatus(patch) {
44
- const next = {
45
- ...readUpdateStatus(),
46
- ...patch,
47
- updatedAt: new Date().toISOString()
48
- };
49
- fs.mkdirSync(path.dirname(UPDATE_STATUS_FILE), { recursive: true });
50
- fs.writeFileSync(UPDATE_STATUS_FILE, JSON.stringify(next, null, 2));
51
- return next;
52
- }
53
-
54
26
  function canApplyGlobalBrowserSetting(userId) {
55
27
  const users = db.prepare('SELECT id FROM users ORDER BY id ASC').all();
56
28
  return users.length <= 1 || users[0]?.id === userId;
@@ -288,6 +260,12 @@ router.post('/update', (req, res) => {
288
260
  return res.status(409).json({ success: false, error: 'An update is already running' });
289
261
  }
290
262
  console.log('[Settings] Triggering update-runner...');
263
+ const child = spawn(process.execPath, ['scripts/update-runner.js'], {
264
+ detached: true,
265
+ stdio: 'ignore',
266
+ cwd: APP_DIR
267
+ });
268
+
291
269
  writeUpdateStatus({
292
270
  state: 'running',
293
271
  progress: 1,
@@ -297,24 +275,19 @@ router.post('/update', (req, res) => {
297
275
  completedAt: null,
298
276
  versionBefore: null,
299
277
  versionAfter: null,
278
+ runnerPid: child.pid,
300
279
  changelog: [],
301
280
  logs: []
302
281
  });
303
282
 
304
- // Spawn detached runner so status survives server restarts.
305
- const child = spawn(process.execPath, ['scripts/update-runner.js'], {
306
- detached: true,
307
- stdio: 'ignore',
308
- cwd: APP_DIR
309
- });
310
-
311
283
  child.once('error', (error) => {
312
284
  writeUpdateStatus({
313
285
  state: 'failed',
314
286
  progress: 100,
315
287
  phase: 'failed',
316
288
  message: `Failed to launch update job: ${error.message}`,
317
- completedAt: new Date().toISOString()
289
+ completedAt: new Date().toISOString(),
290
+ runnerPid: null,
318
291
  });
319
292
  });
320
293
 
@@ -338,8 +311,8 @@ router.put('/update/channel', (req, res) => {
338
311
  res.json({
339
312
  success: true,
340
313
  releaseChannel,
341
- targetBranch: getReleaseChannelBranch(releaseChannel),
342
- npmDistTag: getReleaseChannelDistTag(releaseChannel),
314
+ targetBranch: getReleaseChannelBranchPolicy(releaseChannel),
315
+ npmDistTag: getReleaseChannelNpmPolicy(releaseChannel),
343
316
  });
344
317
  });
345
318
 
@@ -354,9 +327,9 @@ router.get('/update/status', (req, res) => {
354
327
  gitVersion: version.gitVersion,
355
328
  gitSha: version.gitSha,
356
329
  gitBranch: version.gitBranch,
357
- releaseChannel: version.releaseChannel,
358
- targetBranch: version.targetBranch,
359
- npmDistTag: version.npmDistTag,
330
+ releaseChannel: status.releaseChannel || version.releaseChannel,
331
+ targetBranch: status.targetBranch || version.targetBranch,
332
+ npmDistTag: status.npmDistTag || version.npmDistTag,
360
333
  });
361
334
  });
362
335
 
@@ -138,6 +138,22 @@ function normalizeOutgoingMessage(content) {
138
138
  .trim();
139
139
  }
140
140
 
141
+ function joinSentMessages(messages = []) {
142
+ if (!Array.isArray(messages)) return '';
143
+ return messages
144
+ .map((message) => String(message || '').trim())
145
+ .filter(Boolean)
146
+ .join('\n\n');
147
+ }
148
+
149
+ function buildForcedFinalReplyPrompt(triggerSource) {
150
+ if (triggerSource === 'messaging') {
151
+ return 'Tool work is finished. Write the user-visible reply that should be sent back now. Do not call tools. Do not use [NO RESPONSE] unless the user explicitly asked for silence or no confirmation.';
152
+ }
153
+
154
+ return 'Tool work is finished. Write the final user-facing reply now. Do not call tools.';
155
+ }
156
+
141
157
  function clampRunContext(text, maxChars) {
142
158
  const value = normalizeOutgoingMessage(text);
143
159
  if (!value) return '';
@@ -458,6 +474,7 @@ class AgentEngine {
458
474
  aborted: false,
459
475
  messagingSent: false,
460
476
  lastSentMessage: '',
477
+ sentMessages: [],
461
478
  triggerType,
462
479
  triggerSource,
463
480
  startedAt: Date.now(),
@@ -506,7 +523,6 @@ class AgentEngine {
506
523
  let totalTokens = 0;
507
524
  let lastContent = '';
508
525
  let stepIndex = 0;
509
- let forcedFinalResponse = false;
510
526
  let promptMetrics = {};
511
527
 
512
528
  try {
@@ -766,12 +782,17 @@ class AgentEngine {
766
782
 
767
783
  if ((iteration >= maxIterations && messages[messages.length - 1]?.role === 'tool')
768
784
  || (iteration < maxIterations && stepIndex > 0 && !lastContent.trim() && messages[messages.length - 1]?.role !== 'tool')) {
769
- const finalResponse = await provider.chat(sanitizeConversationMessages(messages), [], {
785
+ const finalResponse = await provider.chat(sanitizeConversationMessages([
786
+ ...messages,
787
+ {
788
+ role: 'system',
789
+ content: buildForcedFinalReplyPrompt(triggerSource)
790
+ }
791
+ ]), [], {
770
792
  model,
771
793
  reasoningEffort: this.getReasoningEffort(providerName, options)
772
794
  });
773
795
  lastContent = sanitizeModelOutput(finalResponse.content || '', { model });
774
- forcedFinalResponse = true;
775
796
 
776
797
  const finalAssistantMessage = { role: 'assistant', content: lastContent };
777
798
  if (finalResponse.providerContentBlocks?.length) {
@@ -785,8 +806,18 @@ class AgentEngine {
785
806
  totalTokens += finalResponse.usage?.totalTokens || 0;
786
807
  }
787
808
 
788
- db.prepare('UPDATE agent_runs SET status = ?, total_tokens = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
789
- .run('completed', totalTokens, runId);
809
+ const runMeta = this.activeRuns.get(runId);
810
+ const messagingSent = runMeta?.messagingSent || false;
811
+ const sentMessageText = joinSentMessages(runMeta?.sentMessages);
812
+ const finalResponseText = lastContent.trim() ? lastContent : sentMessageText;
813
+ const lastSentMessage = normalizeOutgoingMessage(
814
+ runMeta?.lastSentMessage
815
+ || (Array.isArray(runMeta?.sentMessages) ? runMeta.sentMessages[runMeta.sentMessages.length - 1] : '')
816
+ || ''
817
+ );
818
+
819
+ db.prepare('UPDATE agent_runs SET status = ?, total_tokens = ?, final_response = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
820
+ .run('completed', totalTokens, finalResponseText || null, runId);
790
821
 
791
822
  if (conversationId) {
792
823
  db.prepare('UPDATE conversations SET total_tokens = total_tokens + ?, updated_at = datetime(\'now\') WHERE id = ?')
@@ -805,13 +836,10 @@ class AgentEngine {
805
836
  triggerSource,
806
837
  runTitle,
807
838
  userMessage,
808
- lastContent,
839
+ lastContent: finalResponseText,
809
840
  stepIndex
810
841
  });
811
842
 
812
- const runMeta = this.activeRuns.get(runId);
813
- const messagingSent = runMeta?.messagingSent || false;
814
- const lastSentMessage = normalizeOutgoingMessage(runMeta?.lastSentMessage || '');
815
843
  this.activeRuns.delete(runId);
816
844
  this.emit(userId, 'run:complete', { runId, content: lastContent, totalTokens, iterations: iteration, triggerSource });
817
845
 
@@ -836,7 +864,7 @@ class AgentEngine {
836
864
  await manager.sendTyping(userId, options.source, options.chatId, true).catch(() => { });
837
865
  await new Promise((resolve) => setTimeout(resolve, delay));
838
866
  }
839
- await manager.sendMessage(userId, options.source, options.chatId, chunks[i]).catch((err) =>
867
+ await manager.sendMessage(userId, options.source, options.chatId, chunks[i], { runId }).catch((err) =>
840
868
  console.error('[Engine] Auto-reply fallback failed:', err.message)
841
869
  );
842
870
  }
@@ -457,7 +457,7 @@ function getAvailableTools(app, options = {}) {
457
457
  },
458
458
  {
459
459
  name: 'send_message',
460
- description: 'Send a message on a connected messaging platform. Supports WhatsApp (text/media), Telnyx Voice (phone calls — TTS), Discord, and Telegram. For WhatsApp: use media_path to attach files. To stay silent, send content "[NO RESPONSE]". For Telnyx Voice: always reply with plain spoken text; never use [NO RESPONSE] or markdown.',
460
+ description: 'Send a message on a connected messaging platform. Supports WhatsApp (text/media), Telnyx Voice (phone calls — TTS), Discord, and Telegram. For WhatsApp: use media_path to attach files. Use content "[NO RESPONSE]" only when the user explicitly asked for silence or no reply. For Telnyx Voice: always reply with plain spoken text; never use [NO RESPONSE] or markdown.',
461
461
  parameters: {
462
462
  type: 'object',
463
463
  properties: {
@@ -1113,12 +1113,18 @@ async function executeTool(toolName, args, context, engine) {
1113
1113
  case 'send_message': {
1114
1114
  const manager = msg();
1115
1115
  if (!manager) return { error: 'Messaging not available' };
1116
- const sendResult = await manager.sendMessage(userId, args.platform, args.to, args.content, args.media_path);
1116
+ const sendResult = await manager.sendMessage(userId, args.platform, args.to, args.content, {
1117
+ mediaPath: args.media_path,
1118
+ runId
1119
+ });
1117
1120
  // Track that the agent explicitly sent a message during this run
1118
1121
  const runState = runId ? engine.activeRuns.get(runId) : null;
1119
1122
  if (runState && args.content !== '[NO RESPONSE]') {
1120
1123
  runState.messagingSent = true;
1121
1124
  runState.lastSentMessage = args.content || '';
1125
+ if (Array.isArray(runState.sentMessages)) {
1126
+ runState.sentMessages.push(args.content || '');
1127
+ }
1122
1128
  }
1123
1129
  return sendResult;
1124
1130
  }
@@ -1369,7 +1375,9 @@ async function executeTool(toolName, args, context, engine) {
1369
1375
  }
1370
1376
 
1371
1377
  try {
1372
- const sendResult = await manager.sendMessage(userId, target.platform, target.to, message);
1378
+ const sendResult = await manager.sendMessage(userId, target.platform, target.to, message, {
1379
+ runId
1380
+ });
1373
1381
  if (taskId && taskConfig && (taskConfig.notifyPlatform !== target.platform || taskConfig.notifyTo !== target.to)) {
1374
1382
  taskConfig.notifyPlatform = target.platform;
1375
1383
  taskConfig.notifyTo = target.to;
@@ -1381,6 +1389,9 @@ async function executeTool(toolName, args, context, engine) {
1381
1389
  if (runState) {
1382
1390
  runState.messagingSent = true;
1383
1391
  runState.lastSentMessage = message;
1392
+ if (Array.isArray(runState.sentMessages)) {
1393
+ runState.sentMessages.push(message);
1394
+ }
1384
1395
  }
1385
1396
  return {
1386
1397
  sent: true,
@@ -200,7 +200,7 @@ function buildIncomingPrompt(msg) {
200
200
  return `You are on a live phone call. The caller (${msg.senderName || msg.sender}) said:\n<caller_speech>\n${msg.content}\n</caller_speech>\n\nRespond via send_message with platform="telnyx" and to="${msg.chatId}".`;
201
201
  }
202
202
 
203
- return `You received a ${msg.platform} message from ${msg.senderName || msg.sender} (chat: ${msg.chatId}):\n<external_message>\n${msg.content}\n</external_message>${mediaNote}${discordContext}${sttNote}\n\nReply via send_message with platform="${msg.platform}" and to="${msg.chatId}".`;
203
+ return `You received a ${msg.platform} message from ${msg.senderName || msg.sender} (chat: ${msg.chatId}):\n<external_message>\n${msg.content}\n</external_message>${mediaNote}${discordContext}${sttNote}\n\nReply via send_message with platform="${msg.platform}" and to="${msg.chatId}". Send at least one user-visible reply before you finish. Do not use [NO RESPONSE] unless the user explicitly asked for silence or no confirmation.`;
204
204
  }
205
205
 
206
206
  async function isAllowedMessagingSender({ io, userId, msg }) {
@@ -164,11 +164,18 @@ class MessagingManager {
164
164
  return { status: 'disconnected' };
165
165
  }
166
166
 
167
- async sendMessage(userId, platformName, to, content, mediaPath) {
167
+ async sendMessage(userId, platformName, to, content, mediaPathOrOptions) {
168
168
  const key = `${userId}:${platformName}`;
169
169
  const platform = this.platforms.get(key);
170
170
  if (!platform) throw new Error(`Platform ${platformName} not connected`);
171
171
 
172
+ const sendOptions =
173
+ mediaPathOrOptions && typeof mediaPathOrOptions === 'object' && !Array.isArray(mediaPathOrOptions)
174
+ ? mediaPathOrOptions
175
+ : { mediaPath: mediaPathOrOptions };
176
+ const mediaPath = sendOptions.mediaPath || null;
177
+ const runId = sendOptions.runId || null;
178
+
172
179
  // Sentinel: agent can choose not to reply by sending [NO RESPONSE]
173
180
  if (!mediaPath && typeof content === 'string' && content.trim().toUpperCase() === '[NO RESPONSE]') {
174
181
  return { success: true, suppressed: true };
@@ -176,15 +183,16 @@ class MessagingManager {
176
183
 
177
184
  const result = await platform.sendMessage(to, content, { mediaPath });
178
185
 
179
- db.prepare('INSERT INTO messages (user_id, role, content, platform, platform_chat_id, media_path) VALUES (?, ?, ?, ?, ?, ?)')
180
- .run(userId, 'assistant', content, platformName, to, mediaPath || null);
186
+ db.prepare('INSERT INTO messages (user_id, run_id, role, content, platform, platform_chat_id, media_path) VALUES (?, ?, ?, ?, ?, ?, ?)')
187
+ .run(userId, runId, 'assistant', content, platformName, to, mediaPath);
181
188
 
182
189
  // Notify the web UI so the sent message appears in chat
183
190
  this.io.to(`user:${userId}`).emit('messaging:sent', {
184
191
  platform: platformName,
185
192
  to,
186
193
  content,
187
- mediaPath: mediaPath || null
194
+ mediaPath,
195
+ runId
188
196
  });
189
197
 
190
198
  return { success: true, result };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { UPDATE_STATUS_FILE } = require('../../runtime/paths');
6
+
7
+ const DEFAULT_UPDATE_STATUS = Object.freeze({
8
+ state: 'idle',
9
+ progress: 0,
10
+ phase: 'idle',
11
+ message: 'No update running',
12
+ startedAt: null,
13
+ completedAt: null,
14
+ versionBefore: null,
15
+ versionAfter: null,
16
+ runnerPid: null,
17
+ changelog: [],
18
+ logs: [],
19
+ });
20
+
21
+ function isProcessAlive(pid) {
22
+ const numericPid = Number(pid);
23
+ if (!Number.isInteger(numericPid) || numericPid <= 0) {
24
+ return false;
25
+ }
26
+
27
+ try {
28
+ process.kill(numericPid, 0);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function readUpdateStatusFile(filePath = UPDATE_STATUS_FILE) {
36
+ try {
37
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
38
+ } catch {
39
+ return { ...DEFAULT_UPDATE_STATUS };
40
+ }
41
+ }
42
+
43
+ function normalizeUpdateStatus(status) {
44
+ const next = {
45
+ ...DEFAULT_UPDATE_STATUS,
46
+ ...(status || {}),
47
+ };
48
+
49
+ if (next.state === 'running' && !isProcessAlive(next.runnerPid)) {
50
+ return {
51
+ ...next,
52
+ state: 'failed',
53
+ progress: 100,
54
+ phase: 'failed',
55
+ message: 'Previous update job stopped unexpectedly. You can try the update again.',
56
+ completedAt: next.completedAt || new Date().toISOString(),
57
+ runnerPid: null,
58
+ };
59
+ }
60
+
61
+ return next;
62
+ }
63
+
64
+ function writeUpdateStatusFile(patch, filePath = UPDATE_STATUS_FILE) {
65
+ const current = normalizeUpdateStatus(readUpdateStatusFile(filePath));
66
+ const next = normalizeUpdateStatus({
67
+ ...current,
68
+ ...patch,
69
+ updatedAt: new Date().toISOString(),
70
+ });
71
+
72
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
73
+ fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
74
+ return next;
75
+ }
76
+
77
+ function readUpdateStatus(filePath = UPDATE_STATUS_FILE) {
78
+ const raw = readUpdateStatusFile(filePath);
79
+ const normalized = normalizeUpdateStatus(raw);
80
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
81
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
82
+ fs.writeFileSync(filePath, JSON.stringify(normalized, null, 2));
83
+ }
84
+ return normalized;
85
+ }
86
+
87
+ module.exports = {
88
+ DEFAULT_UPDATE_STATUS,
89
+ isProcessAlive,
90
+ normalizeUpdateStatus,
91
+ readUpdateStatus,
92
+ writeUpdateStatusFile,
93
+ };
@@ -5,9 +5,9 @@ const path = require('path');
5
5
  const { execSync } = require('child_process');
6
6
  const { APP_DIR } = require('../../runtime/paths');
7
7
  const {
8
- getReleaseChannelBranch,
9
- getReleaseChannelDistTag,
10
8
  readConfiguredReleaseChannel,
9
+ getReleaseChannelBranchPolicy,
10
+ getReleaseChannelNpmPolicy,
11
11
  } = require('../../runtime/release_channel');
12
12
 
13
13
  const PACKAGE_JSON_PATH = path.join(APP_DIR, 'package.json');
@@ -67,8 +67,8 @@ function getVersionInfo() {
67
67
  gitSha,
68
68
  installedVersion: packageVersion,
69
69
  releaseChannel,
70
- targetBranch: getReleaseChannelBranch(releaseChannel),
71
- npmDistTag: getReleaseChannelDistTag(releaseChannel),
70
+ targetBranch: getReleaseChannelBranchPolicy(releaseChannel),
71
+ npmDistTag: getReleaseChannelNpmPolicy(releaseChannel),
72
72
  };
73
73
  }
74
74