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.
- package/commands/clarify.js +11 -0
- package/commands/session.js +129 -28
- package/lib/harnesses.js +15 -2
- package/package.json +1 -1
package/commands/clarify.js
CHANGED
|
@@ -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
|
package/commands/session.js
CHANGED
|
@@ -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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
'
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
|
590
|
-
//
|
|
591
|
-
//
|
|
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 (
|
|
598
|
-
const
|
|
599
|
-
s = s.split(cur).join(
|
|
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
|
-
|
|
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
|
|
1209
|
+
console.log(` ${b(cream('Models'))} ${sage('— via ' + h.label)}`);
|
|
1140
1210
|
ui.divider('line');
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
|
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(
|
|
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 };
|