phewsh 0.15.0 → 0.15.2
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 +2 -2
- package/commands/browse.js +2 -2
- package/commands/clarify.js +13 -2
- package/commands/gate.js +2 -2
- package/commands/intent.js +2 -2
- package/commands/login.js +3 -4
- package/commands/mcp.js +4 -3
- package/commands/serve.js +33 -22
- package/commands/session.js +175 -46
- package/commands/setup.js +14 -5
- package/commands/style.js +2 -1
- package/commands/sync.js +3 -5
- package/commands/watch.js +3 -5
- package/lib/config-file.js +36 -0
- package/lib/cors.js +48 -0
- package/lib/harnesses.js +15 -2
- package/lib/session-input.js +67 -0
- package/mcp/http-server.js +42 -42
- package/package.json +1 -1
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
|
-
|
|
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() {
|
package/commands/browse.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/commands/clarify.js
CHANGED
|
@@ -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
|
-
|
|
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() {
|
|
@@ -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/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
|
-
|
|
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() {
|
package/commands/intent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
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')}`);
|
package/commands/session.js
CHANGED
|
@@ -17,9 +17,11 @@ 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
|
+
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
|
-
|
|
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
|
-
|
|
161
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
161
|
+
configFile.saveConfig(CONFIG_PATH, config);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
function loadIntentContext() {
|
|
@@ -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();
|
|
@@ -484,6 +491,8 @@ async function main() {
|
|
|
484
491
|
|
|
485
492
|
// ── Turn runners — every route records a decision, leaves a receipt ────
|
|
486
493
|
// Both return true on success so the fallback flow can chain them.
|
|
494
|
+
const failureTracker = createFailureTracker();
|
|
495
|
+
let lastTurnFailure = null;
|
|
487
496
|
|
|
488
497
|
async function runHarnessTurn(input, harnessId, fullSystem) {
|
|
489
498
|
const decisionId = recordDecision({
|
|
@@ -491,22 +500,34 @@ async function main() {
|
|
|
491
500
|
});
|
|
492
501
|
decisionsThisSession++;
|
|
493
502
|
try {
|
|
503
|
+
turnInFlight = true;
|
|
494
504
|
const output = await runViaHarness(harnessId, fullSystem, buildHarnessPrompt(messages, input), { model: harnessModel });
|
|
505
|
+
turnInFlight = false;
|
|
495
506
|
messages.push({ role: 'user', content: input });
|
|
496
507
|
messages.push({ role: 'assistant', content: (output || '').trim() });
|
|
497
508
|
recordSessionEvent(harnessId, projectName, 'task_complete', {
|
|
498
509
|
taskId: decisionId, success: true, summary: input.slice(0, 140),
|
|
499
510
|
});
|
|
511
|
+
lastTurnFailure = null;
|
|
500
512
|
awaitingOutcome = decisionId;
|
|
501
513
|
console.log(slate(` via ${HARNESSES[harnessId].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
|
|
502
514
|
return true;
|
|
503
515
|
} catch (err) {
|
|
516
|
+
turnInFlight = false;
|
|
517
|
+
if (userCancelled) {
|
|
518
|
+
userCancelled = false;
|
|
519
|
+
console.log(`\n ${slate('cancelled — esc')}`);
|
|
520
|
+
return true; // user's call, not a failure: no fallback offer
|
|
521
|
+
}
|
|
504
522
|
try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
|
|
505
523
|
recordSessionEvent(harnessId, projectName, 'task_complete', {
|
|
506
524
|
taskId: decisionId, success: false, summary: input.slice(0, 140),
|
|
507
525
|
});
|
|
508
|
-
const
|
|
509
|
-
|
|
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
|
+
}
|
|
510
531
|
return false;
|
|
511
532
|
}
|
|
512
533
|
}
|
|
@@ -519,7 +540,11 @@ async function main() {
|
|
|
519
540
|
messages.push({ role: 'user', content: input });
|
|
520
541
|
console.log('');
|
|
521
542
|
try {
|
|
522
|
-
|
|
543
|
+
turnInFlight = true;
|
|
544
|
+
turnAbort = new AbortController();
|
|
545
|
+
const result = await streamChat(config.apiKey, messages, fullSystem, modelId(currentModel), { signal: turnAbort.signal });
|
|
546
|
+
turnInFlight = false;
|
|
547
|
+
turnAbort = null;
|
|
523
548
|
messages.push({ role: 'assistant', content: result.content });
|
|
524
549
|
if (result.promptTokens) totalPromptTokens += result.promptTokens;
|
|
525
550
|
if (result.completionTokens) totalCompletionTokens += result.completionTokens;
|
|
@@ -537,6 +562,14 @@ async function main() {
|
|
|
537
562
|
});
|
|
538
563
|
return true;
|
|
539
564
|
} catch (err) {
|
|
565
|
+
turnInFlight = false;
|
|
566
|
+
turnAbort = null;
|
|
567
|
+
if (userCancelled || err.name === 'AbortError') {
|
|
568
|
+
userCancelled = false;
|
|
569
|
+
messages.pop();
|
|
570
|
+
console.log(`\n ${slate('cancelled — esc')}`);
|
|
571
|
+
return true; // user's call, not a failure: no fallback offer
|
|
572
|
+
}
|
|
540
573
|
try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
|
|
541
574
|
messages.pop();
|
|
542
575
|
console.error(`\n ${ember('!')} ${sage('API route failed')}${slate(' — ' + err.message.split('\n')[0])}`);
|
|
@@ -547,10 +580,17 @@ async function main() {
|
|
|
547
580
|
// Fallbacks are a first-class flow: the route changes, the context and
|
|
548
581
|
// record do not. Ask by default; auto-switch only if setup said so.
|
|
549
582
|
async function offerFallbacks(input, fullSystem, failedId) {
|
|
583
|
+
if (lastTurnFailure?.duplicate && lastTurnFailure.harnessId === failedId) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
550
587
|
const options = harnesses
|
|
551
588
|
.filter(h => h.installed && h.headless && h.id !== failedId)
|
|
552
589
|
.map(h => h.id);
|
|
553
590
|
if (config?.apiKey && failedId !== 'api') options.push('api');
|
|
591
|
+
if (lastTurnFailure?.kind === 'usage-limit' && lastTurnFailure.harnessId === failedId) {
|
|
592
|
+
options.push(failedId);
|
|
593
|
+
}
|
|
554
594
|
|
|
555
595
|
if (options.length === 0) {
|
|
556
596
|
console.log(` ${sage('No fallback ready.')} ${slate('Install Codex or Gemini, or add an API key with /key — context would travel automatically.')}`);
|
|
@@ -571,8 +611,14 @@ async function main() {
|
|
|
571
611
|
}
|
|
572
612
|
|
|
573
613
|
const list = options.map((id, i) =>
|
|
574
|
-
`${teal(String(i + 1))} ${sage(id === 'api'
|
|
614
|
+
`${teal(String(i + 1))} ${sage(id === 'api'
|
|
615
|
+
? 'direct API (your key)'
|
|
616
|
+
: id === failedId ? HARNESSES[id].label + ' (retry once)' : HARNESSES[id].label)}`
|
|
575
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
|
+
}
|
|
576
622
|
console.log(` ${sage('Retry with your context intact:')} ${list} ${slate('· enter = skip')}`);
|
|
577
623
|
console.log(` ${slate('prefer auto-switching? phewsh setup sets it once')}`);
|
|
578
624
|
awaitingFallback = { input, fullSystem, options };
|
|
@@ -586,23 +632,65 @@ async function main() {
|
|
|
586
632
|
historySize: 100,
|
|
587
633
|
});
|
|
588
634
|
|
|
589
|
-
// Live input coloring
|
|
590
|
-
//
|
|
591
|
-
//
|
|
635
|
+
// Live input coloring — like Claude Code: text stays normal, and only a
|
|
636
|
+
// RECOGNIZED leading /command (or @harness) token turns teal (peach for @)
|
|
637
|
+
// so you know it registered. Arguments stay plain. TTY-only, fail-soft.
|
|
638
|
+
const KNOWN_COMMANDS = new Set([
|
|
639
|
+
'quit', 'exit', 'q', 'help', 'h', 'init', 'intent', 'clarify', 'model',
|
|
640
|
+
'models', 'council', 'all', 'provider', 'route', 'use', 'work', 'run',
|
|
641
|
+
'clear', 'status', 'key', 'login', 'export', 'push', 'pull', 'serve',
|
|
642
|
+
'sync', 'harnesses', 'fallback', 'outcomes', 'tour', 'update', 'upgrade',
|
|
643
|
+
'agents', 'context', 'gate', 'reload', 'sequence', 'setup', 'system', 'watch',
|
|
644
|
+
]);
|
|
645
|
+
const installedIds = harnesses.filter(h => h.installed).map(h => h.id);
|
|
646
|
+
let turnAbort = null; // AbortController while an API turn streams
|
|
647
|
+
let turnInFlight = false; // any route — ESC cancels
|
|
648
|
+
let userCancelled = false; // distinguishes esc from real failures
|
|
649
|
+
|
|
650
|
+
function colorizeInput(cur) {
|
|
651
|
+
const tok = cur.slice(1).split(/\s/)[0].toLowerCase();
|
|
652
|
+
if (!tok) return null;
|
|
653
|
+
if (cur[0] === '/' && KNOWN_COMMANDS.has(tok)) {
|
|
654
|
+
return `\x1b[38;5;79m/${cur.slice(1, 1 + tok.length)}\x1b[0m${cur.slice(1 + tok.length)}`;
|
|
655
|
+
}
|
|
656
|
+
if (cur[0] === '@' && installedIds.some(id => id === tok || id.startsWith(tok))) {
|
|
657
|
+
return `\x1b[38;5;216m@${cur.slice(1, 1 + tok.length)}\x1b[0m${cur.slice(1 + tok.length)}`;
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
592
662
|
if (process.stdout.isTTY && typeof rl._writeToOutput === 'function') {
|
|
593
663
|
const origWrite = rl._writeToOutput.bind(rl);
|
|
594
664
|
rl._writeToOutput = function (s) {
|
|
595
665
|
try {
|
|
596
666
|
const cur = rl.line || '';
|
|
597
|
-
if (
|
|
598
|
-
const
|
|
599
|
-
s = s.split(cur).join(
|
|
667
|
+
if (typeof s === 'string' && cur && s.includes(cur)) {
|
|
668
|
+
const colored = colorizeInput(cur);
|
|
669
|
+
if (colored) s = s.split(cur).join(colored);
|
|
600
670
|
}
|
|
601
671
|
} catch { /* never break input */ }
|
|
602
672
|
origWrite(s);
|
|
603
673
|
};
|
|
604
|
-
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (process.stdin.isTTY) {
|
|
677
|
+
process.stdin.on('keypress', (str, key) => {
|
|
605
678
|
try {
|
|
679
|
+
// ESC: cancel an in-flight turn, or clear the input line.
|
|
680
|
+
if (key && key.name === 'escape') {
|
|
681
|
+
if (turnInFlight) {
|
|
682
|
+
userCancelled = true;
|
|
683
|
+
if (turnAbort) turnAbort.abort();
|
|
684
|
+
cancelActive();
|
|
685
|
+
} else if (rl.line) {
|
|
686
|
+
rl.line = '';
|
|
687
|
+
rl.cursor = 0;
|
|
688
|
+
rl._refreshLine();
|
|
689
|
+
}
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
// Re-render so token coloring tracks edits (and un-colors when it
|
|
693
|
+
// stops matching a known command).
|
|
606
694
|
const cur = rl.line || '';
|
|
607
695
|
if (cur[0] === '/' || cur[0] === '@') rl._refreshLine();
|
|
608
696
|
} catch { /* never break input */ }
|
|
@@ -611,13 +699,7 @@ async function main() {
|
|
|
611
699
|
|
|
612
700
|
rl.prompt();
|
|
613
701
|
|
|
614
|
-
|
|
615
|
-
const input = line.trim();
|
|
616
|
-
|
|
617
|
-
if (!input) {
|
|
618
|
-
rl.prompt();
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
702
|
+
async function handleInput(input) {
|
|
621
703
|
|
|
622
704
|
// A bare number right after a route failure picks the fallback
|
|
623
705
|
if (awaitingFallback) {
|
|
@@ -789,6 +871,7 @@ async function main() {
|
|
|
789
871
|
console.log(` ${cream('session')}`);
|
|
790
872
|
console.log(` ${teal('/work')} ${slate('[harness]')} ${sage('Hand off to interactive Claude Code/Codex — outcome on return')}`);
|
|
791
873
|
console.log(` ${teal('/run')} ${slate('<prompt>')} ${sage('One-shot prompt (no history)')}`);
|
|
874
|
+
console.log(` ${teal('esc')} ${sage('Cancel a running turn · clear the input line')}`);
|
|
792
875
|
console.log(` ${teal('/clear')} ${sage('Clear conversation')}`);
|
|
793
876
|
console.log(` ${teal('/status')} ${sage('Session stats')}`);
|
|
794
877
|
console.log(` ${teal('/quit')} ${sage('Exit')}`);
|
|
@@ -1136,11 +1219,24 @@ async function main() {
|
|
|
1136
1219
|
ui.divider('line');
|
|
1137
1220
|
if (route?.type === 'harness') {
|
|
1138
1221
|
const h = HARNESSES[route.id];
|
|
1139
|
-
console.log(` ${b(cream('Models'))} ${sage('— ' + h.label
|
|
1222
|
+
console.log(` ${b(cream('Models'))} ${sage('— via ' + h.label)}`);
|
|
1140
1223
|
ui.divider('line');
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1224
|
+
if (h.modelHints) {
|
|
1225
|
+
// Aliases the harness resolves to its own current versions —
|
|
1226
|
+
// stable names, so this list can't go stale.
|
|
1227
|
+
console.log(` ${cream('default'.padEnd(12))} ${sage(h.label + "'s own default")}${!harnessModel ? ` ${teal('●')}` : ''}`);
|
|
1228
|
+
h.modelHints.forEach(m => {
|
|
1229
|
+
const active = harnessModel === m ? ` ${teal('●')}` : '';
|
|
1230
|
+
console.log(` ${cream(m.padEnd(12))} ${sage('latest ' + m.charAt(0).toUpperCase() + m.slice(1))}${active}`);
|
|
1231
|
+
});
|
|
1232
|
+
if (harnessModel && !h.modelHints.includes(harnessModel)) {
|
|
1233
|
+
console.log(` ${cream(harnessModel.padEnd(12))} ${sage('(pass-through)')} ${teal('●')}`);
|
|
1234
|
+
}
|
|
1235
|
+
console.log(`\n ${sage('Switch:')} ${cream('/model <name>')} ${slate('— any full model id also works; ' + h.label + ' validates')}`);
|
|
1236
|
+
} else {
|
|
1237
|
+
console.log(` ${sage('Current preference:')} ${cream(harnessModel || h.label + ' default')}`);
|
|
1238
|
+
console.log(` ${sage(h.label + ' owns its model list —')} ${cream('/model <anything it accepts>')} ${slate('passes through; it validates')}`);
|
|
1239
|
+
}
|
|
1144
1240
|
console.log('');
|
|
1145
1241
|
rl.prompt();
|
|
1146
1242
|
return;
|
|
@@ -1230,6 +1326,15 @@ async function main() {
|
|
|
1230
1326
|
const artifacts = ['vision', 'plan', 'next', 'status'];
|
|
1231
1327
|
const intentDir = path.join(process.cwd(), '.intent');
|
|
1232
1328
|
|
|
1329
|
+
// Full markdown render — **bold**, *italic*, `code`, [links] become
|
|
1330
|
+
// terminal formatting, not literal symbols. `base` keeps the line's
|
|
1331
|
+
// color after each inline reset.
|
|
1332
|
+
const inlineMd = (t, base) => t
|
|
1333
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, x) => `\x1b[1m\x1b[38;5;230m${x}\x1b[0m${base}`)
|
|
1334
|
+
.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, (_, p, x) => `${p}\x1b[3m${x}\x1b[23m`)
|
|
1335
|
+
.replace(/`([^`]+)`/g, (_, x) => `\x1b[38;5;79m${x}\x1b[0m${base}`)
|
|
1336
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, x, u) => `\x1b[4m\x1b[38;5;79m${x}\x1b[0m${base} \x1b[2m(${u})\x1b[22m`);
|
|
1337
|
+
const C = { sage: '\x1b[38;5;151m', slate: '\x1b[38;5;247m' };
|
|
1233
1338
|
const renderMd = (raw) => {
|
|
1234
1339
|
let body = raw;
|
|
1235
1340
|
if (body.startsWith('---')) {
|
|
@@ -1237,11 +1342,12 @@ async function main() {
|
|
|
1237
1342
|
if (end !== -1) body = body.slice(end + 4);
|
|
1238
1343
|
}
|
|
1239
1344
|
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(
|
|
1345
|
+
if (/^#{1,2}\s/.test(l)) return `\n ${b(teal(inlineMd(l.replace(/^#+\s*/, ''), '')))}`;
|
|
1346
|
+
if (/^#{3,}\s/.test(l)) return ` ${cream(inlineMd(l.replace(/^#+\s*/, ''), ''))}`;
|
|
1347
|
+
if (/^\s*[-*]\s/.test(l)) return ` ${teal('·')} ${C.sage}${inlineMd(l.replace(/^\s*[-*]\s*/, ''), C.sage)}\x1b[0m`;
|
|
1348
|
+
if (/^\s*\d+\.\s/.test(l)) return ` ${C.sage}${inlineMd(l.trim(), C.sage)}\x1b[0m`;
|
|
1349
|
+
if (/^---+\s*$/.test(l)) return ` ${slate('─'.repeat(40))}`;
|
|
1350
|
+
return ` ${C.slate}${inlineMd(l, C.slate)}\x1b[0m`;
|
|
1245
1351
|
}).join('\n');
|
|
1246
1352
|
};
|
|
1247
1353
|
|
|
@@ -1353,9 +1459,17 @@ async function main() {
|
|
|
1353
1459
|
console.log(` ${slate(members.map(m => m.label).join(' · '))}`);
|
|
1354
1460
|
|
|
1355
1461
|
const prompt = buildHarnessPrompt(messages, cmdArg);
|
|
1462
|
+
turnInFlight = true;
|
|
1356
1463
|
const settled = await Promise.allSettled(members.map(m =>
|
|
1357
1464
|
runViaHarness(m.id, councilSystem, prompt, { quiet: true })
|
|
1358
1465
|
));
|
|
1466
|
+
turnInFlight = false;
|
|
1467
|
+
if (userCancelled) {
|
|
1468
|
+
userCancelled = false;
|
|
1469
|
+
console.log(`\n ${slate('council cancelled — esc')}\n`);
|
|
1470
|
+
rl.prompt();
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1359
1473
|
|
|
1360
1474
|
const answers = [];
|
|
1361
1475
|
settled.forEach((r, i) => {
|
|
@@ -1446,8 +1560,11 @@ async function main() {
|
|
|
1446
1560
|
if (!config?.apiKey) {
|
|
1447
1561
|
console.log(` ${ember('!')} ${sage('No API key set — run /key first.')}`);
|
|
1448
1562
|
} else {
|
|
1563
|
+
config = loadConfig() || {};
|
|
1564
|
+
config.defaultRoute = 'api';
|
|
1565
|
+
saveConfig(config);
|
|
1449
1566
|
route = { type: 'api' };
|
|
1450
|
-
console.log(` ${teal('●')} ${sage('
|
|
1567
|
+
console.log(` ${teal('●')} ${sage('Default route:')} ${cream(routeLabel(route, config))}`);
|
|
1451
1568
|
}
|
|
1452
1569
|
} else if (HARNESSES[target]) {
|
|
1453
1570
|
if (!harnesses.find(h => h.id === target)?.installed) {
|
|
@@ -1455,13 +1572,15 @@ async function main() {
|
|
|
1455
1572
|
} else if (!HARNESSES[target].args) {
|
|
1456
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)')}`);
|
|
1457
1574
|
} else {
|
|
1575
|
+
config = loadConfig() || {};
|
|
1576
|
+
config.defaultRoute = target;
|
|
1577
|
+
saveConfig(config);
|
|
1458
1578
|
route = { type: 'harness', id: target };
|
|
1459
|
-
console.log(` ${teal('●')} ${sage('
|
|
1579
|
+
console.log(` ${teal('●')} ${sage('Default route:')} ${cream(routeLabel(route, config))} ${slate('— saved across sessions')}`);
|
|
1460
1580
|
}
|
|
1461
1581
|
} else {
|
|
1462
1582
|
console.log(` ${sage('Unknown route. Options:')} ${cream(Object.keys(HARNESSES).join(', ') + ', api')}`);
|
|
1463
1583
|
}
|
|
1464
|
-
console.log(` ${slate('make it stick across sessions: phewsh setup')}`);
|
|
1465
1584
|
rl.prompt();
|
|
1466
1585
|
return;
|
|
1467
1586
|
}
|
|
@@ -1705,9 +1824,19 @@ async function main() {
|
|
|
1705
1824
|
|
|
1706
1825
|
console.log('');
|
|
1707
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
|
+
},
|
|
1708
1835
|
});
|
|
1836
|
+
rl.on('line', lineDispatcher.push);
|
|
1709
1837
|
|
|
1710
|
-
rl.on('close', () => {
|
|
1838
|
+
rl.on('close', async () => {
|
|
1839
|
+
await lineDispatcher.drain();
|
|
1711
1840
|
console.log(`\n ${sage('session ended')}\n`);
|
|
1712
1841
|
process.exit(0);
|
|
1713
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
|
-
|
|
21
|
+
return configFile.loadConfig(CONFIG_PATH, {});
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
function saveConfig(config) {
|
|
24
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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 };
|
|
@@ -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
|
+
};
|
package/mcp/http-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...
|
|
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", ...
|
|
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
|
|
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,
|
|
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
|
|
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
|
|