@ducci/jarvis 1.0.72 → 1.0.73

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:
@@ -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.73",
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';
@@ -100,6 +100,7 @@ export async function startTelegramChannel(config) {
100
100
  await bot.api.setMyCommands([
101
101
  { command: 'new', description: 'Start a fresh session' },
102
102
  { command: 'usage', description: 'Show token usage for the current session' },
103
+ { command: 'stop', description: 'Stop the current run' },
103
104
  ]);
104
105
 
105
106
  bot.command('usage', async (ctx) => {
@@ -131,6 +132,23 @@ export async function startTelegramChannel(config) {
131
132
  );
132
133
  });
133
134
 
135
+ bot.command('stop', async (ctx) => {
136
+ const userId = ctx.from?.id;
137
+ if (!allowedUserIds.includes(userId)) return;
138
+
139
+ const chatId = ctx.chat.id;
140
+ const sessionId = sessions[chatId];
141
+
142
+ if (!isRunning.has(chatId) || !sessionId) {
143
+ await ctx.reply('Nothing is currently running.');
144
+ return;
145
+ }
146
+
147
+ requestAbort(sessionId);
148
+ await appendTelegramChatLog(chatId, sessionId, 'SYSTEM', '--- /stop requested ---');
149
+ await ctx.reply('Stopping current run... I\'ll send a summary when done.');
150
+ });
151
+
134
152
  bot.command('new', async (ctx) => {
135
153
  const userId = ctx.from?.id;
136
154
  if (!allowedUserIds.includes(userId)) return;
@@ -698,6 +698,17 @@ 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!'));
702
713
  }
703
714
 
@@ -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;