phewsh 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/phewsh.js CHANGED
@@ -7,7 +7,7 @@ const command = args[0];
7
7
  const b = (s) => `\x1b[1m${s}\x1b[0m`; // bold
8
8
  const d = (s) => `\x1b[2m${s}\x1b[0m`; // dim
9
9
  const w = (s) => `\x1b[97m${s}\x1b[0m`; // bright white
10
- const g = (s) => `\x1b[38;2;130;142;138m${s}\x1b[0m`; // slate (matches ui.js)
10
+ const g = (s) => `\x1b[38;2;152;164;158m${s}\x1b[0m`; // slate (matches ui.js)
11
11
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
12
12
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
13
13
 
@@ -17,7 +17,7 @@ const { select, refreshSession: refreshSess } = require('../lib/supabase');
17
17
  const { readPPS } = require('../lib/pps');
18
18
  const { push, pull, ensureValidToken } = require('./sync');
19
19
  const { HARNESSES, listHarnesses, runViaHarness } = require('../lib/harnesses');
20
- const { recordDecision, labelOutcome, pendingDecisions, OUTCOMES } = require('../lib/outcomes');
20
+ const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
21
21
  const { recordSessionEvent } = require('../lib/receipts-data');
22
22
 
23
23
  // Brand palette shortcuts
@@ -107,14 +107,16 @@ const DEFAULT_MODEL = 'claude-sonnet';
107
107
  // explicit config.defaultRoute → API key if set → first installed harness.
108
108
 
109
109
  function resolveRoute(config, harnesses) {
110
- const installed = harnesses.filter(h => h.installed);
110
+ // Chat routing needs a headless-capable harness; interactive-only ones
111
+ // (Hermes, Pi) are still detected and reachable via /work.
112
+ const chatCapable = harnesses.filter(h => h.installed && h.headless);
111
113
  const preferred = config?.defaultRoute;
112
114
  if (preferred === 'api' && config?.apiKey) return { type: 'api' };
113
- if (preferred && installed.some(h => h.id === preferred)) {
115
+ if (preferred && chatCapable.some(h => h.id === preferred)) {
114
116
  return { type: 'harness', id: preferred };
115
117
  }
116
118
  if (config?.apiKey) return { type: 'api' };
117
- if (installed.length > 0) return { type: 'harness', id: installed[0].id };
119
+ if (chatCapable.length > 0) return { type: 'harness', id: chatCapable[0].id };
118
120
  return null;
119
121
  }
120
122
 
@@ -264,6 +266,7 @@ async function main() {
264
266
  let route = resolveRoute(config, harnesses);
265
267
  let sessionMode = null; // INTENT_MODES id once picked
266
268
  let awaitingOutcome = null; // decision id eligible for 1-4 labeling
269
+ let awaitingFallback = null; // { input, fullSystem, options } after a route failure
267
270
  let decisionsThisSession = 0;
268
271
 
269
272
  // ── The Exhale: animated brand reveal ──────────────────
@@ -276,45 +279,54 @@ async function main() {
276
279
  route = resolveRoute(config, harnesses);
277
280
  }
278
281
 
279
- // ── Project status (compact) ────────────────────────────
280
- const statusParts = [cream(projectName)];
281
- if (intentFiles.length > 0) {
282
- statusParts.push(teal('●') + sage(` .intent/ (${intentFiles.length} files)`));
283
- } else {
284
- statusParts.push(slate('no .intent/ — run /init'));
285
- }
286
- statusParts.push(sage('via ' + routeLabel(route, config)));
287
- console.log(` ${statusParts.join(slate(' · '))}`);
288
-
289
- // Capabilities: what's installed on this machine, no setup required
290
- if (installedHarnesses.length > 0) {
291
- const caps = harnesses.map(h =>
292
- h.installed ? `${teal('✓')} ${sage(h.label)}` : slate(`✗ ${h.label}`)
293
- ).join(slate(' · '));
294
- console.log(` ${caps}`);
295
- }
296
-
297
- // Decisions from past sessions still waiting on an outcome
298
- const pendingPast = pendingDecisions();
299
- if (pendingPast.length > 0) {
300
- console.log(` ${peach('◌')} ${sage(`${pendingPast.length} decision${pendingPast.length !== 1 ? 's' : ''} awaiting outcome`)} ${slate('— /outcomes to label')}`);
301
- }
302
-
303
- // Sync status (one-line, non-blocking)
282
+ // ── Mission control: the whole state of your AI work, one screen ──────
283
+ // PROJECT what am I in · ROUTE where typing goes · BACKUP what's ready if
284
+ // the route hits a wall · WEB am I mirrored · RECORD what's accumulated
285
+ let syncState = null;
304
286
  if (config?.supabaseUserId && intentFiles.length > 0) {
305
- const syncResult = await Promise.race([
287
+ syncState = await Promise.race([
306
288
  checkSyncStatus(config),
307
289
  new Promise(resolve => setTimeout(() => resolve(null), 3000)),
308
290
  ]);
309
- if (syncResult) {
310
- if (syncResult.status === 'cloud-newer') {
311
- console.log(` ${ember('↓')} ${sage('Cloud newer (' + syncResult.ago + ') — /pull')}`);
312
- } else if (syncResult.status === 'local-newer') {
313
- console.log(` ${ember('')} ${sage('Local changes — /push')}`);
314
- } else if (syncResult.status === 'synced') {
315
- console.log(` ${teal('')} ${slate('synced')}`);
316
- }
317
- }
291
+ }
292
+
293
+ const row = (label, value) => console.log(` ${slate(label.padEnd(9))}${value}`);
294
+
295
+ row('PROJECT', cream(projectName) + (intentFiles.length > 0
296
+ ? slate(' · ') + teal('●') + sage(` .intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''}`)
297
+ : slate(' · no memory yet — ') + sage('/init')));
298
+
299
+ row('ROUTE', route
300
+ ? cream(routeLabel(route, config)) + (route.type === 'harness' ? slate(' · no API key needed') : '')
301
+ : ember('none — /key or install an agent CLI'));
302
+
303
+ const backups = harnesses.filter(h => h.installed && h.headless && !(route?.type === 'harness' && route.id === h.id));
304
+ const workOnly = installedHarnesses.filter(h => !h.headless);
305
+ const backupParts = backups.map(h => `${teal('✓')} ${sage(h.label)}`);
306
+ if (config?.apiKey && route?.type !== 'api') backupParts.push(`${teal('✓')} ${sage('direct API')}`);
307
+ workOnly.forEach(h => backupParts.push(sage(h.label) + slate(' /work')));
308
+ row('BACKUP', backupParts.length > 0
309
+ ? backupParts.join(slate(' · ')) + slate(' — context travels if the route hits a wall')
310
+ : slate('none — install Codex or Gemini to cover usage limits'));
311
+
312
+ if (config?.supabaseUserId) {
313
+ const syncLabel = syncState?.status === 'synced' ? teal('↕ ') + sage('mirrored')
314
+ : syncState?.status === 'cloud-newer' ? ember('↓ ') + sage(`cloud newer (${syncState.ago}) — /pull`)
315
+ : syncState?.status === 'local-newer' ? ember('↑ ') + sage('local ahead — /push')
316
+ : sage('linked');
317
+ row('WEB', cream(config.email || 'logged in') + slate(' · ') + syncLabel);
318
+ } else {
319
+ row('WEB', sage('local-only (works fine)') + slate(' · /login mirrors this at phewsh.com/intent'));
320
+ }
321
+
322
+ const oStats = outcomeStats();
323
+ const oLabeled = oStats.total - oStats.pending;
324
+ if (oStats.total > 0) {
325
+ const keptRate = oLabeled > 0 ? Math.round((oStats.kept / oLabeled) * 100) + '% kept' : 'unlabeled';
326
+ row('RECORD', cream(`${oStats.total} decision${oStats.total !== 1 ? 's' : ''}`) + slate(' · ') + sage(keptRate)
327
+ + (oStats.pending > 0 ? slate(' · ') + peach(`${oStats.pending} awaiting outcome`) + slate(' — /outcomes') : ''));
328
+ } else {
329
+ row('RECORD', slate('empty — decisions and outcomes accumulate as you work'));
318
330
  }
319
331
 
320
332
  console.log('');
@@ -331,10 +343,107 @@ async function main() {
331
343
  } else {
332
344
  console.log(` ${b(cream('What are you trying to do?'))}`);
333
345
  console.log(` ${teal('1')} ${sage('Build')} ${slate('·')} ${teal('2')} ${sage('Research')} ${slate('·')} ${teal('3')} ${sage('Decide')} ${slate('·')} ${teal('4')} ${sage('Review')} ${slate('·')} ${teal('5')} ${sage('Ask another model')}`);
334
- console.log(` ${slate('pick a number, or just type — both work')}`);
346
+ console.log(` ${slate('pick a number, or just type — your context travels with every route')}`);
335
347
  }
336
348
  console.log('');
337
349
 
350
+ // ── Turn runners — every route records a decision, leaves a receipt ────
351
+ // Both return true on success so the fallback flow can chain them.
352
+
353
+ async function runHarnessTurn(input, harnessId, fullSystem) {
354
+ const decisionId = recordDecision({
355
+ project: projectName, route: harnessId, mode: sessionMode, summary: input,
356
+ });
357
+ decisionsThisSession++;
358
+ try {
359
+ const output = await runViaHarness(harnessId, fullSystem, buildHarnessPrompt(messages, input));
360
+ messages.push({ role: 'user', content: input });
361
+ messages.push({ role: 'assistant', content: (output || '').trim() });
362
+ recordSessionEvent(harnessId, projectName, 'task_complete', {
363
+ taskId: decisionId, success: true, summary: input.slice(0, 140),
364
+ });
365
+ awaitingOutcome = decisionId;
366
+ console.log(slate(` via ${HARNESSES[harnessId].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
367
+ return true;
368
+ } catch (err) {
369
+ try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
370
+ recordSessionEvent(harnessId, projectName, 'task_complete', {
371
+ taskId: decisionId, success: false, summary: input.slice(0, 140),
372
+ });
373
+ const limitHit = /limit|quota|rate|usage|exhaust/i.test(err.message);
374
+ console.error(`\n ${ember('!')} ${cream(HARNESSES[harnessId].label)} ${sage(limitHit ? 'hit a usage wall' : 'failed')}${slate(' — ' + err.message.split('\n')[0])}`);
375
+ return false;
376
+ }
377
+ }
378
+
379
+ async function runApiTurn(input, fullSystem) {
380
+ const decisionId = recordDecision({
381
+ project: projectName, route: 'api', mode: sessionMode, summary: input,
382
+ });
383
+ decisionsThisSession++;
384
+ messages.push({ role: 'user', content: input });
385
+ console.log('');
386
+ try {
387
+ const result = await streamChat(config.apiKey, messages, fullSystem, MODELS[currentModel].id);
388
+ messages.push({ role: 'assistant', content: result.content });
389
+ if (result.promptTokens) totalPromptTokens += result.promptTokens;
390
+ if (result.completionTokens) totalCompletionTokens += result.completionTokens;
391
+ if (result.promptTokens || result.completionTokens) {
392
+ console.log(slate(` ${result.promptTokens || '?'}→${result.completionTokens || '?'} tokens · ${MODELS[currentModel].name} · outcome? 1-4 or keep typing`));
393
+ }
394
+ awaitingOutcome = decisionId;
395
+ trackSap({
396
+ userId: config.supabaseUserId,
397
+ source: 'cli',
398
+ model: MODELS[currentModel].id,
399
+ promptTokens: result.promptTokens,
400
+ completionTokens: result.completionTokens,
401
+ accessToken: config.supabaseAccessToken,
402
+ });
403
+ return true;
404
+ } catch (err) {
405
+ try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
406
+ messages.pop();
407
+ console.error(`\n ${ember('!')} ${sage('API route failed')}${slate(' — ' + err.message.split('\n')[0])}`);
408
+ return false;
409
+ }
410
+ }
411
+
412
+ // Fallbacks are a first-class flow: the route changes, the context and
413
+ // record do not. Ask by default; auto-switch only if setup said so.
414
+ async function offerFallbacks(input, fullSystem, failedId) {
415
+ const options = harnesses
416
+ .filter(h => h.installed && h.headless && h.id !== failedId)
417
+ .map(h => h.id);
418
+ if (config?.apiKey && failedId !== 'api') options.push('api');
419
+
420
+ if (options.length === 0) {
421
+ console.log(` ${sage('No fallback ready.')} ${slate('Install Codex or Gemini, or add an API key with /key — context would travel automatically.')}`);
422
+ console.log('');
423
+ return;
424
+ }
425
+
426
+ if (config?.fallback === 'auto') {
427
+ const fb = options[0];
428
+ const fbLabel = fb === 'api' ? 'direct API' : HARNESSES[fb].label;
429
+ console.log(` ${peach('↻')} ${sage('auto-fallback →')} ${cream(fbLabel)} ${slate('· same context, same record')}`);
430
+ const ok = fb === 'api'
431
+ ? await runApiTurn(input, fullSystem)
432
+ : await runHarnessTurn(input, fb, fullSystem);
433
+ if (!ok) console.log(` ${ember('!')} ${sage('Fallback failed too — /provider to inspect routes.')}`);
434
+ console.log('');
435
+ return;
436
+ }
437
+
438
+ const list = options.map((id, i) =>
439
+ `${teal(String(i + 1))} ${sage(id === 'api' ? 'direct API (your key)' : HARNESSES[id].label)}`
440
+ ).join(slate(' · '));
441
+ console.log(` ${sage('Retry with your context intact:')} ${list} ${slate('· enter = skip')}`);
442
+ console.log(` ${slate('prefer auto-switching? phewsh setup sets it once')}`);
443
+ awaitingFallback = { input, fullSystem, options };
444
+ console.log('');
445
+ }
446
+
338
447
  const rl = readline.createInterface({
339
448
  input: process.stdin,
340
449
  output: process.stdout,
@@ -352,6 +461,24 @@ async function main() {
352
461
  return;
353
462
  }
354
463
 
464
+ // A bare number right after a route failure picks the fallback
465
+ if (awaitingFallback) {
466
+ const af = awaitingFallback;
467
+ awaitingFallback = null;
468
+ const n = parseInt(input, 10);
469
+ if (n >= 1 && n <= af.options.length) {
470
+ const fb = af.options[n - 1];
471
+ const ok = fb === 'api'
472
+ ? await runApiTurn(af.input, af.fullSystem)
473
+ : await runHarnessTurn(af.input, fb, af.fullSystem);
474
+ if (!ok) await offerFallbacks(af.input, af.fullSystem, fb);
475
+ console.log('');
476
+ rl.prompt();
477
+ return;
478
+ }
479
+ // anything else: drop the offer and treat it as fresh input
480
+ }
481
+
355
482
  // A bare 1-4 right after a routed action labels its outcome
356
483
  if (awaitingOutcome && /^[1-4]$/.test(input)) {
357
484
  const outcome = OUTCOMES[parseInt(input, 10) - 1];
@@ -832,6 +959,7 @@ async function main() {
832
959
  rows.push([h.label, h.installed ? `installed (${h.auth})` : 'not installed', h.installed ? 'green' : undefined]);
833
960
  }
834
961
  rows.push(['API key', config?.apiKey ? config.apiKey.slice(0, 8) + '... (' + (config.provider || 'anthropic') + ')' : 'not set — optional', config?.apiKey ? 'green' : 'yellow']);
962
+ rows.push(['Fallback', config?.fallback === 'auto' ? 'auto-switch on failure' : 'ask before switching', 'peach']);
835
963
  if (route?.type === 'api') rows.push(['Model', MODELS[currentModel].name, 'cyan']);
836
964
  ui.statusPanel('Provider', rows);
837
965
  console.log(` ${slate('switch:')} ${cream('/use <' + harnesses.filter(h => h.installed).map(h => h.id).concat(config?.apiKey ? ['api'] : []).join('|') + '>')}`);
@@ -858,6 +986,8 @@ async function main() {
858
986
  } else if (HARNESSES[target]) {
859
987
  if (!harnesses.find(h => h.id === target)?.installed) {
860
988
  console.log(` ${ember('!')} ${sage(HARNESSES[target].label + ' is not installed on this machine.')}`);
989
+ } else if (!HARNESSES[target].args) {
990
+ console.log(` ${sage(HARNESSES[target].label + ' is interactive-only — drop into it with')} ${cream('/work ' + target)} ${sage('(phewsh records the outcome when you return)')}`);
861
991
  } else {
862
992
  route = { type: 'harness', id: target };
863
993
  console.log(` ${teal('●')} ${sage('Routing via')} ${cream(routeLabel(route, config))} ${slate('— no API key, your subscription')}`);
@@ -876,8 +1006,9 @@ async function main() {
876
1006
  ui.divider('line');
877
1007
  for (const h of harnesses) {
878
1008
  const active = route?.type === 'harness' && route.id === h.id ? ` ${teal('● active')}` : '';
1009
+ const mode = h.headless ? '' : slate(' · interactive (/work)');
879
1010
  const status = h.installed ? green('installed') : slate('not installed');
880
- console.log(` ${cream(h.id.padEnd(12))} ${sage(h.label.padEnd(14))} ${status}${active}`);
1011
+ console.log(` ${cream(h.id.padEnd(12))} ${sage(h.label.padEnd(14))} ${status}${mode}${active}`);
881
1012
  }
882
1013
  console.log('');
883
1014
  rl.prompt();
@@ -1078,68 +1209,12 @@ async function main() {
1078
1209
  : null;
1079
1210
  const fullSystem = modeHint ? `${systemPrompt}\n\n${modeHint}` : systemPrompt;
1080
1211
 
1081
- // Every routed action is a decision — recorded before it runs,
1082
- // labeled (1-4) when the outcome is known.
1083
- const decisionId = recordDecision({
1084
- project: projectName,
1085
- route: route.type === 'api' ? 'api' : route.id,
1086
- mode: sessionMode,
1087
- summary: input,
1088
- });
1089
- decisionsThisSession++;
1090
-
1091
- if (route.type === 'harness') {
1092
- try {
1093
- const output = await runViaHarness(route.id, fullSystem, buildHarnessPrompt(messages, input));
1094
- messages.push({ role: 'user', content: input });
1095
- messages.push({ role: 'assistant', content: (output || '').trim() });
1096
- recordSessionEvent(route.id, projectName, 'task_complete', {
1097
- taskId: decisionId, success: true, summary: input.slice(0, 140),
1098
- });
1099
- awaitingOutcome = decisionId;
1100
- console.log(slate(` via ${HARNESSES[route.id].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
1101
- } catch (err) {
1102
- try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
1103
- recordSessionEvent(route.id, projectName, 'task_complete', {
1104
- taskId: decisionId, success: false, summary: input.slice(0, 140),
1105
- });
1106
- console.error(`\n ${ember('!')} ${err.message}`);
1107
- if (installedHarnesses.length > 1 || config?.apiKey) {
1108
- console.log(` ${slate('switch routes with /use — /provider shows what\'s available')}`);
1109
- }
1110
- }
1111
- console.log('');
1112
- rl.prompt();
1113
- return;
1114
- }
1115
-
1116
- messages.push({ role: 'user', content: input });
1117
- console.log('');
1212
+ const ok = route.type === 'harness'
1213
+ ? await runHarnessTurn(input, route.id, fullSystem)
1214
+ : await runApiTurn(input, fullSystem);
1118
1215
 
1119
- try {
1120
- const result = await streamChat(config.apiKey, messages, fullSystem, MODELS[currentModel].id);
1121
- messages.push({ role: 'assistant', content: result.content });
1122
-
1123
- if (result.promptTokens) totalPromptTokens += result.promptTokens;
1124
- if (result.completionTokens) totalCompletionTokens += result.completionTokens;
1125
-
1126
- if (result.promptTokens || result.completionTokens) {
1127
- console.log(slate(` ${result.promptTokens || '?'}→${result.completionTokens || '?'} tokens · ${MODELS[currentModel].name} · outcome? 1-4 or keep typing`));
1128
- }
1129
- awaitingOutcome = decisionId;
1130
-
1131
- trackSap({
1132
- userId: config.supabaseUserId,
1133
- source: 'cli',
1134
- model: MODELS[currentModel].id,
1135
- promptTokens: result.promptTokens,
1136
- completionTokens: result.completionTokens,
1137
- accessToken: config.supabaseAccessToken,
1138
- });
1139
- } catch (err) {
1140
- try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
1141
- console.error(`\n ${err.message}\n`);
1142
- messages.pop();
1216
+ if (!ok) {
1217
+ await offerFallbacks(input, fullSystem, route.type === 'harness' ? route.id : 'api');
1143
1218
  }
1144
1219
 
1145
1220
  console.log('');
package/commands/setup.js CHANGED
@@ -46,7 +46,8 @@ module.exports = async function setup() {
46
46
  console.log(` ${b(cream('Detected on this machine'))}`);
47
47
  for (const h of harnesses) {
48
48
  const status = h.installed ? green('✓ installed') : slate('✗ not installed');
49
- console.log(` ${cream(h.label.padEnd(14))} ${status} ${slate('(' + h.auth + ')')}`);
49
+ const mode = h.headless ? '' : slate(' · interactive — /work ' + h.id);
50
+ console.log(` ${cream(h.label.padEnd(14))} ${status} ${slate('(' + h.auth + ')')}${mode}`);
50
51
  }
51
52
  if (installed.length === 0) {
52
53
  console.log('');
@@ -58,11 +59,13 @@ module.exports = async function setup() {
58
59
 
59
60
  // Agent-run (no TTY): auto-configure instead of asking questions nobody
60
61
  // can answer. Pick the first installed harness; humans can change it later.
62
+ const chatCapable = installed.filter(h => h.headless);
61
63
  if (!process.stdin.isTTY) {
62
- if (installed.length > 0) {
63
- config.defaultRoute = installed[0].id;
64
+ if (chatCapable.length > 0) {
65
+ config.defaultRoute = chatCapable[0].id;
66
+ if (!config.fallback) config.fallback = 'ask';
64
67
  saveConfig(config);
65
- console.log(` ${teal('●')} ${sage('Auto-configured (non-interactive): default route =')} ${cream(installed[0].label)} ${slate('— no API key needed')}`);
68
+ console.log(` ${teal('●')} ${sage('Auto-configured (non-interactive): default route =')} ${cream(chatCapable[0].label)} ${slate('— no API key needed')}`);
66
69
  console.log(` ${slate('Change anytime: run `phewsh setup` in your own terminal, or /use inside a session.')}`);
67
70
  } else if (config.apiKey) {
68
71
  config.defaultRoute = 'api';
@@ -81,7 +84,9 @@ module.exports = async function setup() {
81
84
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
82
85
 
83
86
  // ── 2. Pick the default route ─────────────────────────
84
- const options = installed.map(h => ({ kind: 'harness', id: h.id, label: `${h.label} your ${h.auth.split(' / ')[0].toLowerCase()}, no API key` }));
87
+ // Interactive-only harnesses (Hermes, Pi) can't take chat routingthey
88
+ // stay reachable via /work in a session, so they're not default options.
89
+ const options = chatCapable.map(h => ({ kind: 'harness', id: h.id, label: `${h.label} — your ${h.auth.split(' / ')[0].toLowerCase()}, no API key` }));
85
90
  options.push({ kind: 'api', id: 'api', label: 'Direct API — bring your own Anthropic/OpenRouter key' });
86
91
 
87
92
  console.log(` ${b(cream('Where should phewsh route your work by default?'))}`);
@@ -124,9 +129,20 @@ module.exports = async function setup() {
124
129
  }
125
130
  }
126
131
 
132
+ // ── 4. Fallback behavior — first-class, not buried config ────────────
133
+ console.log('');
134
+ console.log(` ${b(cream('If your route hits a usage limit or fails:'))}`);
135
+ console.log(` ${teal('1')} ${sage('Ask me before switching')} ${slate('(default — shows what changes, context always travels)')}`);
136
+ console.log(` ${teal('2')} ${sage('Auto-switch to the next available route')}`);
137
+ console.log('');
138
+ const fbAnswer = await ask(rl, ` ${teal('>')} ${slate('1-2, enter = 1: ')}`);
139
+ config.fallback = fbAnswer.trim() === '2' ? 'auto' : 'ask';
140
+ saveConfig(config);
141
+ console.log(` ${teal('●')} ${sage('Fallback:')} ${cream(config.fallback === 'auto' ? 'auto-switch' : 'ask first')} ${slate('— either way, your project context and record stay intact')}`);
142
+
127
143
  rl.close();
128
144
 
129
- // ── 4. Done ───────────────────────────────────────────
145
+ // ── 5. Done ───────────────────────────────────────────
130
146
  console.log('');
131
147
  ui.divider('line');
132
148
  console.log(` ${teal('●')} ${b(cream('Setup complete.'))}`);
package/lib/harnesses.js CHANGED
@@ -11,12 +11,21 @@
11
11
 
12
12
  const { execSync, spawn } = require('child_process');
13
13
 
14
+ // args: how to run a one-shot prompt headlessly. args: null = we only know
15
+ // how to launch it interactively (detection + /work still fully supported —
16
+ // never guess flags; a wrong invocation looks like phewsh being broken).
14
17
  const HARNESSES = {
15
18
  'claude-code': { bin: 'claude', label: 'Claude Code', auth: 'Claude subscription / Console', args: (p) => ['-p', p, '--output-format', 'text'] },
16
19
  'codex': { bin: 'codex', label: 'Codex CLI', auth: 'ChatGPT plan', args: (p) => ['exec', p] },
17
20
  'gemini': { bin: 'gemini', label: 'Gemini CLI', auth: 'Google login', args: (p) => ['-p', p] },
18
21
  'cursor': { bin: 'cursor-agent', label: 'Cursor Agent', auth: 'Cursor account', args: (p) => ['-p', p, '--output-format', 'text'] },
19
22
  'opencode': { bin: 'opencode', label: 'OpenCode', auth: 'OpenCode Zen / configured', args: (p) => ['run', p] },
23
+ 'hermes': { bin: 'hermes', label: 'Hermes', auth: 'Nous account', args: null },
24
+ 'pi': { bin: 'pi', label: 'Pi', auth: 'Pi login', args: null },
25
+ 'aider': { bin: 'aider', label: 'Aider', auth: 'configured keys', args: (p) => ['--message', p] },
26
+ 'goose': { bin: 'goose', label: 'Goose', auth: 'Block / configured', args: (p) => ['run', '-t', p] },
27
+ 'amp': { bin: 'amp', label: 'Amp', auth: 'Sourcegraph account', args: (p) => ['-x', p] },
28
+ 'droid': { bin: 'droid', label: 'Droid', auth: 'Factory account', args: (p) => ['exec', p] },
20
29
  };
21
30
 
22
31
  function isInstalled(id) {
@@ -25,16 +34,16 @@ function isInstalled(id) {
25
34
  try { execSync(`which ${h.bin}`, { stdio: 'pipe' }); return true; } catch { return false; }
26
35
  }
27
36
 
28
- /** First installed harness in preference order, or null. */
37
+ /** First installed chat-capable harness in preference order, or null. */
29
38
  function detectInstalled() {
30
39
  for (const id of Object.keys(HARNESSES)) {
31
- if (isInstalled(id)) return id;
40
+ if (HARNESSES[id].args && isInstalled(id)) return id;
32
41
  }
33
42
  return null;
34
43
  }
35
44
 
36
45
  function listHarnesses() {
37
- return Object.entries(HARNESSES).map(([id, h]) => ({ id, ...h, installed: isInstalled(id) }));
46
+ return Object.entries(HARNESSES).map(([id, h]) => ({ id, ...h, headless: !!h.args, installed: isInstalled(id) }));
38
47
  }
39
48
 
40
49
  /**
@@ -45,6 +54,7 @@ function listHarnesses() {
45
54
  function runViaHarness(id, systemPrompt, userPrompt) {
46
55
  const h = HARNESSES[id];
47
56
  if (!h) return Promise.reject(new Error(`Unknown harness: ${id}`));
57
+ if (!h.args) return Promise.reject(new Error(`${h.label} is interactive-only here — launch it with /work ${id}`));
48
58
  const prompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${userPrompt}` : userPrompt;
49
59
 
50
60
  return new Promise((resolve, reject) => {
package/lib/ui.js CHANGED
@@ -11,8 +11,8 @@ const rgbBg = (r, g, b) => (s) => `\x1b[48;2;${r};${g};${b}m${s}\x1b[0m`;
11
11
  // Brand colors — relief, quiet, future
12
12
  const teal = rgb(100, 215, 195); // cool calm — primary
13
13
  const peach = rgb(255, 195, 145); // warm exhale — accent
14
- const sage = rgb(175, 195, 185); // quiet — secondary text
15
- const slate = rgb(130, 142, 138); // whisper — dim text
14
+ const sage = rgb(190, 208, 198); // quiet — secondary text
15
+ const slate = rgb(152, 164, 158); // whisper — dim text (bright enough for dark terminals)
16
16
  const cream = rgb(240, 235, 225); // clarity — bright text
17
17
  const ember = rgb(220, 140, 90); // glow — warnings/energy
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"