@ducci/jarvis 1.0.72 → 1.0.74

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/README.md CHANGED
@@ -74,6 +74,21 @@ cd ui && npm run build # outputs to ui/dist/, served automatically by the serv
74
74
 
75
75
  Jarvis is designed for **local or private server use only**. The API has no authentication — do not expose port `18008` to the public internet. The `exec` tool runs shell commands with the same permissions as the server process.
76
76
 
77
+ If you run Jarvis on a VPS, make sure your firewall only allows what's necessary. With `ufw`:
78
+
79
+ ```bash
80
+ ufw default deny incoming
81
+ ufw default allow outgoing
82
+ ufw allow 22/tcp # SSH
83
+ ufw enable
84
+ ```
85
+
86
+ Ports like `18008` stay closed to the outside world — access the UI via an SSH tunnel instead:
87
+
88
+ ```bash
89
+ ssh -L 18008:localhost:18008 user@your-vps
90
+ ```
91
+
77
92
  ## Data
78
93
 
79
94
  All runtime data lives in `~/.jarvis/` and is never stored in the repo:
package/docs/cli.md CHANGED
@@ -33,6 +33,10 @@ Lifecycle management is handled by the CLI using the **programmatic PM2 API** fo
33
33
  - The process is named `jarvis-server`.
34
34
  - Enables `autorestart` on crash.
35
35
  - Merges logs into a single file in the user's data directory.
36
+ - Calls `pm2 save` (`pm2.dump()`) after start to persist the process list for reboot recovery.
37
+ - Prints a tip to run `pm2 startup` once to register the PM2 boot hook with the OS init system.
38
+
39
+ > **Reboot persistence**: `pm2 save` persists the process list; `pm2 startup` (run once, may need sudo) installs the OS-level boot hook so PM2 — and therefore Jarvis — starts automatically after a system reboot.
36
40
 
37
41
  ### `jarvis stop`
38
42
  - Stops the background process named `jarvis-server` using PM2.
@@ -72,7 +72,7 @@ You have access to a set of tools. Each tool has a name and description that tel
72
72
  - If a tool fails, record the error in `logSummary` and decide whether to retry with a corrected call or explain the failure to the user.
73
73
  - Proactively save user facts with `save_user_info` when the user shares personal details (name, timezone, preferences) — even if not asked.
74
74
  - Use `write_file` to create or overwrite files — never `exec` with echo/printf/heredoc (shell escaping silently corrupts content).
75
- - For processes that may run longer than 5 minutes: use `nohup command > /tmp/out.log 2>&1 &` and poll with `exec`.
75
+ - Never use `&` to background a process. For any long-running or background process, use tmux: `tmux new-session -d -s jarvis-<purpose> "command"`. Always check first with `tmux has-session -t <name>` before starting. Read output with `tmux capture-pane -t <name> -p`. Stop with `tmux kill-session -t <name>`. Record active session names in checkpoint `state` (e.g. `{"serverSession": "jarvis-server"}`).
76
76
  - Prefer using tools over making assumptions about the state of the system.
77
77
 
78
78
  ## Failure Recovery
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ducci/jarvis",
3
- "version": "1.0.72",
3
+ "version": "1.0.74",
4
4
  "description": "A fully automated agent system that lives on a server.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { Bot } from 'grammy';
4
4
  import { run } from '@grammyjs/runner';
5
- import { handleChat } from '../../server/agent.js';
5
+ import { handleChat, requestAbort } from '../../server/agent.js';
6
6
  import { loadSession } from '../../server/sessions.js';
7
7
  import { PATHS } from '../../server/config.js';
8
8
  import { load, save } from './sessions.js';
@@ -24,6 +24,12 @@ function escapeHtml(str) {
24
24
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
25
25
  }
26
26
 
27
+ function stripHtml(text) {
28
+ return text
29
+ .replace(/<[^>]+>/g, '')
30
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
31
+ }
32
+
27
33
  function markdownToHtml(text) {
28
34
  // 0. Sanitize unsupported Telegram HTML tags
29
35
  // Headings → <b>
@@ -76,7 +82,7 @@ async function sendMessage(api, chatId, text, sessionId) {
76
82
  description: e.description || e.message,
77
83
  }) + '\n', 'utf8').catch(() => {});
78
84
  }
79
- await api.sendMessage(chatId, chunk);
85
+ await api.sendMessage(chatId, stripHtml(chunk));
80
86
  } else {
81
87
  throw e;
82
88
  }
@@ -100,6 +106,7 @@ export async function startTelegramChannel(config) {
100
106
  await bot.api.setMyCommands([
101
107
  { command: 'new', description: 'Start a fresh session' },
102
108
  { command: 'usage', description: 'Show token usage for the current session' },
109
+ { command: 'stop', description: 'Stop the current run' },
103
110
  ]);
104
111
 
105
112
  bot.command('usage', async (ctx) => {
@@ -131,6 +138,23 @@ export async function startTelegramChannel(config) {
131
138
  );
132
139
  });
133
140
 
141
+ bot.command('stop', async (ctx) => {
142
+ const userId = ctx.from?.id;
143
+ if (!allowedUserIds.includes(userId)) return;
144
+
145
+ const chatId = ctx.chat.id;
146
+ const sessionId = sessions[chatId];
147
+
148
+ if (!isRunning.has(chatId) || !sessionId) {
149
+ await ctx.reply('Nothing is currently running.');
150
+ return;
151
+ }
152
+
153
+ requestAbort(sessionId);
154
+ await appendTelegramChatLog(chatId, sessionId, 'SYSTEM', '--- /stop requested ---');
155
+ await ctx.reply('Stopping current run... I\'ll send a summary when done.');
156
+ });
157
+
134
158
  bot.command('new', async (ctx) => {
135
159
  const userId = ctx.from?.id;
136
160
  if (!allowedUserIds.includes(userId)) return;
package/src/index.js CHANGED
@@ -95,6 +95,15 @@ function pm2Restart() {
95
95
  });
96
96
  }
97
97
 
98
+ function pm2Save() {
99
+ return new Promise((resolve, reject) => {
100
+ pm2.dump((err) => {
101
+ if (err) reject(err);
102
+ else resolve();
103
+ });
104
+ });
105
+ }
106
+
98
107
  const program = new Command();
99
108
 
100
109
  program
@@ -154,7 +163,9 @@ program
154
163
  return;
155
164
  }
156
165
  await pm2Start();
166
+ await pm2Save();
157
167
  console.log('Jarvis server started.');
168
+ console.log('Tip: run `pm2 startup` once to make Jarvis survive system reboots.');
158
169
  pm2.disconnect();
159
170
  } catch (e) {
160
171
  console.error('Failed to start Jarvis server:', e.message);
@@ -191,10 +202,13 @@ program
191
202
  const isRunning = desc.length > 0 && desc[0].pm2_env?.status === 'online';
192
203
  if (isRunning) {
193
204
  await pm2Restart();
205
+ await pm2Save();
194
206
  console.log('Jarvis server restarted.');
195
207
  } else {
196
208
  await pm2Start();
209
+ await pm2Save();
197
210
  console.log('Jarvis server started.');
211
+ console.log('Tip: run `pm2 startup` once to make Jarvis survive system reboots.');
198
212
  }
199
213
  pm2.disconnect();
200
214
  } catch (e) {
@@ -698,7 +698,22 @@ async function run() {
698
698
  }
699
699
  }
700
700
 
701
+ // --- TMUX CHECK ---
702
+ const tmuxCheck = spawnSync('which', ['tmux'], { stdio: 'pipe' });
703
+ if (tmuxCheck.status !== 0) {
704
+ console.log(chalk.blue('Installing tmux...'));
705
+ const hasBrew = spawnSync('which', ['brew'], { stdio: 'pipe' }).status === 0;
706
+ const hasApt = spawnSync('which', ['apt-get'], { stdio: 'pipe' }).status === 0;
707
+ if (hasBrew) spawnSync('brew', ['install', 'tmux'], { stdio: 'inherit' });
708
+ else if (hasApt) spawnSync('apt-get', ['install', '-y', 'tmux'], { stdio: 'inherit' });
709
+ else console.log(chalk.yellow('tmux not found. Install manually: apt-get install tmux / brew install tmux'));
710
+ }
711
+
701
712
  console.log(chalk.green.bold('\nSetup complete!'));
713
+ console.log(chalk.cyan('\nNext steps:'));
714
+ console.log(' 1. Run ' + chalk.bold('jarvis start') + ' to launch the server.');
715
+ console.log(' 2. Run ' + chalk.bold('pm2 startup') + ' once to make Jarvis survive system reboots.');
716
+ console.log(' (Follow the command it prints — usually needs sudo on Linux.)');
702
717
  }
703
718
 
704
719
  run().catch(error => {
@@ -53,6 +53,22 @@ function sanitizeJson(text) {
53
53
  const CONSECUTIVE_FAILURE_THRESHOLD = 3;
54
54
  const MAX_TOOL_RESULT = 4000;
55
55
 
56
+ const ABORT_NOTE = `[System: The user has requested an immediate stop. This is your final response for this run.
57
+ Respond with your normal JSON, but add a checkpoint field:
58
+
59
+ {
60
+ "response": "Brief message to the user acknowledging the stop and summarising what was completed.",
61
+ "logSummary": "Human-readable summary of what happened before the stop.",
62
+ "checkpoint": {
63
+ "progress": "What has been fully completed — only include items confirmed by tool output.",
64
+ "remaining": "What still needs to be done to finish the original task — as a plain text string, never an array or object.",
65
+ "failedApproaches": ["Concise description of each approach that failed. Leave as empty array if nothing failed."],
66
+ "state": {"factKey": "factValue — concrete facts confirmed by tool output: file paths, binary locations, config values. Use {} if nothing concrete was discovered."}
67
+ }
68
+ }
69
+
70
+ The checkpoint will allow the task to be resumed later if needed.]`;
71
+
56
72
  const WRAP_UP_NOTE = `[System: You have reached the iteration limit. This is your final response for this run.
57
73
  Respond with your normal JSON, but add a checkpoint field:
58
74
 
@@ -74,6 +90,15 @@ The checkpoint field will be used to automatically resume the task in the next r
74
90
  // queued request finishes).
75
91
  const sessionQueues = new Map();
76
92
 
93
+ // Abort flags: set by requestAbort(), checked at each iteration boundary in
94
+ // runAgentLoop. Always cleared in _runHandleChat's finally block to prevent
95
+ // stale flags from killing subsequent runs.
96
+ const sessionAborts = new Map();
97
+
98
+ export function requestAbort(sessionId) {
99
+ sessionAborts.set(sessionId, true);
100
+ }
101
+
77
102
  function accumulateUsage(accum, result) {
78
103
  const u = result?.usage;
79
104
  if (!u) return;
@@ -235,6 +260,48 @@ export async function runAgentLoop(client, config, session, prepareMessages, usa
235
260
  while (iteration < config.maxIterations) {
236
261
  iteration++;
237
262
 
263
+ // Check for user-requested stop. Do a wrap-up call so the user gets a
264
+ // meaningful summary and the session can be resumed later if needed.
265
+ if (sessionAborts.get(config._sessionId)) {
266
+ sessionAborts.delete(config._sessionId);
267
+ const abortMessages = [
268
+ ...prepareMessages(session.messages),
269
+ { role: 'user', content: ABORT_NOTE },
270
+ ];
271
+ try {
272
+ const abortResult = await callModelWithFallback(client, config, abortMessages, []);
273
+ accumulateUsage(usageAccum, abortResult);
274
+ const abortContent = abortResult.choices[0]?.message?.content || '';
275
+ let parsedAbort = null;
276
+ try { parsedAbort = JSON.parse(sanitizeJson(abortContent)); } catch { /* use raw */ }
277
+ session.messages.push({ role: 'assistant', content: abortContent });
278
+ if (parsedAbort?.checkpoint) {
279
+ const cp = parsedAbort.checkpoint;
280
+ if (typeof cp.remaining !== 'string') cp.remaining = Array.isArray(cp.remaining) ? cp.remaining.map(String).join('\n') : cp.remaining != null ? JSON.stringify(cp.remaining) : '';
281
+ if (!Array.isArray(cp.failedApproaches)) cp.failedApproaches = [];
282
+ else cp.failedApproaches = cp.failedApproaches.map(i => typeof i === 'string' ? i : JSON.stringify(i));
283
+ if (typeof cp.state !== 'object' || cp.state === null || Array.isArray(cp.state)) cp.state = {};
284
+ }
285
+ return {
286
+ iteration,
287
+ response: parsedAbort?.response || abortContent || 'Run stopped.',
288
+ logSummary: parsedAbort?.logSummary || 'Run stopped by user request.',
289
+ status: 'aborted',
290
+ runToolCalls,
291
+ checkpoint: parsedAbort?.checkpoint || null,
292
+ };
293
+ } catch (e) {
294
+ return {
295
+ iteration,
296
+ response: 'Run stopped.',
297
+ logSummary: `Run stopped by user request. Wrap-up call failed: ${e.message}`,
298
+ status: 'aborted',
299
+ runToolCalls,
300
+ checkpoint: null,
301
+ };
302
+ }
303
+ }
304
+
238
305
  let modelResult;
239
306
  const iterationsLeft = config.maxIterations - iteration + 1;
240
307
  const base = prepareMessages(session.messages);
@@ -774,6 +841,21 @@ async function _runHandleChat(config, sessionId, userMessage, attachments = [],
774
841
  // makes the next one more likely (especially on free models with small context
775
842
  // windows). The synthetic note is sufficient context; tool results are preserved
776
843
  // in the JSONL log and accessible via read_session_log.
844
+ // On abort: save checkpoint data so the task can be resumed later,
845
+ // same as the checkpoint_reached path does for handoff runs.
846
+ if (finalStatus === 'aborted' && run.checkpoint) {
847
+ if (run.checkpoint.failedApproaches?.length > 0) {
848
+ if (!session.metadata.failedApproaches) session.metadata.failedApproaches = [];
849
+ session.metadata.failedApproaches.push(...run.checkpoint.failedApproaches);
850
+ }
851
+ if (run.checkpoint.state && Object.keys(run.checkpoint.state).length > 0) {
852
+ session.metadata.checkpointState = { ...(session.metadata.checkpointState || {}), ...run.checkpoint.state };
853
+ }
854
+ if (run.checkpoint.remaining) {
855
+ session.metadata.lastCheckpointRemaining = run.checkpoint.remaining.trim();
856
+ }
857
+ }
858
+
777
859
  if (finalStatus === 'model_error' || finalStatus === 'format_error') {
778
860
  if (finalStatus === 'model_error' && isImageUnsupportedError(run.errorDetail)) {
779
861
  finalResponse = 'This model does not support image input. Please switch to a multimodal model (e.g. claude-3.5-sonnet, gpt-4o) in settings.';
@@ -914,6 +996,10 @@ async function _runHandleChat(config, sessionId, userMessage, attachments = [],
914
996
  });
915
997
  throw e;
916
998
  } finally {
999
+ // Clear any stale abort flag — prevents a flag set just as a run finished
1000
+ // from killing the next run.
1001
+ sessionAborts.delete(sessionId);
1002
+
917
1003
  // Accumulate token usage into session metadata so /usage can read it
918
1004
  if (!session.metadata.tokenUsage) session.metadata.tokenUsage = { prompt: 0, completion: 0, cacheRead: 0, cacheCreation: 0 };
919
1005
  session.metadata.tokenUsage.prompt += usageAccum.prompt;