phewsh 0.14.6 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,20 @@ state with the bridges (`~/.phewsh/`). No second install.
43
43
  Rule of thumb: `phewsh serve` = dispatch tasks *to* your agents.
44
44
  `phewsh mcp setup` = your agents pull tasks *from* PHEWSH mid-session.
45
45
 
46
+ ## Ambient — continuity without launching phewsh
47
+
48
+ ```bash
49
+ phewsh ambient on
50
+ ```
51
+
52
+ A consent screen shows exactly what changes (two hook entries in Claude
53
+ Code's settings), then: every Claude Code session in a project with
54
+ `.intent/` starts pre-briefed, and every session leaves a one-line
55
+ metadata breadcrumb (never transcript contents) in
56
+ `~/.phewsh/ambient-sessions.jsonl`. `phewsh ambient status` shows the
57
+ full ledger; `phewsh ambient off` removes everything. Mission control
58
+ is optional. Continuity is not.
59
+
46
60
  ## Live Execution
47
61
 
48
62
  Connect the web app to your local machine for real-time task execution:
package/bin/phewsh.js CHANGED
@@ -71,6 +71,8 @@ const COMMANDS = {
71
71
  serve: () => require('../commands/serve')(),
72
72
  sequence: () => require('../commands/sequence')(),
73
73
  seq: () => require('../commands/sequence')(),
74
+ ambient: () => require('../commands/ambient')(),
75
+ hook: () => require('../commands/hook')(),
74
76
  help: showHelp,
75
77
  version: showVersion,
76
78
  };
@@ -103,6 +105,7 @@ function showHelp() {
103
105
  console.log(` ${cyan('push/pull')} ${g('Manual sync to/from phewsh.com/intent')}`);
104
106
  console.log(` ${cyan('serve')} ${g('Execution bridge — run from phewsh.com/intent')}`);
105
107
  console.log(` ${cyan('mcp')} ${g('Connect AI agents via MCP protocol')}`);
108
+ console.log(` ${cyan('ambient')} ${g('Continuity without launching phewsh — enhance your other tools')}`);
106
109
  console.log(` ${cyan('receipts')} ${g('Proof trail — what agents actually did, with evidence')}`);
107
110
  console.log(` ${cyan('outcomes')} ${g('Decision record — what was kept, reverted, or failed')}`);
108
111
  console.log(` ${cyan('bypass')} ${g('Went around phewsh? Record why — 10 seconds, no guilt')}`);
@@ -0,0 +1,238 @@
1
+ // phewsh ambient — continuity without launching phewsh.
2
+ //
3
+ // Principle (Jun 12): ambient before primary. Installing phewsh should make
4
+ // the tools you already use better — context arrives, sessions leave
5
+ // breadcrumbs — without you ever typing `phewsh`.
6
+ //
7
+ // Constraint (non-negotiable): radical transparency. Nothing changes
8
+ // without a consent screen that names every file and every line. The
9
+ // ledger (~/.phewsh/ambient.json) records exactly what was applied and
10
+ // how to undo it. Trust matters more than automation here.
11
+ //
12
+ // phewsh ambient — status: detected tools, what's enhanced
13
+ // phewsh ambient on [--yes] — consent screen, then apply
14
+ // phewsh ambient off — remove everything, update ledger
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const readline = require('readline');
20
+ const { listHarnesses } = require('../lib/harnesses');
21
+
22
+ const PHEWSH_DIR = path.join(os.homedir(), '.phewsh');
23
+ const LEDGER_FILE = path.join(PHEWSH_DIR, 'ambient.json');
24
+ const CLAUDE_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
25
+
26
+ const HOOK_START = { type: 'command', command: 'phewsh hook session-start' };
27
+ const HOOK_END = { type: 'command', command: 'phewsh hook session-end' };
28
+
29
+ // ANSI helpers (256-color per cli/lib/ui.js palette rules)
30
+ const b = (s) => `\x1b[1m${s}\x1b[0m`;
31
+ const teal = (s) => `\x1b[38;5;79m${s}\x1b[0m`;
32
+ const sage = (s) => `\x1b[38;5;151m${s}\x1b[0m`;
33
+ const slate = (s) => `\x1b[38;5;247m${s}\x1b[0m`;
34
+ const cream = (s) => `\x1b[38;5;230m${s}\x1b[0m`;
35
+ const peach = (s) => `\x1b[38;5;216m${s}\x1b[0m`;
36
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
37
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
38
+
39
+ function loadLedger() {
40
+ try { return JSON.parse(fs.readFileSync(LEDGER_FILE, 'utf-8')); } catch { return { version: 1, applied: {}, seenHarnesses: [] }; }
41
+ }
42
+
43
+ function saveLedger(ledger) {
44
+ if (!fs.existsSync(PHEWSH_DIR)) fs.mkdirSync(PHEWSH_DIR, { recursive: true });
45
+ fs.writeFileSync(LEDGER_FILE, JSON.stringify(ledger, null, 2));
46
+ }
47
+
48
+ function loadClaudeSettings() {
49
+ try { return JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf-8')); } catch { return null; }
50
+ }
51
+
52
+ function hasHook(settings, eventName, command) {
53
+ const entries = settings?.hooks?.[eventName];
54
+ if (!Array.isArray(entries)) return false;
55
+ return entries.some(e => (e.hooks || []).some(h => h.command === command));
56
+ }
57
+
58
+ function claudeApplied() {
59
+ const s = loadClaudeSettings();
60
+ return !!s && hasHook(s, 'SessionStart', HOOK_START.command) && hasHook(s, 'SessionEnd', HOOK_END.command);
61
+ }
62
+
63
+ function applyClaudeHooks() {
64
+ const settings = loadClaudeSettings() || {};
65
+ settings.hooks = settings.hooks || {};
66
+ const changes = [];
67
+ for (const [event, hook] of [['SessionStart', HOOK_START], ['SessionEnd', HOOK_END]]) {
68
+ settings.hooks[event] = settings.hooks[event] || [];
69
+ if (!hasHook(settings, event, hook.command)) {
70
+ settings.hooks[event].push({ hooks: [hook] });
71
+ changes.push(`hooks.${event} += "${hook.command}"`);
72
+ }
73
+ }
74
+ if (changes.length > 0) {
75
+ fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
76
+ }
77
+ return changes;
78
+ }
79
+
80
+ function removeClaudeHooks() {
81
+ const settings = loadClaudeSettings();
82
+ if (!settings?.hooks) return [];
83
+ const removed = [];
84
+ for (const [event, hook] of [['SessionStart', HOOK_START], ['SessionEnd', HOOK_END]]) {
85
+ const entries = settings.hooks[event];
86
+ if (!Array.isArray(entries)) continue;
87
+ const before = entries.length;
88
+ settings.hooks[event] = entries
89
+ .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => h.command !== hook.command) }))
90
+ .filter(e => e.hooks.length > 0);
91
+ if (settings.hooks[event].length !== before) removed.push(`hooks.${event} -= "${hook.command}"`);
92
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
93
+ }
94
+ if (removed.length > 0) fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
95
+ return removed;
96
+ }
97
+
98
+ function showConsentScreen(harnesses) {
99
+ console.log('');
100
+ console.log(` ${b(cream('PHEWSH ambient'))} ${sage('— continuity without launching phewsh')}`);
101
+ console.log('');
102
+ console.log(` ${sage('Detected on this machine:')}`);
103
+ for (const h of harnesses.filter(h => h.installed)) {
104
+ const note = h.id === 'claude-code' ? teal('enhanceable now') : slate('detection only in v1 — `phewsh sequence` + `phewsh mcp setup` work today');
105
+ console.log(` ${green('✓')} ${cream(h.label.padEnd(14))} ${note}`);
106
+ }
107
+ console.log('');
108
+ console.log(` ${b('Claude Code enhancements:')}`);
109
+ console.log(` ${teal('Context sync')} ${sage('SessionStart hook — when a project has')} ${cream('.intent/')}${sage(', a short brief')}`);
110
+ console.log(` ${sage('(vision, next steps, constraints) is injected at session start.')}`);
111
+ console.log(` ${teal('Session capture')} ${sage('SessionEnd hook — appends one metadata line (time, project, cwd)')}`);
112
+ console.log(` ${sage('to')} ${cream('~/.phewsh/ambient-sessions.jsonl')}${sage('.')} ${b(sage('Never transcript contents.'))}`);
113
+ console.log('');
114
+ console.log(` ${peach('Exactly what changes:')} ${sage('two hook entries in')} ${cream(CLAUDE_SETTINGS)}${sage('. Nothing else is touched.')}`);
115
+ console.log(` ${sage('Undo anytime:')} ${cream('phewsh ambient off')} ${sage('· full record:')} ${cream('phewsh ambient status')}`);
116
+ console.log('');
117
+ }
118
+
119
+ async function confirm(question) {
120
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
121
+ return new Promise(resolve => {
122
+ rl.question(question, answer => {
123
+ rl.close();
124
+ resolve(/^y(es)?$/i.test(answer.trim()));
125
+ });
126
+ });
127
+ }
128
+
129
+ async function turnOn(skipConfirm) {
130
+ const harnesses = listHarnesses();
131
+ showConsentScreen(harnesses);
132
+
133
+ if (claudeApplied()) {
134
+ console.log(` ${green('Already applied.')} ${sage('Status:')} ${cream('phewsh ambient status')}`);
135
+ console.log('');
136
+ return;
137
+ }
138
+
139
+ if (!skipConfirm) {
140
+ const ok = await confirm(` ${b('Apply?')} ${slate('[y/N] ')}`);
141
+ if (!ok) {
142
+ console.log(` ${sage('Nothing changed.')}`);
143
+ console.log('');
144
+ return;
145
+ }
146
+ }
147
+
148
+ const changes = applyClaudeHooks();
149
+ const ledger = loadLedger();
150
+ ledger.applied['claude-code'] = {
151
+ at: new Date().toISOString(),
152
+ file: CLAUDE_SETTINGS,
153
+ changes,
154
+ captures: '~/.phewsh/ambient-sessions.jsonl — timestamp, project, cwd only',
155
+ undo: 'phewsh ambient off',
156
+ };
157
+ ledger.seenHarnesses = harnesses.filter(h => h.installed).map(h => h.id);
158
+ saveLedger(ledger);
159
+
160
+ console.log('');
161
+ console.log(` ${green('●')} ${b('Ambient is on.')}`);
162
+ changes.forEach(c => console.log(` ${teal('+')} ${slate(c)}`));
163
+ console.log('');
164
+ console.log(` ${sage('Next Claude Code session in a project with')} ${cream('.intent/')} ${sage('starts pre-briefed.')}`);
165
+ console.log(` ${sage('You never have to launch phewsh for this to work.')}`);
166
+ console.log('');
167
+ }
168
+
169
+ function turnOff() {
170
+ const removed = removeClaudeHooks();
171
+ const ledger = loadLedger();
172
+ delete ledger.applied['claude-code'];
173
+ saveLedger(ledger);
174
+ console.log('');
175
+ if (removed.length > 0) {
176
+ console.log(` ${green('●')} ${b('Ambient is off.')}`);
177
+ removed.forEach(c => console.log(` ${peach('-')} ${slate(c)}`));
178
+ console.log(` ${sage('Breadcrumb log kept at')} ${cream('~/.phewsh/ambient-sessions.jsonl')} ${sage('— delete it if you want; phewsh never will.')}`);
179
+ } else {
180
+ console.log(` ${sage('Nothing was applied — nothing to remove.')}`);
181
+ }
182
+ console.log('');
183
+ }
184
+
185
+ function status() {
186
+ const harnesses = listHarnesses();
187
+ const ledger = loadLedger();
188
+ console.log('');
189
+ console.log(` ${b(cream('PHEWSH ambient'))} ${sage('— status')}`);
190
+ console.log('');
191
+ for (const h of harnesses.filter(h => h.installed)) {
192
+ if (h.id === 'claude-code') {
193
+ const on = claudeApplied();
194
+ console.log(` ${on ? green('●') : yellow('○')} ${cream(h.label.padEnd(14))} ${on ? teal('ambient on') : sage('not enhanced — phewsh ambient on')}`);
195
+ const entry = ledger.applied['claude-code'];
196
+ if (entry) {
197
+ console.log(` ${slate('applied ' + entry.at)}`);
198
+ (entry.changes || []).forEach(c => console.log(` ${slate('· ' + c + ' (' + entry.file + ')')}`));
199
+ console.log(` ${slate('· captures: ' + entry.captures)}`);
200
+ }
201
+ } else {
202
+ console.log(` ${slate('○')} ${cream(h.label.padEnd(14))} ${slate('detected — v1 enhances Claude Code only')}`);
203
+ }
204
+ }
205
+
206
+ // New harnesses since last apply — the re-offer.
207
+ const seen = ledger.seenHarnesses || [];
208
+ const fresh = harnesses.filter(h => h.installed && !seen.includes(h.id));
209
+ if (seen.length > 0 && fresh.length > 0) {
210
+ console.log('');
211
+ console.log(` ${peach('New since last time:')} ${cream(fresh.map(h => h.label).join(', '))}`);
212
+ }
213
+
214
+ // Recent breadcrumbs — show the user exactly what ambient has recorded.
215
+ const logFile = path.join(PHEWSH_DIR, 'ambient-sessions.jsonl');
216
+ try {
217
+ const lines = fs.readFileSync(logFile, 'utf-8').trim().split('\n');
218
+ console.log('');
219
+ console.log(` ${sage('Last ambient breadcrumbs (' + lines.length + ' total):')}`);
220
+ lines.slice(-3).forEach(l => {
221
+ try {
222
+ const e = JSON.parse(l);
223
+ console.log(` ${slate(e.ts + ' · ' + e.event + ' · ' + (e.project || path.basename(e.cwd || '?')))}`);
224
+ } catch { /* skip bad line */ }
225
+ });
226
+ } catch { /* no breadcrumbs yet */ }
227
+ console.log('');
228
+ }
229
+
230
+ async function main() {
231
+ const sub = process.argv[3] || 'status';
232
+ const skipConfirm = process.argv.includes('--yes');
233
+ if (sub === 'on') return turnOn(skipConfirm);
234
+ if (sub === 'off') return turnOff();
235
+ return status();
236
+ }
237
+
238
+ module.exports = main;
@@ -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
@@ -0,0 +1,120 @@
1
+ // phewsh hook — runtime endpoints for ambient harness hooks.
2
+ //
3
+ // These are invoked BY other tools (Claude Code hooks), not by people.
4
+ // Contract: fast, silent when there's nothing to say, and they never
5
+ // read or store transcript contents — metadata only. What gets written
6
+ // is documented in `phewsh ambient status`.
7
+ //
8
+ // phewsh hook session-start stdout → injected into the agent's context
9
+ // phewsh hook session-end stdin (hook JSON) → metadata breadcrumb
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ const PHEWSH_DIR = path.join(os.homedir(), '.phewsh');
16
+ const AMBIENT_LOG = path.join(PHEWSH_DIR, 'ambient-sessions.jsonl');
17
+ const INTENT_DIR = path.join(process.cwd(), '.intent');
18
+
19
+ function readIfExists(p, maxBytes = 16384) {
20
+ try { return fs.readFileSync(p, 'utf-8').slice(0, maxBytes); } catch { return null; }
21
+ }
22
+
23
+ function projectName() {
24
+ const meta = readIfExists(path.join(INTENT_DIR, 'project.json'));
25
+ if (meta) {
26
+ try { const m = JSON.parse(meta); if (m.name) return m.name; } catch { /* fall through */ }
27
+ }
28
+ return path.basename(process.cwd());
29
+ }
30
+
31
+ function appendBreadcrumb(event, extra = {}) {
32
+ try {
33
+ if (!fs.existsSync(PHEWSH_DIR)) fs.mkdirSync(PHEWSH_DIR, { recursive: true });
34
+ const line = JSON.stringify({
35
+ ts: new Date().toISOString(),
36
+ event,
37
+ cwd: process.cwd(),
38
+ project: fs.existsSync(INTENT_DIR) ? projectName() : null,
39
+ source: 'claude-code-ambient',
40
+ ...extra,
41
+ });
42
+ fs.appendFileSync(AMBIENT_LOG, line + '\n');
43
+ } catch { /* breadcrumbs must never break the host tool */ }
44
+ }
45
+
46
+ function firstLines(text, n) {
47
+ let body = text;
48
+ // Strip YAML frontmatter — metadata, not context.
49
+ if (body.startsWith('---')) {
50
+ const end = body.indexOf('\n---', 3);
51
+ if (end !== -1) body = body.slice(end + 4);
52
+ }
53
+ return body.split('\n')
54
+ .filter(l => l.trim())
55
+ .filter(l => !/^#+\s*$/.test(l.trim()))
56
+ .slice(0, n)
57
+ .join('\n');
58
+ }
59
+
60
+ function sessionStart() {
61
+ if (!fs.existsSync(path.join(INTENT_DIR, 'vision.md')) &&
62
+ !fs.existsSync(path.join(INTENT_DIR, 'plan.md'))) {
63
+ // No .intent/ here — stay silent, cost the host nothing.
64
+ process.exit(0);
65
+ }
66
+
67
+ const parts = [];
68
+ parts.push(`# Project brief (from .intent/ — PHEWSH continuity layer)`);
69
+ parts.push(`Project: ${projectName()}`);
70
+
71
+ const meta = readIfExists(path.join(INTENT_DIR, 'project.json'));
72
+ if (meta) {
73
+ try {
74
+ const m = JSON.parse(meta);
75
+ if (m.tldr) parts.push(`TLDR: ${m.tldr}`);
76
+ if (m.constraints) {
77
+ const c = Object.entries(m.constraints).map(([k, v]) => `${k}: ${v}`).join(' · ');
78
+ if (c) parts.push(`Constraints: ${c}`);
79
+ }
80
+ } catch { /* skip meta */ }
81
+ }
82
+
83
+ const vision = readIfExists(path.join(INTENT_DIR, 'vision.md'));
84
+ if (vision) parts.push(`\n## Vision (excerpt)\n${firstLines(vision, 8)}`);
85
+
86
+ const next = readIfExists(path.join(INTENT_DIR, 'next.md'));
87
+ if (next) parts.push(`\n## Next (excerpt)\n${firstLines(next, 8)}`);
88
+
89
+ const status = readIfExists(path.join(INTENT_DIR, 'status.md'));
90
+ if (status) parts.push(`\n## Status (excerpt)\n${firstLines(status, 5)}`);
91
+
92
+ parts.push(`\n(Brief injected by PHEWSH ambient from .intent/. Honor the constraints above. The human can run \`phewsh\` for mission control — council, outcomes, the decision record.)`);
93
+
94
+ process.stdout.write(parts.join('\n') + '\n');
95
+ appendBreadcrumb('session-start');
96
+ process.exit(0);
97
+ }
98
+
99
+ function sessionEnd() {
100
+ let stdin = '';
101
+ process.stdin.on('data', d => { stdin += d.toString(); });
102
+ process.stdin.on('end', () => {
103
+ let reason = null;
104
+ try { reason = JSON.parse(stdin).reason || null; } catch { /* metadata only; fine */ }
105
+ appendBreadcrumb('session-end', reason ? { reason } : {});
106
+ process.exit(0);
107
+ });
108
+ // If the host never closes stdin, don't hang it.
109
+ setTimeout(() => { appendBreadcrumb('session-end'); process.exit(0); }, 1500);
110
+ }
111
+
112
+ function main() {
113
+ const event = process.argv[3];
114
+ if (event === 'session-start') return sessionStart();
115
+ if (event === 'session-end') return sessionEnd();
116
+ // Unknown event: exit silently — hooks must never error the host tool.
117
+ process.exit(0);
118
+ }
119
+
120
+ module.exports = main;
@@ -17,7 +17,7 @@ const intentDir = () => path.join(process.cwd(), '.intent');
17
17
  const { select, refreshSession: refreshSess } = require('../lib/supabase');
18
18
  const { readPPS } = require('../lib/pps');
19
19
  const { push, pull, ensureValidToken } = require('./sync');
20
- const { HARNESSES, listHarnesses, runViaHarness } = require('../lib/harnesses');
20
+ const { HARNESSES, listHarnesses, runViaHarness, cancelActive } = require('../lib/harnesses');
21
21
  const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
22
22
  const { recordSessionEvent } = require('../lib/receipts-data');
23
23
  const { recordProject, listProjects, scanForProjects, fmtAgo } = require('../lib/projects-index');
@@ -198,21 +198,28 @@ function buildHarnessPrompt(messages, input) {
198
198
  return `Conversation so far:\n\n${transcript}\n\n---\n\nUser: ${input}\n\nRespond to the last user message.`;
199
199
  }
200
200
 
201
- async function streamChat(apiKey, messages, systemPrompt, modelId) {
201
+ async function streamChat(apiKey, messages, systemPrompt, modelId, opts = {}) {
202
202
  const body = { model: modelId, max_tokens: 2048, messages, stream: true };
203
203
  if (systemPrompt) body.system = systemPrompt;
204
204
 
205
205
  const spin = ui.spinner('thinking');
206
206
 
207
- const response = await fetch('https://api.anthropic.com/v1/messages', {
208
- method: 'POST',
209
- headers: {
210
- 'x-api-key': apiKey,
211
- 'anthropic-version': '2023-06-01',
212
- 'content-type': 'application/json',
213
- },
214
- body: JSON.stringify(body),
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();
@@ -274,6 +281,31 @@ async function main() {
274
281
  }
275
282
  let currentModel = DEFAULT_MODEL;
276
283
  let harnessModel = null; // pass-through preference; the harness validates it
284
+ let liveModelsCache = null; // fetched once per session from the provider's own list
285
+
286
+ // Discovery, not hardcoding: the provider's models endpoint is the truth.
287
+ async function fetchLiveModels() {
288
+ if (liveModelsCache) return liveModelsCache;
289
+ try {
290
+ if (config?.provider === 'openrouter') {
291
+ const res = await fetch('https://openrouter.ai/api/v1/models', { signal: AbortSignal.timeout(4000) });
292
+ if (res.ok) {
293
+ const data = await res.json();
294
+ liveModelsCache = (data.data || []).map(m => m.id);
295
+ }
296
+ } else if (config?.apiKey) {
297
+ const res = await fetch('https://api.anthropic.com/v1/models?limit=100', {
298
+ headers: { 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01' },
299
+ signal: AbortSignal.timeout(4000),
300
+ });
301
+ if (res.ok) {
302
+ const data = await res.json();
303
+ liveModelsCache = (data.data || []).map(m => m.id);
304
+ }
305
+ }
306
+ } catch { /* offline or no key — aliases still work */ }
307
+ return liveModelsCache;
308
+ }
277
309
  let totalPromptTokens = 0;
278
310
  let totalCompletionTokens = 0;
279
311
 
@@ -466,7 +498,9 @@ async function main() {
466
498
  });
467
499
  decisionsThisSession++;
468
500
  try {
501
+ turnInFlight = true;
469
502
  const output = await runViaHarness(harnessId, fullSystem, buildHarnessPrompt(messages, input), { model: harnessModel });
503
+ turnInFlight = false;
470
504
  messages.push({ role: 'user', content: input });
471
505
  messages.push({ role: 'assistant', content: (output || '').trim() });
472
506
  recordSessionEvent(harnessId, projectName, 'task_complete', {
@@ -476,6 +510,12 @@ async function main() {
476
510
  console.log(slate(` via ${HARNESSES[harnessId].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
477
511
  return true;
478
512
  } catch (err) {
513
+ turnInFlight = false;
514
+ if (userCancelled) {
515
+ userCancelled = false;
516
+ console.log(`\n ${slate('cancelled — esc')}`);
517
+ return true; // user's call, not a failure: no fallback offer
518
+ }
479
519
  try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
480
520
  recordSessionEvent(harnessId, projectName, 'task_complete', {
481
521
  taskId: decisionId, success: false, summary: input.slice(0, 140),
@@ -494,7 +534,11 @@ async function main() {
494
534
  messages.push({ role: 'user', content: input });
495
535
  console.log('');
496
536
  try {
497
- const result = await streamChat(config.apiKey, messages, fullSystem, modelId(currentModel));
537
+ turnInFlight = true;
538
+ turnAbort = new AbortController();
539
+ const result = await streamChat(config.apiKey, messages, fullSystem, modelId(currentModel), { signal: turnAbort.signal });
540
+ turnInFlight = false;
541
+ turnAbort = null;
498
542
  messages.push({ role: 'assistant', content: result.content });
499
543
  if (result.promptTokens) totalPromptTokens += result.promptTokens;
500
544
  if (result.completionTokens) totalCompletionTokens += result.completionTokens;
@@ -512,6 +556,14 @@ async function main() {
512
556
  });
513
557
  return true;
514
558
  } catch (err) {
559
+ turnInFlight = false;
560
+ turnAbort = null;
561
+ if (userCancelled || err.name === 'AbortError') {
562
+ userCancelled = false;
563
+ messages.pop();
564
+ console.log(`\n ${slate('cancelled — esc')}`);
565
+ return true; // user's call, not a failure: no fallback offer
566
+ }
515
567
  try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
516
568
  messages.pop();
517
569
  console.error(`\n ${ember('!')} ${sage('API route failed')}${slate(' — ' + err.message.split('\n')[0])}`);
@@ -561,6 +613,71 @@ async function main() {
561
613
  historySize: 100,
562
614
  });
563
615
 
616
+ // Live input coloring — like Claude Code: text stays normal, and only a
617
+ // RECOGNIZED leading /command (or @harness) token turns teal (peach for @)
618
+ // so you know it registered. Arguments stay plain. TTY-only, fail-soft.
619
+ const KNOWN_COMMANDS = new Set([
620
+ 'quit', 'exit', 'q', 'help', 'h', 'init', 'intent', 'clarify', 'model',
621
+ 'models', 'council', 'all', 'provider', 'route', 'use', 'work', 'run',
622
+ 'clear', 'status', 'key', 'login', 'export', 'push', 'pull', 'serve',
623
+ 'sync', 'harnesses', 'fallback', 'outcomes', 'tour', 'update', 'upgrade',
624
+ 'agents', 'context', 'gate', 'reload', 'sequence', 'setup', 'system', 'watch',
625
+ ]);
626
+ const installedIds = harnesses.filter(h => h.installed).map(h => h.id);
627
+ let turnAbort = null; // AbortController while an API turn streams
628
+ let turnInFlight = false; // any route — ESC cancels
629
+ let userCancelled = false; // distinguishes esc from real failures
630
+
631
+ function colorizeInput(cur) {
632
+ const tok = cur.slice(1).split(/\s/)[0].toLowerCase();
633
+ if (!tok) return null;
634
+ if (cur[0] === '/' && KNOWN_COMMANDS.has(tok)) {
635
+ return `\x1b[38;5;79m/${cur.slice(1, 1 + tok.length)}\x1b[0m${cur.slice(1 + tok.length)}`;
636
+ }
637
+ if (cur[0] === '@' && installedIds.some(id => id === tok || id.startsWith(tok))) {
638
+ return `\x1b[38;5;216m@${cur.slice(1, 1 + tok.length)}\x1b[0m${cur.slice(1 + tok.length)}`;
639
+ }
640
+ return null;
641
+ }
642
+
643
+ if (process.stdout.isTTY && typeof rl._writeToOutput === 'function') {
644
+ const origWrite = rl._writeToOutput.bind(rl);
645
+ rl._writeToOutput = function (s) {
646
+ try {
647
+ const cur = rl.line || '';
648
+ if (typeof s === 'string' && cur && s.includes(cur)) {
649
+ const colored = colorizeInput(cur);
650
+ if (colored) s = s.split(cur).join(colored);
651
+ }
652
+ } catch { /* never break input */ }
653
+ origWrite(s);
654
+ };
655
+ }
656
+
657
+ if (process.stdin.isTTY) {
658
+ process.stdin.on('keypress', (str, key) => {
659
+ try {
660
+ // ESC: cancel an in-flight turn, or clear the input line.
661
+ if (key && key.name === 'escape') {
662
+ if (turnInFlight) {
663
+ userCancelled = true;
664
+ if (turnAbort) turnAbort.abort();
665
+ cancelActive();
666
+ } else if (rl.line) {
667
+ rl.line = '';
668
+ rl.cursor = 0;
669
+ rl._refreshLine();
670
+ }
671
+ return;
672
+ }
673
+ // Re-render so token coloring tracks edits (and un-colors when it
674
+ // stops matching a known command).
675
+ const cur = rl.line || '';
676
+ if (cur[0] === '/' || cur[0] === '@') rl._refreshLine();
677
+ } catch { /* never break input */ }
678
+ });
679
+ }
680
+
564
681
  rl.prompt();
565
682
 
566
683
  rl.on('line', async (line) => {
@@ -713,6 +830,7 @@ async function main() {
713
830
  console.log('');
714
831
  console.log(` ${cream('author .intent/')}`);
715
832
  console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
833
+ console.log(` ${teal('/intent')} ${sage('Pause and reflect — view or update .intent/ before moving on')}`);
716
834
  console.log(` ${teal('/clarify')} ${sage('Turn ideas into .intent/ artifacts')}`);
717
835
  console.log(` ${teal('/gate')} ${sage('Set constraints (budget, time, skill)')}`);
718
836
  console.log(` ${teal('/context')} ${sage('Show loaded .intent/ files')}`);
@@ -740,6 +858,7 @@ async function main() {
740
858
  console.log(` ${cream('session')}`);
741
859
  console.log(` ${teal('/work')} ${slate('[harness]')} ${sage('Hand off to interactive Claude Code/Codex — outcome on return')}`);
742
860
  console.log(` ${teal('/run')} ${slate('<prompt>')} ${sage('One-shot prompt (no history)')}`);
861
+ console.log(` ${teal('esc')} ${sage('Cancel a running turn · clear the input line')}`);
743
862
  console.log(` ${teal('/clear')} ${sage('Clear conversation')}`);
744
863
  console.log(` ${teal('/status')} ${sage('Session stats')}`);
745
864
  console.log(` ${teal('/quit')} ${sage('Exit')}`);
@@ -1085,16 +1204,54 @@ async function main() {
1085
1204
  if (cmd === 'models') {
1086
1205
  console.log('');
1087
1206
  ui.divider('line');
1088
- console.log(` ${b(cream('Available models'))}`);
1089
- ui.divider('line');
1090
- for (const [key, model] of Object.entries(MODELS)) {
1091
- const active = key === currentModel ? ` ${teal('')}` : '';
1092
- console.log(` ${cream(key.padEnd(16))} ${sage(model.name)}${active}`);
1207
+ if (route?.type === 'harness') {
1208
+ const h = HARNESSES[route.id];
1209
+ console.log(` ${b(cream('Models'))} ${sage('— via ' + h.label)}`);
1210
+ ui.divider('line');
1211
+ if (h.modelHints) {
1212
+ // Aliases the harness resolves to its own current versions —
1213
+ // stable names, so this list can't go stale.
1214
+ console.log(` ${cream('default'.padEnd(12))} ${sage(h.label + "'s own default")}${!harnessModel ? ` ${teal('●')}` : ''}`);
1215
+ h.modelHints.forEach(m => {
1216
+ const active = harnessModel === m ? ` ${teal('●')}` : '';
1217
+ console.log(` ${cream(m.padEnd(12))} ${sage('latest ' + m.charAt(0).toUpperCase() + m.slice(1))}${active}`);
1218
+ });
1219
+ if (harnessModel && !h.modelHints.includes(harnessModel)) {
1220
+ console.log(` ${cream(harnessModel.padEnd(12))} ${sage('(pass-through)')} ${teal('●')}`);
1221
+ }
1222
+ console.log(`\n ${sage('Switch:')} ${cream('/model <name>')} ${slate('— any full model id also works; ' + h.label + ' validates')}`);
1223
+ } else {
1224
+ console.log(` ${sage('Current preference:')} ${cream(harnessModel || h.label + ' default')}`);
1225
+ console.log(` ${sage(h.label + ' owns its model list —')} ${cream('/model <anything it accepts>')} ${slate('passes through; it validates')}`);
1226
+ }
1227
+ console.log('');
1228
+ rl.prompt();
1229
+ return;
1093
1230
  }
1094
- if (!MODELS[currentModel] && route?.type !== 'harness') {
1231
+ // API route: ask the provider for its real list.
1232
+ const providerName = config?.provider === 'openrouter' ? 'OpenRouter' : 'Anthropic';
1233
+ const live = await fetchLiveModels();
1234
+ if (live && live.length > 0) {
1235
+ console.log(` ${b(cream('Available models'))} ${sage('— live from ' + providerName)}`);
1236
+ ui.divider('line');
1237
+ const shown = live.slice(0, 24);
1238
+ shown.forEach(id => {
1239
+ const active = modelId(currentModel) === id ? ` ${teal('●')}` : '';
1240
+ console.log(` ${cream(id)}${active}`);
1241
+ });
1242
+ if (live.length > shown.length) console.log(` ${slate('… +' + (live.length - shown.length) + ' more — /model <id> takes any of them')}`);
1243
+ } else {
1244
+ console.log(` ${b(cream('Available models'))} ${slate('(offline — shortcuts only; any id still passes through)')}`);
1245
+ ui.divider('line');
1246
+ for (const [key, model] of Object.entries(MODELS)) {
1247
+ const active = key === currentModel ? ` ${teal('●')}` : '';
1248
+ console.log(` ${cream(key.padEnd(16))} ${sage(model.name)}${active}`);
1249
+ }
1250
+ }
1251
+ if (!MODELS[currentModel] && !live?.includes(modelId(currentModel))) {
1095
1252
  console.log(` ${cream(String(currentModel).padEnd(16))} ${sage('(pass-through)')} ${teal('●')}`);
1096
1253
  }
1097
- console.log(`\n ${sage('Switch with:')} ${cream('/model <name>')} ${slate('— aliases above, or any model id (passed through)')}\n`);
1254
+ console.log(`\n ${sage('Switch with:')} ${cream('/model <name>')} ${slate('— shortcuts: ' + Object.keys(MODELS).map(k => k.replace('claude-', '')).join(' · '))}\n`);
1098
1255
  rl.prompt();
1099
1256
  return;
1100
1257
  }
@@ -1150,6 +1307,115 @@ async function main() {
1150
1307
  return;
1151
1308
  }
1152
1309
 
1310
+ if (cmd === 'intent') {
1311
+ // Pause and reflect before moving forward — the samurai check.
1312
+ // View what the project says it is; update it when reality moved.
1313
+ const artifacts = ['vision', 'plan', 'next', 'status'];
1314
+ const intentDir = path.join(process.cwd(), '.intent');
1315
+
1316
+ // Full markdown render — **bold**, *italic*, `code`, [links] become
1317
+ // terminal formatting, not literal symbols. `base` keeps the line's
1318
+ // color after each inline reset.
1319
+ const inlineMd = (t, base) => t
1320
+ .replace(/\*\*([^*]+)\*\*/g, (_, x) => `\x1b[1m\x1b[38;5;230m${x}\x1b[0m${base}`)
1321
+ .replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, (_, p, x) => `${p}\x1b[3m${x}\x1b[23m`)
1322
+ .replace(/`([^`]+)`/g, (_, x) => `\x1b[38;5;79m${x}\x1b[0m${base}`)
1323
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, x, u) => `\x1b[4m\x1b[38;5;79m${x}\x1b[0m${base} \x1b[2m(${u})\x1b[22m`);
1324
+ const C = { sage: '\x1b[38;5;151m', slate: '\x1b[38;5;247m' };
1325
+ const renderMd = (raw) => {
1326
+ let body = raw;
1327
+ if (body.startsWith('---')) {
1328
+ const end = body.indexOf('\n---', 3);
1329
+ if (end !== -1) body = body.slice(end + 4);
1330
+ }
1331
+ return body.trim().split('\n').map(l => {
1332
+ if (/^#{1,2}\s/.test(l)) return `\n ${b(teal(inlineMd(l.replace(/^#+\s*/, ''), '')))}`;
1333
+ if (/^#{3,}\s/.test(l)) return ` ${cream(inlineMd(l.replace(/^#+\s*/, ''), ''))}`;
1334
+ if (/^\s*[-*]\s/.test(l)) return ` ${teal('·')} ${C.sage}${inlineMd(l.replace(/^\s*[-*]\s*/, ''), C.sage)}\x1b[0m`;
1335
+ if (/^\s*\d+\.\s/.test(l)) return ` ${C.sage}${inlineMd(l.trim(), C.sage)}\x1b[0m`;
1336
+ if (/^---+\s*$/.test(l)) return ` ${slate('─'.repeat(40))}`;
1337
+ return ` ${C.slate}${inlineMd(l, C.slate)}\x1b[0m`;
1338
+ }).join('\n');
1339
+ };
1340
+
1341
+ if (!fs.existsSync(intentDir)) {
1342
+ console.log(` ${sage('No .intent/ here yet.')} ${cream('/init')} ${sage('creates it — that is the whole point.')}`);
1343
+ rl.prompt();
1344
+ return;
1345
+ }
1346
+
1347
+ const sub = (cmdArg || '').trim().toLowerCase();
1348
+
1349
+ if (sub.startsWith('view')) {
1350
+ const which = sub.split(/\s+/)[1];
1351
+ const targets = which && artifacts.includes(which) ? [which] : artifacts;
1352
+ for (const a of targets) {
1353
+ const p = path.join(intentDir, `${a}.md`);
1354
+ if (!fs.existsSync(p)) continue;
1355
+ console.log('');
1356
+ ui.divider('line');
1357
+ console.log(` ${b(cream(a.toUpperCase()))} ${slate('.intent/' + a + '.md')}`);
1358
+ ui.divider('line');
1359
+ console.log(renderMd(fs.readFileSync(p, 'utf-8')));
1360
+ }
1361
+ console.log('');
1362
+ console.log(` ${slate('moved on since this was written?')} ${cream('/intent update')}`);
1363
+ console.log('');
1364
+ rl.prompt();
1365
+ return;
1366
+ }
1367
+
1368
+ if (sub === 'update') {
1369
+ // Reflect first: what does the record say happened since last update?
1370
+ console.log('');
1371
+ console.log(` ${b(cream('Before updating — what actually happened:'))}`);
1372
+ try {
1373
+ const { recentDecisions } = require('../lib/outcomes');
1374
+ const recent = recentDecisions(5, { project: projectName });
1375
+ if (recent.length > 0) {
1376
+ recent.forEach(d => {
1377
+ const mark = d.outcome === 'kept' ? green('✓') : d.outcome ? yellow('~') : slate('·');
1378
+ console.log(` ${mark} ${sage((d.summary || '').slice(0, 70))} ${slate('[' + (d.outcome || 'unlabeled') + ']')}`);
1379
+ });
1380
+ } else {
1381
+ console.log(` ${slate('no decisions recorded yet')}`);
1382
+ }
1383
+ } catch { console.log(` ${slate('record unavailable')}`); }
1384
+ console.log('');
1385
+ console.log(` ${teal('●')} ${sage('Handing you to the guided update')} ${slate('— exit to come back to phewsh')}`);
1386
+ console.log('');
1387
+ rl.pause();
1388
+ const { spawnSync } = require('child_process');
1389
+ spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'phewsh.js'), 'clarify'], { stdio: 'inherit' });
1390
+ rl.resume();
1391
+ console.log('');
1392
+ console.log(` ${teal('●')} ${sage('Back in phewsh.')} ${slate('/intent view to see the result — agents pick it up automatically')}`);
1393
+ console.log('');
1394
+ rl.prompt();
1395
+ return;
1396
+ }
1397
+
1398
+ // No arg: the pause. Where the project stands, in one screen.
1399
+ const present = artifacts.filter(a => fs.existsSync(path.join(intentDir, `${a}.md`)));
1400
+ const stale = present.map(a => {
1401
+ const raw = fs.readFileSync(path.join(intentDir, `${a}.md`), 'utf-8');
1402
+ const m = raw.match(/^updated:\s*(\S+)/m);
1403
+ return { a, updated: m ? m[1] : null };
1404
+ });
1405
+ console.log('');
1406
+ console.log(` ${b(cream('INTENT'))} ${slate('— .intent/ in ' + projectName)}`);
1407
+ stale.forEach(({ a, updated }) => {
1408
+ const age = updated ? slate('updated ' + updated) : slate('no date');
1409
+ console.log(` ${teal('·')} ${cream(a.padEnd(8))} ${age}`);
1410
+ });
1411
+ console.log('');
1412
+ console.log(` ${cream('/intent view')} ${slate('[vision|plan|next|status]')} ${sage('read it, rendered')}`);
1413
+ console.log(` ${cream('/intent update')} ${sage('reflect on the record, then guided rewrite')}`);
1414
+ console.log('');
1415
+ rl.prompt();
1416
+ return;
1417
+ }
1418
+
1153
1419
  if (cmd === 'council' || cmd === 'all') {
1154
1420
  // One prompt, every installed harness, in parallel. Different
1155
1421
  // models disagreeing is the signal — and which answer you KEEP
@@ -1180,9 +1446,17 @@ async function main() {
1180
1446
  console.log(` ${slate(members.map(m => m.label).join(' · '))}`);
1181
1447
 
1182
1448
  const prompt = buildHarnessPrompt(messages, cmdArg);
1449
+ turnInFlight = true;
1183
1450
  const settled = await Promise.allSettled(members.map(m =>
1184
1451
  runViaHarness(m.id, councilSystem, prompt, { quiet: true })
1185
1452
  ));
1453
+ turnInFlight = false;
1454
+ if (userCancelled) {
1455
+ userCancelled = false;
1456
+ console.log(`\n ${slate('council cancelled — esc')}\n`);
1457
+ rl.prompt();
1458
+ return;
1459
+ }
1186
1460
 
1187
1461
  const answers = [];
1188
1462
  settled.forEach((r, i) => {
package/lib/harnesses.js CHANGED
@@ -20,7 +20,7 @@ const { execSync, spawn } = require('child_process');
20
20
  // list of its own, so it can never go stale. Harnesses without a known
21
21
  // model flag ignore the preference and use their own config.
22
22
  const HARNESSES = {
23
- 'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', models: true, args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
23
+ 'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', models: true, modelHints: ['sonnet', 'opus', 'haiku', 'fable'], args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
24
24
  'codex': { bin: 'codex', label: 'Codex CLI', role: 'reasons & reviews', auth: 'ChatGPT plan', models: true, args: (p, m) => ['exec', ...(m ? ['-m', m] : []), p] },
25
25
  'gemini': { bin: 'gemini', label: 'Gemini CLI', role: "another model's take", auth: 'Google login', models: true, args: (p, m) => ['-p', p, ...(m ? ['-m', m] : [])] },
26
26
  'cursor': { bin: 'cursor-agent', label: 'Cursor Agent', role: 'edits files', auth: 'Cursor account', models: true, args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
@@ -36,6 +36,17 @@ const HARNESSES = {
36
36
  'droid': { bin: 'droid', label: 'Droid', role: 'agentic coding', auth: 'Factory account', args: (p) => ['exec', p] },
37
37
  };
38
38
 
39
+ // In-flight harness children — so ESC in the session can cancel a turn.
40
+ const ACTIVE_CHILDREN = new Set();
41
+
42
+ function cancelActive() {
43
+ let n = 0;
44
+ for (const c of ACTIVE_CHILDREN) {
45
+ try { c.kill('SIGTERM'); n++; } catch { /* already gone */ }
46
+ }
47
+ return n;
48
+ }
49
+
39
50
  function isInstalled(id) {
40
51
  const h = HARNESSES[id];
41
52
  if (!h) return false;
@@ -68,6 +79,8 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
68
79
 
69
80
  return new Promise((resolve, reject) => {
70
81
  const child = spawn(h.bin, h.args(prompt, model), { stdio: ['pipe', 'pipe', 'pipe'] });
82
+ ACTIVE_CHILDREN.add(child);
83
+ child.on('close', () => ACTIVE_CHILDREN.delete(child));
71
84
  // Some harnesses (codex exec, gemini) wait for stdin EOF before running.
72
85
  child.stdin.end();
73
86
 
@@ -87,4 +100,4 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
87
100
  });
88
101
  }
89
102
 
90
- module.exports = { HARNESSES, isInstalled, detectInstalled, listHarnesses, runViaHarness };
103
+ module.exports = { HARNESSES, isInstalled, detectInstalled, listHarnesses, runViaHarness, cancelActive };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.14.6",
3
+ "version": "0.15.1",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"