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 +1 -1
- package/commands/session.js +177 -102
- package/commands/setup.js +22 -6
- package/lib/harnesses.js +13 -3
- package/lib/ui.js +2 -2
- package/package.json +1 -1
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;
|
|
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
|
|
package/commands/session.js
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
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 (
|
|
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
|
-
// ──
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
287
|
+
syncState = await Promise.race([
|
|
306
288
|
checkSyncStatus(config),
|
|
307
289
|
new Promise(resolve => setTimeout(() => resolve(null), 3000)),
|
|
308
290
|
]);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 —
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
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 (
|
|
63
|
-
config.defaultRoute =
|
|
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(
|
|
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
|
-
|
|
87
|
+
// Interactive-only harnesses (Hermes, Pi) can't take chat routing — they
|
|
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
|
-
// ──
|
|
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(
|
|
15
|
-
const slate = rgb(
|
|
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
|
|