phewsh 0.15.1 → 0.15.3

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/ai.js CHANGED
@@ -7,6 +7,7 @@ const {
7
7
  buildHeaders, buildBody, getUrl, streamParser,
8
8
  } = require('../lib/providers');
9
9
  const { HARNESSES, detectInstalled, listHarnesses, runViaHarness } = require('../lib/harnesses');
10
+ const configFile = require('../lib/config-file');
10
11
 
11
12
  const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
12
13
  const INTENT_DIR = path.join(process.cwd(), '.intent');
@@ -15,8 +16,7 @@ const args = process.argv.slice(3);
15
16
  const subcommand = args[0];
16
17
 
17
18
  function loadConfig() {
18
- if (!fs.existsSync(CONFIG_PATH)) return null;
19
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
19
+ return configFile.loadConfig(CONFIG_PATH);
20
20
  }
21
21
 
22
22
  function loadIntentContext() {
@@ -4,6 +4,7 @@ const os = require('os');
4
4
  const {
5
5
  getProvider, buildHeaders, buildBody, getUrl, streamParser, detectProvider,
6
6
  } = require('../lib/providers');
7
+ const configFile = require('../lib/config-file');
7
8
 
8
9
  const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
9
10
 
@@ -17,8 +18,7 @@ const green = (s) => `\x1b[32m${s}\x1b[0m`;
17
18
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
18
19
 
19
20
  function loadConfig() {
20
- if (!fs.existsSync(CONFIG_PATH)) return null;
21
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
21
+ return configFile.loadConfig(CONFIG_PATH);
22
22
  }
23
23
 
24
24
  function resolveApiKey(config, provider) {
@@ -8,6 +8,7 @@ const path = require('path');
8
8
  const os = require('os');
9
9
  const readline = require('readline');
10
10
  const { readPPS, writePPS, createPPS, generateViews } = require('../lib/pps');
11
+ const configFile = require('../lib/config-file');
11
12
 
12
13
  const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
13
14
  const INTENT_DIR = path.join(process.cwd(), '.intent');
@@ -18,8 +19,7 @@ const rawFromFlag = textFlag !== -1 ? args.slice(textFlag + 1).join(' ') : null;
18
19
  const isUpdate = args.includes('--update') || args.includes('-u');
19
20
 
20
21
  function loadConfig() {
21
- if (!fs.existsSync(CONFIG_PATH)) return null;
22
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
22
+ return configFile.loadConfig(CONFIG_PATH);
23
23
  }
24
24
 
25
25
  function getProjectName() {
package/commands/gate.js CHANGED
@@ -6,6 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
8
  const readline = require('readline');
9
+ const configFile = require('../lib/config-file');
9
10
 
10
11
  const INTENT_DIR = path.join(process.cwd(), '.intent');
11
12
  const PROJECT_PATH = path.join(INTENT_DIR, 'project.json');
@@ -91,8 +92,7 @@ function saveGate(gate) {
91
92
  }
92
93
 
93
94
  function loadConfig() {
94
- if (!fs.existsSync(CONFIG_PATH)) return null;
95
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
95
+ return configFile.loadConfig(CONFIG_PATH);
96
96
  }
97
97
 
98
98
  function createPrompter() {
@@ -5,6 +5,7 @@ const { execSync } = require('child_process');
5
5
  const { createPPS, writePPS, generateViews } = require('../lib/pps');
6
6
 
7
7
  const os = require('os');
8
+ const configFile = require('../lib/config-file');
8
9
  const args = process.argv.slice(3);
9
10
  const INTENT_DIR = path.join(process.cwd(), '.intent');
10
11
  const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
@@ -156,8 +157,7 @@ async function initIntent() {
156
157
  }
157
158
 
158
159
  function loadConfig() {
159
- if (!fs.existsSync(CONFIG_PATH)) return null;
160
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
160
+ return configFile.loadConfig(CONFIG_PATH);
161
161
  }
162
162
 
163
163
  function loadGate() {
package/commands/login.js CHANGED
@@ -4,18 +4,17 @@ const readline = require('readline');
4
4
  const os = require('os');
5
5
  const crypto = require('crypto');
6
6
  const { sendOtp, verifyOtp, refreshSession } = require('../lib/supabase');
7
+ const configFile = require('../lib/config-file');
7
8
 
8
9
  const CONFIG_DIR = path.join(os.homedir(), '.phewsh');
9
10
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
10
11
 
11
12
  function loadConfig() {
12
- if (!fs.existsSync(CONFIG_PATH)) return null;
13
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
13
+ return configFile.loadConfig(CONFIG_PATH);
14
14
  }
15
15
 
16
16
  function saveConfig(config) {
17
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
18
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
17
+ configFile.saveConfig(CONFIG_PATH, config);
19
18
  }
20
19
 
21
20
  function createPrompter() {
package/commands/mcp.js CHANGED
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const os = require('os');
16
+ const configFile = require('../lib/config-file');
16
17
  const { spawn } = require('child_process');
17
18
 
18
19
  const PHEWSH_DIR = path.join(os.homedir(), '.phewsh');
@@ -79,8 +80,8 @@ async function loadCloudProjects() {
79
80
  const configPath = path.join(PHEWSH_DIR, 'config.json');
80
81
  if (!fs.existsSync(configPath)) return [];
81
82
 
82
- let config;
83
- try { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch { return []; }
83
+ const config = configFile.loadConfig(configPath);
84
+ if (!config) return [];
84
85
  if (!config?.supabaseAccessToken || !config?.supabaseUserId) return [];
85
86
 
86
87
  try {
@@ -92,7 +93,7 @@ async function loadCloudProjects() {
92
93
  if (session?.access_token) {
93
94
  config.supabaseAccessToken = session.access_token;
94
95
  config.supabaseRefreshToken = session.refresh_token;
95
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
96
+ configFile.saveConfig(configPath, config);
96
97
  }
97
98
  }
98
99
 
package/commands/serve.js CHANGED
@@ -17,6 +17,8 @@ const crypto = require('crypto');
17
17
  const os = require('os');
18
18
  const path = require('path');
19
19
  const fs = require('fs');
20
+ const { corsHeaders, isAllowedRequest } = require('../lib/cors');
21
+ const configFile = require('../lib/config-file');
20
22
 
21
23
  const b = (s) => `\x1b[1m${s}\x1b[0m`;
22
24
  const g = (s) => `\x1b[90m${s}\x1b[0m`;
@@ -204,14 +206,14 @@ function parseBody(req) {
204
206
  });
205
207
  }
206
208
 
207
- function cors(res) {
208
- res.setHeader('Access-Control-Allow-Origin', '*');
209
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
210
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
209
+ function cors(req, res) {
210
+ for (const [name, value] of Object.entries(corsHeaders(req))) {
211
+ res.setHeader(name, value);
212
+ }
211
213
  }
212
214
 
213
- function json(res, data, status = 200) {
214
- cors(res);
215
+ function json(req, res, data, status = 200) {
216
+ cors(req, res);
215
217
  res.writeHead(status, { 'Content-Type': 'application/json' });
216
218
  res.end(JSON.stringify(data));
217
219
  }
@@ -221,12 +223,16 @@ function main() {
221
223
  const runtimes = detectRuntimes();
222
224
  const hasClaudeCode = runtimes.find(r => r.id === 'claude-code')?.connected;
223
225
 
224
- const server = http.createServer(async (req, res) => {
226
+ const handleRequest = async (req, res) => {
225
227
  const url = new URL(req.url, `http://localhost:${port}`);
226
228
 
229
+ if (!isAllowedRequest(req)) {
230
+ return json(req, res, { error: 'Origin not allowed' }, 403);
231
+ }
232
+
227
233
  // CORS preflight
228
234
  if (req.method === 'OPTIONS') {
229
- cors(res);
235
+ cors(req, res);
230
236
  res.writeHead(204);
231
237
  res.end();
232
238
  return;
@@ -234,7 +240,7 @@ function main() {
234
240
 
235
241
  // Health check
236
242
  if (url.pathname === '/health' && req.method === 'GET') {
237
- return json(res, {
243
+ return json(req, res, {
238
244
  status: 'ok',
239
245
  runtimes: detectRuntimes(),
240
246
  version: require('../package.json').version,
@@ -249,7 +255,7 @@ function main() {
249
255
  const { actionId, runtimeId, packet } = body;
250
256
 
251
257
  if (!actionId || !runtimeId || !packet) {
252
- return json(res, { error: 'Missing actionId, runtimeId, or packet' }, 400);
258
+ return json(req, res, { error: 'Missing actionId, runtimeId, or packet' }, 400);
253
259
  }
254
260
 
255
261
  const jobId = createJob(actionId, runtimeId, packet);
@@ -258,9 +264,9 @@ function main() {
258
264
  // Start execution in background
259
265
  executeJob(jobId);
260
266
 
261
- return json(res, { jobId, status: 'queued' });
267
+ return json(req, res, { jobId, status: 'queued' });
262
268
  } catch (err) {
263
- return json(res, { error: err.message }, 400);
269
+ return json(req, res, { error: err.message }, 400);
264
270
  }
265
271
  }
266
272
 
@@ -272,8 +278,7 @@ function main() {
272
278
  const { outcomeStats, pendingDecisions, bypassStats } = require('../lib/outcomes');
273
279
  const { listProjects } = require('../lib/projects-index');
274
280
 
275
- let config = {};
276
- try { config = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.phewsh', 'config.json'), 'utf-8')) || {}; } catch { /* none */ }
281
+ const config = configFile.loadConfig(path.join(os.homedir(), '.phewsh', 'config.json'), {});
277
282
 
278
283
  const harnessList = listHarnesses().map(h => ({
279
284
  id: h.id, label: h.label, role: h.role, installed: h.installed, headless: h.headless,
@@ -290,7 +295,7 @@ function main() {
290
295
  const intentFiles = ['vision.md', 'plan.md', 'next.md']
291
296
  .filter(f => fs.existsSync(path.join(process.cwd(), '.intent', f)));
292
297
 
293
- return json(res, {
298
+ return json(req, res, {
294
299
  project: { name: path.basename(process.cwd()), cwd: process.cwd(), intentFiles },
295
300
  route: routeId === 'api'
296
301
  ? { id: 'api', label: `API (${config.provider || 'anthropic'} key)` }
@@ -305,7 +310,7 @@ function main() {
305
310
  version: require('../package.json').version,
306
311
  });
307
312
  } catch (err) {
308
- return json(res, { error: err.message }, 500);
313
+ return json(req, res, { error: err.message }, 500);
309
314
  }
310
315
  }
311
316
 
@@ -315,7 +320,7 @@ function main() {
315
320
  if (url.pathname === '/receipts' && req.method === 'GET') {
316
321
  const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200);
317
322
  const project = url.searchParams.get('project') || null;
318
- return json(res, gatherReceipts({ project, limit }));
323
+ return json(req, res, gatherReceipts({ project, limit }));
319
324
  }
320
325
 
321
326
  // Check job status
@@ -323,8 +328,8 @@ function main() {
323
328
  if (statusMatch && req.method === 'GET') {
324
329
  const jobId = statusMatch[1];
325
330
  const job = jobs.get(jobId);
326
- if (!job) return json(res, { error: 'Job not found' }, 404);
327
- return json(res, {
331
+ if (!job) return json(req, res, { error: 'Job not found' }, 404);
332
+ return json(req, res, {
328
333
  jobId: job.jobId,
329
334
  status: job.status,
330
335
  statusText: job.statusText,
@@ -334,10 +339,16 @@ function main() {
334
339
  }
335
340
 
336
341
  // 404
337
- json(res, { error: 'Not found' }, 404);
338
- });
342
+ json(req, res, { error: 'Not found' }, 404);
343
+ };
344
+
345
+ const server = http.createServer(handleRequest);
346
+
347
+ server.listen(port, '127.0.0.1', () => {
348
+ const mirror = http.createServer(handleRequest);
349
+ mirror.on('error', () => { /* IPv6 unavailable or already bound */ });
350
+ mirror.listen(port, '::1');
339
351
 
340
- server.listen(port, () => {
341
352
  console.log('');
342
353
  console.log(` ${b(w('PHEWSH Serve'))} ${g('v' + require('../package.json').version)}`);
343
354
  console.log(` ${g('Live execution bridge for phewsh.com/intent')}`);
@@ -20,6 +20,8 @@ const { push, pull, ensureValidToken } = require('./sync');
20
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
+ const configFile = require('../lib/config-file');
24
+ const { createFailureTracker, createLineDispatcher } = require('../lib/session-input');
23
25
  const { recordProject, listProjects, scanForProjects, fmtAgo } = require('../lib/projects-index');
24
26
 
25
27
  // Brand palette shortcuts
@@ -152,13 +154,11 @@ const INTENT_MODES = {
152
154
  };
153
155
 
154
156
  function loadConfig() {
155
- if (!fs.existsSync(CONFIG_PATH)) return null;
156
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
157
+ return configFile.loadConfig(CONFIG_PATH);
157
158
  }
158
159
 
159
160
  function saveConfig(config) {
160
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
161
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
161
+ configFile.saveConfig(CONFIG_PATH, config);
162
162
  }
163
163
 
164
164
  function loadIntentContext() {
@@ -491,6 +491,8 @@ async function main() {
491
491
 
492
492
  // ── Turn runners — every route records a decision, leaves a receipt ────
493
493
  // Both return true on success so the fallback flow can chain them.
494
+ const failureTracker = createFailureTracker();
495
+ let lastTurnFailure = null;
494
496
 
495
497
  async function runHarnessTurn(input, harnessId, fullSystem) {
496
498
  const decisionId = recordDecision({
@@ -506,6 +508,7 @@ async function main() {
506
508
  recordSessionEvent(harnessId, projectName, 'task_complete', {
507
509
  taskId: decisionId, success: true, summary: input.slice(0, 140),
508
510
  });
511
+ lastTurnFailure = null;
509
512
  awaitingOutcome = decisionId;
510
513
  console.log(slate(` via ${HARNESSES[harnessId].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
511
514
  return true;
@@ -520,8 +523,11 @@ async function main() {
520
523
  recordSessionEvent(harnessId, projectName, 'task_complete', {
521
524
  taskId: decisionId, success: false, summary: input.slice(0, 140),
522
525
  });
523
- const limitHit = /limit|quota|rate|usage|exhaust/i.test(err.message);
524
- console.error(`\n ${ember('!')} ${cream(HARNESSES[harnessId].label)} ${sage(limitHit ? 'hit a usage wall' : 'failed')}${slate(' — ' + err.message.split('\n')[0])}`);
526
+ const failure = failureTracker.classify(harnessId, err.message);
527
+ lastTurnFailure = { ...failure, harnessId };
528
+ if (!failure.duplicate) {
529
+ console.error(`\n ${ember('!')} ${cream(HARNESSES[harnessId].label)} ${sage(failure.kind === 'usage-limit' ? 'hit a usage wall' : 'failed')}${slate(' — ' + err.message.split('\n')[0])}`);
530
+ }
525
531
  return false;
526
532
  }
527
533
  }
@@ -574,10 +580,17 @@ async function main() {
574
580
  // Fallbacks are a first-class flow: the route changes, the context and
575
581
  // record do not. Ask by default; auto-switch only if setup said so.
576
582
  async function offerFallbacks(input, fullSystem, failedId) {
583
+ if (lastTurnFailure?.duplicate && lastTurnFailure.harnessId === failedId) {
584
+ return;
585
+ }
586
+
577
587
  const options = harnesses
578
588
  .filter(h => h.installed && h.headless && h.id !== failedId)
579
589
  .map(h => h.id);
580
590
  if (config?.apiKey && failedId !== 'api') options.push('api');
591
+ if (lastTurnFailure?.kind === 'usage-limit' && lastTurnFailure.harnessId === failedId) {
592
+ options.push(failedId);
593
+ }
581
594
 
582
595
  if (options.length === 0) {
583
596
  console.log(` ${sage('No fallback ready.')} ${slate('Install Codex or Gemini, or add an API key with /key — context would travel automatically.')}`);
@@ -598,8 +611,14 @@ async function main() {
598
611
  }
599
612
 
600
613
  const list = options.map((id, i) =>
601
- `${teal(String(i + 1))} ${sage(id === 'api' ? 'direct API (your key)' : HARNESSES[id].label)}`
614
+ `${teal(String(i + 1))} ${sage(id === 'api'
615
+ ? 'direct API (your key)'
616
+ : id === failedId ? HARNESSES[id].label + ' (retry once)' : HARNESSES[id].label)}`
602
617
  ).join(slate(' · '));
618
+ if (lastTurnFailure?.kind === 'usage-limit' && lastTurnFailure.harnessId === failedId) {
619
+ const codexReady = options.includes('codex');
620
+ console.log(` ${sage(codexReady ? '/use codex switches the session now, or retry Claude once below.' : 'You can retry this route once below, or /use another installed route.')}`);
621
+ }
603
622
  console.log(` ${sage('Retry with your context intact:')} ${list} ${slate('· enter = skip')}`);
604
623
  console.log(` ${slate('prefer auto-switching? phewsh setup sets it once')}`);
605
624
  awaitingFallback = { input, fullSystem, options };
@@ -680,13 +699,7 @@ async function main() {
680
699
 
681
700
  rl.prompt();
682
701
 
683
- rl.on('line', async (line) => {
684
- const input = line.trim();
685
-
686
- if (!input) {
687
- rl.prompt();
688
- return;
689
- }
702
+ async function handleInput(input) {
690
703
 
691
704
  // A bare number right after a route failure picks the fallback
692
705
  if (awaitingFallback) {
@@ -1547,8 +1560,11 @@ async function main() {
1547
1560
  if (!config?.apiKey) {
1548
1561
  console.log(` ${ember('!')} ${sage('No API key set — run /key first.')}`);
1549
1562
  } else {
1563
+ config = loadConfig() || {};
1564
+ config.defaultRoute = 'api';
1565
+ saveConfig(config);
1550
1566
  route = { type: 'api' };
1551
- console.log(` ${teal('●')} ${sage('Routing via')} ${cream(routeLabel(route, config))}`);
1567
+ console.log(` ${teal('●')} ${sage('Default route:')} ${cream(routeLabel(route, config))}`);
1552
1568
  }
1553
1569
  } else if (HARNESSES[target]) {
1554
1570
  if (!harnesses.find(h => h.id === target)?.installed) {
@@ -1556,13 +1572,15 @@ async function main() {
1556
1572
  } else if (!HARNESSES[target].args) {
1557
1573
  console.log(` ${sage(HARNESSES[target].label + ' is interactive-only — drop into it with')} ${cream('/work ' + target)} ${sage('(phewsh records the outcome when you return)')}`);
1558
1574
  } else {
1575
+ config = loadConfig() || {};
1576
+ config.defaultRoute = target;
1577
+ saveConfig(config);
1559
1578
  route = { type: 'harness', id: target };
1560
- console.log(` ${teal('●')} ${sage('Routing via')} ${cream(routeLabel(route, config))} ${slate('— no API key, your subscription')}`);
1579
+ console.log(` ${teal('●')} ${sage('Default route:')} ${cream(routeLabel(route, config))} ${slate('— saved across sessions')}`);
1561
1580
  }
1562
1581
  } else {
1563
1582
  console.log(` ${sage('Unknown route. Options:')} ${cream(Object.keys(HARNESSES).join(', ') + ', api')}`);
1564
1583
  }
1565
- console.log(` ${slate('make it stick across sessions: phewsh setup')}`);
1566
1584
  rl.prompt();
1567
1585
  return;
1568
1586
  }
@@ -1806,9 +1824,19 @@ async function main() {
1806
1824
 
1807
1825
  console.log('');
1808
1826
  rl.prompt();
1827
+ }
1828
+
1829
+ const lineDispatcher = createLineDispatcher(handleInput, {
1830
+ onNoop: () => rl.prompt(),
1831
+ onError: (err) => {
1832
+ console.error(`\n ${ember('!')} ${sage('Input failed:')} ${err.message}`);
1833
+ rl.prompt();
1834
+ },
1809
1835
  });
1836
+ rl.on('line', lineDispatcher.push);
1810
1837
 
1811
- rl.on('close', () => {
1838
+ rl.on('close', async () => {
1839
+ await lineDispatcher.drain();
1812
1840
  console.log(`\n ${sage('session ended')}\n`);
1813
1841
  process.exit(0);
1814
1842
  });
package/commands/setup.js CHANGED
@@ -10,6 +10,7 @@ const os = require('os');
10
10
  const readline = require('readline');
11
11
  const ui = require('../lib/ui');
12
12
  const { HARNESSES, listHarnesses } = require('../lib/harnesses');
13
+ const configFile = require('../lib/config-file');
13
14
 
14
15
  const { b, teal, sage, slate, cream, ember, green } = ui;
15
16
 
@@ -17,12 +18,11 @@ const CONFIG_DIR = path.join(os.homedir(), '.phewsh');
17
18
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
18
19
 
19
20
  function loadConfig() {
20
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return {}; }
21
+ return configFile.loadConfig(CONFIG_PATH, {});
21
22
  }
22
23
 
23
24
  function saveConfig(config) {
24
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
25
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
25
+ configFile.saveConfig(CONFIG_PATH, config);
26
26
  }
27
27
 
28
28
  function ask(rl, prompt) {
@@ -58,10 +58,19 @@ module.exports = async function setup() {
58
58
  console.log('');
59
59
 
60
60
  // Agent-run (no TTY): auto-configure instead of asking questions nobody
61
- // can answer. Pick the first installed harness; humans can change it later.
61
+ // can answer. Keep an existing valid route; otherwise pick the first
62
+ // installed harness.
62
63
  const chatCapable = installed.filter(h => h.headless);
63
64
  if (!process.stdin.isTTY) {
64
- if (chatCapable.length > 0) {
65
+ const configuredHarness = chatCapable.find(h => h.id === config.defaultRoute);
66
+ if (configuredHarness) {
67
+ if (!config.fallback) config.fallback = 'ask';
68
+ saveConfig(config);
69
+ console.log(` ${teal('●')} ${sage('Kept configured default route:')} ${cream(configuredHarness.label)} ${slate('— no API key needed')}`);
70
+ } else if (config.defaultRoute === 'api' && config.apiKey) {
71
+ saveConfig(config);
72
+ console.log(` ${teal('●')} ${sage('Kept configured default route: API (existing key found)')}`);
73
+ } else if (chatCapable.length > 0) {
65
74
  config.defaultRoute = chatCapable[0].id;
66
75
  if (!config.fallback) config.fallback = 'ask';
67
76
  saveConfig(config);
package/commands/style.js CHANGED
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const os = require('os');
7
7
  const readline = require('readline');
8
8
  const { select, upsert, SUPABASE_URL, SUPABASE_ANON_KEY } = require('../lib/supabase');
9
+ const configFile = require('../lib/config-file');
9
10
 
10
11
  const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
11
12
  const STYLE_CACHE_DIR = path.join(os.homedir(), '.phewsh', 'styletree');
@@ -18,7 +19,7 @@ const c = (s) => `\x1b[36m${s}\x1b[0m`;
18
19
  const y = (s) => `\x1b[33m${s}\x1b[0m`;
19
20
 
20
21
  function loadConfig() {
21
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
22
+ return configFile.loadConfig(CONFIG_PATH);
22
23
  }
23
24
 
24
25
  function ask(rl, q) {
package/commands/sync.js CHANGED
@@ -7,6 +7,7 @@ const os = require('os');
7
7
  const crypto = require('crypto');
8
8
  const { select, upsert, refreshSession } = require('../lib/supabase');
9
9
  const { readPPS, writePPS } = require('../lib/pps');
10
+ const configFile = require('../lib/config-file');
10
11
 
11
12
  const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
12
13
  const INTENT_DIR = path.join(process.cwd(), '.intent');
@@ -15,14 +16,11 @@ const FILE_TO_KIND = { 'vision.md': 'vision', 'plan.md': 'plan', 'next.md': 'nex
15
16
  const KIND_TO_FILE = { vision: 'vision.md', plan: 'plan.md', next: 'next.md' };
16
17
 
17
18
  function loadConfig() {
18
- if (!fs.existsSync(CONFIG_PATH)) return null;
19
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
19
+ return configFile.loadConfig(CONFIG_PATH);
20
20
  }
21
21
 
22
22
  function saveConfig(config) {
23
- const dir = path.dirname(CONFIG_PATH);
24
- fs.mkdirSync(dir, { recursive: true });
25
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
23
+ configFile.saveConfig(CONFIG_PATH, config);
26
24
  }
27
25
 
28
26
  function genProjectId() {
package/commands/watch.js CHANGED
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const os = require('os');
16
+ const configFile = require('../lib/config-file');
16
17
 
17
18
  const INTENT_DIR = path.join(process.cwd(), '.intent');
18
19
  const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
@@ -38,14 +39,11 @@ const red = (s) => `\x1b[31m${s}\x1b[0m`;
38
39
  // ── Config + Auth ──────────────────────────────────────────────────────────
39
40
 
40
41
  function loadConfig() {
41
- if (!fs.existsSync(CONFIG_PATH)) return null;
42
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
42
+ return configFile.loadConfig(CONFIG_PATH);
43
43
  }
44
44
 
45
45
  function saveConfig(config) {
46
- const dir = path.dirname(CONFIG_PATH);
47
- fs.mkdirSync(dir, { recursive: true });
48
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
46
+ configFile.saveConfig(CONFIG_PATH, config);
49
47
  }
50
48
 
51
49
  // ── Context Generation (mirrors context.js) ────────────────────────────────
@@ -0,0 +1,36 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function hardenConfigPath(configPath) {
5
+ const dir = path.dirname(configPath);
6
+ if (fs.existsSync(dir)) {
7
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort on non-POSIX filesystems */ }
8
+ }
9
+ if (fs.existsSync(configPath)) {
10
+ try { fs.chmodSync(configPath, 0o600); } catch { /* best effort on non-POSIX filesystems */ }
11
+ }
12
+ }
13
+
14
+ function loadConfig(configPath, fallback = null) {
15
+ if (!fs.existsSync(configPath)) return fallback;
16
+ hardenConfigPath(configPath);
17
+ try {
18
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ function saveConfig(configPath, config) {
25
+ const dir = path.dirname(configPath);
26
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
27
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort on non-POSIX filesystems */ }
28
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
29
+ try { fs.chmodSync(configPath, 0o600); } catch { /* existing files keep their prior mode */ }
30
+ }
31
+
32
+ module.exports = {
33
+ hardenConfigPath,
34
+ loadConfig,
35
+ saveConfig,
36
+ };
package/lib/cors.js ADDED
@@ -0,0 +1,48 @@
1
+ const DEFAULT_ALLOWED_ORIGINS = new Set([
2
+ 'https://phewsh.com',
3
+ 'https://www.phewsh.com',
4
+ 'http://localhost:3000',
5
+ 'http://127.0.0.1:3000',
6
+ 'http://[::1]:3000',
7
+ ]);
8
+
9
+ function allowedOrigins() {
10
+ const origins = new Set(DEFAULT_ALLOWED_ORIGINS);
11
+ for (const origin of (process.env.PHEWSH_ALLOWED_ORIGINS || '').split(',')) {
12
+ const trimmed = origin.trim();
13
+ if (trimmed) origins.add(trimmed);
14
+ }
15
+ return origins;
16
+ }
17
+
18
+ function requestOrigin(req) {
19
+ const origin = req.headers.origin;
20
+ return Array.isArray(origin) ? origin[0] : origin;
21
+ }
22
+
23
+ function isAllowedRequest(req) {
24
+ const origin = requestOrigin(req);
25
+ if (origin) return allowedOrigins().has(origin);
26
+
27
+ // CLI clients do not send browser fetch metadata. A browser can omit Origin
28
+ // on some cross-site requests, so reject those before they reach any route.
29
+ return req.headers['sec-fetch-site'] !== 'cross-site';
30
+ }
31
+
32
+ function corsHeaders(req) {
33
+ const origin = requestOrigin(req);
34
+ if (!origin || !allowedOrigins().has(origin)) return {};
35
+
36
+ return {
37
+ 'Access-Control-Allow-Origin': origin,
38
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
39
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Phewsh-Runtime',
40
+ 'Access-Control-Max-Age': '600',
41
+ Vary: 'Origin',
42
+ };
43
+ }
44
+
45
+ module.exports = {
46
+ corsHeaders,
47
+ isAllowedRequest,
48
+ };
package/lib/harnesses.js CHANGED
@@ -21,7 +21,7 @@ const { execSync, spawn } = require('child_process');
21
21
  // model flag ignore the preference and use their own config.
22
22
  const HARNESSES = {
23
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
- 'codex': { bin: 'codex', label: 'Codex CLI', role: 'reasons & reviews', auth: 'ChatGPT plan', models: true, args: (p, m) => ['exec', ...(m ? ['-m', m] : []), p] },
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] : [])] },
27
27
  'opencode': { bin: 'opencode', label: 'OpenCode', role: 'general agent', auth: 'OpenCode Zen / configured', args: (p) => ['run', p] },
@@ -0,0 +1,67 @@
1
+ function createLineDispatcher(handleInput, {
2
+ onError = (err) => { throw err; },
3
+ onNoop = () => {},
4
+ schedule = setImmediate,
5
+ } = {}) {
6
+ let pendingLines = [];
7
+ let scheduled = false;
8
+ let chain = Promise.resolve();
9
+
10
+ function flush() {
11
+ scheduled = false;
12
+ const lines = pendingLines;
13
+ pendingLines = [];
14
+ const input = lines.join('\n').trim();
15
+ if (!input) {
16
+ onNoop();
17
+ return;
18
+ }
19
+ chain = chain.then(() => handleInput(input)).catch(onError);
20
+ }
21
+
22
+ function push(line) {
23
+ pendingLines.push(String(line));
24
+ if (scheduled) return;
25
+ scheduled = true;
26
+ schedule(flush);
27
+ }
28
+
29
+ async function drain() {
30
+ if (scheduled) flush();
31
+ await chain;
32
+ }
33
+
34
+ return { push, drain };
35
+ }
36
+
37
+ function createFailureTracker() {
38
+ const seen = new Map();
39
+
40
+ function classify(harnessId, message) {
41
+ const text = String(message || '').trim();
42
+ const firstLine = text.split('\n')[0].trim();
43
+ const isClaudeUsageLimit = harnessId === 'claude-code'
44
+ && /(usage|rate|session|weekly|monthly)?\s*(limit|quota)|exhaust|resets?\s/i.test(text);
45
+
46
+ if (!isClaudeUsageLimit) {
47
+ return { kind: 'failure', duplicate: false, key: null };
48
+ }
49
+
50
+ const normalized = text.toLowerCase().replace(/\s+/g, ' ').slice(0, 300);
51
+ const key = `${harnessId}:${normalized}`;
52
+ const count = (seen.get(key) || 0) + 1;
53
+ seen.set(key, count);
54
+ return {
55
+ kind: 'usage-limit',
56
+ duplicate: count > 1,
57
+ key,
58
+ };
59
+ }
60
+
61
+ return { classify };
62
+ }
63
+
64
+ module.exports = {
65
+ createFailureTracker,
66
+ createLineDispatcher,
67
+ };
@@ -27,6 +27,7 @@ import { URL } from "url";
27
27
 
28
28
  import { readFileSync, readdirSync } from "fs";
29
29
  import { join } from "path";
30
+ import corsPolicy from "../lib/cors.js";
30
31
 
31
32
  import {
32
33
  loadProjects, recordResult, recordSession, updateLocalStatusMd,
@@ -35,6 +36,8 @@ import {
35
36
  import * as runtimes from "./lib/runtime-registry.js";
36
37
  import * as queue from "./lib/dispatch-queue.js";
37
38
 
39
+ const { corsHeaders, isAllowedRequest } = corsPolicy;
40
+
38
41
  // Version comes from the phewsh package this server ships inside — never hardcode it.
39
42
  const VERSION = (() => {
40
43
  try {
@@ -49,23 +52,16 @@ const DEFAULT_HOST = "127.0.0.1";
49
52
 
50
53
  // ─── HTTP helpers ───────────────────────────────────────────────────────────
51
54
 
52
- const CORS_HEADERS = {
53
- "Access-Control-Allow-Origin": "*",
54
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
55
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Phewsh-Runtime",
56
- "Access-Control-Max-Age": "600",
57
- };
58
-
59
- function json(res, status, body) {
55
+ function json(req, res, status, body) {
60
56
  res.writeHead(status, {
61
57
  "Content-Type": "application/json",
62
- ...CORS_HEADERS,
58
+ ...corsHeaders(req),
63
59
  });
64
60
  res.end(JSON.stringify(body));
65
61
  }
66
62
 
67
- function text(res, status, body) {
68
- res.writeHead(status, { "Content-Type": "text/plain", ...CORS_HEADERS });
63
+ function text(req, res, status, body) {
64
+ res.writeHead(status, { "Content-Type": "text/plain", ...corsHeaders(req) });
69
65
  res.end(body);
70
66
  }
71
67
 
@@ -85,7 +81,7 @@ async function readJsonBody(req) {
85
81
 
86
82
  function handleHealth(req, res) {
87
83
  const connected = runtimes.list();
88
- json(res, 200, {
84
+ json(req, res, 200, {
89
85
  status: "ok",
90
86
  runtimes: connected,
91
87
  version: VERSION,
@@ -97,7 +93,7 @@ async function handleDispatch(req, res) {
97
93
  try {
98
94
  const body = await readJsonBody(req);
99
95
  if (!body.packet || !body.packet.objective) {
100
- return json(res, 400, { error: "Missing packet.objective" });
96
+ return json(req, res, 400, { error: "Missing packet.objective" });
101
97
  }
102
98
 
103
99
  // Auto-register the target runtime as "expected" so /health reflects
@@ -122,26 +118,26 @@ async function handleDispatch(req, res) {
122
118
  taskSummary: body.packet?.objective?.task?.slice(0, 120),
123
119
  });
124
120
 
125
- json(res, 200, { jobId: job.jobId, status: job.status });
121
+ json(req, res, 200, { jobId: job.jobId, status: job.status });
126
122
  } catch (err) {
127
- json(res, 400, { error: err.message });
123
+ json(req, res, 400, { error: err.message });
128
124
  }
129
125
  }
130
126
 
131
- function handleStatus(res, jobId) {
127
+ function handleStatus(req, res, jobId) {
132
128
  const job = queue.getStatus(jobId);
133
- if (!job) return json(res, 404, { error: "Job not found" });
129
+ if (!job) return json(req, res, 404, { error: "Job not found" });
134
130
  const { packet, ...rest } = job;
135
- json(res, 200, rest);
131
+ json(req, res, 200, rest);
136
132
  }
137
133
 
138
- function handleResult(res, jobId) {
134
+ function handleResult(req, res, jobId) {
139
135
  const job = queue.getStatus(jobId);
140
- if (!job) return json(res, 404, { error: "Job not found" });
136
+ if (!job) return json(req, res, 404, { error: "Job not found" });
141
137
  if (job.status !== "done" && job.status !== "error") {
142
- return json(res, 202, { status: job.status, message: "Job not yet complete" });
138
+ return json(req, res, 202, { status: job.status, message: "Job not yet complete" });
143
139
  }
144
- json(res, 200, {
140
+ json(req, res, 200, {
145
141
  jobId: job.jobId,
146
142
  status: job.status,
147
143
  result: job.result,
@@ -150,7 +146,7 @@ function handleResult(res, jobId) {
150
146
  });
151
147
  }
152
148
 
153
- function handleReceipts(res, url) {
149
+ function handleReceipts(req, res, url) {
154
150
  // The merged proof trail: every claim an agent made, with the evidence file
155
151
  // behind it. Same data `phewsh receipts` shows in the terminal — exposed
156
152
  // here so the web app can render it.
@@ -213,13 +209,13 @@ function handleReceipts(res, url) {
213
209
  if (e.kind === "dispatch_enqueued") counts.dispatched++;
214
210
  }
215
211
 
216
- json(res, 200, {
212
+ json(req, res, 200, {
217
213
  summary: { ...counts, totalEvents: filtered.length },
218
214
  events: filtered.slice(0, limit),
219
215
  });
220
216
  }
221
217
 
222
- function handleJobsList(res, url) {
218
+ function handleJobsList(req, res, url) {
223
219
  const limit = Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 200);
224
220
  const statusFilter = url.searchParams.get("status") || undefined;
225
221
  const jobs = queue.list({ limit, status: statusFilter }).map(j => {
@@ -229,20 +225,20 @@ function handleJobsList(res, url) {
229
225
  summary: packet?.objective?.task?.slice(0, 140) || null,
230
226
  };
231
227
  });
232
- json(res, 200, { jobs });
228
+ json(req, res, 200, { jobs });
233
229
  }
234
230
 
235
- function handleNextForRuntime(res, url, req) {
231
+ function handleNextForRuntime(req, res, url) {
236
232
  const runtimeId = url.searchParams.get("runtime") || req.headers["x-phewsh-runtime"];
237
- if (!runtimeId) return json(res, 400, { error: "Missing ?runtime= or X-Phewsh-Runtime header" });
233
+ if (!runtimeId) return json(req, res, 400, { error: "Missing ?runtime= or X-Phewsh-Runtime header" });
238
234
 
239
235
  runtimes.register({ id: runtimeId, label: runtimeId, transport: "http" });
240
236
 
241
237
  const job = queue.nextForRuntime(runtimeId);
242
- if (!job) return json(res, 204, null);
238
+ if (!job) return json(req, res, 204, null);
243
239
 
244
240
  queue.markExecuting(job.jobId, runtimeId, "Picked up by HTTP harness");
245
- json(res, 200, { jobId: job.jobId, actionId: job.actionId, packet: job.packet });
241
+ json(req, res, 200, { jobId: job.jobId, actionId: job.actionId, packet: job.packet });
246
242
  }
247
243
 
248
244
  async function handleJobComplete(req, res, jobId) {
@@ -251,7 +247,7 @@ async function handleJobComplete(req, res, jobId) {
251
247
  const { success = true, result = "", issues, agentId, projectId } = body;
252
248
 
253
249
  const job = queue.getStatus(jobId);
254
- if (!job) return json(res, 404, { error: "Job not found" });
250
+ if (!job) return json(req, res, 404, { error: "Job not found" });
255
251
 
256
252
  const updated = success
257
253
  ? queue.complete(jobId, result)
@@ -279,9 +275,9 @@ async function handleJobComplete(req, res, jobId) {
279
275
 
280
276
  if (agentId) runtimes.touch(agentId);
281
277
 
282
- json(res, 200, { jobId, status: updated.status });
278
+ json(req, res, 200, { jobId, status: updated.status });
283
279
  } catch (err) {
284
- json(res, 400, { error: err.message });
280
+ json(req, res, 400, { error: err.message });
285
281
  }
286
282
  }
287
283
 
@@ -289,8 +285,12 @@ async function handleJobComplete(req, res, jobId) {
289
285
 
290
286
  export function startHttpServer({ port = DEFAULT_PORT, host = DEFAULT_HOST } = {}) {
291
287
  const server = createServer(async (req, res) => {
288
+ if (!isAllowedRequest(req)) {
289
+ return json(req, res, 403, { error: "Origin not allowed" });
290
+ }
291
+
292
292
  if (req.method === "OPTIONS") {
293
- res.writeHead(204, CORS_HEADERS);
293
+ res.writeHead(204, corsHeaders(req));
294
294
  return res.end();
295
295
  }
296
296
 
@@ -302,26 +302,26 @@ export function startHttpServer({ port = DEFAULT_PORT, host = DEFAULT_HOST } = {
302
302
  if (req.method === "POST" && pathname === "/dispatch") return await handleDispatch(req, res);
303
303
  if (req.method === "GET" && pathname.startsWith("/status/")) {
304
304
  const jobId = pathname.slice("/status/".length);
305
- return handleStatus(res, jobId);
305
+ return handleStatus(req, res, jobId);
306
306
  }
307
307
  if (req.method === "GET" && pathname.startsWith("/result/")) {
308
308
  const jobId = pathname.slice("/result/".length);
309
- return handleResult(res, jobId);
309
+ return handleResult(req, res, jobId);
310
310
  }
311
- if (req.method === "GET" && pathname === "/jobs") return handleJobsList(res, url);
312
- if (req.method === "GET" && pathname === "/receipts") return handleReceipts(res, url);
313
- if (req.method === "GET" && pathname === "/next") return handleNextForRuntime(res, url, req);
311
+ if (req.method === "GET" && pathname === "/jobs") return handleJobsList(req, res, url);
312
+ if (req.method === "GET" && pathname === "/receipts") return handleReceipts(req, res, url);
313
+ if (req.method === "GET" && pathname === "/next") return handleNextForRuntime(req, res, url);
314
314
  if (req.method === "POST" && pathname.match(/^\/jobs\/[^/]+\/complete$/)) {
315
315
  const jobId = pathname.split("/")[2];
316
316
  return await handleJobComplete(req, res, jobId);
317
317
  }
318
318
  if (req.method === "GET" && pathname === "/") {
319
- return text(res, 200, `PHEWSH MCP HTTP transport v${VERSION}\nEndpoints: /health /dispatch /status/:id /result/:id /jobs /receipts /next /jobs/:id/complete\n`);
319
+ return text(req, res, 200, `PHEWSH MCP HTTP transport v${VERSION}\nEndpoints: /health /dispatch /status/:id /result/:id /jobs /receipts /next /jobs/:id/complete\n`);
320
320
  }
321
- return json(res, 404, { error: `No route for ${req.method} ${pathname}` });
321
+ return json(req, res, 404, { error: `No route for ${req.method} ${pathname}` });
322
322
  } catch (err) {
323
323
  console.error("[http]", err);
324
- return json(res, 500, { error: err.message });
324
+ return json(req, res, 500, { error: err.message });
325
325
  }
326
326
  });
327
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"