phewsh 0.15.7 → 0.15.9

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.
@@ -331,9 +331,12 @@ async function main() {
331
331
  await ui.brandReveal();
332
332
 
333
333
  if (config?.apiKey && !config.apiKey.startsWith('sk-')) {
334
- console.log(` ${ember('!')} ${sage('Stored API key looks invalidignoring it.')} ${slate('/key to replace')}`);
334
+ // Persist the cleanup so this never nags again an unusable key
335
+ // (not sk-…) helps nobody, and harness routes need no key at all.
336
+ console.log(` ${ember('!')} ${sage('Cleared an unusable stored API key.')} ${slate('harness routes need none — /key to add one')}`);
335
337
  console.log('');
336
338
  config.apiKey = null;
339
+ try { saveConfig(config); } catch { /* read-only home — in-memory clear still holds */ }
337
340
  route = resolveRoute(config, harnesses);
338
341
  }
339
342
 
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, modelHints: ['sonnet', 'opus', 'haiku', 'fable'], 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'], streamFormat: 'claude-json', args: (p, m) => ['-p', p, '--output-format', 'stream-json', '--include-partial-messages', '--verbose', ...(m ? ['--model', m] : [])] },
24
24
  'codex': { bin: 'codex', label: 'Codex CLI', role: 'reasons & reviews', auth: 'ChatGPT plan', models: true, args: (p, m) => ['exec', '--skip-git-repo-check', ...(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] : [])] },
@@ -87,6 +87,17 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
87
87
  const prompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${userPrompt}` : userPrompt;
88
88
  const model = h.models ? opts.model : undefined;
89
89
 
90
+ // Spinner during the wait, and live token streaming where the harness
91
+ // supports it — no path is ever a dead blank screen. quiet (council) parses
92
+ // silently so parallel runs don't interleave into soup.
93
+ // Output is written whenever this isn't a quiet (council) run. The spinner
94
+ // additionally needs a TTY — piped/test contexts get the text, no spinner.
95
+ const show = !opts.quiet;
96
+ let spin = null;
97
+ if (show && process.stdout.isTTY) {
98
+ try { spin = require('./ui').spinner('thinking'); } catch { spin = null; }
99
+ }
100
+
90
101
  return new Promise((resolve, reject) => {
91
102
  const child = spawn(h.bin, h.args(prompt, model), { stdio: ['pipe', 'pipe', 'pipe'] });
92
103
  ACTIVE_CHILDREN.add(child);
@@ -94,20 +105,73 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
94
105
  // Some harnesses (codex exec, gemini) wait for stdin EOF before running.
95
106
  child.stdin.end();
96
107
 
97
- let stdout = '';
98
108
  let stderr = '';
99
- // quiet: collect without streaming parallel runs (council) would
100
- // interleave live output into soup.
101
- if (!opts.quiet) process.stdout.write('\n');
102
- child.stdout.on('data', (d) => { if (!opts.quiet) process.stdout.write(d); stdout += d.toString(); });
109
+ let assembled = ''; // text shown to the user and returned
110
+ let resultFallback = ''; // claude's final `result` field — never-blank guard
111
+ let jsonBuf = '';
112
+ let firstByte = false;
113
+
114
+ function emit(text) {
115
+ if (!text) return;
116
+ if (!firstByte) {
117
+ firstByte = true;
118
+ if (spin) { spin.stop(); spin = null; }
119
+ if (show) process.stdout.write('\n');
120
+ }
121
+ assembled += text;
122
+ if (show) process.stdout.write(text);
123
+ }
124
+
125
+ child.stdout.on('data', (d) => {
126
+ if (h.streamFormat === 'claude-json') {
127
+ // Newline-delimited JSON: each line is one event. text_delta events
128
+ // carry the live tokens; the final `result` is the fallback.
129
+ jsonBuf += d.toString();
130
+ let nl;
131
+ while ((nl = jsonBuf.indexOf('\n')) !== -1) {
132
+ const line = jsonBuf.slice(0, nl).trim();
133
+ jsonBuf = jsonBuf.slice(nl + 1);
134
+ if (!line) continue;
135
+ let obj;
136
+ // A complete line that isn't JSON is real output (plain-text error,
137
+ // or a non-streaming stub) — show it rather than swallow it.
138
+ try { obj = JSON.parse(line); } catch { emit(line + '\n'); continue; }
139
+ if (obj.type === 'stream_event'
140
+ && obj.event?.type === 'content_block_delta'
141
+ && obj.event.delta?.type === 'text_delta') {
142
+ emit(obj.event.delta.text);
143
+ } else if (obj.type === 'result' && typeof obj.result === 'string') {
144
+ resultFallback = obj.result;
145
+ }
146
+ }
147
+ } else {
148
+ emit(d.toString());
149
+ }
150
+ });
103
151
  child.stderr.on('data', (d) => { stderr += d.toString(); });
104
152
  child.on('close', (code) => {
105
- if (!opts.quiet) process.stdout.write('\n');
153
+ if (spin) { spin.stop(); spin = null; }
154
+ if (h.streamFormat === 'claude-json' && jsonBuf.trim()) {
155
+ try {
156
+ const obj = JSON.parse(jsonBuf.trim());
157
+ if (obj.type === 'result' && typeof obj.result === 'string') resultFallback = obj.result;
158
+ } catch { /* partial trailing line */ }
159
+ }
160
+ // Streaming produced nothing but the result has text → show it. Never blank.
161
+ let finalText = assembled;
162
+ if (!assembled.trim() && resultFallback) {
163
+ finalText = resultFallback;
164
+ if (show) process.stdout.write('\n' + resultFallback);
165
+ }
166
+ if (show) process.stdout.write('\n');
106
167
  if (child._phewshCancelled) return reject(new Error(`${h.label} cancelled`));
107
- if (code === 0) resolve(stdout);
168
+ if (code === 0) resolve(finalText);
108
169
  else reject(new Error(`${h.label} exited ${code}${stderr ? `\n ${stderr.trim().split('\n').slice(-3).join('\n ')}` : ''}`));
109
170
  });
110
- child.on('error', (e) => reject(new Error(`Could not run ${h.bin}: ${e.message}`)));
171
+ child.on('error', (e) => {
172
+ if (spin) { spin.stop(); spin = null; }
173
+ reject(new Error(`Could not run ${h.bin}: ${e.message}`));
174
+ });
111
175
  });
112
176
  }
113
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.7",
3
+ "version": "0.15.9",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"