jaitus 0.3.0

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.
Files changed (55) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +248 -0
  3. package/bin/jaitus.js +60 -0
  4. package/package.json +46 -0
  5. package/src/agent/loop.js +548 -0
  6. package/src/agent/permissions.js +218 -0
  7. package/src/agent/rules.js +190 -0
  8. package/src/agent/tools.js +923 -0
  9. package/src/agent/websearch.js +522 -0
  10. package/src/api.js +72 -0
  11. package/src/background.js +85 -0
  12. package/src/brand.js +232 -0
  13. package/src/commands/code-review.js +8 -0
  14. package/src/commands/commit.js +73 -0
  15. package/src/commands/completion.js +68 -0
  16. package/src/commands/doctor.js +185 -0
  17. package/src/commands/explain.js +33 -0
  18. package/src/commands/init.js +62 -0
  19. package/src/commands/login.js +201 -0
  20. package/src/commands/logout.js +25 -0
  21. package/src/commands/research.js +60 -0
  22. package/src/commands/review.js +80 -0
  23. package/src/commands/security-review.js +8 -0
  24. package/src/commands/simplify.js +8 -0
  25. package/src/commands/test.js +68 -0
  26. package/src/commands/update.js +97 -0
  27. package/src/config.js +95 -0
  28. package/src/diff.js +129 -0
  29. package/src/errors.js +101 -0
  30. package/src/hooks.js +55 -0
  31. package/src/index.js +2503 -0
  32. package/src/ledger.js +73 -0
  33. package/src/logo-data.js +33 -0
  34. package/src/mcp.js +160 -0
  35. package/src/open.js +50 -0
  36. package/src/project.js +0 -0
  37. package/src/rewind.js +130 -0
  38. package/src/session.js +207 -0
  39. package/src/sessions.js +179 -0
  40. package/src/sixel.js +131 -0
  41. package/src/skills.js +62 -0
  42. package/src/term-sim.js +103 -0
  43. package/src/terminal.js +90 -0
  44. package/src/tui/ansi.js +287 -0
  45. package/src/tui/entries.js +366 -0
  46. package/src/tui/highlight.js +211 -0
  47. package/src/tui/logo.js +93 -0
  48. package/src/tui/markdown.js +280 -0
  49. package/src/tui/mouse.js +125 -0
  50. package/src/tui/theme.js +201 -0
  51. package/src/tui/widgets.js +226 -0
  52. package/src/tui/wordmark.js +135 -0
  53. package/src/tui-ink.js +1927 -0
  54. package/src/tui.js +2555 -0
  55. package/src/ui.js +562 -0
@@ -0,0 +1,218 @@
1
+ // ─── Asking before acting ───────────────────────────────────────────────────────
2
+ //
3
+ // The agent can change files and run commands. Whether that is fine or catastrophic
4
+ // depends entirely on what it decided to do, and it decided in a language model.
5
+ //
6
+ // Four permission modes, cycled from the prompt with Shift+Tab:
7
+ //
8
+ // default ask about every write and every command (the start)
9
+ // acceptEdits write_file and edit_file run without asking; commands still ask
10
+ // plan nothing runs at all — the agent reads, thinks, and proposes a plan
11
+ // auto nothing asks; for CI and people who enjoy living dangerously
12
+ //
13
+ // "Allow for this session" still exists inside default, because being asked forty
14
+ // times in a row trains people to hit `a` without reading, which is worse than not
15
+ // asking at all.
16
+
17
+ import path from 'node:path';
18
+ import { c, out, askKey, glyph } from '../ui.js';
19
+ import { decide, ruleForCall, broaderRule, DEFAULT_DENY, subjectOf } from './rules.js';
20
+
21
+ export class PermissionDenied extends Error {}
22
+
23
+ export const MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
24
+
25
+ /** opencode permission icons per tool: shell #, edits ←, reads →, search ✱. */
26
+ const TOOL_ICONS = {
27
+ run_command: '#', write_file: '←', edit_file: '←',
28
+ read_file: '→', read_image: '→', list_files: '→',
29
+ search: '✱', web_fetch: '%', web_search: '◈',
30
+ background_run: '◉', todo_write: '⚙',
31
+ };
32
+
33
+ export function createPermissions({
34
+ autoApprove = false, mode = 'default', onModeChange = null,
35
+ allow = [], deny = [], onRulesChange = null,
36
+ } = {}) {
37
+ let current = autoApprove ? 'bypassPermissions' : (MODES.includes(mode) ? mode : mode === 'auto' ? 'bypassPermissions' : 'default');
38
+ // Session rules live beside the saved ones and are never written to disk: "allow for
39
+ // this session" has to mean this session, or the word is a lie.
40
+ const savedAllow = [...allow];
41
+ const savedDeny = [...DEFAULT_DENY, ...deny];
42
+ const sessionAllow = [];
43
+
44
+ const api = {
45
+ get mode() { return current; },
46
+ get rules() { return { allow: [...savedAllow], deny: [...savedDeny], session: [...sessionAllow] }; },
47
+
48
+ /** Add a rule. `persist` writes it to settings via onRulesChange. */
49
+ addRule(rule, { persist = false, kind = 'allow' } = {}) {
50
+ const list = kind === 'deny' ? savedDeny : (persist ? savedAllow : sessionAllow);
51
+ if (!list.includes(rule)) list.push(rule);
52
+ if (persist) onRulesChange?.({ allow: savedAllow, deny: savedDeny.filter((r) => !DEFAULT_DENY.includes(r)) });
53
+ return rule;
54
+ },
55
+
56
+ removeRule(rule) {
57
+ for (const list of [savedAllow, savedDeny, sessionAllow]) {
58
+ const at = list.indexOf(rule);
59
+ if (at >= 0) list.splice(at, 1);
60
+ }
61
+ onRulesChange?.({ allow: savedAllow, deny: savedDeny.filter((r) => !DEFAULT_DENY.includes(r)) });
62
+ },
63
+
64
+ /** What the rules say about this call, without asking. */
65
+ verdict(toolName, input) {
66
+ return decide({ allow: [...savedAllow, ...sessionAllow], deny: savedDeny }, toolName, input);
67
+ },
68
+
69
+ setMode(next) {
70
+ if (!MODES.includes(next)) return;
71
+ current = next;
72
+ onModeChange?.(current);
73
+ },
74
+
75
+ /** Shift+Tab: default → acceptEdits → plan → auto → default. */
76
+ cycleMode() {
77
+ const next = MODES[(MODES.indexOf(current) + 1) % MODES.length];
78
+ api.setMode(next);
79
+ return current;
80
+ },
81
+
82
+ /**
83
+ * @returns true to proceed. Throws PermissionDenied if the user says no.
84
+ */
85
+ async request(tool, input, { cwd, tui = null } = {}) {
86
+ // A deny rule outranks everything, including bypass. Someone who wrote
87
+ // `deny: ["run_command(git push:*)"]` and then turned on bypass to get through a
88
+ // long refactor did not mean "except that one".
89
+ const verdict = api.verdict(tool.name, input);
90
+ if (verdict === 'deny') {
91
+ throw new PermissionDenied(
92
+ `A deny rule blocks ${tool.name}${subjectOf(tool.name, input) ? `: ${subjectOf(tool.name, input).slice(0, 80)}` : ''}. `
93
+ + 'Change it with /permissions if that is wrong.'
94
+ );
95
+ }
96
+ if (!tool.needsPermission) return true;
97
+ if (verdict === 'allow') return true;
98
+ if (current === 'bypassPermissions') return true;
99
+ if (current === 'acceptEdits' && (tool.name === 'write_file' || tool.name === 'edit_file')) return true;
100
+
101
+ // Not a terminal — a script cannot answer, and silently proceeding would be the
102
+ // worst possible reading of "no answer". In plan mode the same wall is the point.
103
+ if (!process.stdin.isTTY) {
104
+ throw new PermissionDenied(
105
+ current === 'plan'
106
+ ? 'Plan mode blocks every write and command. Shift+Tab out of plan mode, or run it with a mode that acts.'
107
+ : `${tool.name} needs permission, but there is no terminal to ask. Re-run with --yes to allow it.`
108
+ );
109
+ }
110
+
111
+ // Inside the TUI the ask is an inline panel pinned above the prompt
112
+ // (opencode shape), with the preview inside it; outside it stays
113
+ // inline below the last output.
114
+ // The scope of "always" is spelled out in the option itself. Previously it read
115
+ // "Allow always" and meant the whole tool, so approving `npm test` also approved
116
+ // every future shell command — including the one you would never have allowed.
117
+ const exact = ruleForCall(tool.name, input, cwd);
118
+ const broad = broaderRule(tool.name, input, cwd);
119
+
120
+ if (tui) {
121
+ tui.bellIfAway?.();
122
+ const previewText = previewTextOf(tool, input);
123
+ const options = [
124
+ { label: 'Allow once', value: 'y' },
125
+ { label: `Allow ${describeRule(exact)} for this session`, value: 'session' },
126
+ { label: `Always allow ${describeRule(exact)}`, value: 'always' },
127
+ ];
128
+ if (broad !== exact) options.push({ label: `Always allow ${describeRule(broad)}`, value: 'broad' });
129
+ options.push({ label: 'Reject', value: 'deny' });
130
+
131
+ const answer = await tui.askDialog(describe(tool, input, cwd), options, previewText, {
132
+ icon: TOOL_ICONS[tool.name] || '⚙',
133
+ pills: true,
134
+ title: 'Permission',
135
+ tone: tool.name === 'run_command' ? 'danger' : undefined,
136
+ });
137
+ const value = answer && typeof answer === 'object' ? answer.value : answer;
138
+ const comment = (answer?.comment || '').trim();
139
+ if (value === 'session') { api.addRule(exact); return true; }
140
+ if (value === 'always') { api.addRule(exact, { persist: true }); return true; }
141
+ if (value === 'broad') { api.addRule(broad, { persist: true }); return true; }
142
+ if (value === 'y') return true;
143
+ const err = new PermissionDenied(`You declined: ${tool.name}`);
144
+ if (comment) err.comment = comment;
145
+ throw err;
146
+ }
147
+
148
+ out(` ${c.permission(glyph('△', '!'))} ${c.bold(describe(tool, input, cwd))}`);
149
+ preview(tool, input);
150
+ out('');
151
+
152
+ out(` ${c.dim(`always → adds the rule ${c.bold(exact)}`)}`);
153
+ const answer = await askKey(
154
+ ` ${c.dim('[y] Allow once [s] this session [a] always [n] Reject ›')} `,
155
+ ['y', 's', 'a', 'n', '\r']
156
+ );
157
+ out('');
158
+
159
+ if (answer === 's') { api.addRule(exact); return true; }
160
+ if (answer === 'a') { api.addRule(exact, { persist: true }); return true; }
161
+ if (answer === 'y' || answer === '\r') return true;
162
+ throw new PermissionDenied(`You declined: ${tool.name}`);
163
+ },
164
+ };
165
+
166
+ return api;
167
+ }
168
+
169
+ /** `run_command(npm test:*)` → `npm test *`, which is what the rule means out loud. */
170
+ function describeRule(rule) {
171
+ const m = /^([a-z_]+)\((.*)\)$/i.exec(rule);
172
+ if (!m) return `every ${String(rule).replace(/_/g, ' ')}`;
173
+ return m[2].endsWith(':*') ? `${m[2].slice(0, -2)} …` : m[2];
174
+ }
175
+
176
+ /** The whole tool call as plain lines, for the TUI permission dialog. */
177
+ function previewTextOf(tool, input) {
178
+ const lines = [];
179
+ if (tool.name === 'run_command') lines.push(`$ ${input.command}`);
180
+ else if (tool.name === 'write_file') lines.push(...String(input.content ?? '').split('\n').slice(0, 12));
181
+ else if (tool.name === 'edit_file') {
182
+ for (const l of String(input.old ?? '').split('\n').slice(0, 6)) lines.push(`- ${l}`);
183
+ for (const l of String(input.new ?? '').split('\n').slice(0, 6)) lines.push(`+ ${l}`);
184
+ } else lines.push(JSON.stringify(input).slice(0, 200));
185
+ return lines.slice(0, 12);
186
+ }
187
+
188
+ function describe(tool, input, cwd) {
189
+ switch (tool.name) {
190
+ case 'write_file': return `Write ${path.relative(cwd, path.resolve(cwd, input.path)) || input.path}`;
191
+ case 'edit_file': return `Edit ${path.relative(cwd, path.resolve(cwd, input.path)) || input.path}`;
192
+ case 'run_command': return `Run a command`;
193
+ default: return `${tool.name}`;
194
+ }
195
+ }
196
+
197
+ /** Show enough that the answer is informed, but not so much it scrolls away. */
198
+ function preview(tool, input) {
199
+ const show = (label, text, limit = 14) => {
200
+ const lines = String(text ?? '').split('\n');
201
+ out(` ${c.dim(label)}`);
202
+ lines.slice(0, limit).forEach((l) => out(` ${c.grey(glyph('│', '|'))} ${l.slice(0, 160)}`));
203
+ if (lines.length > limit) out(` ${c.grey(glyph('│', '|'))} ${c.dim(`${glyph('…', '...')} ${lines.length - limit} more lines`)}`);
204
+ };
205
+
206
+ if (tool.name === 'run_command') {
207
+ out(` ${c.grey('$')} ${c.bold(input.command)}`);
208
+ return;
209
+ }
210
+ if (tool.name === 'write_file') {
211
+ show('new contents', input.content);
212
+ return;
213
+ }
214
+ if (tool.name === 'edit_file') {
215
+ String(input.old ?? '').split('\n').slice(0, 10).forEach((l) => out(` ${c.red('-')} ${l.slice(0, 160)}`));
216
+ String(input.new ?? '').split('\n').slice(0, 10).forEach((l) => out(` ${c.green('+')} ${l.slice(0, 160)}`));
217
+ }
218
+ }
@@ -0,0 +1,190 @@
1
+ // ─── Permission rules ───────────────────────────────────────────────────────────
2
+ //
3
+ // "Allow always" used to add the TOOL to a set. Answering it once for `npm test`
4
+ // therefore also approved `rm -rf /`, `curl | sh`, and every other command for the
5
+ // rest of the session — from a prompt whose preview showed only the harmless one. It
6
+ // is the most dangerous kind of bug: the safe answer and the catastrophic answer are
7
+ // the same keystroke, and nothing on screen distinguishes them.
8
+ //
9
+ // A rule names the tool AND what it may be used on:
10
+ //
11
+ // run_command(npm test:*) that command and its arguments
12
+ // run_command(git status) exactly that, nothing else
13
+ // edit_file(src/**) any path under src
14
+ // web_fetch(https://docs.*) any URL matching the glob
15
+ // read_file the whole tool, for the genuinely harmless ones
16
+ //
17
+ // Deny wins over allow, always, at any specificity. A deny rule is the only mechanism
18
+ // here that someone would rely on to stop something dangerous, and a precedence order
19
+ // that lets a broad allow override a narrow deny would make it worthless.
20
+
21
+ import path from 'node:path';
22
+
23
+ /** The argument a rule matches against, per tool. */
24
+ export function subjectOf(toolName, input = {}) {
25
+ switch (toolName) {
26
+ case 'run_command':
27
+ case 'background_run':
28
+ return String(input.command ?? '');
29
+ case 'write_file':
30
+ case 'edit_file':
31
+ case 'read_file':
32
+ case 'read_image':
33
+ case 'list_files':
34
+ return normalise(input.path);
35
+ case 'web_fetch':
36
+ return String(input.url ?? '');
37
+ case 'web_search':
38
+ return String(input.query ?? '');
39
+ default:
40
+ return '';
41
+ }
42
+ }
43
+
44
+ const normalise = (value) => String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
45
+
46
+ /** `tool(pattern)` or `tool`. Returns null for anything unparseable. */
47
+ export function parseRule(text) {
48
+ const raw = String(text ?? '').trim();
49
+ if (!raw) return null;
50
+ const m = /^([a-z_][\w]*)\s*(?:\(([\s\S]*)\))?$/i.exec(raw);
51
+ if (!m) return null;
52
+ return { tool: m[1], pattern: m[2] === undefined ? null : m[2].trim(), source: raw };
53
+ }
54
+
55
+ /**
56
+ * Glob match, with one extension that matters more than the rest of the syntax
57
+ * combined: a trailing `:*` means "this command and any arguments after it". Without
58
+ * it, approving `npm test` would not cover `npm test -- --watch`, and people would
59
+ * write `npm*` instead — which also matches `npm publish`.
60
+ */
61
+ export function matches(pattern, subject) {
62
+ if (pattern === null || pattern === '') return true;
63
+ const value = String(subject ?? '');
64
+
65
+ if (pattern.endsWith(':*')) {
66
+ const prefix = pattern.slice(0, -2).trim();
67
+ if (!value.startsWith(prefix)) return false;
68
+ // A prefix must end at a word boundary: `git pu:*` must not match `git push`
69
+ // by accident of spelling, but `git push:*` must match `git push --force`.
70
+ const rest = value.slice(prefix.length);
71
+ return rest === '' || /^[\s\-]/.test(rest);
72
+ }
73
+
74
+ return globToRegExp(pattern).test(value);
75
+ }
76
+
77
+ const cache = new Map();
78
+ function globToRegExp(pattern) {
79
+ if (cache.has(pattern)) return cache.get(pattern);
80
+ let out = '';
81
+ for (let i = 0; i < pattern.length; i++) {
82
+ const ch = pattern[i];
83
+ if (ch === '*') {
84
+ // `**` crosses directory separators; a single `*` does not, so `edit_file(src/*)`
85
+ // covers src/a.js and not src/deep/a.js — which is what someone writing it means.
86
+ if (pattern[i + 1] === '*') { out += '.*'; i += 1; if (pattern[i + 1] === '/') i += 1; }
87
+ else out += '[^/]*';
88
+ } else if (ch === '?') out += '[^/]';
89
+ else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
90
+ }
91
+ const re = new RegExp(`^${out}$`, 'i');
92
+ cache.set(pattern, re);
93
+ return re;
94
+ }
95
+
96
+ /**
97
+ * Decide from a rule set. Returns 'deny', 'allow' or 'ask'.
98
+ *
99
+ * Both lists are scanned in full even after a match, because a deny anywhere has to
100
+ * beat an allow everywhere and short-circuiting on the first allow would lose that.
101
+ */
102
+ export function decide({ allow = [], deny = [] }, toolName, input) {
103
+ const subject = subjectOf(toolName, input);
104
+ for (const rule of deny) {
105
+ const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
106
+ if (parsed && parsed.tool === toolName && matches(parsed.pattern, subject)) return 'deny';
107
+ }
108
+ for (const rule of allow) {
109
+ const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
110
+ if (parsed && parsed.tool === toolName && matches(parsed.pattern, subject)) return 'allow';
111
+ }
112
+ return 'ask';
113
+ }
114
+
115
+ /**
116
+ * The rule "Allow always" should create for this exact call.
117
+ *
118
+ * Narrow on purpose. For a shell command it is the executable and its first
119
+ * subcommand — `npm test:*`, `git status:*` — because that is the unit a person
120
+ * actually means when they say "yes, this is fine". For a path it is the file, not its
121
+ * directory: approving one edit is not approving the tree.
122
+ */
123
+ export function ruleForCall(toolName, input, cwd) {
124
+ const subject = subjectOf(toolName, input);
125
+ if (!subject) return `${toolName}`;
126
+
127
+ if (toolName === 'run_command' || toolName === 'background_run') {
128
+ const words = subject.trim().split(/\s+/);
129
+ // A command with a shell operator in it cannot be generalised safely: `npm test &&
130
+ // rm -rf .` would become a rule that approves the rm. Pin those exactly.
131
+ if (/[;&|>`$(){}]/.test(subject)) return `${toolName}(${subject})`;
132
+ const head = words.slice(0, words[1] && !words[1].startsWith('-') ? 2 : 1).join(' ');
133
+ return `${toolName}(${head}:*)`;
134
+ }
135
+
136
+ if (toolName === 'web_fetch') {
137
+ try {
138
+ const url = new URL(subject);
139
+ return `${toolName}(${url.protocol}//${url.host}/**)`;
140
+ } catch { return `${toolName}(${subject})`; }
141
+ }
142
+
143
+ const relative = cwd ? normalise(path.relative(cwd, path.resolve(cwd, subject))) : subject;
144
+ return `${toolName}(${relative && !relative.startsWith('..') ? relative : subject})`;
145
+ }
146
+
147
+ /** A broader rule, offered beside the exact one: the whole directory, or the tool. */
148
+ export function broaderRule(toolName, input, cwd) {
149
+ const subject = subjectOf(toolName, input);
150
+ if (['write_file', 'edit_file', 'read_file', 'read_image', 'list_files'].includes(toolName) && subject) {
151
+ const relative = cwd ? normalise(path.relative(cwd, path.resolve(cwd, subject))) : subject;
152
+ const dir = relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : '';
153
+ return dir && !dir.startsWith('..') ? `${toolName}(${dir}/**)` : `${toolName}`;
154
+ }
155
+ return `${toolName}`;
156
+ }
157
+
158
+ /**
159
+ * Rules that ship on by default.
160
+ *
161
+ * Deny-only. Nothing is pre-approved, because a default allow-list is a promise about
162
+ * someone else's repository that this code is in no position to make — but these are
163
+ * destructive in a way no plausible task needs, and a model that has talked itself into
164
+ * one of them should be stopped rather than asked.
165
+ *
166
+ * This is a tripwire, not a sandbox. It catches the handful of literal spellings that
167
+ * have destroyed real machines; it does not and cannot catch an equivalent written any
168
+ * other way, and nothing here should be read as making `bypassPermissions` safe.
169
+ */
170
+ export const DEFAULT_DENY = [
171
+ 'run_command(**rm -rf /)',
172
+ 'run_command(**rm -rf / **)',
173
+ 'run_command(**rm -rf /*)',
174
+ 'run_command(**mkfs**)',
175
+ 'run_command(**:(){ :|:& };:**)',
176
+ 'run_command(**of=/dev/sd**)',
177
+ 'run_command(**> /dev/sd**)',
178
+ ];
179
+
180
+ /** Validate a list of rule strings, returning { rules, bad }. */
181
+ export function normaliseRules(list) {
182
+ const rules = [];
183
+ const bad = [];
184
+ for (const entry of Array.isArray(list) ? list : []) {
185
+ const parsed = parseRule(entry);
186
+ if (parsed) rules.push(parsed.source);
187
+ else bad.push(String(entry));
188
+ }
189
+ return { rules, bad };
190
+ }