jaitus 0.3.24 → 0.3.26

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/src/index.js CHANGED
@@ -23,17 +23,18 @@ import { DEFAULT_DENY } from './agent/rules.js';
23
23
  import { forgetIgnoreCache, imageBlock, IMAGE_MIME } from './agent/tools.js';
24
24
  import { shrinkImageFile, clipboardBytes } from './tui/clipboard.js';
25
25
  import { THEME_NAMES } from './tui/theme.js';
26
- import { loadContext, CONTEXT_FILE } from './project.js';
26
+ import { loadContext, loadUserContext, userContextFile, appendNote, USER_CONTEXT_HEADER, PROJECT_CONTEXT_HEADER, CONTEXT_FILE } from './project.js';
27
27
  import { discoverSkills, skillCatalog } from './skills.js';
28
28
  import { runHook, configuredHookEvents } from './hooks.js';
29
29
  import { newSession, saveSession, loadSession, latestSession, listSessions, sessionSummary, replaySession, sameDir } from './sessions.js';
30
30
  import { createJournal, displayPath } from './rewind.js';
31
31
  import { createLedger } from './ledger.js';
32
- import { listMcpServers, addMcpServer, removeMcpServer, untrustedServers, trustServer, discoverMcpTools, mcpProblems, allServers } from './mcp.js';
32
+ import { listMcpServers, addMcpServer, removeMcpServer, untrustedServers, trustServer, discoverMcpTools, mcpProblems, allServers, isRemote, expandEnv } from './mcp.js';
33
33
  import { banner, wordmark, renderHomeLogo, startupTip } from './brand.js';
34
+ import { adSourceFor } from './adSlots.js';
34
35
  import {
35
36
  c, out, err, ok, fail, info, warn, table, present,
36
- promptLine, useHistoryFile, watchEsc, setTuiSink, askKey, colorLevel,
37
+ promptLine, useHistoryFile, watchEsc, setTuiSink, askKey, ask, colorLevel,
37
38
  unicode, glyph, columns,
38
39
  } from './ui.js';
39
40
  import { createTui as createClassicTui } from './tui.js';
@@ -583,7 +584,7 @@ const SLASH = {
583
584
  '/diff': { summary: 'uncommitted changes, staged or not, and new files' },
584
585
  '/tasks': { summary: 'review tool activity from this session' },
585
586
  '/image <path>': { summary: 'attach an image (png/jpg/gif/webp) the model will see with your next message' },
586
- '/memory [text]': { summary: 'show or append to JAITUS.md (# does this inline)' },
587
+ '/memory [text]': { summary: 'show your notes for the agent, or add one (# does this inline)' },
587
588
  '/permissions [mode|rules|default]': { summary: 'permission mode, the mode new sessions start in, and the allow/deny rules for this project' },
588
589
  '/skills': { summary: 'list project workflows' },
589
590
  '/sessions': { summary: 'list saved sessions in this directory' },
@@ -843,6 +844,8 @@ async function session({ flags }) {
843
844
  ],
844
845
  fileProvider: (frag) => completeFiles(frag),
845
846
  settings: loadSettings(),
847
+ // Sponsored lines (adSlots.js). The server decides who sees them; Max never does.
848
+ ads: adSourceFor(client),
846
849
  meta: {
847
850
  version: VERSION, cwd: flags.cwd, model, mode: permissions.mode, intent: 'build',
848
851
  session: sessionState.id, branch: gitBranch(flags.cwd),
@@ -1244,8 +1247,7 @@ async function session({ flags }) {
1244
1247
  if (input.startsWith('#')) {
1245
1248
  const note = input.slice(1).trim();
1246
1249
  if (note) {
1247
- appendMemory(flags.cwd, note);
1248
- ok(`Remembered in ${CONTEXT_FILE}: ${c.bold(note.slice(0, 60))}${note.length > 60 ? '…' : ''}`);
1250
+ await rememberNote(flags.cwd, note, tui);
1249
1251
  } else {
1250
1252
  const existing = loadContext(flags.cwd);
1251
1253
  out(existing ? existing : c.dim(`(no ${CONTEXT_FILE} yet — type "# note text" to add one)`));
@@ -1407,6 +1409,11 @@ async function session({ flags }) {
1407
1409
  const picked = list[Number(key) - 1];
1408
1410
  return picked === undefined ? null : (picked && typeof picked === 'object' ? picked : { value: picked });
1409
1411
  };
1412
+ tui.askText = async (question) => {
1413
+ if (!process.stdin.isTTY) return null;
1414
+ const typed = await ask(` ${c.bold(String(question).replace(/\x1b\[[0-9;]*m/g, ''))}\n ${c.dim('›')} `);
1415
+ return typed || null;
1416
+ };
1410
1417
  tui.showPanel = async (title, lines) => {
1411
1418
  out(` ${c.bold(String(title))}`);
1412
1419
  for (const line of (Array.isArray(lines) ? lines : String(lines).split('\n'))) out(` ${line}`);
@@ -1436,11 +1443,16 @@ async function session({ flags }) {
1436
1443
  // exact command is approved here — asked once, remembered per project and command.
1437
1444
  void (async () => {
1438
1445
  for (const server of untrustedServers(flags.cwd)) {
1439
- const line = [server.config.command, ...(server.config.args || [])].join(' ');
1446
+ const remote = isRemote(server.config);
1447
+ const line = remote ? server.config.url : [server.config.command, ...(server.config.args || [])].join(' ');
1440
1448
  const answer = dialogValue(await tui.askDialog(
1441
- `This project's .mcp.json wants to start an MCP server "${server.name}". It runs on your computer, with your files.`,
1449
+ remote
1450
+ ? `This project's .mcp.json wants to connect to a remote MCP server "${server.name}". What the agent sends its tools goes to that address.`
1451
+ : `This project's .mcp.json wants to start an MCP server "${server.name}". It runs on your computer, with your files.`,
1442
1452
  [{ label: 'Allow and remember for this project', value: 'trust' }, { label: 'Not now', value: 'no' }],
1443
- [`command: ${line.slice(0, 300)}`, ...(server.config.env ? [`env: ${Object.keys(server.config.env).join(', ')}`] : [])],
1453
+ remote
1454
+ ? [`address: ${String(line).slice(0, 300)}`, ...(server.config.headers ? [`headers: ${Object.keys(server.config.headers).join(', ')}`] : [])]
1455
+ : [`command: ${line.slice(0, 300)}`, ...(server.config.env ? [`env: ${Object.keys(server.config.env).join(', ')}`] : [])],
1444
1456
  { title: 'MCP SERVER', hideComment: true },
1445
1457
  ));
1446
1458
  if (answer === 'trust') { trustServer(flags.cwd, server); tui.notice(`MCP server ${server.name} approved for this project.`, 'info'); }
@@ -1684,15 +1696,32 @@ function appendHistoryLine(line) {
1684
1696
  } catch { /* history is a convenience, never a reason to fail */ }
1685
1697
  }
1686
1698
 
1687
- /** Append one line to the project memory file, creating it with a header if needed. */
1688
- function appendMemory(cwd, note) {
1689
- const file = path.join(cwd, CONTEXT_FILE);
1699
+ /**
1700
+ * Save a note for the agent: this project's JAITUS.md, or the personal notes sent with
1701
+ * every project. Asked when a dialog can be shown; a script or a plain terminal keeps the
1702
+ * old behaviour (the project file). Resolves 'project' | 'user' | null (not saved).
1703
+ */
1704
+ async function rememberNote(cwd, note, tui) {
1705
+ let where = 'project';
1706
+ if (tui?.askDialog) {
1707
+ const shown = note.length > 80 ? `${note.slice(0, 79)}…` : note;
1708
+ const answer = await tui.askDialog(`Remember "${shown}" for…`, [
1709
+ { label: `This project — ${CONTEXT_FILE}`, value: 'project' },
1710
+ { label: 'All my projects — ~/.jaitus/JAITUS.md', value: 'user' },
1711
+ ], [], { title: 'Save a note', hideComment: true });
1712
+ const picked = dialogValue(answer);
1713
+ if (picked !== 'project' && picked !== 'user') { out(c.dim(' Not saved.')); return null; }
1714
+ where = picked;
1715
+ }
1716
+ const file = where === 'user' ? userContextFile() : path.join(cwd, CONTEXT_FILE);
1690
1717
  try {
1691
- if (!fs.existsSync(file)) fs.writeFileSync(file, `# ${CONTEXT_FILE}\n\nNotes for the Jaitus agent working in this directory.\n\n`, 'utf8');
1692
- fs.appendFileSync(file, `- ${note}\n`, 'utf8');
1718
+ appendNote(file, note, where === 'user' ? USER_CONTEXT_HEADER : PROJECT_CONTEXT_HEADER);
1693
1719
  } catch (e) {
1694
- fail(`Could not write ${CONTEXT_FILE}: ${e.message}`);
1720
+ fail(`Could not write ${file}: ${e.message}`);
1721
+ return null;
1695
1722
  }
1723
+ ok(`Remembered ${where === 'user' ? 'for every project (~/.jaitus/JAITUS.md)' : `in ${CONTEXT_FILE}`}: ${c.bold(note.slice(0, 60))}${note.length > 60 ? '…' : ''}`);
1724
+ return where;
1696
1725
  }
1697
1726
 
1698
1727
  /** Run a shell command with output streaming to the terminal. Returns the exit code. */
@@ -1832,6 +1861,7 @@ async function slashCommand(verb, args, ctx) {
1832
1861
  { label: 'Messages', value: String(ctx.messages.length) },
1833
1862
  { label: 'Tool results', value: String(count((b) => b.type === 'tool_result')) },
1834
1863
  { label: 'Images', value: String(count((b) => b.type === 'image')) },
1864
+ ...(loadUserContext() ? [{ label: 'Personal notes', value: '~/.jaitus/JAITUS.md (loaded)' }] : []),
1835
1865
  ...(loadContext(ctx.flags.cwd) ? [{ label: 'Project notes', value: `${CONTEXT_FILE} (loaded)` }] : []),
1836
1866
  { gap: true },
1837
1867
  { label: '', value: '/compact summarises it now' },
@@ -1844,6 +1874,7 @@ async function slashCommand(verb, args, ctx) {
1844
1874
  [` ${c.dim('assistant turns')}`, String(ctx.messages.filter((m) => m.role === 'assistant').length)],
1845
1875
  [` ${c.dim('tool results')}`, String(ctx.messages.reduce((n, m) => n + (Array.isArray(m.content) ? m.content.filter((b) => b.type === 'tool_result').length : 0), 0))],
1846
1876
  [` ${c.dim('estimated tokens')}`, tokens.toLocaleString()],
1877
+ ...(loadUserContext() ? [[` ${c.dim('personal notes')}`, '~/.jaitus/JAITUS.md (loaded)']] : []),
1847
1878
  ...(loadContext(ctx.flags.cwd) ? [[` ${c.dim('project notes')}`, `${CONTEXT_FILE} (loaded)`]] : []),
1848
1879
  ]);
1849
1880
  info(`${c.dim('Run')} ${c.bold('/compact')} ${c.dim('to summarise and free context when this grows.')}`);
@@ -1989,9 +2020,21 @@ async function slashCommand(verb, args, ctx) {
1989
2020
 
1990
2021
  case '/memory': {
1991
2022
  const note = args.join(' ').trim();
1992
- if (note) { appendMemory(ctx.flags.cwd, note); ok(`Remembered in ${CONTEXT_FILE}.`); return null; }
1993
- const existing = loadContext(ctx.flags.cwd);
1994
- out(existing ? existing : c.dim(`(no ${CONTEXT_FILE} yet — /memory <text> creates one)`));
2023
+ if (note) { await rememberNote(ctx.flags.cwd, note, ctx.tui); return null; }
2024
+ // Both halves of what the agent is told, each with where it lives.
2025
+ const personal = loadUserContext();
2026
+ const project = loadContext(ctx.flags.cwd);
2027
+ const lines = [
2028
+ c.bold(`Every project — ${userContextFile()}`),
2029
+ ...(personal ? personal.split('\n').map((l) => ` ${l}`) : [c.dim(' (nothing yet)')]),
2030
+ '',
2031
+ c.bold(`This project — ${path.join(ctx.flags.cwd, CONTEXT_FILE)}`),
2032
+ ...(project ? project.split('\n').map((l) => ` ${l}`) : [c.dim(' (nothing yet)')]),
2033
+ '',
2034
+ c.dim('Type "# a note" (or /memory a note) to add one; you choose where it goes. Edit the files for anything longer.'),
2035
+ ];
2036
+ if (ctx.tui?.showPanel) await ctx.tui.showPanel('Memory', lines);
2037
+ else out(lines.join('\n'));
1995
2038
  return null;
1996
2039
  }
1997
2040
 
@@ -2815,7 +2858,7 @@ const commands = {
2815
2858
  // In the app view: a popup over the conversation that Esc closes, not rows that
2816
2859
  // stay in the transcript for good.
2817
2860
  if (tui?.showMeters && !json) {
2818
- void tui.showMeters('Usage', sections);
2861
+ void tui.showMeters('Usage', sections, { ad: 'usage' });
2819
2862
  return 0;
2820
2863
  }
2821
2864
  present(json, withoutCredits({ ...d, planStatus: u?.planStatus || null }), () => table(sectionsAsTable(sections)));
@@ -2859,18 +2902,50 @@ const commands = {
2859
2902
  usage: [
2860
2903
  'jaitus mcp list',
2861
2904
  'jaitus mcp add [--global] <name> -- <command> [args...]',
2905
+ 'jaitus mcp add [--global] --transport http|sse <name> <url> [--header "Name: value"]...',
2862
2906
  'jaitus mcp trust <name>',
2863
2907
  'jaitus mcp remove <name>',
2864
2908
  ],
2865
2909
  detail: [
2866
- 'Local stdio servers from your own ~/.jaitus/mcp.json (--global) and the project\'s .mcp.json, merged,',
2867
- 'exposed to the agent as permission-gated tools. A project\'s servers start only once you approve them.',
2868
- 'Put the command after -- so its own flags (-y, --port) are not read as Jaitus flags.',
2910
+ 'Servers from your own ~/.jaitus/mcp.json (--global) and the project\'s .mcp.json, merged, exposed to',
2911
+ 'the agent as permission-gated tools. A project\'s servers start only once you approve them.',
2912
+ 'Local: put the command after -- so its own flags (-y, --port) are not read as Jaitus flags.',
2913
+ 'Remote: an https address (Streamable HTTP, or sse for older servers). Write a token as ${VAR} to',
2914
+ 'read it from the environment rather than saving it, e.g. --header "Authorization: Bearer ${API_TOKEN}".',
2869
2915
  ],
2870
2916
  run: (argv, { json, flags }) => {
2871
2917
  const global = argv.includes('--global');
2872
- const rest = argv.filter((a) => a !== '--global');
2918
+ // --transport http|sse and repeated --header/-H "Name: value" (remote servers).
2919
+ let transport = null;
2920
+ const headers = {};
2921
+ const rest = [];
2922
+ for (let i = 0; i < argv.length; i += 1) {
2923
+ const a = argv[i];
2924
+ if (a === '--global') continue;
2925
+ if (a === '--transport' || a === '-t') { transport = String(argv[i + 1] || '').toLowerCase(); i += 1; continue; }
2926
+ if (a === '--header' || a === '-H') {
2927
+ const h = String(argv[i + 1] || ''); i += 1;
2928
+ const at = h.indexOf(':');
2929
+ if (at <= 0) { fail(`A header is "Name: value" — got ${JSON.stringify(h)}.`); return 1; }
2930
+ headers[h.slice(0, at).trim()] = h.slice(at + 1).trim();
2931
+ continue;
2932
+ }
2933
+ rest.push(a);
2934
+ }
2873
2935
  const [action = 'list', name, command, ...args] = rest;
2936
+ if (action === 'add' && transport && transport !== 'stdio') {
2937
+ if (!['http', 'sse'].includes(transport)) { fail('--transport is http, sse or stdio.'); return 1; }
2938
+ if (!name || !command) { fail('Usage: jaitus mcp add --transport http|sse <name> <url> [--header "Name: value"]'); return 1; }
2939
+ const config = { type: transport, url: command, ...(Object.keys(headers).length ? { headers } : {}) };
2940
+ if (!isRemote(config)) { fail('Give the server address, e.g. https://example.com/mcp'); return 1; }
2941
+ try { new URL(expandEnv(command)); } catch { fail(`Not a valid address: ${command}`); return 1; }
2942
+ addMcpServer(flags.cwd, name, config, { global });
2943
+ ok(`Added remote MCP server ${c.bold(name)} (${transport})${global ? ' for every project' : ''}.`);
2944
+ if (Object.values(headers).some((v) => /[A-Za-z0-9_-]{24,}/.test(v) && !/\$\{/.test(v))) {
2945
+ warn(`The header holds what looks like a token, saved as text in ${global ? '~/.jaitus/mcp.json' : '.mcp.json'}. Prefer \${VAR} so it is read from the environment${global ? '' : ' — and never commit it'}.`);
2946
+ }
2947
+ return 0;
2948
+ }
2874
2949
  if (action === 'list') {
2875
2950
  const servers = listMcpServers(flags.cwd);
2876
2951
  present(json, { servers }, () => {
@@ -2888,7 +2963,7 @@ const commands = {
2888
2963
  const server = allServers(flags.cwd).find((s) => s.name === name);
2889
2964
  if (!server) { warn(`No MCP server named ${name}.`); return 1; }
2890
2965
  trustServer(flags.cwd, server);
2891
- ok(`Approved ${c.bold(name)} for this project: ${[server.config.command, ...(server.config.args || [])].join(' ')}`);
2966
+ ok(`Approved ${c.bold(name)} for this project: ${isRemote(server.config) ? server.config.url : [server.config.command, ...(server.config.args || [])].join(' ')}`);
2892
2967
  return 0;
2893
2968
  }
2894
2969
  if (action === 'remove' && name) {
@@ -2896,7 +2971,7 @@ const commands = {
2896
2971
  ok(`Removed MCP server ${c.bold(name)}.`);
2897
2972
  return 0;
2898
2973
  }
2899
- fail('Usage: jaitus mcp list | add [--global] <name> -- <command> [args...] | trust <name> | remove <name>');
2974
+ fail('Usage: jaitus mcp list | add [--global] <name> -- <command> [args...] | add --transport http|sse <name> <url> | trust <name> | remove <name>');
2900
2975
  return 1;
2901
2976
  },
2902
2977
  },
package/src/mcp.js CHANGED
@@ -1,7 +1,8 @@
1
- // ─── Model Context Protocol over stdio ─────────────────────────────────────────
1
+ // ─── Model Context Protocol: local (stdio) and remote (HTTP) servers ────────────
2
2
  //
3
- // Jaitus supports the portable part of MCP that is most useful in a terminal: local
4
- // stdio servers exposing tools.
3
+ // Jaitus supports the portable part of MCP that is most useful in a terminal: servers
4
+ // exposing tools — local ones over stdio, and remote ones over Streamable HTTP or the
5
+ // older HTTP+SSE transport.
5
6
  //
6
7
  // Servers come from two places, merged: your own (~/.jaitus/mcp.json, added with
7
8
  // `jaitus mcp add --global`) and the project's (.mcp.json). A project's file comes with
@@ -16,6 +17,8 @@
16
17
 
17
18
  import crypto from 'node:crypto';
18
19
  import fs from 'node:fs';
20
+ import http from 'node:http';
21
+ import https from 'node:https';
19
22
  import path from 'node:path';
20
23
  import { spawn, spawnSync } from 'node:child_process';
21
24
  import { SESSIONS, loadSettings, saveSettings } from './config.js';
@@ -45,10 +48,40 @@ export function readMcpConfig(cwd) {
45
48
  return { file: projectFile(cwd), servers };
46
49
  }
47
50
 
51
+ // ── Remote servers ──
52
+ // The two HTTP transports other agents read from the same .mcp.json shape:
53
+ // { "type": "http", "url": "https://…/mcp", "headers": { "Authorization": "Bearer ${TOKEN}" } }
54
+ // { "type": "sse", "url": "https://…/sse", … } (the older HTTP+SSE transport)
55
+ // ${VAR} and ${VAR:-fallback} in the url and headers are read from the environment, so
56
+ // a token never has to be written into a file that is committed.
57
+ const REMOTE_TYPES = new Set(['http', 'streamable-http', 'sse']);
58
+ export const isRemote = (config) => Boolean(config?.url) && REMOTE_TYPES.has(String(config?.type || 'http').toLowerCase()) && !config?.command;
59
+ export function expandEnv(value, env = process.env) {
60
+ return String(value ?? '').replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, fallback) => (env[name] !== undefined && env[name] !== '' ? env[name] : (fallback ?? '')));
61
+ }
62
+ function remoteTarget(config) {
63
+ const url = expandEnv(config.url);
64
+ let u;
65
+ try { u = new URL(url); } catch { throw new Error(`MCP server url is not a valid address: ${url}`); }
66
+ // A token in a header must not cross the internet in plain text: http only to this
67
+ // computer, https everywhere else.
68
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
69
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error(`MCP server url must be https:// (http:// only for this computer): ${url}`);
70
+ const headers = {};
71
+ for (const [k, v] of Object.entries(config.headers || {})) headers[String(k)] = expandEnv(v);
72
+ return { url: u, headers };
73
+ }
74
+
48
75
  // ── Trust ──
49
- /** What is approved: the command line itself. Change the command and it asks again. */
76
+ /**
77
+ * What is approved: the command line itself, or for a remote server its address and
78
+ * headers. Change it and it asks again. (A stdio server's fingerprint is unchanged from
79
+ * before remote servers existed, so nothing already approved has to be approved again.)
80
+ */
50
81
  export function fingerprint(config) {
51
- const body = JSON.stringify({ command: config?.command || '', args: config?.args || [], env: config?.env || {} });
82
+ const body = isRemote(config)
83
+ ? JSON.stringify({ type: String(config.type || 'http').toLowerCase(), url: config.url, headers: config.headers || {} })
84
+ : JSON.stringify({ command: config?.command || '', args: config?.args || [], env: config?.env || {} });
52
85
  return crypto.createHash('sha256').update(body).digest('hex').slice(0, 24);
53
86
  }
54
87
  const trustKey = (cwd) => path.resolve(cwd).toLowerCase();
@@ -65,7 +98,7 @@ export function trustServer(cwd, server) {
65
98
  discovery.clear();
66
99
  }
67
100
  export function untrustedServers(cwd) {
68
- return allServers(cwd).filter((s) => s.config?.command && !isTrusted(cwd, s));
101
+ return allServers(cwd).filter((s) => (s.config?.command || isRemote(s.config)) && !isTrusted(cwd, s));
69
102
  }
70
103
 
71
104
  export function listMcpServers(cwd) {
@@ -73,7 +106,7 @@ export function listMcpServers(cwd) {
73
106
  name: s.name,
74
107
  scope: s.scope,
75
108
  trusted: isTrusted(cwd, s),
76
- transport: s.config?.type || (s.config?.command ? 'stdio' : 'unknown'),
109
+ transport: s.config?.command ? 'stdio' : isRemote(s.config) ? String(s.config.type || 'http').toLowerCase() : 'unknown',
77
110
  command: [s.config?.command || s.config?.url || '', ...(s.config?.args || [])].join(' ').trim(),
78
111
  }));
79
112
  }
@@ -218,6 +251,214 @@ class StdioClient {
218
251
  }
219
252
  }
220
253
 
254
+ /**
255
+ * Server-sent events from a stream of text chunks: yields { event, data } per event.
256
+ * Lines are `field: value`; an event ends at a blank line; `data` lines are joined.
257
+ */
258
+ function sseParser(onEvent) {
259
+ let buffer = '';
260
+ let event = 'message';
261
+ let data = [];
262
+ return (chunk) => {
263
+ buffer += chunk;
264
+ let at;
265
+ while ((at = buffer.search(/\r?\n/)) >= 0) {
266
+ const line = buffer.slice(0, at);
267
+ buffer = buffer.slice(at + (buffer[at] === '\r' ? 2 : 1));
268
+ if (line === '') {
269
+ if (data.length) onEvent({ event, data: data.join('\n') });
270
+ event = 'message'; data = [];
271
+ } else if (!line.startsWith(':')) {
272
+ const colon = line.indexOf(':');
273
+ const field = colon < 0 ? line : line.slice(0, colon);
274
+ const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
275
+ if (field === 'event') event = value;
276
+ else if (field === 'data') data.push(value);
277
+ }
278
+ }
279
+ };
280
+ }
281
+
282
+ const remoteError = (status, text) => {
283
+ if (status === 401 || status === 403) return new Error(`MCP server refused the request (${status}) — check the token in its "headers" (an unset \${VAR} becomes empty).`);
284
+ return new Error(`MCP server answered ${status}${text ? `: ${String(text).replace(/\s+/g, ' ').slice(0, 200)}` : ''}`);
285
+ };
286
+
287
+ /**
288
+ * Streamable HTTP (MCP 2025-03-26 and later): every message is a POST; the answer is JSON,
289
+ * or an event stream carrying it. The server may hand out a session id on initialize,
290
+ * which then goes with every request.
291
+ */
292
+ class HttpClient {
293
+ constructor(config) {
294
+ this.config = config;
295
+ this.nextId = 1;
296
+ this.sessionId = null;
297
+ this.protocol = '2025-06-18';
298
+ this.initialized = false;
299
+ this.alive = false;
300
+ }
301
+
302
+ async start() {
303
+ this.target = remoteTarget(this.config);
304
+ const result = await this.request('initialize', { protocolVersion: this.protocol, capabilities: {}, clientInfo: { name: 'jaitus', version: '0.3' } }, 20_000);
305
+ if (result?.protocolVersion) this.protocol = result.protocolVersion;
306
+ this.initialized = true;
307
+ this.alive = true;
308
+ await this.notify('notifications/initialized', {});
309
+ return this;
310
+ }
311
+
312
+ async post(message, timeoutMs) {
313
+ const ctl = new AbortController();
314
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
315
+ const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...this.target.headers };
316
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
317
+ if (this.initialized) headers['mcp-protocol-version'] = this.protocol;
318
+ try {
319
+ const res = await fetch(this.target.url, { method: 'POST', headers, body: JSON.stringify(message), signal: ctl.signal });
320
+ const sid = res.headers.get('mcp-session-id');
321
+ if (sid) this.sessionId = sid;
322
+ if (message.id === undefined) { try { await res.body?.cancel(); } catch { /* done */ } return null; }
323
+ if (res.status === 404 && this.sessionId) { this.alive = false; throw new Error('MCP session ended on the server; it is started again on the next call'); }
324
+ if (!res.ok) throw remoteError(res.status, await res.text().catch(() => ''));
325
+ const type = String(res.headers.get('content-type') || '');
326
+ if (type.includes('text/event-stream')) {
327
+ // The answer is one event among possibly several (progress, server requests).
328
+ const reader = res.body.getReader();
329
+ const decoder = new TextDecoder();
330
+ let found = null;
331
+ const feed = sseParser(({ data }) => {
332
+ try { const m = JSON.parse(data); for (const one of Array.isArray(m) ? m : [m]) if (one?.id === message.id && ('result' in one || 'error' in one)) found = one; } catch { /* not JSON */ }
333
+ });
334
+ while (!found) {
335
+ const { done, value } = await reader.read();
336
+ if (done) break;
337
+ feed(decoder.decode(value, { stream: true }));
338
+ }
339
+ try { await reader.cancel(); } catch { /* done */ }
340
+ if (!found) throw new Error(`MCP server closed the stream without answering ${message.method}`);
341
+ return found;
342
+ }
343
+ const body = await res.json();
344
+ const list = Array.isArray(body) ? body : [body];
345
+ const found = list.find((m) => m?.id === message.id);
346
+ if (!found) throw new Error(`MCP server did not answer ${message.method}`);
347
+ return found;
348
+ } catch (e) {
349
+ if (e?.name === 'AbortError') throw new Error(`MCP request timed out after ${Math.round(timeoutMs / 1000)}s: ${message.method}`);
350
+ if (e?.cause?.code === 'ECONNREFUSED' || /fetch failed/.test(e?.message || '')) throw new Error(`Could not reach MCP server at ${this.target.url.origin}`);
351
+ throw e;
352
+ } finally {
353
+ clearTimeout(timer);
354
+ }
355
+ }
356
+
357
+ async request(method, params, timeoutMs = 120_000) {
358
+ const id = this.nextId++;
359
+ const m = await this.post({ jsonrpc: '2.0', id, method, params }, timeoutMs);
360
+ if (m.error) throw new Error(m.error.message || 'MCP request failed');
361
+ return m.result;
362
+ }
363
+
364
+ notify(method, params) {
365
+ return this.post({ jsonrpc: '2.0', method, params }, 10_000).catch(() => null);
366
+ }
367
+
368
+ close() {
369
+ this.alive = false;
370
+ // Tell the server the session is over (it may keep state for it). Best effort.
371
+ if (this.sessionId && this.target) {
372
+ fetch(this.target.url, { method: 'DELETE', headers: { ...this.target.headers, 'mcp-session-id': this.sessionId }, signal: AbortSignal.timeout(2000) }).catch(() => {});
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * The older HTTP+SSE transport (MCP 2024-11-05): a GET opens an event stream whose first
379
+ * event names where to POST; answers arrive on the stream. The stream's socket does not
380
+ * hold the process open (a finished one-shot run still exits).
381
+ */
382
+ class SseClient {
383
+ constructor(config) {
384
+ this.config = config;
385
+ this.nextId = 1;
386
+ this.pending = new Map();
387
+ this.alive = false;
388
+ }
389
+
390
+ start() {
391
+ this.target = remoteTarget(this.config);
392
+ return new Promise((resolve, reject) => {
393
+ const u = this.target.url;
394
+ const lib = u.protocol === 'https:' ? https : http;
395
+ const giveUp = setTimeout(() => { reject(new Error('MCP server did not send its message address within 20s')); this.close(); }, 20_000);
396
+ this.req = lib.request(u, { method: 'GET', headers: { accept: 'text/event-stream', ...this.target.headers } }, (res) => {
397
+ if (res.statusCode !== 200) {
398
+ clearTimeout(giveUp);
399
+ let text = '';
400
+ res.setEncoding('utf8');
401
+ res.on('data', (d) => { text += d; });
402
+ res.on('end', () => reject(remoteError(res.statusCode, text)));
403
+ return;
404
+ }
405
+ res.socket?.unref?.();
406
+ res.setEncoding('utf8');
407
+ const feed = sseParser(({ event, data }) => {
408
+ if (event === 'endpoint') {
409
+ clearTimeout(giveUp);
410
+ try { this.postUrl = new URL(data.trim(), u); } catch { reject(new Error(`MCP server sent a bad message address: ${data}`)); return; }
411
+ this.alive = true;
412
+ this.initialize().then(() => resolve(this), (e) => { this.close(); reject(e); });
413
+ return;
414
+ }
415
+ try {
416
+ const m = JSON.parse(data);
417
+ if (m?.id !== undefined && this.pending.has(m.id)) {
418
+ const p = this.pending.get(m.id);
419
+ this.pending.delete(m.id);
420
+ if (m.error) p.reject(new Error(m.error.message || 'MCP request failed')); else p.resolve(m.result);
421
+ }
422
+ } catch { /* not JSON */ }
423
+ });
424
+ res.on('data', feed);
425
+ res.on('close', () => { this.alive = false; this.rejectAll(new Error('MCP server closed its event stream')); });
426
+ });
427
+ this.req.on('error', (e) => { clearTimeout(giveUp); this.alive = false; reject(new Error(`Could not reach MCP server at ${u.origin}: ${e.message}`)); this.rejectAll(e); });
428
+ this.req.end();
429
+ });
430
+ }
431
+
432
+ async initialize() {
433
+ await this.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'jaitus', version: '0.3' } }, 20_000);
434
+ await this.notify('notifications/initialized', {});
435
+ }
436
+
437
+ async send(message) {
438
+ const res = await fetch(this.postUrl, { method: 'POST', headers: { 'content-type': 'application/json', ...this.target.headers }, body: JSON.stringify(message), signal: AbortSignal.timeout(20_000) });
439
+ if (!res.ok) throw remoteError(res.status, await res.text().catch(() => ''));
440
+ try { await res.body?.cancel(); } catch { /* done */ }
441
+ }
442
+
443
+ request(method, params, timeoutMs = 120_000) {
444
+ const id = this.nextId++;
445
+ return new Promise((resolve, reject) => {
446
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`MCP request timed out after ${Math.round(timeoutMs / 1000)}s: ${method}`)); }, timeoutMs);
447
+ this.pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); } });
448
+ this.send({ jsonrpc: '2.0', id, method, params }).catch((e) => { const p = this.pending.get(id); if (p) { this.pending.delete(id); p.reject(e); } });
449
+ });
450
+ }
451
+
452
+ notify(method, params) { return this.send({ jsonrpc: '2.0', method, params }).catch(() => null); }
453
+
454
+ rejectAll(error) { for (const p of this.pending.values()) p.reject(error); this.pending.clear(); }
455
+
456
+ close() {
457
+ this.alive = false;
458
+ try { this.req?.destroy(); } catch { /* gone */ }
459
+ }
460
+ }
461
+
221
462
  // One live client per server for the session.
222
463
  const clients = new Map();
223
464
  function closeAll() { for (const client of clients.values()) client.close(); clients.clear(); }
@@ -226,7 +467,8 @@ process.once('exit', closeAll);
226
467
  async function clientFor(key, config, cwd) {
227
468
  const have = clients.get(key);
228
469
  if (have?.alive) return have;
229
- const fresh = new StdioClient(config, cwd);
470
+ const fresh = !isRemote(config) ? new StdioClient(config, cwd)
471
+ : String(config.type || 'http').toLowerCase() === 'sse' ? new SseClient(config) : new HttpClient(config);
230
472
  await fresh.start();
231
473
  clients.set(key, fresh);
232
474
  return fresh;
@@ -258,7 +500,7 @@ export function discoverMcpTools(cwd) {
258
500
  const key = `${path.resolve(cwd)}|${stamp(cwd)}|${JSON.stringify(loadSettings().trustedMcp?.[trustKey(cwd)] || {})}`;
259
501
  if (!discovery.has(key)) {
260
502
  discovery.set(key, (async () => {
261
- const servers = allServers(cwd).filter((s) => s.config?.command && isTrusted(cwd, s));
503
+ const servers = allServers(cwd).filter((s) => (s.config?.command || isRemote(s.config)) && isTrusted(cwd, s));
262
504
  const lists = await Promise.all(servers.map(async (server) => {
263
505
  const ckey = `${path.resolve(cwd)}|${server.scope}|${server.name}|${fingerprint(server.config)}`;
264
506
  try {
package/src/project.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // starts from the code, which is what it did before these existed.
12
12
 
13
13
  import fs from 'node:fs';
14
+ import os from 'node:os';
14
15
  import path from 'node:path';
15
16
 
16
17
  export const CONTEXT_FILE = 'JAITUS.md';
@@ -46,6 +47,38 @@ npm test
46
47
  `;
47
48
 
48
49
  /** The project briefing, or null. Truncated rather than refused if someone writes an essay. */
50
+ /**
51
+ * Personal notes: ~/.jaitus/JAITUS.md, sent with EVERY project — how you like to work,
52
+ * wherever you work ("answer briefly", "I use pnpm", "never push without asking"). The
53
+ * project's own JAITUS.md is the other half; the two are labelled apart in the prompt.
54
+ */
55
+ export function userContextFile() {
56
+ return path.join(os.homedir(), '.jaitus', CONTEXT_FILE); // the same folder as config.HOME
57
+ }
58
+
59
+ export function loadUserContext() {
60
+ return readNotes(userContextFile(), '~/.jaitus/JAITUS.md');
61
+ }
62
+
63
+ /** Append one line to a notes file, creating it (and its folder) with a header. */
64
+ export function appendNote(file, note, header) {
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ if (!fs.existsSync(file)) fs.writeFileSync(file, header, 'utf8');
67
+ const text = fs.readFileSync(file, 'utf8');
68
+ fs.appendFileSync(file, `${text.endsWith('\n') || !text ? '' : '\n'}- ${String(note).replace(/[\r\n]+/g, ' ').trim()}\n`, 'utf8');
69
+ }
70
+
71
+ export const USER_CONTEXT_HEADER = `# My notes for Jaitus\n\nSent with every project. Keep it short and true — how you like to work, wherever you work.\n\n`;
72
+ export const PROJECT_CONTEXT_HEADER = `# ${CONTEXT_FILE}\n\nNotes for the Jaitus agent working in this directory.\n\n`;
73
+
74
+ function readNotes(file, name) {
75
+ let text;
76
+ try { text = fs.readFileSync(file, 'utf8').trim(); } catch { return null; }
77
+ if (!text) return null;
78
+ if (text.length > MAX_CONTEXT) text = `${text.slice(0, MAX_CONTEXT)}\n\n[${name} truncated here — it is longer than the agent will read]`;
79
+ return text;
80
+ }
81
+
49
82
  export function loadContext(cwd) {
50
83
  const file = path.join(cwd, CONTEXT_FILE);
51
84
  let text;