phewsh 0.15.0 → 0.15.1

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.
@@ -110,6 +110,17 @@ function writeViews(intentDir, pps) {
110
110
  }
111
111
 
112
112
  async function main() {
113
+ // ESC backs out cleanly at any point — nothing half-written, no error.
114
+ if (process.stdin.isTTY) {
115
+ readline.emitKeypressEvents(process.stdin);
116
+ process.stdin.on('keypress', (str, key) => {
117
+ if (key && key.name === 'escape') {
118
+ console.log('\n\n stopped — esc. Nothing changed.\n');
119
+ process.exit(0);
120
+ }
121
+ });
122
+ }
123
+
113
124
  if (args.includes('--help') || args.includes('-h')) {
114
125
  console.log(`
115
126
  😮‍💨🤫 phewsh clarify
@@ -17,7 +17,7 @@ const intentDir = () => path.join(process.cwd(), '.intent');
17
17
  const { select, refreshSession: refreshSess } = require('../lib/supabase');
18
18
  const { readPPS } = require('../lib/pps');
19
19
  const { push, pull, ensureValidToken } = require('./sync');
20
- const { HARNESSES, listHarnesses, runViaHarness } = require('../lib/harnesses');
20
+ const { HARNESSES, listHarnesses, runViaHarness, cancelActive } = require('../lib/harnesses');
21
21
  const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
22
22
  const { recordSessionEvent } = require('../lib/receipts-data');
23
23
  const { recordProject, listProjects, scanForProjects, fmtAgo } = require('../lib/projects-index');
@@ -198,21 +198,28 @@ function buildHarnessPrompt(messages, input) {
198
198
  return `Conversation so far:\n\n${transcript}\n\n---\n\nUser: ${input}\n\nRespond to the last user message.`;
199
199
  }
200
200
 
201
- async function streamChat(apiKey, messages, systemPrompt, modelId) {
201
+ async function streamChat(apiKey, messages, systemPrompt, modelId, opts = {}) {
202
202
  const body = { model: modelId, max_tokens: 2048, messages, stream: true };
203
203
  if (systemPrompt) body.system = systemPrompt;
204
204
 
205
205
  const spin = ui.spinner('thinking');
206
206
 
207
- const response = await fetch('https://api.anthropic.com/v1/messages', {
208
- method: 'POST',
209
- headers: {
210
- 'x-api-key': apiKey,
211
- 'anthropic-version': '2023-06-01',
212
- 'content-type': 'application/json',
213
- },
214
- body: JSON.stringify(body),
215
- });
207
+ let response;
208
+ try {
209
+ response = await fetch('https://api.anthropic.com/v1/messages', {
210
+ method: 'POST',
211
+ headers: {
212
+ 'x-api-key': apiKey,
213
+ 'anthropic-version': '2023-06-01',
214
+ 'content-type': 'application/json',
215
+ },
216
+ body: JSON.stringify(body),
217
+ signal: opts.signal,
218
+ });
219
+ } catch (err) {
220
+ spin.stop();
221
+ throw err;
222
+ }
216
223
 
217
224
  if (!response.ok) {
218
225
  spin.stop();
@@ -491,7 +498,9 @@ async function main() {
491
498
  });
492
499
  decisionsThisSession++;
493
500
  try {
501
+ turnInFlight = true;
494
502
  const output = await runViaHarness(harnessId, fullSystem, buildHarnessPrompt(messages, input), { model: harnessModel });
503
+ turnInFlight = false;
495
504
  messages.push({ role: 'user', content: input });
496
505
  messages.push({ role: 'assistant', content: (output || '').trim() });
497
506
  recordSessionEvent(harnessId, projectName, 'task_complete', {
@@ -501,6 +510,12 @@ async function main() {
501
510
  console.log(slate(` via ${HARNESSES[harnessId].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
502
511
  return true;
503
512
  } catch (err) {
513
+ turnInFlight = false;
514
+ if (userCancelled) {
515
+ userCancelled = false;
516
+ console.log(`\n ${slate('cancelled — esc')}`);
517
+ return true; // user's call, not a failure: no fallback offer
518
+ }
504
519
  try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
505
520
  recordSessionEvent(harnessId, projectName, 'task_complete', {
506
521
  taskId: decisionId, success: false, summary: input.slice(0, 140),
@@ -519,7 +534,11 @@ async function main() {
519
534
  messages.push({ role: 'user', content: input });
520
535
  console.log('');
521
536
  try {
522
- const result = await streamChat(config.apiKey, messages, fullSystem, modelId(currentModel));
537
+ turnInFlight = true;
538
+ turnAbort = new AbortController();
539
+ const result = await streamChat(config.apiKey, messages, fullSystem, modelId(currentModel), { signal: turnAbort.signal });
540
+ turnInFlight = false;
541
+ turnAbort = null;
523
542
  messages.push({ role: 'assistant', content: result.content });
524
543
  if (result.promptTokens) totalPromptTokens += result.promptTokens;
525
544
  if (result.completionTokens) totalCompletionTokens += result.completionTokens;
@@ -537,6 +556,14 @@ async function main() {
537
556
  });
538
557
  return true;
539
558
  } catch (err) {
559
+ turnInFlight = false;
560
+ turnAbort = null;
561
+ if (userCancelled || err.name === 'AbortError') {
562
+ userCancelled = false;
563
+ messages.pop();
564
+ console.log(`\n ${slate('cancelled — esc')}`);
565
+ return true; // user's call, not a failure: no fallback offer
566
+ }
540
567
  try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
541
568
  messages.pop();
542
569
  console.error(`\n ${ember('!')} ${sage('API route failed')}${slate(' — ' + err.message.split('\n')[0])}`);
@@ -586,23 +613,65 @@ async function main() {
586
613
  historySize: 100,
587
614
  });
588
615
 
589
- // Live input coloring: slash commands render teal, @mentions peach, as
590
- // you type same signal Claude Code gives. TTY-only, fail-soft: if the
591
- // private readline API ever changes, input falls back to plain.
616
+ // Live input coloring like Claude Code: text stays normal, and only a
617
+ // RECOGNIZED leading /command (or @harness) token turns teal (peach for @)
618
+ // so you know it registered. Arguments stay plain. TTY-only, fail-soft.
619
+ const KNOWN_COMMANDS = new Set([
620
+ 'quit', 'exit', 'q', 'help', 'h', 'init', 'intent', 'clarify', 'model',
621
+ 'models', 'council', 'all', 'provider', 'route', 'use', 'work', 'run',
622
+ 'clear', 'status', 'key', 'login', 'export', 'push', 'pull', 'serve',
623
+ 'sync', 'harnesses', 'fallback', 'outcomes', 'tour', 'update', 'upgrade',
624
+ 'agents', 'context', 'gate', 'reload', 'sequence', 'setup', 'system', 'watch',
625
+ ]);
626
+ const installedIds = harnesses.filter(h => h.installed).map(h => h.id);
627
+ let turnAbort = null; // AbortController while an API turn streams
628
+ let turnInFlight = false; // any route — ESC cancels
629
+ let userCancelled = false; // distinguishes esc from real failures
630
+
631
+ function colorizeInput(cur) {
632
+ const tok = cur.slice(1).split(/\s/)[0].toLowerCase();
633
+ if (!tok) return null;
634
+ if (cur[0] === '/' && KNOWN_COMMANDS.has(tok)) {
635
+ return `\x1b[38;5;79m/${cur.slice(1, 1 + tok.length)}\x1b[0m${cur.slice(1 + tok.length)}`;
636
+ }
637
+ if (cur[0] === '@' && installedIds.some(id => id === tok || id.startsWith(tok))) {
638
+ return `\x1b[38;5;216m@${cur.slice(1, 1 + tok.length)}\x1b[0m${cur.slice(1 + tok.length)}`;
639
+ }
640
+ return null;
641
+ }
642
+
592
643
  if (process.stdout.isTTY && typeof rl._writeToOutput === 'function') {
593
644
  const origWrite = rl._writeToOutput.bind(rl);
594
645
  rl._writeToOutput = function (s) {
595
646
  try {
596
647
  const cur = rl.line || '';
597
- if ((cur[0] === '/' || cur[0] === '@') && typeof s === 'string' && s.includes(cur)) {
598
- const c = cur[0] === '/' ? '\x1b[38;5;79m' : '\x1b[38;5;216m';
599
- s = s.split(cur).join(c + cur + '\x1b[0m');
648
+ if (typeof s === 'string' && cur && s.includes(cur)) {
649
+ const colored = colorizeInput(cur);
650
+ if (colored) s = s.split(cur).join(colored);
600
651
  }
601
652
  } catch { /* never break input */ }
602
653
  origWrite(s);
603
654
  };
604
- process.stdin.on('keypress', () => {
655
+ }
656
+
657
+ if (process.stdin.isTTY) {
658
+ process.stdin.on('keypress', (str, key) => {
605
659
  try {
660
+ // ESC: cancel an in-flight turn, or clear the input line.
661
+ if (key && key.name === 'escape') {
662
+ if (turnInFlight) {
663
+ userCancelled = true;
664
+ if (turnAbort) turnAbort.abort();
665
+ cancelActive();
666
+ } else if (rl.line) {
667
+ rl.line = '';
668
+ rl.cursor = 0;
669
+ rl._refreshLine();
670
+ }
671
+ return;
672
+ }
673
+ // Re-render so token coloring tracks edits (and un-colors when it
674
+ // stops matching a known command).
606
675
  const cur = rl.line || '';
607
676
  if (cur[0] === '/' || cur[0] === '@') rl._refreshLine();
608
677
  } catch { /* never break input */ }
@@ -789,6 +858,7 @@ async function main() {
789
858
  console.log(` ${cream('session')}`);
790
859
  console.log(` ${teal('/work')} ${slate('[harness]')} ${sage('Hand off to interactive Claude Code/Codex — outcome on return')}`);
791
860
  console.log(` ${teal('/run')} ${slate('<prompt>')} ${sage('One-shot prompt (no history)')}`);
861
+ console.log(` ${teal('esc')} ${sage('Cancel a running turn · clear the input line')}`);
792
862
  console.log(` ${teal('/clear')} ${sage('Clear conversation')}`);
793
863
  console.log(` ${teal('/status')} ${sage('Session stats')}`);
794
864
  console.log(` ${teal('/quit')} ${sage('Exit')}`);
@@ -1136,11 +1206,24 @@ async function main() {
1136
1206
  ui.divider('line');
1137
1207
  if (route?.type === 'harness') {
1138
1208
  const h = HARNESSES[route.id];
1139
- console.log(` ${b(cream('Models'))} ${sage('— ' + h.label + ' owns its own model list')}`);
1209
+ console.log(` ${b(cream('Models'))} ${sage('— via ' + h.label)}`);
1140
1210
  ui.divider('line');
1141
- console.log(` ${sage('Current preference:')} ${cream(harnessModel || h.label + ' default')}`);
1142
- console.log(` ${sage('Set one with')} ${cream('/model <anything ' + h.label + ' accepts>')} ${slate(' it validates, not phewsh')}`);
1143
- console.log(` ${slate('shortcuts that work for Claude Code: sonnet · opus · haiku · fable · any full model id')}`);
1211
+ if (h.modelHints) {
1212
+ // Aliases the harness resolves to its own current versions
1213
+ // stable names, so this list can't go stale.
1214
+ console.log(` ${cream('default'.padEnd(12))} ${sage(h.label + "'s own default")}${!harnessModel ? ` ${teal('●')}` : ''}`);
1215
+ h.modelHints.forEach(m => {
1216
+ const active = harnessModel === m ? ` ${teal('●')}` : '';
1217
+ console.log(` ${cream(m.padEnd(12))} ${sage('latest ' + m.charAt(0).toUpperCase() + m.slice(1))}${active}`);
1218
+ });
1219
+ if (harnessModel && !h.modelHints.includes(harnessModel)) {
1220
+ console.log(` ${cream(harnessModel.padEnd(12))} ${sage('(pass-through)')} ${teal('●')}`);
1221
+ }
1222
+ console.log(`\n ${sage('Switch:')} ${cream('/model <name>')} ${slate('— any full model id also works; ' + h.label + ' validates')}`);
1223
+ } else {
1224
+ console.log(` ${sage('Current preference:')} ${cream(harnessModel || h.label + ' default')}`);
1225
+ console.log(` ${sage(h.label + ' owns its model list —')} ${cream('/model <anything it accepts>')} ${slate('passes through; it validates')}`);
1226
+ }
1144
1227
  console.log('');
1145
1228
  rl.prompt();
1146
1229
  return;
@@ -1230,6 +1313,15 @@ async function main() {
1230
1313
  const artifacts = ['vision', 'plan', 'next', 'status'];
1231
1314
  const intentDir = path.join(process.cwd(), '.intent');
1232
1315
 
1316
+ // Full markdown render — **bold**, *italic*, `code`, [links] become
1317
+ // terminal formatting, not literal symbols. `base` keeps the line's
1318
+ // color after each inline reset.
1319
+ const inlineMd = (t, base) => t
1320
+ .replace(/\*\*([^*]+)\*\*/g, (_, x) => `\x1b[1m\x1b[38;5;230m${x}\x1b[0m${base}`)
1321
+ .replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, (_, p, x) => `${p}\x1b[3m${x}\x1b[23m`)
1322
+ .replace(/`([^`]+)`/g, (_, x) => `\x1b[38;5;79m${x}\x1b[0m${base}`)
1323
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, x, u) => `\x1b[4m\x1b[38;5;79m${x}\x1b[0m${base} \x1b[2m(${u})\x1b[22m`);
1324
+ const C = { sage: '\x1b[38;5;151m', slate: '\x1b[38;5;247m' };
1233
1325
  const renderMd = (raw) => {
1234
1326
  let body = raw;
1235
1327
  if (body.startsWith('---')) {
@@ -1237,11 +1329,12 @@ async function main() {
1237
1329
  if (end !== -1) body = body.slice(end + 4);
1238
1330
  }
1239
1331
  return body.trim().split('\n').map(l => {
1240
- if (/^#{1,2}\s/.test(l)) return ` ${b(teal(l.replace(/^#+\s*/, '')))}`;
1241
- if (/^#{3,}\s/.test(l)) return ` ${cream(l.replace(/^#+\s*/, ''))}`;
1242
- if (/^\s*[-*]\s/.test(l)) return ` ${teal('·')} ${sage(l.replace(/^\s*[-*]\s*/, ''))}`;
1243
- if (/^\s*\d+\.\s/.test(l)) return ` ${sage(l.trim())}`;
1244
- return ` ${slate(l)}`;
1332
+ if (/^#{1,2}\s/.test(l)) return `\n ${b(teal(inlineMd(l.replace(/^#+\s*/, ''), '')))}`;
1333
+ if (/^#{3,}\s/.test(l)) return ` ${cream(inlineMd(l.replace(/^#+\s*/, ''), ''))}`;
1334
+ if (/^\s*[-*]\s/.test(l)) return ` ${teal('·')} ${C.sage}${inlineMd(l.replace(/^\s*[-*]\s*/, ''), C.sage)}\x1b[0m`;
1335
+ if (/^\s*\d+\.\s/.test(l)) return ` ${C.sage}${inlineMd(l.trim(), C.sage)}\x1b[0m`;
1336
+ if (/^---+\s*$/.test(l)) return ` ${slate('─'.repeat(40))}`;
1337
+ return ` ${C.slate}${inlineMd(l, C.slate)}\x1b[0m`;
1245
1338
  }).join('\n');
1246
1339
  };
1247
1340
 
@@ -1353,9 +1446,17 @@ async function main() {
1353
1446
  console.log(` ${slate(members.map(m => m.label).join(' · '))}`);
1354
1447
 
1355
1448
  const prompt = buildHarnessPrompt(messages, cmdArg);
1449
+ turnInFlight = true;
1356
1450
  const settled = await Promise.allSettled(members.map(m =>
1357
1451
  runViaHarness(m.id, councilSystem, prompt, { quiet: true })
1358
1452
  ));
1453
+ turnInFlight = false;
1454
+ if (userCancelled) {
1455
+ userCancelled = false;
1456
+ console.log(`\n ${slate('council cancelled — esc')}\n`);
1457
+ rl.prompt();
1458
+ return;
1459
+ }
1359
1460
 
1360
1461
  const answers = [];
1361
1462
  settled.forEach((r, i) => {
package/lib/harnesses.js CHANGED
@@ -20,7 +20,7 @@ const { execSync, spawn } = require('child_process');
20
20
  // list of its own, so it can never go stale. Harnesses without a known
21
21
  // model flag ignore the preference and use their own config.
22
22
  const HARNESSES = {
23
- 'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', models: true, args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
23
+ 'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', models: true, modelHints: ['sonnet', 'opus', 'haiku', 'fable'], args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
24
24
  'codex': { bin: 'codex', label: 'Codex CLI', role: 'reasons & reviews', auth: 'ChatGPT plan', models: true, args: (p, m) => ['exec', ...(m ? ['-m', m] : []), p] },
25
25
  'gemini': { bin: 'gemini', label: 'Gemini CLI', role: "another model's take", auth: 'Google login', models: true, args: (p, m) => ['-p', p, ...(m ? ['-m', m] : [])] },
26
26
  'cursor': { bin: 'cursor-agent', label: 'Cursor Agent', role: 'edits files', auth: 'Cursor account', models: true, args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
@@ -36,6 +36,17 @@ const HARNESSES = {
36
36
  'droid': { bin: 'droid', label: 'Droid', role: 'agentic coding', auth: 'Factory account', args: (p) => ['exec', p] },
37
37
  };
38
38
 
39
+ // In-flight harness children — so ESC in the session can cancel a turn.
40
+ const ACTIVE_CHILDREN = new Set();
41
+
42
+ function cancelActive() {
43
+ let n = 0;
44
+ for (const c of ACTIVE_CHILDREN) {
45
+ try { c.kill('SIGTERM'); n++; } catch { /* already gone */ }
46
+ }
47
+ return n;
48
+ }
49
+
39
50
  function isInstalled(id) {
40
51
  const h = HARNESSES[id];
41
52
  if (!h) return false;
@@ -68,6 +79,8 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
68
79
 
69
80
  return new Promise((resolve, reject) => {
70
81
  const child = spawn(h.bin, h.args(prompt, model), { stdio: ['pipe', 'pipe', 'pipe'] });
82
+ ACTIVE_CHILDREN.add(child);
83
+ child.on('close', () => ACTIVE_CHILDREN.delete(child));
71
84
  // Some harnesses (codex exec, gemini) wait for stdin EOF before running.
72
85
  child.stdin.end();
73
86
 
@@ -87,4 +100,4 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
87
100
  });
88
101
  }
89
102
 
90
- module.exports = { HARNESSES, isInstalled, detectInstalled, listHarnesses, runViaHarness };
103
+ module.exports = { HARNESSES, isInstalled, detectInstalled, listHarnesses, runViaHarness, cancelActive };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"