jaitus 0.3.7 → 0.3.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Jaitus — a coding agent in your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent/loop.js CHANGED
@@ -31,7 +31,7 @@ import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
31
31
  import { c, out, write, spinner, glyph } from '../ui.js';
32
32
  import { discoverMcpTools } from '../mcp.js';
33
33
  import { unifiedDiff } from '../diff.js';
34
- import { fromResponse, fromGatewayBody } from '../errors.js';
34
+ import { fromResponse, fromGatewayBody, JaitusError } from '../errors.js';
35
35
 
36
36
  const MAX_ITERATIONS = 1000;
37
37
  const STUCK_REPEATS = 4;
@@ -106,6 +106,9 @@ How to work:
106
106
  - Run the project's own tests or build after a change when one exists, and report the real result — including when it fails.
107
107
  - Use run_command for anything the shell does better: git, tests, package managers, linters.
108
108
  - Never invent a file path, a function name, or a test result. If you are unsure, look.
109
+ - Every turn re-sends the whole conversation, so make independent reads, searches and
110
+ listings together in ONE turn rather than one per turn. Read the part of a large file you
111
+ need (offset/limit) rather than all of it.
109
112
  - For current news or other web facts, report only details grounded in web_search or
110
113
  web_fetch output. Include the supplied source URLs; never fill a missing headline or
111
114
  date from memory.
@@ -241,7 +244,11 @@ export function repairToolPairs(messages) {
241
244
  // at least TRIM_AT_LEAST at a time, because each clearing breaks the prompt cache once
242
245
  // — clearing a little on every step would cost more than it saves. Small results (a
243
246
  // "file written", an exit code) are kept: they carry meaning and cost nothing.
244
- export const TRIM_AT = 100_000; // estimated tokens of conversation (the documented default)
247
+ // 100k was the documented default for 200k-window models. Here the window is up to 1M, so
248
+ // that ceiling was rarely near: production requests averaged 53k tokens and two thirds of
249
+ // all input tokens came from requests over 60k. At 40k a clearing (one cache break, ~17
250
+ // credits on glm-5.3-flash) pays for itself within about ten later requests.
251
+ export const TRIM_AT = 40_000; // estimated tokens of conversation
245
252
  export const KEEP_RECENT = 6; // latest tool results always kept whole
246
253
  export const TRIM_AT_LEAST = 12_000; // estimated tokens a clearing must free
247
254
  const TRIM_MIN_CHARS = 1_500; // results shorter than this are never cleared
@@ -418,7 +425,12 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
418
425
  if (streamError) break;
419
426
  }
420
427
  } catch (e) {
421
- if (signal?.aborted || e?.name === 'AbortError') throw e;
428
+ // Esc is the only abort that reads as an interruption. Any OTHER AbortError —
429
+ // the request timeout in api.js firing mid-body, a dropped socket — is a `timeout`
430
+ // JaitusError so the agent loop backs off and retries it (TRANSIENT_CODES) instead
431
+ // of reporting "Interrupted." for something the user never stopped.
432
+ if (signal?.aborted) throw e;
433
+ if (e?.name === 'AbortError') throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
422
434
  // The connection dropped mid-answer. What arrived is kept and continued from; with
423
435
  // nothing received yet, the caller retries the request instead.
424
436
  if (!blocks.size) throw e;
@@ -16,7 +16,7 @@
16
16
 
17
17
  import path from 'node:path';
18
18
  import { c, out, askKey, glyph } from '../ui.js';
19
- import { decide, ruleForCall, broaderRule, DEFAULT_DENY, subjectOf } from './rules.js';
19
+ import { decide, ruleForCall, broaderRule, DEFAULT_DENY, subjectOf, wideAllows } from './rules.js';
20
20
 
21
21
  export class PermissionDenied extends Error {}
22
22
 
@@ -44,6 +44,8 @@ export function createPermissions({
44
44
  const api = {
45
45
  get mode() { return current; },
46
46
  get rules() { return { allow: [...savedAllow], deny: [...savedDeny], session: [...sessionAllow] }; },
47
+ /** Saved rules that take a whole tool out of the mode's hands (see wideAllows). */
48
+ get wide() { return wideAllows(savedAllow); },
47
49
 
48
50
  /** Add a rule. `persist` writes it to settings via onRulesChange. */
49
51
  addRule(rule, { persist = false, kind = 'allow' } = {}) {
@@ -206,15 +206,39 @@ export function ruleForCall(toolName, input, cwd) {
206
206
  return `${toolName}(${relative && !relative.startsWith('..') ? relative : subject})`;
207
207
  }
208
208
 
209
- /** A broader rule, offered beside the exact one: the whole directory, or the tool. */
209
+ /**
210
+ * A broader rule, offered beside the exact one: the file's directory. Never the whole
211
+ * tool. "Always allow every run command" used to be the second option on every command
212
+ * prompt; one click on it saved a rule that approves every command in that folder in
213
+ * every mode, forever — after which Ask, Auto-edit and Bypass all behaved the same and
214
+ * nothing on screen said why. A tool-wide rule can still be written on purpose with
215
+ * `/permissions allow run_command`; it is just never one click away.
216
+ */
210
217
  export function broaderRule(toolName, input, cwd) {
211
218
  const subject = subjectOf(toolName, input);
212
219
  if (['write_file', 'edit_file', 'read_file', 'read_image', 'list_files'].includes(toolName) && subject) {
213
220
  const relative = cwd ? normalise(path.relative(cwd, path.resolve(cwd, subject))) : subject;
214
221
  const dir = relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : '';
215
- return dir && !dir.startsWith('..') ? `${toolName}(${dir}/**)` : `${toolName}`;
222
+ if (dir && !dir.startsWith('..')) return `${toolName}(${dir}/**)`;
223
+ }
224
+ return ruleForCall(toolName, input, cwd);
225
+ }
226
+
227
+ /**
228
+ * Saved allow rules that approve a whole tool — `run_command`, `edit_file(**)` — for the
229
+ * tools a mode is about. While one exists, the mode no longer decides that tool, so the
230
+ * mode's label is only half the truth; callers say so beside it.
231
+ */
232
+ export function wideAllows(allow = []) {
233
+ const GATED = ['run_command', 'background_run', 'write_file', 'edit_file'];
234
+ const out = [];
235
+ for (const rule of allow) {
236
+ const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
237
+ if (!parsed || !GATED.includes(parsed.tool)) continue;
238
+ const p = parsed.pattern;
239
+ if (p === null || p === '' || p === '*' || p === '**' || p === '**/*') out.push(parsed.source);
216
240
  }
217
- return `${toolName}`;
241
+ return out;
218
242
  }
219
243
 
220
244
  /**
@@ -26,6 +26,18 @@ const MAX_READ_LINES = 2_000;
26
26
 
27
27
  export class ToolError extends Error {}
28
28
 
29
+ /**
30
+ * Keep the start AND the end. For a test run or a build the end is where the failures and
31
+ * the summary are; cutting from the front kept the noise and threw away the answer.
32
+ */
33
+ function truncateMiddle(text, limit) {
34
+ const s = String(text ?? '');
35
+ if (s.length <= limit) return s;
36
+ const head = Math.floor(limit / 3);
37
+ const tail = limit - head;
38
+ return `${s.slice(0, head)}\n\n[… ${s.length - limit} characters cut from the middle …]\n\n${s.slice(-tail)}`;
39
+ }
40
+
29
41
  function truncate(text, limit = MAX_OUTPUT) {
30
42
  const s = String(text ?? '');
31
43
  if (s.length <= limit) return s;
@@ -560,8 +572,10 @@ export const TOOLS = [
560
572
  // the joined text cut the exit code off the end of any long output, so the
561
573
  // model could not tell whether the command had worked.
562
574
  const parts = [];
563
- const stdout = truncate(out.text().trim(), 40_000);
564
- const stderr = truncate(err.text().trim(), 16_000);
575
+ // 30k in all (the common agent limit for shell output): every later request
576
+ // re-sends it until it is cleared.
577
+ const stdout = truncateMiddle(out.text().trim(), 22_000);
578
+ const stderr = truncateMiddle(err.text().trim(), 8_000);
565
579
  if (stdout) parts.push(stdout);
566
580
  if (stderr) parts.push(`stderr:\n${stderr}`);
567
581
  if (note) parts.push(note);
@@ -832,6 +846,29 @@ export const TOOLS = [
832
846
  return `Stopped ${input.id}.`;
833
847
  },
834
848
  },
849
+
850
+ {
851
+ // The model loads a skill's instructions itself when a task matches one. Only each
852
+ // skill's one-line description rides along with every request; the body (a few
853
+ // thousand tokens) is paid for only in conversations that need it.
854
+ name: 'load_skill',
855
+ description: 'Load the full instructions of a skill listed under "Skills" in the system prompt. Call it BEFORE starting a task that matches a skill\'s description, then follow what it says.',
856
+ needsPermission: false,
857
+ input_schema: {
858
+ type: 'object',
859
+ properties: { name: { type: 'string', description: 'The skill name, e.g. frontend-design.' } },
860
+ required: ['name'],
861
+ },
862
+ summarise: (i) => `skill ${i.name}`,
863
+ async run(input, { cwd }) {
864
+ const { discoverSkills } = await import('../skills.js');
865
+ const skills = discoverSkills(cwd || process.cwd());
866
+ const name = String(input.name || '').replace(/^\//, '').trim();
867
+ const skill = skills[name];
868
+ if (!skill) throw new ToolError(`No skill named ${name}. Available: ${Object.keys(skills).join(', ') || 'none'}.`);
869
+ return `Skill: ${skill.name}\n\n${skill.content}`;
870
+ },
871
+ },
835
872
  ];
836
873
 
837
874
  /** The live task list todo_write maintains; the session renders it in the panel. */
package/src/api.js CHANGED
@@ -10,6 +10,47 @@ import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
10
10
 
11
11
  export class NotSignedIn extends Error {}
12
12
 
13
+ // fetch() has no timeout of its own, and the gateway's worst case is minutes of
14
+ // serial failover across provider keys — so a sick window used to pin the CLI on
15
+ // "Working..." forever. Every call below carries its own ceiling: slow becomes a
16
+ // `timeout` JaitusError (retryable by the agent loop, shown with a request id)
17
+ // instead of eternity. The caller's signal (Esc) still wins: it aborts through the
18
+ // same controller, and only a genuine user abort reads as one.
19
+ const MESSAGES_TIMEOUT_MS = 10 * 60 * 1000; // streamed turns can legitimately run minutes
20
+ const API_TIMEOUT_MS = 30 * 1000; // dashboard calls are small JSON
21
+
22
+ // Combine an optional caller signal with a timeout into one signal.
23
+ // Manual combine (not AbortSignal.any): .any needs Node 20.3+, this works everywhere.
24
+ function withTimeout(signal, ms) {
25
+ const ctrl = new AbortController();
26
+ let timedOut = false;
27
+ const timer = setTimeout(() => {
28
+ timedOut = true;
29
+ try { ctrl.abort(new DOMException('Request timed out', 'TimeoutError')); }
30
+ catch { try { ctrl.abort(); } catch { /* already gone */ } }
31
+ }, ms);
32
+ if (timer.unref) timer.unref();
33
+ const onAbort = () => {
34
+ clearTimeout(timer);
35
+ try { ctrl.abort(signal.reason); } catch { try { ctrl.abort(); } catch { /* already gone */ } }
36
+ };
37
+ if (signal) {
38
+ if (signal.aborted) onAbort();
39
+ else signal.addEventListener('abort', onAbort, { once: true });
40
+ }
41
+ return {
42
+ signal: ctrl.signal,
43
+ // True only when OUR timer fired. A user abort also lands here as an AbortError,
44
+ // but signal.aborted is set then — never mistake Esc for a timeout.
45
+ timedOut: () => timedOut && !signal?.aborted,
46
+ cleanup: () => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); },
47
+ };
48
+ }
49
+
50
+ function timeoutError() {
51
+ return new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
52
+ }
53
+
13
54
  // The context window the gateway last advertised, or null before it has been asked.
14
55
  let contextWindowSeen = null;
15
56
  export const advertisedContextWindow = () => contextWindowSeen;
@@ -18,7 +59,9 @@ export function createClient() {
18
59
  const { baseUrl } = endpoints();
19
60
  if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
20
61
 
21
- const base = { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli' };
62
+ // x-jaitus-features: what this build understands, so the gateway can use newer error
63
+ // codes (a used-up usage window) without an older CLI reading them as "went wrong".
64
+ const base = { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli', 'x-jaitus-features': 'usage-window' };
22
65
  const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
23
66
 
24
67
  return {
@@ -26,45 +69,63 @@ export function createClient() {
26
69
 
27
70
  /** POST /v1/messages. Returns the raw Response so the caller can stream it. */
28
71
  async messages(body, { signal } = {}) {
72
+ // The timer is deliberately NOT cleared when headers arrive: a stall can happen
73
+ // mid-body too (headers fast, tokens never come), and only the stream reader ends
74
+ // that. Aborting a finished request is harmless; the timer never holds the
75
+ // process open (unref). 10 minutes: streamed agentic turns legitimately run long.
76
+ const t = withTimeout(signal, MESSAGES_TIMEOUT_MS);
29
77
  try {
30
78
  return await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
31
79
  method: 'POST',
32
80
  headers: headers(token),
33
81
  body: JSON.stringify(body),
34
- signal,
82
+ signal: t.signal,
35
83
  }));
36
84
  } catch (cause) {
37
- if (cause?.name === 'JaitusError' || cause?.name === 'AbortError') throw cause;
85
+ if (cause?.name === 'JaitusError') throw cause;
86
+ if (t.timedOut()) throw timeoutError();
87
+ if (cause?.name === 'AbortError') throw cause;
38
88
  throw fromNetwork(cause);
39
89
  }
40
90
  },
41
91
 
42
92
  /** A dashboard API call, returning parsed JSON. */
43
93
  async api(pathname, init = {}) {
94
+ const t = withTimeout(init.signal, API_TIMEOUT_MS);
44
95
  let res;
45
96
  try {
46
97
  res = await withAuth((token) => fetch(`${baseUrl}/api${pathname}`, {
47
98
  ...init,
48
99
  headers: { ...headers(token), ...(init.headers || {}) },
100
+ signal: t.signal,
49
101
  }));
102
+ const body = await res.json().catch(() => ({}));
103
+ return { ok: res.ok, status: res.status, body };
50
104
  } catch (cause) {
51
105
  if (cause?.name === 'JaitusError') throw cause;
106
+ if (t.timedOut()) throw timeoutError();
107
+ if (cause?.name === 'AbortError') throw cause;
52
108
  throw fromNetwork(cause);
109
+ } finally {
110
+ t.cleanup();
53
111
  }
54
- const body = await res.json().catch(() => ({}));
55
- return { ok: res.ok, status: res.status, body };
56
112
  },
57
113
 
58
114
  /** Model ids the gateway advertises. */
59
115
  async models() {
116
+ const t = withTimeout(undefined, API_TIMEOUT_MS);
60
117
  let res;
61
118
  try {
62
- res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token) }));
119
+ res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token), signal: t.signal }));
63
120
  } catch (cause) {
121
+ t.cleanup();
64
122
  if (cause?.name === 'JaitusError') throw cause;
123
+ if (t.timedOut()) throw timeoutError();
124
+ if (cause?.name === 'AbortError') throw cause;
65
125
  throw fromNetwork(cause);
66
126
  }
67
127
  const body = await res.json().catch(() => ({}));
128
+ t.cleanup();
68
129
  if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
69
130
  const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
70
131
  // How much conversation fits, as the gateway computed it for this account.
package/src/errors.js CHANGED
@@ -18,7 +18,8 @@ const CATALOG = {
18
18
  rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
19
19
  context_too_long: { title: 'Conversation too long', hint: 'Run /compact, or start a new session.' },
20
20
  request_too_large: { title: 'Request too large', hint: 'Usually images: remove some, or run /compact to drop old ones. Images over 3.5 MB are shrunk automatically.' },
21
- daily_limit_reached: { title: 'Daily limit reached', hint: 'Today\'s credits reset at midnight. /usage shows where you are.' },
21
+ daily_limit_reached: { title: 'Usage limit reached', hint: 'It refills automatically. /usage shows where you are.' },
22
+ window_limit_reached: { title: 'This usage window is used up', hint: 'It refills when the window ends — the rest of your month is still there. /usage shows when.' },
22
23
  token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
23
24
  refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
24
25
  content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  endpoints, version, HOME, HISTORY,
18
18
  } from './config.js';
19
19
  import { runAgent, repairToolPairs } from './agent/loop.js';
20
- import { createPermissions } from './agent/permissions.js';
20
+ import { createPermissions, MODES as PERMISSION_MODES } from './agent/permissions.js';
21
21
  import { DEFAULT_DENY } from './agent/rules.js';
22
22
  import { forgetIgnoreCache, imageBlock, IMAGE_MIME } from './agent/tools.js';
23
23
  import { shrinkImageFile } from './tui/clipboard.js';
@@ -372,6 +372,11 @@ async function loadContextBudget(client) {
372
372
  // Compact at this fraction of the budget. Low enough that the summarisation request
373
373
  // itself still fits comfortably, high enough that a normal session never sees it.
374
374
  const AUTO_COMPACT_AT = 0.78;
375
+ // The budget is what the gateway advertises — the providers' limit, or the admin's
376
+ // Context cap when it is lower (Admin → Settings → Plans & credits). That cap is the ONE
377
+ // control for how big a session gets before it is summarised: a second ceiling here
378
+ // would silently override whatever the admin set.
379
+ const compactThreshold = () => CONTEXT_BUDGET * AUTO_COMPACT_AT;
375
380
 
376
381
  // An expanded @mention is capped: pulling a 4 MB lockfile into the prompt because
377
382
  // someone typed `@package-lock.json` would blow the context it is meant to save.
@@ -421,8 +426,20 @@ async function summarise(client, model, messages, focus) {
421
426
  messages: [
422
427
  {
423
428
  role: 'user',
424
- content: 'Summarize this conversation compactly for a successor agent: what was asked, '
425
- + 'what was done (files changed, commands run, outcomes), what remains. Be specific and terse.'
429
+ // Sections a successor needs to carry on without re-asking or re-reading — the
430
+ // same structure agent tools use for their own compaction. Losing the user's
431
+ // exact instructions or where the work stopped is what makes a compacted session
432
+ // feel worse; everything else can be re-read from disk.
433
+ content: 'Summarize this conversation for a successor agent that will continue the work with no other memory of it. '
434
+ + 'Use these headings, and be specific (paths, names, numbers) rather than general:\n'
435
+ + '1. Request — what the user asked for, with their exact words for any instruction, constraint or preference.\n'
436
+ + '2. Corrections — anything the user rejected, corrected or asked to do differently, verbatim where short.\n'
437
+ + '3. Files — every file read or changed: path, what changed or what matters in it.\n'
438
+ + '4. Commands and results — what was run and what it showed (test/build results, errors and how each was fixed).\n'
439
+ + '5. Decisions — what was chosen and why.\n'
440
+ + '6. Current state — exactly what was being done when this summary was made, and what is unfinished.\n'
441
+ + '7. Next step — the single next action, if the work was mid-way.\n'
442
+ + 'Omit headings that have nothing. No preamble.'
426
443
  + (focus ? ` Focus on: ${focus}.` : '')
427
444
  + `\n\n<conversation>\n${transcriptForSummary(messages)}\n</conversation>`,
428
445
  },
@@ -502,6 +519,37 @@ function projectRules(cwd) {
502
519
  return { allow: Array.isArray(saved.allow) ? saved.allow : [], deny: Array.isArray(saved.deny) ? saved.deny : [] };
503
520
  }
504
521
 
522
+ /** The bypass confirmation, wherever bypass is switched on from. */
523
+ const BYPASS_CHOICES = [
524
+ { label: 'Yes, for this session', value: 'y' },
525
+ { label: 'Yes, and start every new session in bypass', value: 'always' },
526
+ { label: 'No, stay put', value: 'n' },
527
+ ];
528
+ const BYPASS_WARNING = [
529
+ 'Writes and shell commands will run with NO permission prompts.',
530
+ 'Use only in a trusted workspace you can afford to rebuild. Deny rules still apply.',
531
+ ];
532
+
533
+ /** The mode every new session starts in; null goes back to "the last one used". */
534
+ function saveDefaultMode(mode) {
535
+ const settings = loadSettings();
536
+ if (mode) settings.defaultMode = mode;
537
+ else delete settings.defaultMode;
538
+ saveSettings(settings);
539
+ }
540
+
541
+ /**
542
+ * What to say beside a mode when a saved rule already approves a whole tool here —
543
+ * otherwise "Ask" is on screen while every command runs unasked, and the modes look
544
+ * like they do nothing.
545
+ */
546
+ function wideRuleNote(permissions, mode) {
547
+ if (mode === 'plan' || mode === 'bypassPermissions') return '';
548
+ const wide = permissions?.wide || [];
549
+ if (!wide.length) return '';
550
+ return `Saved rule ${wide.join(', ')} skips the permission question in every mode here — /permissions forget ${wide[0]} to let the mode decide.`;
551
+ }
552
+
505
553
  /** The key rules are saved under: the existing spelling when there is one. */
506
554
  function rulesKey(cwd) {
507
555
  return Object.keys(loadSettings().permissionRules || {}).find((k) => sameDir(k, cwd)) || path.resolve(cwd);
@@ -547,8 +595,11 @@ async function oneShot(task, { flags }) {
547
595
  messages.push({ role: 'user', content: task });
548
596
 
549
597
  const explicitPlan = flags.permissionModeExplicit && flags.permissionMode === 'plan';
598
+ // The saved default mode applies here too — "every session" includes `jaitus -p`.
599
+ const savedDefault = loadSettings().defaultMode;
600
+ const oneShotMode = !flags.permissionModeExplicit && PERMISSION_MODES.includes(savedDefault) ? savedDefault : flags.permissionMode;
550
601
  const permissions = createPermissions({
551
- autoApprove: flags.yes && !explicitPlan, mode: flags.permissionMode,
602
+ autoApprove: flags.yes && !explicitPlan, mode: oneShotMode,
552
603
  interactive: false, ...projectRules(flags.cwd),
553
604
  });
554
605
 
@@ -558,7 +609,7 @@ async function oneShot(task, { flags }) {
558
609
  ...(flags.maxTurns ? { maxIterations: flags.maxTurns } : {}),
559
610
  silent: true,
560
611
  systemOverride: flags.systemPrompt || null,
561
- extraSystem: flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : '',
612
+ extraSystem: `${skillCatalog(discoverSkills(flags.cwd))}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
562
613
  onText: (text) => {
563
614
  answer += text;
564
615
  if (format === 'stream-json') out(JSON.stringify({ type: 'content_block_delta', delta: { text } }));
@@ -613,7 +664,7 @@ const SLASH = {
613
664
  '/tasks': { summary: 'review tool activity from this session' },
614
665
  '/image <path>': { summary: 'attach an image (png/jpg/gif/webp) the model will see with your next message' },
615
666
  '/memory [text]': { summary: 'show or append to JAITUS.md (# does this inline)' },
616
- '/permissions [mode|rules]': { summary: 'permission mode, and the allow/deny rules for this project' },
667
+ '/permissions [mode|rules|default]': { summary: 'permission mode, the mode new sessions start in, and the allow/deny rules for this project' },
617
668
  '/skills': { summary: 'list project workflows' },
618
669
  '/sessions': { summary: 'list saved sessions in this directory' },
619
670
  '/session': { summary: 'show this session id', hidden: true }, // the id is already in /status
@@ -778,11 +829,21 @@ async function session({ flags }) {
778
829
  // flag used to be overridden by it, so after one Shift+Tab `--permission-mode plan`
779
830
  // started a session that edited files without asking. Plan (often set by /ask or
780
831
  // /plan, not chosen) and bypass never carry into a new session on their own.
781
- const lastMode = loadSettings().lastMode;
782
- if (lastMode && !flags.yes && !flags.permissionModeExplicit && ['default', 'acceptEdits'].includes(lastMode)) permissions.setMode(lastMode);
832
+ // A saved default mode (`/permissions default <mode>`, or "Yes, every session" on the
833
+ // bypass prompt) is a choice made once, on purpose, and it wins over the last mode:
834
+ // turning bypass on in every new chat was the thing people had to repeat.
835
+ const startSettings = loadSettings();
836
+ const defaultMode = PERMISSION_MODES.includes(startSettings.defaultMode) ? startSettings.defaultMode : null;
837
+ const lastMode = startSettings.lastMode;
838
+ if (!flags.yes && !flags.permissionModeExplicit) {
839
+ if (defaultMode) permissions.setMode(defaultMode);
840
+ else if (['default', 'acceptEdits'].includes(lastMode)) permissions.setMode(lastMode);
841
+ }
842
+ const bypassIsDefault = defaultMode === 'bypassPermissions' && !flags.permissionModeExplicit;
783
843
  // Starting straight into bypass gets one explicit confirmation (§11) when a
784
- // human is present; headless runs just get the warning on stderr.
785
- if (permissions.mode === 'bypassPermissions' && !flags.yes) {
844
+ // human is present; headless runs just get the warning on stderr. A saved bypass
845
+ // default was confirmed when it was saved and is not asked again.
846
+ if (permissions.mode === 'bypassPermissions' && !flags.yes && !bypassIsDefault) {
786
847
  if (process.stdin.isTTY && process.stdout.isTTY) {
787
848
  out(`\n ${c.red(c.bold('● BYPASS'))} ${c.red('requested — writes and shell commands would run with no permission checks.')}`);
788
849
  const answer = await askKey(` ${c.dim(`Enable bypass mode for this session? [y/N] ${glyph('›', '>')}`)} `, ['y', 'n', '\r', ' ']);
@@ -873,17 +934,15 @@ async function session({ flags }) {
873
934
  const order = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
874
935
  const next = order[(order.indexOf(permissions.mode) + 1) % order.length];
875
936
  if (next === 'bypassPermissions') {
876
- const ans = await tui.askDialog('Enable bypass mode?', [
877
- { label: 'Yes, skip all checks', value: 'y' },
878
- { label: 'No, stay put', value: 'n' },
879
- ], [
880
- 'Writes and shell commands will run with NO permission prompts.',
881
- 'Use only in a trusted workspace you can afford to rebuild.',
882
- ]);
883
- if (dialogValue(ans) !== 'y') return permissions.mode;
937
+ const ans = await tui.askDialog('Enable bypass mode?', BYPASS_CHOICES, BYPASS_WARNING);
938
+ const picked = dialogValue(ans);
939
+ if (picked !== 'y' && picked !== 'always') return permissions.mode;
940
+ if (picked === 'always') saveDefaultMode('bypassPermissions');
884
941
  }
885
942
  const m = permissions.cycleMode();
886
943
  refreshStatusLine('mode');
944
+ const note = wideRuleNote(permissions, m);
945
+ if (note) tui.flash?.(note, 'warn', 4500);
887
946
  return m;
888
947
  },
889
948
  };
@@ -1109,7 +1168,7 @@ async function session({ flags }) {
1109
1168
  const autoCompactIfNeeded = async () => {
1110
1169
  if (loadSettings().autoCompact === false) return;
1111
1170
  const used = estimateTokens(messages);
1112
- if (used < CONTEXT_BUDGET * AUTO_COMPACT_AT) return;
1171
+ if (used < compactThreshold()) return;
1113
1172
  if (messages.length < 4) return;
1114
1173
 
1115
1174
  tui.setStatus?.(`Compacting — ${format(used)} of ${format(CONTEXT_BUDGET)} context used`);
@@ -1426,6 +1485,15 @@ async function session({ flags }) {
1426
1485
  showContext();
1427
1486
  tui.setSpent?.(spent);
1428
1487
  refreshStatusLine('start');
1488
+ // Why the session is in the mode it is in, when that is not obvious from the badge.
1489
+ const startNotes = [
1490
+ bypassIsDefault && permissions.mode === 'bypassPermissions' ? 'Bypass is your default for new sessions — /permissions default ask turns that off.' : '',
1491
+ wideRuleNote(permissions, permissions.mode),
1492
+ ].filter(Boolean);
1493
+ // The welcome screen hides transcript notices until the first message, so the status
1494
+ // line carries them at start; the notice keeps the record in the transcript.
1495
+ for (const note of startNotes) { if (tui.notice) tui.notice(note, 'warn'); else tui.print(c.yellow(` ${note}`)); }
1496
+ if (startNotes.length) tui.flash?.(startNotes.join(' · '), 'warn', 9000);
1429
1497
 
1430
1498
  /** Esc+Esc: pick a checkpoint, restore the conversation and the files. */
1431
1499
  const doRewind = async () => {
@@ -1891,17 +1959,15 @@ async function slashCommand(verb, args, ctx) {
1891
1959
  const applyMode = async (next) => {
1892
1960
  const real = next === 'auto' ? 'bypassPermissions' : next;
1893
1961
  if ((real === 'bypassPermissions') && ctx.tui && ctx.permissions.mode !== 'bypassPermissions') {
1894
- const ans = await ctx.tui.askDialog('Enable bypass mode?', [
1895
- { label: 'Yes, skip all checks', value: 'y' },
1896
- { label: 'No, stay put', value: 'n' },
1897
- ], [
1898
- 'Writes and shell commands will run with NO permission prompts.',
1899
- 'Use only in a trusted workspace you can afford to rebuild.',
1900
- ], { title: 'Bypass mode', hideComment: true });
1901
- if (dialogValue(ans) !== 'y') { info(`Staying in ${c.bold(ctx.permissions.mode)}.`); return; }
1962
+ const ans = await ctx.tui.askDialog('Enable bypass mode?', BYPASS_CHOICES, BYPASS_WARNING, { title: 'Bypass mode', hideComment: true });
1963
+ const picked = dialogValue(ans);
1964
+ if (picked !== 'y' && picked !== 'always') { info(`Staying in ${c.bold(ctx.permissions.mode)}.`); return; }
1965
+ if (picked === 'always') { saveDefaultMode('bypassPermissions'); ok('Bypass is now the default for every new session — /permissions default ask undoes it.'); }
1902
1966
  }
1903
1967
  ctx.permissions.setMode(real);
1904
1968
  ok(`Permission mode: ${c.bold(next)}`);
1969
+ const note = wideRuleNote(ctx.permissions, real);
1970
+ if (note) warn(note);
1905
1971
  if (real === 'bypassPermissions') warn('The agent will not ask before writing files or running commands.');
1906
1972
  if (next === 'plan') info('The agent can read and think, but nothing runs until you switch out of plan mode.');
1907
1973
  };
@@ -1910,6 +1976,27 @@ async function slashCommand(verb, args, ctx) {
1910
1976
  // shows what has accumulated, which matters because "always allow" writes to
1911
1977
  // disk and a list nobody can read is a list nobody can revoke.
1912
1978
  const next = args[0];
1979
+ // ── the mode every new session starts in ──
1980
+ if (next === 'default' && args.length > 1 || next === 'startup') {
1981
+ const wanted = normaliseMode(args[1]) || (/^(off|none|last|unset)$/i.test(args[1] || '') ? 'off' : null);
1982
+ if (!wanted) {
1983
+ const current = loadSettings().defaultMode;
1984
+ info(`New sessions start in ${c.bold(current || 'the last mode used (ask or auto-edit)')}.`);
1985
+ info(`${c.dim('Change it with')} ${c.bold('/permissions default bypass')}${c.dim(', ask, acceptEdits, plan — or off.')}`);
1986
+ return null;
1987
+ }
1988
+ if (wanted === 'bypassPermissions' && ctx.tui && loadSettings().defaultMode !== 'bypassPermissions') {
1989
+ const ans = await ctx.tui.askDialog('Start every new session in bypass?', [
1990
+ { label: 'Yes, every session', value: 'y' },
1991
+ { label: 'No', value: 'n' },
1992
+ ], BYPASS_WARNING, { title: 'Bypass mode', hideComment: true });
1993
+ if (dialogValue(ans) !== 'y') { info('Unchanged.'); return null; }
1994
+ }
1995
+ saveDefaultMode(wanted === 'off' ? null : wanted);
1996
+ ok(wanted === 'off' ? 'New sessions start in the last mode used (ask or auto-edit).' : `New sessions start in ${c.bold(wanted)}.`);
1997
+ if (wanted !== 'off' && ctx.permissions.mode !== wanted) info(`This session stays in ${c.bold(ctx.permissions.mode)} — Shift+Tab or /permissions ${wanted} to switch now.`);
1998
+ return null;
1999
+ }
1913
2000
  if (next === 'rules' || next === 'allow' || next === 'deny' || next === 'forget') {
1914
2001
  const { parseRule } = await import('./agent/rules.js');
1915
2002
  if (next === 'rules') {
@@ -1942,8 +2029,10 @@ async function slashCommand(verb, args, ctx) {
1942
2029
  }
1943
2030
 
1944
2031
  if (next) {
1945
- if (!Object.keys(MODES).concat('auto').includes(next)) { warn(`Unknown mode: ${next}. Try a mode, or ${c.bold('/permissions rules')}.`); return null; }
1946
- await applyMode(next);
2032
+ // `bypass`, `ask`, `auto-edit` are what the badge says; they are modes too.
2033
+ const named = normaliseMode(next);
2034
+ if (!named) { warn(`Unknown mode: ${next}. Try ask, acceptEdits, plan, bypass — or ${c.bold('/permissions rules')}.`); return null; }
2035
+ await applyMode(named);
1947
2036
  return null;
1948
2037
  }
1949
2038
  if (!ctx.tui) {
@@ -2601,7 +2690,12 @@ function planRows(u) {
2601
2690
  if (st?.monthlyCredits) {
2602
2691
  const resets = st.periodResetsAt ? ` · resets ${new Date(st.periodResetsAt).toLocaleDateString()}` : '';
2603
2692
  rows.push(['This period', `${cr(st.periodCredits)} of ${cr(st.monthlyCredits)} credits${resets}`]);
2604
- if (st.dailyCredits) rows.push(['Today', `${cr(st.todayCredits)} of ${cr(st.dailyCredits)} credits · resets at midnight`]);
2693
+ if (st.windowCredits) {
2694
+ const refill = st.windowResetsAt
2695
+ ? `refills at ${new Date(st.windowResetsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
2696
+ : 'opens with your next request';
2697
+ rows.push([`This ${st.windowHours || 5}h window`, `${cr(st.windowUsed)} of ${cr(st.windowCredits)} credits · ${refill}`]);
2698
+ }
2605
2699
  if (st.periodTokens) rows.push(['Behind them', `${fmtTokens(st.periodTokens)} tokens of input and replies`]);
2606
2700
  }
2607
2701
  if (u.planExpiresAt) rows.push(['Renews by', new Date(u.planExpiresAt).toLocaleDateString()]);
@@ -2755,9 +2849,10 @@ const commands = {
2755
2849
  'jaitus config theme <name> dark, light, ocean, daltonized, auto',
2756
2850
  'jaitus config <key> --unset back to the default',
2757
2851
  'jaitus config copyOnSelect <on|off> auto-copy mouse selection',
2852
+ 'jaitus config defaultMode bypass start every session in bypass (ask, acceptEdits, plan; --unset = last mode used)',
2758
2853
  ],
2759
2854
  detail: [
2760
- 'Settable: model, baseUrl, siteUrl, theme, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, trimToolResults, mouse, statusLine,',
2855
+ 'Settable: model, baseUrl, siteUrl, theme, defaultMode, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, trimToolResults, mouse, statusLine,',
2761
2856
  'braveSearchKey, tavilyKey, serperKey. Stored in ~/.jaitus/settings.json.',
2762
2857
  '',
2763
2858
  'A search API key is the single biggest improvement available to web_search: the free',
@@ -2781,6 +2876,7 @@ const commands = {
2781
2876
  website: e.siteUrl,
2782
2877
  model: settings.model || null,
2783
2878
  theme: settings.theme || 'auto',
2879
+ defaultMode: settings.defaultMode || null,
2784
2880
  statusLine: settings.statusLine || null,
2785
2881
  copyOnSelect: settings.copyOnSelect !== false,
2786
2882
  scrollSpeed: settings.scrollSpeed ?? 1,
@@ -2800,13 +2896,14 @@ const commands = {
2800
2896
  ['Website', data.website],
2801
2897
  ['Model', data.model || 'whatever the gateway lists first'],
2802
2898
  ['Theme', `${data.theme} (${[...THEME_NAMES, 'auto'].join(', ')})`],
2899
+ ['Start mode', data.defaultMode || 'the last mode used (ask or auto-edit)'],
2803
2900
  ['Copy on select', data.copyOnSelect ? 'on (drag selects, auto-copies)' : 'off (Ctrl+Shift+C copies)'],
2804
2901
  ['Scroll speed', String(data.scrollSpeed)],
2805
2902
  ['Wheel accel', data.wheelAccel ? 'on' : 'off'],
2806
2903
  ['Auto-scroll', data.autoScroll ? 'on' : 'off'],
2807
2904
  ['Animations', data.animations ? 'on' : 'off'],
2808
- ['Auto-compact', data.autoCompact ? 'on (summarises near the context limit)' : 'off'],
2809
- ['Trim old tool output', data.trimToolResults ? 'on (clears old outputs past ~100k tokens, keeps the last 6)' : 'off'],
2905
+ ['Auto-compact', data.autoCompact ? 'on (summarises at 78% of the context size the service allows)' : 'off'],
2906
+ ['Trim old tool output', data.trimToolResults ? 'on (clears old outputs past ~40k tokens, keeps the last 6)' : 'off'],
2810
2907
  ['Mouse', data.mouse ? 'on (wheel scrolls, clicks act, drag or double-click copies)' : 'off (the terminal selects text itself)'],
2811
2908
  ['Search API', data.searchApi ? `${data.searchApi} (set)` : 'none — using free engines + npm/GitHub/MDN/Stack Overflow'],
2812
2909
  ['Status line', data.statusLine || '(unset — set a command to render one)'],
@@ -2814,13 +2911,13 @@ const commands = {
2814
2911
  ]));
2815
2912
  return 0;
2816
2913
  }
2817
- if (!['model', 'baseUrl', 'siteUrl', 'theme', 'statusLine', ...ONOFF, ...SECRET, 'scrollSpeed'].includes(key)) {
2818
- fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
2914
+ if (!['model', 'baseUrl', 'siteUrl', 'theme', 'defaultMode', 'statusLine', ...ONOFF, ...SECRET, 'scrollSpeed'].includes(key)) {
2915
+ fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, defaultMode, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
2819
2916
  return 1;
2820
2917
  }
2821
2918
  // Clearing a value (there was no way: an empty value just printed the current one).
2822
2919
  if (unset) {
2823
- if (!['model', 'baseUrl', 'siteUrl', 'statusLine', 'theme', 'scrollSpeed', ...SECRET].includes(key)) { fail(`${key} is on/off — set it with on or off.`); return 1; }
2920
+ if (!['model', 'baseUrl', 'siteUrl', 'statusLine', 'theme', 'defaultMode', 'scrollSpeed', ...SECRET].includes(key)) { fail(`${key} is on/off — set it with on or off.`); return 1; }
2824
2921
  delete settings[key];
2825
2922
  saveSettings(settings);
2826
2923
  ok(`${key} cleared — back to the default.`);
@@ -2836,6 +2933,15 @@ const commands = {
2836
2933
  if (key === 'scrollSpeed') { out(String(settings.scrollSpeed ?? 1)); return 0; }
2837
2934
  out(String(settings[key] ?? '')); return 0;
2838
2935
  }
2936
+ if (key === 'defaultMode') {
2937
+ const mode = normaliseMode(value);
2938
+ if (!mode) { fail('Use ask, acceptEdits, plan or bypass (or --unset for the last mode used).'); return 1; }
2939
+ settings.defaultMode = mode;
2940
+ saveSettings(settings);
2941
+ ok(`defaultMode = ${mode} — every new session starts in it`);
2942
+ if (mode === 'bypassPermissions') warn('Writes and shell commands will run with no permission prompts in every session. Deny rules still apply.');
2943
+ return 0;
2944
+ }
2839
2945
  if (key === 'theme') {
2840
2946
  // The themes that exist (ocean was refused; plain was accepted and did nothing).
2841
2947
  const names = [...THEME_NAMES, 'auto'];
@@ -0,0 +1,91 @@
1
+ ---
2
+ description: Load BEFORE making a PDF or any printable/shareable document — report, proposal, invoice, resume/CV, letter, certificate, brochure, one-pager, handout, slide deck. Produces typeset, professional documents instead of plain text dumps.
3
+ ---
4
+
5
+ # Professional documents and PDFs
6
+
7
+ The reliable route is: **write semantic HTML with print CSS, then print it to PDF with a
8
+ headless browser.** It gives real typography, exact page control and is easy to revise.
9
+ Use a PDF library (reportlab, pdfkit, jsPDF) only if the project already uses one.
10
+
11
+ ## 1. Plan the document
12
+
13
+ - What is it, who reads it, and what must they do after reading? Put that first.
14
+ - Pick a document direction that fits: corporate report (clean sans, blue-black ink, one
15
+ accent, data tables) · editorial/proposal (serif body, generous margins, pull quotes) ·
16
+ modern minimal (one sans, lots of white, strong headings) · formal letter/certificate
17
+ (centred serif, restrained ornament) · resume (dense but calm, two-column, no graphics
18
+ that ATS software cannot read).
19
+ - Outline the sections before writing; every page should have one clear purpose.
20
+
21
+ ## 2. Page setup (print CSS)
22
+
23
+ ```css
24
+ @page { size: A4; margin: 20mm 18mm 22mm; } /* or: size: Letter; */
25
+ @page :first { margin-top: 0; } /* full-bleed cover if wanted */
26
+ html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
27
+ body { font: 10.5pt/1.55 "Source Serif 4", Georgia, serif; color: #1b1d22; }
28
+ h1, h2, h3 { font-family: "Inter Tight", "Helvetica Neue", Arial, sans-serif; line-height: 1.15; break-after: avoid; }
29
+ h2 { break-before: auto; margin: 18pt 0 6pt; }
30
+ table, figure, blockquote, .card { break-inside: avoid; }
31
+ p { orphans: 3; widows: 3; }
32
+ .page-break { break-before: page; }
33
+ a { color: inherit; text-decoration: none; border-bottom: 0.5pt solid currentColor; }
34
+ ```
35
+
36
+ - Use pt/mm for print, never viewport units. Body 10–11.5pt, headings on a scale
37
+ (e.g. 10.5 · 13 · 16 · 22 · 32pt). Line length ~65–80 characters; wide pages use two
38
+ columns (`columns: 2; column-gap: 8mm;`) or a wide margin column for notes.
39
+ - Colour: dark ink on white, ONE accent colour for headings rules, key numbers and table
40
+ headers, tinted greys for secondary text. Keep it printable in greyscale.
41
+ - Page numbers / running footer: put a footer element on each page section, or when the
42
+ browser supports it use `@page { @bottom-right { content: counter(page) " / " counter(pages); } }`.
43
+
44
+ ## 3. Typesetting details that make it look professional
45
+
46
+ - A proper cover or title block: title, subtitle, author/organisation, date — aligned to a
47
+ grid, not just a big centred heading.
48
+ - Consistent vertical rhythm: spacing in multiples of the body line height.
49
+ - Tables: left-align text, right-align numbers with tabular figures
50
+ (`font-variant-numeric: tabular-nums`), light horizontal rules only, a bold or tinted
51
+ header row, units in the header, totals row emphasised. No full grid of borders.
52
+ - Numbers: thousands separators, consistent decimals, currency symbols (₹ 1,49,900 for
53
+ Indian format when the audience is Indian).
54
+ - Callouts/key figures as a small highlighted block, not colourful boxes everywhere.
55
+ - Charts: inline SVG (or Chart.js rendered before printing) with direct labels, no 3D, no
56
+ heavy gridlines, the accent colour for the series that matters and grey for the rest.
57
+ - Real content — no lorem ipsum, no "[Your Name]" left in unless it is a template.
58
+ - Invoices: seller/buyer blocks, invoice number, dates, line items table, tax lines, total,
59
+ payment details and terms — every legally expected field for the country.
60
+ - Resumes: name large, contact on one line, sections in a clear order, consistent date
61
+ alignment on the right, verbs and numbers in bullets, one page unless senior.
62
+
63
+ ## 4. Render to PDF
64
+
65
+ Save the HTML (e.g. `report.html`) and print it with whichever browser is installed:
66
+
67
+ ```sh
68
+ # Windows (Edge ships with Windows)
69
+ "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --headless --disable-gpu --no-pdf-header-footer --print-to-pdf="report.pdf" "file:///C:/path/to/report.html"
70
+ # Chrome (any OS)
71
+ chrome --headless --disable-gpu --no-pdf-header-footer --print-to-pdf=report.pdf file:///abs/path/report.html
72
+ # macOS Chrome
73
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless --no-pdf-header-footer --print-to-pdf=report.pdf file:///abs/path/report.html
74
+ ```
75
+
76
+ If the project has Node and Playwright or Puppeteer, `page.pdf({ format: 'A4',
77
+ printBackground: true, preferCSSPageSize: true })` works the same. Web fonts must finish
78
+ loading before printing — with a headless browser add `--virtual-time-budget=5000`, or
79
+ embed the fonts locally.
80
+
81
+ ## 5. Check before handing over
82
+
83
+ 1. The PDF exists and opens; report its path and page count.
84
+ 2. No heading stranded at the bottom of a page, no table row split, no blank last page.
85
+ 3. Margins are even, nothing clipped at the edges, backgrounds printed.
86
+ 4. Hierarchy is obvious at a glance; one accent colour; consistent spacing.
87
+ 5. All placeholder text replaced; numbers, dates and names are correct.
88
+
89
+ For slide decks: one idea per slide, 16:9 (`@page { size: 297mm 167mm; margin: 0 }` with
90
+ one `section` per slide), a big headline that states the point, at most ~30 words of body,
91
+ visual consistency across slides, and slide numbers.
@@ -0,0 +1,102 @@
1
+ ---
2
+ description: Load BEFORE building or restyling anything people look at — a website, landing page, web app screen, dashboard, component, HTML email, or any HTML/CSS/React/Vue/Svelte UI. Makes it look designed and professional instead of generic.
3
+ ---
4
+
5
+ # Designing interfaces that look made by a designer
6
+
7
+ The default look of generated UI is instantly recognisable and reads as cheap: a blue or
8
+ purple gradient hero, Inter or system font everywhere, three identical rounded cards with
9
+ emoji icons, grey text on white, soft shadows on everything, centred everything. Avoid all
10
+ of it. Every screen must look like a deliberate choice by someone with taste.
11
+
12
+ ## 1. Decide the direction before writing code
13
+
14
+ Write these down (in your head or a comment) first — they drive every later decision:
15
+
16
+ - **Who it is for and what it must make them feel** (trust, speed, calm, delight, authority).
17
+ - **One visual direction**, named concretely. Pick what suits the subject, e.g.:
18
+ editorial (serif display, generous whitespace, rules and columns) · Swiss/International
19
+ (strict grid, one sans, big type, flat colour) · warm product (off-white paper, soft ink,
20
+ one saturated accent) · dark technical (near-black, luminous accent, mono details) ·
21
+ brutalist (raw borders, heavy type, no radius) · luxury (restrained, large margins, thin
22
+ serif, muted palette) · playful (bold colour blocks, chunky type, big radius).
23
+ - **The one memorable thing** — an oversized headline, an unusual colour, a striking
24
+ layout, a detail. A design with nothing memorable is the generic one.
25
+
26
+ If the project already has a design system, tokens, a brand colour or a component library,
27
+ **follow it** — consistency beats novelty. Read the existing CSS/theme files first.
28
+
29
+ ## 2. Tokens first
30
+
31
+ Define design tokens as CSS custom properties (or the theme file of the framework in use)
32
+ before any component, and use only them afterwards:
33
+
34
+ - **Colour:** a background, a surface, 2–3 text shades, ONE accent, borders, and semantic
35
+ success/warning/danger. Neutrals should be tinted toward the accent's hue (warm or cool
36
+ greys), never flat `#808080`. Avoid pure `#000` on pure `#fff` for large areas — use ink
37
+ like `#16181d` on paper like `#faf8f5`. Accent used sparingly: buttons, links, one
38
+ highlight. Check contrast: body text ≥ 4.5:1, large text ≥ 3:1.
39
+ - **Type:** two families at most — a characterful display face and a readable text face
40
+ (or one family with strong weight contrast). Good free pairs (Google Fonts):
41
+ Fraunces + Inter Tight · Instrument Serif + Geist · Space Grotesk + IBM Plex Sans ·
42
+ DM Serif Display + DM Sans · Playfair Display + Source Sans 3 · JetBrains Mono for code.
43
+ A modular scale (e.g. 1.25): 12 · 14 · 16 · 20 · 25 · 31 · 39 · 49 · 61. Headlines tight
44
+ (line-height 1.05–1.15, letter-spacing −0.01 to −0.03em); body 1.5–1.65; measure
45
+ 60–75 characters. Use weight and size for hierarchy, not colour alone.
46
+ - **Space:** a 4 or 8px scale (4 8 12 16 24 32 48 64 96 128). Be generous — cramped is the
47
+ most common amateur tell. Sections breathe with 96–160px vertical padding on desktop.
48
+ - **Radius and elevation:** choose one radius personality (0, 4–6, or 12–16) and keep it.
49
+ Prefer borders and background shifts over shadows; when shadows are used, make them
50
+ subtle and layered (`0 1px 2px rgb(0 0 0 / .06), 0 8px 24px rgb(0 0 0 / .06)`).
51
+
52
+ ## 3. Layout
53
+
54
+ - Build on a real grid (12 columns, or a clear 2–3 column system) with a max content width
55
+ (1120–1280px) and consistent gutters.
56
+ - Break symmetry on purpose: asymmetric hero (text left, visual right, offset), a wide
57
+ feature next to narrow ones, a pull quote, a full-bleed band. Not every section centred.
58
+ - Establish hierarchy in every section: one thing clearly first, then second, then detail.
59
+ - Align everything to the grid; mismatched left edges look broken.
60
+ - Mobile first-class: stack sensibly, keep 16–20px side padding, tap targets ≥ 44px, no
61
+ horizontal scroll. Test at 375px and 1440px widths mentally or in a browser.
62
+
63
+ ## 4. Components and detail
64
+
65
+ - **Buttons:** one primary style (solid accent), one secondary (outline or ghost); clear
66
+ hover, focus-visible (a visible ring), active and disabled states. Label with verbs.
67
+ - **Icons:** one consistent SVG set (Lucide, Heroicons, Phosphor, Tabler) at one stroke
68
+ weight — never emoji as icons.
69
+ - **Imagery:** real content or tasteful placeholders — CSS/SVG shapes, a gradient mesh used
70
+ subtly, product mock frames. Never broken image links or stock-photo clichés.
71
+ - **Copy:** specific and concrete; no "Lorem ipsum", no "Welcome to our website", no
72
+ "Revolutionize your workflow". Real numbers and nouns.
73
+ - **States:** empty, loading (skeletons that match the layout), error (says what happened
74
+ and what to do), success. Forms: labels above inputs, helpful inline errors.
75
+ - **Motion:** small and purposeful — 150–250ms ease-out for hover/press, a staggered fade-up
76
+ on first view at most. Respect `prefers-reduced-motion`.
77
+ - **Dark mode** if the product warrants it: redefine the tokens, don't invert colours; dark
78
+ surfaces are dark grey (`#111318`), not black, with slightly desaturated accents.
79
+
80
+ ## 5. Things that make it look generic — don't
81
+
82
+ - Purple/blue gradient backgrounds, gradient text on everything, glowing blobs by default.
83
+ - Three equal cards with an emoji, a bold title and two lines of grey text as the main idea.
84
+ - Every section centred with the same padding and the same card component.
85
+ - Default Tailwind blue (`blue-500`) or Bootstrap primary as the brand colour.
86
+ - Heavy drop shadows, borders AND shadows AND gradients on the same element.
87
+ - Tiny low-contrast grey text; ALL CAPS paragraphs; more than two font families.
88
+
89
+ ## 6. Build, then review like a designer
90
+
91
+ After building, check honestly and fix before reporting done:
92
+
93
+ 1. Squint test: is the hierarchy obvious — what is first, second, third?
94
+ 2. Is the spacing consistent and generous? Any cramped block or misaligned edge?
95
+ 3. Does the palette hold together — one accent, tinted neutrals, contrast passes?
96
+ 4. Is there one memorable, distinctive element? Would a designer recognise a direction?
97
+ 5. Every interactive element has hover and focus states; works at 375px wide.
98
+ 6. Nothing from section 5 slipped in.
99
+
100
+ If a browser or screenshot tool is available, look at the result and iterate. Deliver
101
+ single-file HTML with inline CSS unless the project has its own structure; load fonts from
102
+ Google Fonts with `display=swap` and give a system fallback stack.
package/src/skills.js CHANGED
@@ -8,6 +8,10 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import os from 'node:os';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ // Skills that ship with Jaitus. A project or personal skill with the same name wins.
14
+ const BUILTIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'skills', 'builtin');
11
15
 
12
16
  const MAX_SKILL = 32_000;
13
17
 
@@ -47,6 +51,7 @@ export function discoverSkills(cwd) {
47
51
  [path.join(cwd, '.claude', 'skills'), 'project'],
48
52
  [path.join(os.homedir(), '.jaitus', 'skills'), 'user'],
49
53
  [path.join(os.homedir(), '.claude', 'skills'), 'user'],
54
+ [BUILTIN, 'builtin'],
50
55
  ];
51
56
  const skills = new Map();
52
57
  for (const [root, scope] of roots) {
@@ -58,5 +63,5 @@ export function discoverSkills(cwd) {
58
63
  export function skillCatalog(skills) {
59
64
  const entries = Object.values(skills || {});
60
65
  if (!entries.length) return '';
61
- return `\n\nAvailable project workflows (invoke with /<name>):\n${entries.map((s) => `- /${s.name}: ${s.description}`).join('\n')}`;
66
+ return `\n\nSkills — instructions for particular kinds of work. When the task matches one, call load_skill with its name before you start, and follow it. The user can also run one as /<name>.\n${entries.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`;
62
67
  }