shmakk 1.2.2 → 1.2.4

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": "shmakk",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "AI-supervised terminal wrapper — command correction, tool-driven tasks, safety controls",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/src/agent.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const { makeClient, modelFor, isConfigured, getDeepSeekOptions } = require('./llm');
10
+ const { makeClient, modelFor, isConfigured, getDeepSeekOptions, supportsVision } = require('./llm');
11
11
  const {
12
12
  sanitizeAssistantContent,
13
13
  isLeakedToolMarkup,
@@ -87,13 +87,16 @@ function clearTaskJournal(root) {
87
87
  const { renderBlock } = require('./markdown');
88
88
 
89
89
  // Tiny spinner so the user sees "the agent is thinking" while we wait on
90
- // the model. Erased when stop() is called.
90
+ // the model. Erased when stop() is called. Also updates the terminal tab
91
+ // title so you can see agent activity even when looking at another tab.
91
92
  function startSpinner(write, label = 'thinking') {
92
93
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
93
94
  let i = 0; let line = '';
94
95
  const draw = () => {
95
96
  line = `\x1b[2m${frames[i % frames.length]} ${label}…\x1b[0m`;
96
97
  write('\r' + line);
98
+ // Update terminal tab/window title so activity is visible from other tabs
99
+ write(`\x1b]0;${frames[i % frames.length]} ${label} — shmakk\x07`);
97
100
  i++;
98
101
  };
99
102
  draw();
@@ -101,6 +104,8 @@ function startSpinner(write, label = 'thinking') {
101
104
  return () => {
102
105
  clearInterval(tm);
103
106
  write('\r' + ' '.repeat(line.replace(/\x1b\[[0-9;]*m/g, '').length + 2) + '\r\r');
107
+ // Clear the terminal title — shell will restore normal title on next prompt
108
+ write('\x1b]0;\x07');
104
109
  };
105
110
  }
106
111
 
@@ -251,6 +256,7 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
251
256
  mcpToolHint,
252
257
  userRulesText,
253
258
  userMemoryText,
259
+ supportsVision: supportsVision(),
254
260
  });
255
261
 
256
262
  const boundedHistory = trimForContext(history, runtimeProfile.historyEntries);
@@ -292,14 +298,22 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
292
298
  // Tool loop. Streams content as it arrives; prints each tool call.
293
299
  let producedAnything = false;
294
300
  const runState = { _dsmlLeakRetries: 0 };
301
+ let taskSpinnerStop = null;
302
+ const ensureSpinner = () => {
303
+ if (!taskSpinnerStop) taskSpinnerStop = startSpinner(write, 'thinking');
304
+ };
305
+ const stopTaskSpinner = () => {
306
+ if (taskSpinnerStop) { taskSpinnerStop(); taskSpinnerStop = null; }
307
+ };
295
308
  for (let i = 0; i < dynamicToolBudget; i++) {
296
- if (signal && signal.aborted) return messages.slice(1);
309
+ if (signal && signal.aborted) { stopTaskSpinner(); return messages.slice(1); }
297
310
 
298
311
  // Stream the response so the user sees text as it generates.
299
312
  const cacheKey = promptCacheEnabled ? promptCache.makeKey({ model: modelFor('agent'), messages, toolChoice: 'auto' }) : null;
300
313
  if (promptCacheEnabled && cacheKey) {
301
314
  const hit = promptCache.get(roots[0], cacheKey);
302
315
  if (hit && hit.content) {
316
+ stopTaskSpinner();
303
317
  write(dim('[shmakk] prompt cache hit', colors) + '\n');
304
318
  write(highlightCodeBlocks(hit.content, colors));
305
319
  if (!hit.content.endsWith('\n')) write('\n');
@@ -309,7 +323,7 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
309
323
  }
310
324
  }
311
325
 
312
- const stop = startSpinner(write, i === 0 ? 'thinking' : 'continuing');
326
+ ensureSpinner();
313
327
  const allTools = mcpManager ? [...TOOLS, ...mcpManager.getToolDefinitions()] : TOOLS;
314
328
  let stream;
315
329
  try {
@@ -324,14 +338,13 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
324
338
  ...getDeepSeekOptions('tool_loop'),
325
339
  }, { signal });
326
340
  } catch (e) {
327
- stop();
341
+ stopTaskSpinner();
328
342
  throw e;
329
343
  }
330
344
 
331
345
  let content = '';
332
346
  let reasoningContent = '';
333
347
  const toolCalls = []; // [{id, type:'function', function:{name, arguments}}]
334
- let spinnerStopped = false;
335
348
  let streamingContentOk = true; // flipped to false on leak
336
349
  try {
337
350
  for await (const chunk of stream) {
@@ -370,7 +383,6 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
370
383
  reasoningContent += delta.reasoning_content;
371
384
  }
372
385
  if (delta.tool_calls) {
373
- if (!spinnerStopped) { stop(); spinnerStopped = true; }
374
386
  for (const tc of delta.tool_calls) {
375
387
  const idx = tc.index ?? 0;
376
388
  if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
@@ -382,7 +394,8 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
382
394
  }
383
395
  }
384
396
  } finally {
385
- if (!spinnerStopped) stop();
397
+ // Spinner runs continuously from loop start — stopped below when
398
+ // we're about to show output or dispatch tools that may need input.
386
399
  }
387
400
 
388
401
  // ── DSML leak detection (after stream completes) ────────────────────
@@ -427,6 +440,7 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
427
440
  // Max retries exceeded — strip and show what we can.
428
441
  content = sanitized.visibleText;
429
442
  if (!content) {
443
+ stopTaskSpinner();
430
444
  write(dim('[shmakk] response contained only leaked tool markup — blocked.', colors) + '\n');
431
445
  clearTaskJournal(roots[0]);
432
446
  return messages.slice(1);
@@ -477,6 +491,7 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
477
491
 
478
492
  // No tools → done.
479
493
  if (!normalizedToolCalls.length) {
494
+ stopTaskSpinner();
480
495
  if (content) {
481
496
  write(renderBlock(content, { enabled: markdown, colors }));
482
497
  if (!content.endsWith('\n')) write('\n');
@@ -497,6 +512,7 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
497
512
  // Dispatch tool calls.
498
513
  // Reads/lists are noisy — collect them silently and show a single dim summary.
499
514
  // Writes, edits, runs, and errors always print clearly.
515
+ stopTaskSpinner();
500
516
  const SILENT_TOOLS = new Set(['read_file', 'list_dir', 'web_search', 'fetch_url']);
501
517
  let iterProgress = false;
502
518
  let silentReads = [];
@@ -534,7 +550,34 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
534
550
  write(dim(`→ ${c.function.name}(${shortArgs(args)})${summary ? ' — ' + summary : ''}`, colors) + '\n');
535
551
  }
536
552
 
537
- messages.push({ role: 'tool', tool_call_id: c.id, content: JSON.stringify(result).slice(0, 8000) });
553
+ // Build tool result message. If the result includes images with real
554
+ // base64 data AND the current endpoint has `vision: true`, send them as
555
+ // vision content blocks in a follow-up user message. Otherwise just
556
+ // include image metadata in the text. Keep the tool message text-only.
557
+ const toolImages = Array.isArray(result.images) ? result.images.filter((img) => img.data) : [];
558
+ const toolText = result.content || '';
559
+ const toolMeta = Object.fromEntries(
560
+ Object.entries(result || {}).filter(([k]) => !['content', 'images', 'error', 'isRetryable'].includes(k)),
561
+ );
562
+ let toolContent = (toolText + (Object.keys(toolMeta).length ? ' ' + JSON.stringify(toolMeta) : '')).trim();
563
+ if (toolImages.length > 0 && !supportsVision()) {
564
+ // Endpoint doesn't support vision — include image metadata as text
565
+ const imgDesc = toolImages.map((img, i) => `[Image #${i + 1}: ${img.mimeType}, base64=${img.dataLength} chars${img.truncated ? ', truncated' : ''}]`).join(', ');
566
+ toolContent = toolContent ? `${toolContent} ${imgDesc}` : imgDesc;
567
+ }
568
+ messages.push({ role: 'tool', tool_call_id: c.id, content: toolContent.slice(0, 8000) });
569
+ if (toolImages.length > 0 && supportsVision()) {
570
+ messages.push({
571
+ role: 'user',
572
+ content: [
573
+ { type: 'text', text: `[Images returned by ${c.function.name}${toolImages.some((img) => img.truncated) ? ' (some truncated to ~2MB base64)' : ''}]` },
574
+ ...toolImages.map((img) => ({
575
+ type: 'image_url',
576
+ image_url: { url: `data:${img.mimeType};base64,${img.data}`, detail: 'auto' },
577
+ })),
578
+ ],
579
+ });
580
+ }
538
581
  producedAnything = true;
539
582
  persistJournal('running');
540
583
  if (signal && signal.aborted) return messages.slice(1);
@@ -553,6 +596,7 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
553
596
  }
554
597
 
555
598
  if (repeatedSignatureCount >= runtimeProfile.stallRepeat && noProgressRepeats >= 2) {
599
+ stopTaskSpinner();
556
600
  break;
557
601
  }
558
602
  }
@@ -589,6 +633,7 @@ async function runAgent({ input, roots, glossary, confirmTool, write, signal, hi
589
633
  }
590
634
  } catch {}
591
635
 
636
+ stopTaskSpinner();
592
637
  write(dim('[shmakk] paused after several tool rounds. Resume later continues from task journal; try `shmakk --reset` to clear.', colors) + '\n');
593
638
  persistJournal('paused');
594
639
  return messages.slice(1);
package/src/cli.js CHANGED
@@ -41,8 +41,10 @@ function parseArgs(argv) {
41
41
  voiceSilenceStartSec: null,
42
42
  voicePadStartSec: null,
43
43
  ttsVoice: null,
44
- notify: false,
44
+ notify: true,
45
45
  completion: null,
46
+ helpCategory: null,
47
+ shell: null,
46
48
  unknown: [],
47
49
  };
48
50
 
@@ -53,7 +55,13 @@ function parseArgs(argv) {
53
55
  case '--yes-files': opts.yesFiles = true; break;
54
56
  case '--update-command-glossary': opts.updateGlossary = true; break;
55
57
  case '-h':
56
- case '--help': opts.help = true; break;
58
+ case '--help':
59
+ opts.help = true;
60
+ // Capture optional category: shmakk --help voice
61
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
62
+ opts.helpCategory = argv[++i];
63
+ }
64
+ break;
57
65
  case '--debug': opts.debug = true; break;
58
66
  case '--no-ai': opts.noAi = true; break;
59
67
  case '--no-correction': opts.noCorrection = true; break;
@@ -102,34 +110,67 @@ function parseArgs(argv) {
102
110
  case '--markdown': opts.markdown = argv[++i] || null; break;
103
111
  case '--endpoint': opts.endpoint = argv[++i] || null; break;
104
112
  case '--model-recommendation': opts.modelRecommendation = true; break;
113
+ case '--shell':
114
+ {
115
+ const v = argv[++i];
116
+ if (!v || !['fish', 'bash', 'zsh'].includes(v)) {
117
+ process.stderr.write('[shmakk] invalid --shell. Use: fish|bash|zsh\n');
118
+ process.exit(2);
119
+ }
120
+ opts.shell = v;
121
+ }
122
+ break;
105
123
  default: opts.unknown.push(a);
106
124
  }
107
125
  }
108
126
  return opts;
109
127
  }
110
128
 
111
- const HELP = `shmakk - AI-supervised terminal wrapper
129
+ // ── Help: category-based navigation ──────────────────────────────────────────
130
+
131
+ const HELP_SUMMARY = `shmakk - AI-supervised terminal wrapper
112
132
 
113
133
  Launch shmakk, then type commands as usual. shmakk watches the shell, catches
114
134
  failures, and calls an LLM to fix them, plan tasks, and edit files.
115
135
 
116
- You can also type natural-language self-commands directly into the session
117
- (e.g. "list skills", "agent overview", "compact"). See SELF-COMMANDS below.
118
-
119
136
  Type "help" inside a session to see this same text.
120
137
 
121
- ═══════════════════════════════════════════════════════════════════════════
122
- LAUNCH OPTIONS
138
+ Usage: shmakk [--flag ...]
139
+ shmakk --help [category]
140
+
141
+ Categories (shmakk --help <name> for details):
142
+
143
+ launch Startup modes, profiles, tuning flags
144
+ session Status, stats, restart, exit, control signals
145
+ skills Skill discovery, loading, listing, management
146
+ models Provider configuration, endpoint presets
147
+ voice Speech-to-Text / Text-to-Speech options
148
+ env Environment variable reference
149
+ mcp MCP servers and browser automation
150
+ ssh Remote host execution
151
+ self Natural-language self-commands (inside a session)
152
+
153
+ `;
154
+
155
+ const HELP_SECTIONS = {};
156
+
157
+ HELP_SECTIONS.launch = `═══════════════════════════════════════════════════════════════════════════
158
+ LAUNCH OPTIONS (shmakk --flag from outside a session)
123
159
  ═══════════════════════════════════════════════════════════════════════════
124
160
 
161
+ These flags only apply when starting a new shmakk session. They are
162
+ ignored if you are already inside shmakk (SHMAKK=1).
163
+
125
164
  shmakk Launch in auto mode (AI acts on failures)
126
165
  shmakk --review Launch in review mode (confirm every AI action)
127
166
  shmakk --yes-files Auto-accept file writes, edits, mkdir
128
167
 
129
- shmakk --help Show this help
168
+ shmakk --help Show overview (this text)
169
+ shmakk --help <category> Show detailed help for a category
130
170
  shmakk --build-history [files] Parse shell history for better corrections
131
171
  shmakk --update-command-glossary Scan PATH and build local command glossary
132
172
 
173
+ --shell <fish|bash|zsh> Use a specific shell (default: current $SHELL)
133
174
  --no-ai Disable AI entirely (pure passthrough)
134
175
  --no-correction Disable command correction
135
176
  --debug Verbose logging to stderr
@@ -140,8 +181,9 @@ const HELP = `shmakk - AI-supervised terminal wrapper
140
181
  --colors <true|false> Enable or disable ANSI colors
141
182
  --markdown <true|false> Enable or disable markdown rendering
142
183
  --notify Desktop notifications for Y/n prompts
184
+ `;
143
185
 
144
- ═══════════════════════════════════════════════════════════════════════════
186
+ HELP_SECTIONS.models = `═══════════════════════════════════════════════════════════════════════════
145
187
  MODEL PROVIDERS
146
188
  ═══════════════════════════════════════════════════════════════════════════
147
189
 
@@ -159,8 +201,9 @@ const HELP = `shmakk - AI-supervised terminal wrapper
159
201
  "model":"qwen/qwen3.5-9b" }
160
202
  }
161
203
  }
204
+ `;
162
205
 
163
- ═══════════════════════════════════════════════════════════════════════════
206
+ HELP_SECTIONS.session = `═══════════════════════════════════════════════════════════════════════════
164
207
  SESSION CONTROL (shmakk --flag from another terminal)
165
208
  ═══════════════════════════════════════════════════════════════════════════
166
209
 
@@ -176,6 +219,11 @@ const HELP = `shmakk - AI-supervised terminal wrapper
176
219
  shmakk --exit Cleanly exit the parent shmakk
177
220
 
178
221
  shmakk --profile-set <name> Switch profile and restart
222
+ `;
223
+
224
+ HELP_SECTIONS.skills = `═══════════════════════════════════════════════════════════════════════════
225
+ SKILLS
226
+ ═══════════════════════════════════════════════════════════════════════════
179
227
 
180
228
  shmakk --load-skill <name> Load a skill into workspace state
181
229
  shmakk --install-skill <url> Download skill markdown from URL, validate, load
@@ -183,71 +231,9 @@ const HELP = `shmakk - AI-supervised terminal wrapper
183
231
  shmakk --list-skills List all registered skills (workspace + global)
184
232
  shmakk --skill-status Active skill and registry status
185
233
  shmakk --unload-skill <name> Remove skill from whichever registry has it
234
+ `;
186
235
 
187
- ═══════════════════════════════════════════════════════════════════════════
188
- SELF-COMMANDS (type inside an shmakk session)
189
- ═══════════════════════════════════════════════════════════════════════════
190
-
191
- ── Skills ──
192
- list skills List all registered skills
193
- list skills <category> List skills in a specific category
194
- list skill categories Show available skill categories
195
- find skills <query> Search skills by name/description
196
- load skill <name> Load a skill into the active workspace
197
- unload skill <name> Remove a skill from its registry
198
- skill status Show active skill and registry state
199
-
200
- ── Agents & Team ──
201
- agent overview Show all agents and their specialisms
202
- agent skills List all agent skills
203
- agent <name> Show detail for a specific agent
204
- list agents Alias for agent overview
205
-
206
- ── Context & Session ──
207
- status Show session status
208
- stats Show session/task statistics
209
- resume status Show task journal for resume continuity
210
- show plan Display current plan and progress
211
- compact Clear conversation + task journal
212
- reset Clear AI conversation history
213
-
214
- ── Memory & Search ──
215
- recall <query> Search past sessions by content
216
- find session <query> Find a session by topic
217
- last sessions Show recent sessions
218
- search db status Display session search DB info
219
- show memory List stored memories
220
- forget <query> Remove matching memories
221
-
222
- ── Configuration ──
223
- show config Print resolved configuration
224
- mcp status Show MCP servers and tools
225
- show rules Display active workspace rules
226
- list endpoints List configured model endpoints
227
- use endpoint <name> Switch to a named model endpoint
228
- set model to <name> Change the active model
229
- set url to <url> Change the base URL
230
- set api key to <key> Change the API key
231
-
232
- ── Toggles ──
233
- enable review | disable review
234
- enable correction | disable correction
235
- enable yes-files | disable yes-files
236
- enable colors | disable colors
237
- enable debug | disable debug
238
-
239
- ── Workflows ──
240
- list workflows Show available automation workflows
241
- run workflow <name> Execute a named workflow
242
-
243
- ── Edits ──
244
- review edits Step through pending file changes
245
-
246
- ── Meta ──
247
- sidebar <query> Out-of-band agent query (not added to history)
248
- help Show this help
249
-
250
- ═══════════════════════════════════════════════════════════════════════════
236
+ HELP_SECTIONS.voice = `═══════════════════════════════════════════════════════════════════════════
251
237
  VOICE (Speech-to-Text / Text-to-Speech)
252
238
  ═══════════════════════════════════════════════════════════════════════════
253
239
 
@@ -266,8 +252,9 @@ const HELP = `shmakk - AI-supervised terminal wrapper
266
252
  STT: Whisper-base ONNX in-process. No Python, no server, no API key.
267
253
  TTS: kokoro-js (Kokoro-82M ONNX, ~334MB fp16). Auto-download on first use.
268
254
  Requires aplay, paplay, or afplay for audio. 28 voices, rotated daily.
255
+ `;
269
256
 
270
- ═══════════════════════════════════════════════════════════════════════════
257
+ HELP_SECTIONS.env = `═══════════════════════════════════════════════════════════════════════════
271
258
  ENVIRONMENT
272
259
  ═══════════════════════════════════════════════════════════════════════════
273
260
 
@@ -287,8 +274,9 @@ const HELP = `shmakk - AI-supervised terminal wrapper
287
274
  SHMAKK_VOICE_SILENCE_SEC VAD silence threshold seconds
288
275
  SHMAKK_VOICE_SILENCE_THRESHOLD VAD amplitude threshold
289
276
  SHMAKK_VOICE_PAD_START_SEC Start-of-recording padding
277
+ `;
290
278
 
291
- ═══════════════════════════════════════════════════════════════════════════
279
+ HELP_SECTIONS.mcp = `═══════════════════════════════════════════════════════════════════════════
292
280
  MCP & BROWSER
293
281
  ═══════════════════════════════════════════════════════════════════════════
294
282
 
@@ -299,8 +287,9 @@ const HELP = `shmakk - AI-supervised terminal wrapper
299
287
  npm install playwright && npx playwright install chromium
300
288
  Tools: navigate, click, type, read_page, screenshot, evaluate, select,
301
289
  wait, scroll, close.
290
+ `;
302
291
 
303
- ═══════════════════════════════════════════════════════════════════════════
292
+ HELP_SECTIONS.ssh = `═══════════════════════════════════════════════════════════════════════════
304
293
  REMOTE HOSTS (SSH)
305
294
  ═══════════════════════════════════════════════════════════════════════════
306
295
 
@@ -326,7 +315,106 @@ const HELP = `shmakk - AI-supervised terminal wrapper
326
315
  ControlMaster auto
327
316
  ControlPath ~/.ssh/controlmasters/%r@%h:%p
328
317
  ControlPersist 600
318
+ `;
319
+
320
+ HELP_SECTIONS.self = `═══════════════════════════════════════════════════════════════════════════
321
+ SELF-COMMANDS (type inside an shmakk session)
322
+ ═══════════════════════════════════════════════════════════════════════════
323
+
324
+ Self-commands work with a prefix — /cmd or shmakk cmd.
325
+ Bare words like "status" or "stats" go to the shell, not shmakk.
326
+
327
+ -- Session --
328
+ /status | shmakk status Show session status
329
+ /stats | shmakk stats Show session/task statistics
330
+ /sessions | shmakk sessions Show recent sessions
331
+ show sessions | last sessions (same as /sessions)
332
+ resume status Show task journal for resume continuity
333
+ show plan Display current plan and progress
334
+ /compact | shmakk compact Clear conversation + task journal
335
+ /reset | shmakk reset Clear AI conversation history
336
+
337
+ -- Skills --
338
+ list skills List all registered skills
339
+ list skills <category> List skills in a specific category
340
+ list skill categories Show available skill categories
341
+ find skills <query> Search skills by name/description
342
+ load skill <name> Load a skill into the active workspace
343
+ unload skill <name> Remove a skill from its registry
344
+ skill status Show active skill and registry state
345
+
346
+ -- Agents & Team --
347
+ agent overview Show all agents and their specialisms
348
+ agent skills List all agent skills
349
+ agent <name> Show detail for a specific agent
350
+ list agents Alias for agent overview
351
+
352
+ -- Memory & Search --
353
+ recall <query> Search past sessions by content
354
+ find session <query> Find a session by topic
355
+ search db status Display session search DB info
356
+ show memory List stored memories
357
+ forget <query> Remove matching memories
358
+
359
+ -- Configuration --
360
+ show config Print resolved configuration
361
+ mcp status Show MCP servers and tools
362
+ show rules Display active workspace rules
363
+ list endpoints List configured model endpoints
364
+ use endpoint <name> Switch to a named model endpoint
365
+ set model to <name> Change the active model
366
+ set url to <url> Change the base URL
367
+ set api key to <key> Change the API key
368
+
369
+ -- Toggles --
370
+ enable review | disable review
371
+ enable correction | disable correction
372
+ enable yes-files | disable yes-files
373
+ enable colors | disable colors
374
+ enable debug | disable debug
375
+
376
+ -- Workflows --
377
+ list workflows Show available automation workflows
378
+ run workflow <name> Execute a named workflow
379
+
380
+ -- Edits --
381
+ review edits Step through pending file changes
382
+
383
+ -- Meta --
384
+ sidebar <query> Out-of-band agent query (not added to history)
385
+ help Show this help
386
+ `;
387
+
388
+ // Resolve: returns the full old HELP string for backward compat, or the category
389
+ // text, or the summary.
390
+ function resolveHelp(category) {
391
+ if (category) {
392
+ const key = category.toLowerCase();
393
+ if (HELP_SECTIONS[key]) return HELP_SECTIONS[key];
394
+ // Unknown category: show summary + available categories
395
+ const available = Object.keys(HELP_SECTIONS).join(', ');
396
+ return HELP_SUMMARY + `Unknown category "${category}". Available: ${available}\n`;
397
+ }
398
+ return HELP_SUMMARY;
399
+ }
400
+
401
+ // Help text shown INSIDE a session (no launch flags — you're already running)
402
+ const HELP_SESSION_SUMMARY = `shmakk — inside a session
403
+
404
+ Type commands as usual. shmakk watches the shell, catches failures, and
405
+ calls an LLM to fix them, plan tasks, and edit files.
406
+
407
+ Self-commands use a prefix: /cmd or shmakk cmd
408
+ Examples: /status /sessions shmakk status shmakk show sessions
409
+
410
+ Multi-word natural language also works: "show help" "list skills"
411
+
412
+ For the full reference: shmakk --help self (from outside the session)
413
+ For launch flags: shmakk --help launch
329
414
 
330
415
  `;
331
416
 
332
- module.exports = { parseArgs, HELP };
417
+ // Full legacy HELP for backward compat
418
+ const HELP = HELP_SUMMARY + Object.values(HELP_SECTIONS).join('\n');
419
+
420
+ module.exports = { parseArgs, HELP, resolveHelp, HELP_SUMMARY, HELP_SESSION_SUMMARY };
package/src/correction.js CHANGED
@@ -115,6 +115,18 @@ function preserveCase(original, corrected) {
115
115
  }
116
116
 
117
117
  async function correct({ input, glossary, signal: _unused }) {
118
+ // Null/empty input: can happen at shell startup when precmd fires before
119
+ // any command is executed (especially in zsh). Nothing to correct.
120
+ if (!input || !input.trim()) {
121
+ return { category: 'not_a_correction', proposed: null, safety: 'uncertain', reason: 'empty input' };
122
+ }
123
+
124
+ // /-prefixed and "shmakk ..." commands are shmakk self-commands.
125
+ // They should never reach the correction engine — bail out immediately.
126
+ if (/^\//.test(input) || /^shmakk\s/i.test(input)) {
127
+ return { category: 'not_a_correction', proposed: null, safety: 'uncertain', reason: 'shmakk self-command prefix — not a shell command' };
128
+ }
129
+
118
130
  // Pre-filter natural language
119
131
  if (looksLikeNaturalLanguage(input)) {
120
132
  return { category: 'not_a_correction', proposed: null, safety: 'uncertain', reason: 'looks like natural language' };
package/src/endpoints.js CHANGED
@@ -73,6 +73,7 @@ function normalizeModelConfig(name, cfg) {
73
73
  headers: cfg.headers || cfg.headears || null,
74
74
  registry: cfg.registry || null,
75
75
  main: !!cfg.main,
76
+ vision: !!cfg.vision,
76
77
  };
77
78
  }
78
79
 
@@ -143,6 +144,10 @@ function getCurrentEndpointName() {
143
144
  return currentEndpointName;
144
145
  }
145
146
 
147
+ function supportsVision() {
148
+ return !!(currentEndpointConfig && currentEndpointConfig.vision);
149
+ }
150
+
146
151
  function listEndpoints(cwd) {
147
152
  return Object.keys(loadModelRegistry(cwd).models);
148
153
  }
@@ -160,5 +165,6 @@ module.exports = {
160
165
  listEndpoints,
161
166
  getCurrentEndpoint,
162
167
  getCurrentEndpointName,
168
+ supportsVision,
163
169
  getModelRegistry,
164
170
  };
package/src/hooks/bash.js CHANGED
@@ -3,6 +3,21 @@ const os = require('os');
3
3
  const path = require('path');
4
4
 
5
5
  const INIT = `
6
+ # Source login scripts first (equivalent to bash -l).
7
+ # When --rcfile is used, bash skips the normal login sequence, so we must
8
+ # replicate it manually. Order matters: profile.d → /etc/profile, then
9
+ # first found of: ~/.bash_profile, ~/.bash_login, ~/.profile, ~/.bashrc.
10
+ [ -d /etc/profile.d ] && for f in /etc/profile.d/*.sh; do [ -r "$f" ] && . "$f"; done
11
+ [ -f /etc/profile ] && . /etc/profile
12
+ if [ -f "$HOME/.bash_profile" ]; then
13
+ . "$HOME/.bash_profile"
14
+ elif [ -f "$HOME/.bash_login" ]; then
15
+ . "$HOME/.bash_login"
16
+ elif [ -f "$HOME/.profile" ]; then
17
+ . "$HOME/.profile"
18
+ fi
19
+ # Then interactive rc files (these may be already sourced above, but
20
+ # sourcing twice is harmless for well-behaved scripts).
6
21
  [ -f /etc/bash.bashrc ] && . /etc/bash.bashrc
7
22
  [ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"
8
23
 
@@ -13,13 +28,13 @@ __shmakk_preexec() {
13
28
  [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return
14
29
  __shmakk_armed=
15
30
  local cmd
16
- cmd=$(printf '%s' "$BASH_COMMAND" | base64 -w0 2>/dev/null || printf '%s' "$BASH_COMMAND" | base64)
31
+ cmd=$(printf '%s' "$BASH_COMMAND" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
17
32
  printf '\\e]6973;B;%s\\a' "$cmd"
18
33
  }
19
34
  __shmakk_precmd() {
20
35
  local ec=$?
21
36
  local p
22
- p=$(printf '%s' "$PWD" | base64 -w0 2>/dev/null || printf '%s' "$PWD" | base64)
37
+ p=$(printf '%s' "$PWD" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
23
38
  printf '\\e]6973;C;%s\\a' "$ec"
24
39
  printf '\\e]6973;D;%s\\a' "$p"
25
40
  __shmakk_armed=1
package/src/hooks/fish.js CHANGED
@@ -1,17 +1,36 @@
1
1
  // Returns { args, env, cleanup } for spawning fish with markers wired up.
2
2
  // fish supports `-C COMMAND` to run init code after config.fish.
3
+ //
4
+ // base64 encoding: try `-w0` (GNU coreutils), fall back to `-b 0` (BSD/macOS),
5
+ // then plain `base64` as last resort. `tr -d '\n'` strips any line wrapping
6
+ // so the OSC marker payload stays on one line.
3
7
 
4
8
  const INIT = `
5
9
  function __shmakk_pre --on-event fish_preexec
6
- set -l c (printf '%s' "$argv" | base64 -w0 2>/dev/null; or printf '%s' "$argv" | base64)
10
+ set -l c (printf '%s' "$argv" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
7
11
  printf '\\e]6973;B;%s\\a' "$c"
8
12
  end
9
13
  function __shmakk_post --on-event fish_postexec
10
14
  set -l ec $status
11
- set -l p (printf '%s' "$PWD" | base64 -w0 2>/dev/null; or printf '%s' "$PWD" | base64)
15
+ set -l p (printf '%s' "$PWD" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
12
16
  printf '\\e]6973;C;%s\\a' $ec
13
17
  printf '\\e]6973;D;%s\\a' "$p"
14
18
  end
19
+ # Override shmakk binary inside a session so "shmakk <cmd>" routes to
20
+ # local self-commands instead of forking a nested shmakk process.
21
+ # Passes through --flags to the real shmakk binary.
22
+ function shmakk
23
+ if set -q argv[1]; and string match -qr '^--' -- "$argv[1]"
24
+ command shmakk $argv
25
+ return $status
26
+ end
27
+ set -l raw (printf '%s' "shmakk $argv" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
28
+ set -l pwd_b64 (printf '%s' "$PWD" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
29
+ printf '\\e]6973;B;%s\\a' "$raw"
30
+ printf '\\e]6973;C;127\\a'
31
+ printf '\\e]6973;D;%s\\a' "$pwd_b64"
32
+ return 127
33
+ end
15
34
  `.trim();
16
35
 
17
36
  function configure() {
package/src/hooks/zsh.js CHANGED
@@ -2,6 +2,28 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
 
5
+ // zsh under a custom ZDOTDIR sources .zshenv, .zprofile, .zshrc, .zlogin from
6
+ // that directory. We create all four so nothing from the user's real ZDOTDIR
7
+ // is skipped.
8
+
9
+ const ZSHENV = `
10
+ # Source the real .zshenv so PATH and env vars are available.
11
+ if [ -n "$SHMAKK_REAL_ZDOTDIR" ] && [ -f "$SHMAKK_REAL_ZDOTDIR/.zshenv" ]; then
12
+ source "$SHMAKK_REAL_ZDOTDIR/.zshenv"
13
+ elif [ -f "$HOME/.zshenv" ]; then
14
+ source "$HOME/.zshenv"
15
+ fi
16
+ `;
17
+
18
+ const ZPROFILE = `
19
+ # Source the real .zprofile (login shell initialization).
20
+ if [ -n "$SHMAKK_REAL_ZDOTDIR" ] && [ -f "$SHMAKK_REAL_ZDOTDIR/.zprofile" ]; then
21
+ source "$SHMAKK_REAL_ZDOTDIR/.zprofile"
22
+ elif [ -f "$HOME/.zprofile" ]; then
23
+ source "$HOME/.zprofile"
24
+ fi
25
+ `;
26
+
5
27
  const ZSHRC = `
6
28
  # preserve real ZDOTDIR so user config is sourced
7
29
  if [ -n "$SHMAKK_REAL_ZDOTDIR" ]; then
@@ -12,13 +34,13 @@ fi
12
34
 
13
35
  __shmakk_preexec() {
14
36
  local cmd
15
- cmd=$(printf '%s' "$1" | base64 -w0 2>/dev/null || printf '%s' "$1" | base64)
37
+ cmd=$(printf '%s' "$1" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
16
38
  printf '\\e]6973;B;%s\\a' "$cmd"
17
39
  }
18
40
  __shmakk_precmd() {
19
41
  local ec=$?
20
42
  local p
21
- p=$(printf '%s' "$PWD" | base64 -w0 2>/dev/null || printf '%s' "$PWD" | base64)
43
+ p=$(printf '%s' "$PWD" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
22
44
  printf '\\e]6973;C;%s\\a' "$ec"
23
45
  printf '\\e]6973;D;%s\\a' "$p"
24
46
  }
@@ -30,7 +52,14 @@ precmd_functions+=(__shmakk_precmd)
30
52
  function configure() {
31
53
  const dir = path.join(os.tmpdir(), `shmakk-zsh-${process.pid}`);
32
54
  fs.mkdirSync(dir, { recursive: true });
55
+ // zsh under a custom ZDOTDIR sources .zshenv, .zprofile, .zshrc, .zlogin
56
+ // from that directory. We must provide all four so the user's environment
57
+ // is complete.
58
+ fs.writeFileSync(path.join(dir, '.zshenv'), ZSHENV, { mode: 0o600 });
59
+ fs.writeFileSync(path.join(dir, '.zprofile'), ZPROFILE, { mode: 0o600 });
33
60
  fs.writeFileSync(path.join(dir, '.zshrc'), ZSHRC, { mode: 0o600 });
61
+ // No .zlogin needed — zsh docs say .zlogin is for commands to run at the
62
+ // start of an interactive login shell; .zprofile already covers env setup.
34
63
  const realZ = process.env.ZDOTDIR || '';
35
64
  return {
36
65
  args: ['-i'],
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const { parseArgs, HELP } = require('./cli');
1
+ const { parseArgs, HELP, resolveHelp } = require('./cli');
2
2
  const { normalizeProfile, resolveProfile } = require('./profiles');
3
3
  const { applyEndpoint, getCurrentEndpoint, getCurrentEndpointName } = require('./endpoints');
4
4
  const { ensureModelRuntime } = require('./llm');
@@ -66,7 +66,7 @@ async function main() {
66
66
  }
67
67
 
68
68
  if (opts.help) {
69
- process.stdout.write(HELP);
69
+ process.stdout.write(resolveHelp(opts.helpCategory));
70
70
  process.exit(0);
71
71
  }
72
72
 
@@ -204,6 +204,15 @@ async function main() {
204
204
  if (opts.ttsVoice) process.env.SHMAKK_TTS_VOICE = opts.ttsVoice;
205
205
 
206
206
  const { start } = require('./orchestrator');
207
+
208
+ // Refuse to nest sessions: launching shmakk inside shmakk would
209
+ // create a recursive PTY tree with no benefit.
210
+ if (process.env.SHMAKK === '1') {
211
+ process.stderr.write('[shmakk] already inside an shmakk session (SHMAKK=1).\n');
212
+ process.stderr.write('[shmakk] use --help to see in-session commands, or exit the current session first.\n');
213
+ process.exit(1);
214
+ }
215
+
207
216
  const exitCode = await start(opts);
208
217
  process.exit(exitCode);
209
218
  }
package/src/llm.js CHANGED
@@ -4,7 +4,7 @@ try { OpenAI = require('openai'); } catch { OpenAI = null; }
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const fs = require('fs');
7
- const { getCurrentEndpoint, getCurrentEndpointName, getModelRegistry } = require('./endpoints');
7
+ const { getCurrentEndpoint, getCurrentEndpointName, getModelRegistry, supportsVision } = require('./endpoints');
8
8
 
9
9
  function parseHeaders(s) {
10
10
  const out = {};
@@ -536,4 +536,4 @@ function getDeepSeekOptions(taskType) {
536
536
  };
537
537
  }
538
538
 
539
- module.exports = { makeClient, modelFor, isConfigured, ensureModelRuntime, getDeepSeekOptions, isDeepSeekProvider };
539
+ module.exports = { makeClient, modelFor, isConfigured, ensureModelRuntime, getDeepSeekOptions, isDeepSeekProvider, supportsVision };
package/src/mcp-client.js CHANGED
@@ -212,9 +212,15 @@ class MCPServer {
212
212
  if (item.type === 'text') {
213
213
  texts.push(item.text);
214
214
  } else if (item.type === 'image') {
215
+ // Preserve base64 image data so vision-capable providers can process it.
216
+ // Cap at ~2MB of base64 to avoid blowing out context windows.
217
+ const raw = String(item.data || '');
218
+ const capped = raw.length > 2_000_000 ? raw.slice(0, 2_000_000) : raw;
215
219
  images.push({
216
220
  mimeType: item.mimeType || 'image/png',
217
- dataLength: (item.data || '').length,
221
+ data: capped,
222
+ dataLength: raw.length,
223
+ truncated: raw.length > 2_000_000,
218
224
  });
219
225
  } else if (item.type === 'resource') {
220
226
  texts.push(`[resource: ${item.resource?.uri || 'unknown'}]`);
package/src/notify.js CHANGED
@@ -2,14 +2,17 @@
2
2
  // Falls back silently if notify-send is not available or no notification
3
3
  // daemon is running.
4
4
 
5
- const { execFile } = require('child_process');
5
+ const { execFile, execFileSync } = require('child_process');
6
+ const { existsSync } = require('fs');
6
7
 
7
8
  const NOTIFY_BIN = 'notify-send';
8
9
 
9
10
  function available() {
10
11
  try {
11
- const { execFileSync } = require('child_process');
12
- execFileSync('which', [NOTIFY_BIN], { stdio: 'ignore' });
12
+ // Prefer direct path check; fall back to `command -v` if not at known paths
13
+ if (existsSync('/usr/bin/notify-send')) return true;
14
+ if (existsSync('/usr/local/bin/notify-send')) return true;
15
+ execFileSync('command', ['-v', NOTIFY_BIN], { stdio: 'ignore' });
13
16
  return true;
14
17
  } catch {
15
18
  return false;
package/src/pty.js CHANGED
@@ -13,8 +13,8 @@ function getSize() {
13
13
 
14
14
  const VOICE_HOTKEY = 0x0f; // Ctrl+O — triggers voice recording
15
15
 
16
- function startSession({ debug = false, voiceEnabled = false } = {}) {
17
- const shell = detectShell();
16
+ function startSession({ debug = false, voiceEnabled = false, shellOverride = null } = {}) {
17
+ const shell = detectShell(shellOverride);
18
18
  const cfg = configureForShell(shell.name);
19
19
  const { cols, rows } = getSize();
20
20
 
package/src/review.js CHANGED
@@ -2,13 +2,13 @@
2
2
  // optional `{ onCancel }` callback that fires when the user hits Ctrl-C.
3
3
 
4
4
  function makePrompter(pty, write, opts = {}) {
5
- const { onNotify } = opts;
6
5
  return function ask(question, defaultYes, { onCancel, onWhy } = {}) {
7
6
  return new Promise((resolve) => {
8
- if (onNotify) {
7
+ if (opts.notify) {
9
8
  try {
9
+ const { notify } = require('./notify');
10
10
  const body = typeof question === 'string' ? question.replace(/\x1b\[[0-9;]*m/g, '') : String(question || '');
11
- onNotify('shmakk needs your attention', body.slice(0, 120));
11
+ notify('shmakk needs your attention', body.slice(0, 120));
12
12
  } catch {}
13
13
  }
14
14
  const tag = defaultYes ? '[Y/n/?]' : '[y/N/?]';
@@ -110,12 +110,14 @@ const SELF_COMMANDS = [
110
110
  },
111
111
 
112
112
  // ── Session ──
113
+ // NOTE: Bare "status", "stats" etc. are NOT intercepted — they may be
114
+ // real shell commands. Use /status, /stats, shmakk status, etc. instead.
113
115
  {
114
- patterns: [/^(?:shmakk\s+)?status$/i],
116
+ patterns: [/^status$/i],
115
117
  action: 'status',
116
118
  },
117
119
  {
118
- patterns: [/^(?:show\s+)?stats$/i, /^session\s+stats$/i],
120
+ patterns: [/^stats$/i, /^session\s+stats$/i],
119
121
  action: 'stats',
120
122
  },
121
123
  {
@@ -164,8 +166,8 @@ const SELF_COMMANDS = [
164
166
  },
165
167
  {
166
168
  patterns: [
167
- /^(?:show\s+)?last\s+sessions?$/i,
168
- /^recent\s+sessions?$/i,
169
+ /^(?:show\s+)?(?:last\s+|recent\s+)?sessions?$/i,
170
+ /^sessions?$/i,
169
171
  ],
170
172
  action: 'last-sessions',
171
173
  },
@@ -320,6 +322,22 @@ const SELF_COMMANDS = [
320
322
  action: 'disable-yes-files',
321
323
  },
322
324
 
325
+ // ── Notify ──
326
+ {
327
+ patterns: [
328
+ /^(?:enable|turn\s+on)\s+notify$/i,
329
+ /^notify\s+on$/i,
330
+ ],
331
+ action: 'enable-notify',
332
+ },
333
+ {
334
+ patterns: [
335
+ /^(?:disable|turn\s+off|no)\s+notify$/i,
336
+ /^notify\s+off$/i,
337
+ ],
338
+ action: 'disable-notify',
339
+ },
340
+
323
341
  // ── Colors ──
324
342
  {
325
343
  patterns: [
@@ -399,20 +417,65 @@ const SELF_COMMANDS = [
399
417
  },
400
418
  ];
401
419
 
420
+ // Self-command prefixes accepted by the shell:
421
+ // /cmd — e.g. /status, /sessions, /compact
422
+ // shmakk cmd — e.g. shmakk status, shmakk show sessions
423
+ // Bare words like "status" are NOT intercepted (they go to the shell).
424
+ const SELF_PREFIX_RE = /^\/(.+)$/;
425
+ const SHMAKK_PREFIX_RE = /^shmakk\s+(.+)$/i;
426
+
427
+ function hasSelfCommandPrefix(input) {
428
+ const text = String(input || '').trim();
429
+ return SELF_PREFIX_RE.test(text) || SHMAKK_PREFIX_RE.test(text);
430
+ }
431
+
432
+ function stripSelfCommandPrefix(input) {
433
+ const text = String(input || '').trim();
434
+ let m = SELF_PREFIX_RE.exec(text);
435
+ if (m) return m[1].trim();
436
+ m = SHMAKK_PREFIX_RE.exec(text);
437
+ if (m) return m[1].trim();
438
+ return text;
439
+ }
440
+
402
441
  function matchSelfCommand(input) {
403
442
  const text = String(input || '').trim();
404
443
  if (!text) return { matched: false };
405
444
 
406
- for (const entry of SELF_COMMANDS) {
407
- for (const pattern of entry.patterns) {
408
- const m = pattern.exec(text);
409
- if (m) {
410
- return {
411
- matched: true,
412
- action: entry.action,
413
- arg: entry.needsArg && m[1] ? m[1].trim() : null,
414
- confirm: !!entry.confirm,
415
- };
445
+ // Try matching with prefix stripped first (for /status, shmakk status, etc.)
446
+ const stripped = stripSelfCommandPrefix(text);
447
+ if (stripped !== text) {
448
+ for (const entry of SELF_COMMANDS) {
449
+ for (const pattern of entry.patterns) {
450
+ const m = pattern.exec(stripped);
451
+ if (m) {
452
+ return {
453
+ matched: true,
454
+ action: entry.action,
455
+ arg: entry.needsArg && m[1] ? m[1].trim() : null,
456
+ confirm: !!entry.confirm,
457
+ };
458
+ }
459
+ }
460
+ }
461
+ return { matched: false };
462
+ }
463
+
464
+ // Multi-word natural-language commands (no prefix needed).
465
+ // Single bare words are NOT matched — they could be real shell commands.
466
+ const wordCount = text.split(/\s+/).length;
467
+ if (wordCount >= 2) {
468
+ for (const entry of SELF_COMMANDS) {
469
+ for (const pattern of entry.patterns) {
470
+ const m = pattern.exec(text);
471
+ if (m) {
472
+ return {
473
+ matched: true,
474
+ action: entry.action,
475
+ arg: entry.needsArg && m[1] ? m[1].trim() : null,
476
+ confirm: !!entry.confirm,
477
+ };
478
+ }
416
479
  }
417
480
  }
418
481
  }
@@ -422,6 +485,9 @@ function matchSelfCommand(input) {
422
485
 
423
486
  // ctx is optional: { opts, HELP, setColors }
424
487
  function executeSelfCommand(match, write, ctx = {}) {
488
+ // Update terminal tab title so self-command activity is visible from other tabs
489
+ const label = match.action.replace(/-/g, ' ');
490
+ write(`\x1b]0;${label} — shmakk\x07`);
425
491
  const ctl = require('./control');
426
492
  const opts = ctx.opts || {};
427
493
 
@@ -429,7 +495,7 @@ function executeSelfCommand(match, write, ctx = {}) {
429
495
 
430
496
  // ── Help ──
431
497
  case 'show-help': {
432
- const helpText = ctx.HELP || '[shmakk] help text not available';
498
+ const helpText = ctx.HELP_SESSION_SUMMARY || ctx.HELP_SUMMARY || ctx.HELP || '[shmakk] help text not available';
433
499
  write(helpText.replace(/\n/g, '\r\n'));
434
500
  break;
435
501
  }
@@ -803,6 +869,18 @@ function executeSelfCommand(match, write, ctx = {}) {
803
869
  break;
804
870
  }
805
871
 
872
+ // ── Notify ──
873
+ case 'enable-notify': {
874
+ if (ctx.opts) ctx.opts.notify = true;
875
+ write('[shmakk] desktop notifications enabled\r\n');
876
+ break;
877
+ }
878
+ case 'disable-notify': {
879
+ if (ctx.opts) ctx.opts.notify = false;
880
+ write('[shmakk] desktop notifications disabled\r\n');
881
+ break;
882
+ }
883
+
806
884
  // ── Colors ──
807
885
  case 'enable-colors': {
808
886
  if (ctx.opts) ctx.opts.colors = true;
@@ -858,6 +936,8 @@ function executeSelfCommand(match, write, ctx = {}) {
858
936
  default:
859
937
  write(`[shmakk] unknown self-command: ${match.action}\r\n`);
860
938
  }
939
+ // Clear terminal title — shell will restore normal title on next prompt
940
+ write('\x1b]0;\x07');
861
941
  }
862
942
 
863
- module.exports = { matchSelfCommand, executeSelfCommand, SELF_COMMANDS };
943
+ module.exports = { matchSelfCommand, executeSelfCommand, hasSelfCommandPrefix, stripSelfCommandPrefix, SELF_COMMANDS };
package/src/session.js CHANGED
@@ -20,7 +20,7 @@ const { runTeam, looksMultiDomain } = require('./team');
20
20
  const { addPlanTasks, markTaskComplete, markTaskSkipped } = require('./task-file');
21
21
  const { captureGitSha, runPostPlanReview } = require('./code-reviewer');
22
22
  const sessionSearch = require('./session-search');
23
- const { HELP } = require('./cli');
23
+ const { HELP, HELP_SUMMARY, HELP_SESSION_SUMMARY } = require('./cli');
24
24
  const audit = require('./audit');
25
25
  const { setMaxListeners } = require('events');
26
26
 
@@ -163,13 +163,11 @@ function makeToolConfirm(opts, ask, out, getAbort) {
163
163
  }
164
164
 
165
165
  async function runOneSession(opts, registerSession) {
166
- const session = startSession({ debug: opts.debug, voiceEnabled: !!opts.voice });
166
+ const session = startSession({ debug: opts.debug, voiceEnabled: !!opts.voice, shellOverride: opts.shell });
167
167
  let colorsEnabled = opts.colors !== false;
168
168
  let markdownEnabled = opts.markdown !== false;
169
169
  const out = (s) => session.stdoutWrite(colorsEnabled ? s : stripAnsi(s));
170
- const ask = makePrompter(session, out, {
171
- onNotify: opts.notify ? (summary, body) => notify(summary, body) : null,
172
- });
170
+ const ask = makePrompter(session, out, opts);
173
171
  const glossary = loadGlossary();
174
172
  // Workspace tracking: explicit --workspace is "pinned"; otherwise cwd
175
173
  // floats with the inner shell's `cd`. When both pinned and cwd differ,
@@ -373,6 +371,8 @@ async function runOneSession(opts, registerSession) {
373
371
  executeSelfCommand(voiceSelfCmd, out, {
374
372
  opts,
375
373
  HELP,
374
+ HELP_SUMMARY,
375
+ HELP_SESSION_SUMMARY,
376
376
  setColors: (v) => { colorsEnabled = v; },
377
377
  });
378
378
  return;
@@ -613,6 +613,14 @@ async function runOneSession(opts, registerSession) {
613
613
  lastCommand = null;
614
614
  if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
615
615
 
616
+ // No command was tracked — precmd can fire at shell startup (especially
617
+ // in zsh) before any command executes. There's nothing to correct or
618
+ // route to the agent.
619
+ if (!lastCmd) {
620
+ discardPending();
621
+ return;
622
+ }
623
+
616
624
  // ── Self-command detection (FIRST — before ANY other processing) ──
617
625
  // Self-commands are pure local execution. They MUST bypass:
618
626
  // - the noAi early-return (they don't need an LLM)
@@ -672,11 +680,20 @@ async function runOneSession(opts, registerSession) {
672
680
  executeSelfCommand(selfCmd, out, {
673
681
  opts,
674
682
  HELP,
683
+ HELP_SUMMARY,
684
+ HELP_SESSION_SUMMARY,
675
685
  setColors: (v) => { colorsEnabled = v; },
676
686
  });
677
687
  session.childWrite('\r');
678
688
  return;
679
689
  }
690
+ // /-prefixed and "shmakk ..." commands that didn't match a known
691
+ // self-command are invalid shmakk commands. Don't send them to the
692
+ // correction engine — the user was explicitly addressing shmakk.
693
+ if (/^\//.test(lastCmd) || /^shmakk\s/i.test(lastCmd)) {
694
+ flushPending();
695
+ return;
696
+ }
680
697
  }
681
698
 
682
699
  // Determine the command to feed forward.
package/src/shell.js CHANGED
@@ -1,32 +1,52 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
- function detectShell() {
4
+ // Map shell name to the preferred executable path.
5
+ // Resolve's dctl paths on Arch-like systems can put bash in /usr/bin instead of /bin.
6
+ // Map shell name to candidate executable paths.
7
+ // Ordered by likelihood on the current platform; first existing path wins.
8
+ const SHELL_PATH_CANDIDATES = {
9
+ fish: ['/usr/bin/fish', '/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/bin/fish'],
10
+ bash: ['/usr/bin/bash', '/bin/bash', '/opt/homebrew/bin/bash', '/usr/local/bin/bash'],
11
+ zsh: ['/usr/bin/zsh', '/bin/zsh', '/opt/homebrew/bin/zsh', '/usr/local/bin/zsh'],
12
+ };
13
+
14
+ function shellPath(name) {
15
+ // Name given explicitly (--shell flag): try known paths first, then PATH.
16
+ const candidates = SHELL_PATH_CANDIDATES[name];
17
+ if (candidates) {
18
+ for (const candidate of candidates) {
19
+ if (fs.existsSync(candidate)) return candidate;
20
+ }
21
+ }
22
+
23
+ // Fall back to PATH search for the requested shell.
24
+ const { execSync } = require('child_process');
25
+ try {
26
+ const p = execSync(`command -v ${name}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
27
+ if (p && fs.existsSync(p)) return p;
28
+ } catch {}
29
+
30
+ return null;
31
+ }
32
+
33
+ function detectShell(shellOverride) {
34
+ // Explicit --shell flag overrides everything.
35
+ if (shellOverride) {
36
+ const p = shellPath(shellOverride);
37
+ if (p) return { path: p, name: shellOverride };
38
+ process.stderr.write(`[shmakk] shell "${shellOverride}" not found, falling back to default\n`);
39
+ }
40
+
5
41
  const env = process.env.SHELL;
6
42
  if (env && fs.existsSync(env)) {
7
43
  return { path: env, name: path.basename(env) };
8
44
  }
9
- const fallbacks = ['/bin/bash', '/usr/bin/bash', '/bin/sh'];
45
+ const fallbacks = ['/bin/bash', '/usr/bin/bash', '/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/bin/sh'];
10
46
  for (const f of fallbacks) {
11
47
  if (fs.existsSync(f)) return { path: f, name: path.basename(f) };
12
48
  }
13
49
  return { path: '/bin/sh', name: 'sh' };
14
50
  }
15
51
 
16
- function shellArgs(name) {
17
- // Login + interactive so the user's normal init runs.
18
- // We deliberately keep this minimal: do NOT inject rc files,
19
- // do NOT alter prompt. Phase 2 will add hooks for command metadata.
20
- switch (name) {
21
- case 'fish':
22
- return ['-i', '-l'];
23
- case 'zsh':
24
- return ['-i', '-l'];
25
- case 'bash':
26
- return ['-i', '-l'];
27
- default:
28
- return ['-i'];
29
- }
30
- }
31
-
32
- module.exports = { detectShell, shellArgs };
52
+ module.exports = { detectShell, shellPath };
@@ -15,6 +15,7 @@ function buildSystemPrompt({
15
15
  mcpToolHint = null,
16
16
  userRulesText = null,
17
17
  userMemoryText = null,
18
+ supportsVision = false,
18
19
  }) {
19
20
  return `You are an expert AI coding assistant running inside shmakk.
20
21
 
@@ -65,12 +66,13 @@ Tool Call Format:
65
66
 
66
67
  Available Tools:
67
68
  - list_dir: list files/directories
68
- - read_file: read file contents
69
+ - read_file: read file contents (text or images)
69
70
  - write_file: create or overwrite a file
70
71
  - make_dir: create a directory
71
72
  - run: execute shell commands
72
73
  - web_search: search the web
73
74
  - fetch_url: fetch a URL
75
+ ${supportsVision ? '\nIf images are included in tool results (e.g. read_file on a PNG, or MCP tools that return visuals), you can describe and analyze what you see in them.\n' : ''}
74
76
 
75
77
  Path Rules:
76
78
  - Always use relative paths resolved against \`${roots[0]}\`.
package/src/tools.js CHANGED
@@ -81,7 +81,7 @@ function within(roots, p) {
81
81
  const TOOLS = [
82
82
  { type: 'function', function: {
83
83
  name: 'read_file',
84
- description: 'Read a UTF-8 file inside the workspace. Supports compact partial reads.',
84
+ description: 'Read a file inside the workspace. Text files support partial reads (head, tail, grep, imports, exports, symbol). Image files (.png/.jpg/.gif/.webp/.bmp/.svg) are returned as base64 for vision analysis.',
85
85
  parameters: {
86
86
  type: 'object',
87
87
  required: ['path'],
@@ -397,6 +397,31 @@ async function dispatchTool(name, args, roots, confirmTool, signal, mcpManager)
397
397
  if (!p) return { error: 'path outside workspace' };
398
398
  try {
399
399
  const buf = fs.readFileSync(p);
400
+ const ext = path.extname(p).toLowerCase();
401
+
402
+ // Image files: return as base64 for vision-capable providers.
403
+ // Mode-specific sub-reads don't apply to images — always return full.
404
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
405
+ const MIME_MAP = {
406
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
407
+ '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
408
+ '.svg': 'image/svg+xml',
409
+ };
410
+ if (IMAGE_EXTS.has(ext)) {
411
+ const maxImageBytes = 2 * 1024 * 1024; // 2 MB binary (~2.7 MB base64)
412
+ const slice = buf.length > maxImageBytes ? buf.subarray(0, maxImageBytes) : buf;
413
+ const b64 = slice.toString('base64');
414
+ return {
415
+ content: `[Image: ${path.basename(p)} — ${buf.length} bytes${buf.length > maxImageBytes ? ' (truncated to 2MB for display)' : ''}]`,
416
+ images: [{
417
+ mimeType: MIME_MAP[ext],
418
+ data: b64,
419
+ dataLength: b64.length,
420
+ truncated: buf.length > maxImageBytes,
421
+ }],
422
+ };
423
+ }
424
+
400
425
  const text = buf.slice(0, MAX_FILE_BYTES).toString('utf8');
401
426
  const lines = text.split(/\r?\n/);
402
427
  const mode = args.mode || 'full';