@aitherium/shell-cli 1.11.1 → 1.12.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/dist/repl.js CHANGED
@@ -602,7 +602,7 @@ export async function startRepl(client, config) {
602
602
  }
603
603
  // ── /jobs — built-in job management ──
604
604
  if (cmdName === 'jobs') {
605
- handleJobsCommand(cmdArgs);
605
+ await handleJobsCommand(cmdArgs);
606
606
  return;
607
607
  }
608
608
  // ── /relay — native group chat (humans + agents) ──
@@ -1046,9 +1046,47 @@ export async function startRepl(client, config) {
1046
1046
  }
1047
1047
  }
1048
1048
  /* ── /jobs command handler ──────────────────────────────────── */
1049
- function handleJobsCommand(args) {
1049
+ async function handleJobsCommand(args) {
1050
1050
  const parts = args.trim().split(/\s+/);
1051
1051
  const sub = parts[0] || '';
1052
+ // ── Cloud expeditions ──
1053
+ if (sub === 'cloud' || sub === 'cjobs') {
1054
+ const expId = parts[1] || '';
1055
+ if (expId) {
1056
+ // /jobs cloud <id>
1057
+ await handleExpeditionDetail(client, expId);
1058
+ }
1059
+ else {
1060
+ // /jobs cloud
1061
+ await handleExpeditionList(client);
1062
+ }
1063
+ return;
1064
+ }
1065
+ // ── Steer & hint ──
1066
+ if (sub === 'steer' || sub === 'hint') {
1067
+ const expId = parts[1];
1068
+ if (!expId) {
1069
+ console.log(chalk.yellow(` Usage: /jobs ${sub} <expedition-id> <message...>`));
1070
+ return;
1071
+ }
1072
+ const message = parts.slice(2).join(' ');
1073
+ if (!message) {
1074
+ console.log(chalk.yellow(` Usage: /jobs ${sub} <expedition-id> <message...>`));
1075
+ return;
1076
+ }
1077
+ await handleExpeditionSteer(client, expId, message, sub === 'hint' ? 'hint' : 'append');
1078
+ return;
1079
+ }
1080
+ // ── Watch ──
1081
+ if (sub === 'watch') {
1082
+ const expId = parts[1];
1083
+ if (!expId) {
1084
+ console.log(chalk.yellow(' Usage: /jobs watch <expedition-id>'));
1085
+ return;
1086
+ }
1087
+ await handleExpeditionWatch(client, expId);
1088
+ return;
1089
+ }
1052
1090
  // /jobs cancel <id>
1053
1091
  if (sub === 'cancel' || sub === 'kill') {
1054
1092
  const id = Number(parts[1]);
@@ -1076,19 +1114,175 @@ export async function startRepl(client, config) {
1076
1114
  console.log(formatJobOutput(job));
1077
1115
  return;
1078
1116
  }
1079
- // /jobs — list all
1117
+ // /jobs — list all local + help
1080
1118
  const allJobs = listJobs();
1081
1119
  if (allJobs.length === 0) {
1082
- console.log(chalk.dim(' No background jobs.'));
1083
- return;
1120
+ console.log(chalk.dim(' No local background jobs.'));
1084
1121
  }
1085
- console.log(chalk.bold('\n Background Jobs\n'));
1086
- for (const job of allJobs) {
1087
- console.log(formatJobLine(job));
1122
+ else {
1123
+ console.log(chalk.bold('\n Local Background Jobs\n'));
1124
+ for (const job of allJobs) {
1125
+ console.log(formatJobLine(job));
1126
+ }
1088
1127
  }
1089
- console.log(chalk.dim(`\n /jobs <id> View output`));
1090
- console.log(chalk.dim(` /jobs cancel <id> Cancel a running job`));
1091
1128
  console.log();
1129
+ console.log(chalk.bold(' Commands\n'));
1130
+ console.log(chalk.dim(' Local jobs:'));
1131
+ console.log(` ${chalk.cyan('/jobs')} List local background jobs`);
1132
+ console.log(` ${chalk.cyan('/jobs <id>')} View job output`);
1133
+ console.log(` ${chalk.cyan('/jobs cancel <id>')} Cancel a running job`);
1134
+ console.log();
1135
+ console.log(chalk.dim(' Cloud expeditions (durable):'));
1136
+ console.log(` ${chalk.cyan('/jobs cloud')} List expeditions`);
1137
+ console.log(` ${chalk.cyan('/jobs cloud <id>')} View expedition details`);
1138
+ console.log(` ${chalk.cyan('/jobs steer <id> msg')} Send message to expedition`);
1139
+ console.log(` ${chalk.cyan('/jobs hint <id> msg')} Send invisible hint to expedition`);
1140
+ console.log(` ${chalk.cyan('/jobs watch <id>')} Watch expedition stream live`);
1141
+ console.log();
1142
+ }
1143
+ /* ── Expedition helpers ────────────────────────────────────── */
1144
+ async function handleExpeditionList(client) {
1145
+ try {
1146
+ const result = await client.listExpeditions();
1147
+ if (!result || result.error) {
1148
+ console.log(chalk.red(` Error: ${result?.error || 'Failed to list expeditions'}`));
1149
+ return;
1150
+ }
1151
+ const expeditions = result.expeditions || [];
1152
+ if (!expeditions.length) {
1153
+ console.log(chalk.dim(' No expeditions.'));
1154
+ return;
1155
+ }
1156
+ console.log(chalk.bold('\n Cloud Expeditions (Durable Jobs)\n'));
1157
+ // Format as a table: ID | Status | Title | Owner
1158
+ const headers = ['ID', 'Status', 'Title', 'Owner'];
1159
+ const rows = expeditions.map((exp) => [
1160
+ exp.id.slice(0, 8),
1161
+ formatExpeditionStatus(exp.status),
1162
+ (exp.title || '').slice(0, 40),
1163
+ exp.owner || 'unknown',
1164
+ ]);
1165
+ // Simple table format
1166
+ const maxLens = [8, 12, 40, 15];
1167
+ console.log(` ${headers.map((h, i) => h.padEnd(maxLens[i])).join(' ')}`);
1168
+ console.log(` ${headers.map(() => '─'.repeat(10)).join(' ')}`);
1169
+ for (const row of rows) {
1170
+ console.log(` ${row.map((c, i) => String(c).padEnd(maxLens[i])).join(' ')}`);
1171
+ }
1172
+ console.log(chalk.dim(`\n /jobs cloud <id> View expedition details`));
1173
+ console.log();
1174
+ }
1175
+ catch (err) {
1176
+ console.log(chalk.red(` Error: ${err.message}`));
1177
+ }
1178
+ }
1179
+ async function handleExpeditionDetail(client, expId) {
1180
+ try {
1181
+ const status = await client.getExpeditionStatus(expId);
1182
+ if (!status || status.error) {
1183
+ console.log(chalk.red(` Expedition not found: ${expId}`));
1184
+ return;
1185
+ }
1186
+ const tasks = await client.getExpeditionTasks(expId);
1187
+ const taskList = tasks?.tasks || [];
1188
+ console.log(chalk.bold(`\n Expedition: ${expId}\n`));
1189
+ console.log(` Status: ${formatExpeditionStatus(status.status || 'unknown')}`);
1190
+ if (status.title)
1191
+ console.log(` Title: ${status.title}`);
1192
+ if (status.owner)
1193
+ console.log(` Owner: ${status.owner}`);
1194
+ if (status.created_at)
1195
+ console.log(` Created: ${new Date(status.created_at).toLocaleString()}`);
1196
+ if (taskList.length > 0) {
1197
+ console.log(chalk.bold('\n Tasks\n'));
1198
+ for (const task of taskList) {
1199
+ const icon = task.status === 'completed' ? chalk.green('✓') : task.status === 'failed' ? chalk.red('✗') : chalk.blue('•');
1200
+ const title = task.title || task.id.slice(0, 8);
1201
+ console.log(` ${icon} ${title.padEnd(30)} ${chalk.dim(task.status)}`);
1202
+ if (task.result_summary) {
1203
+ const summary = typeof task.result_summary === 'string'
1204
+ ? task.result_summary
1205
+ : JSON.stringify(task.result_summary, null, 2);
1206
+ const lines = summary.split('\n').slice(0, 5);
1207
+ for (const line of lines) {
1208
+ console.log(` ${chalk.dim(line)}`);
1209
+ }
1210
+ if (summary.split('\n').length > 5) {
1211
+ console.log(` ${chalk.dim('...')}`);
1212
+ }
1213
+ }
1214
+ if (task.error) {
1215
+ console.log(` ${chalk.red(task.error)}`);
1216
+ }
1217
+ }
1218
+ }
1219
+ console.log();
1220
+ }
1221
+ catch (err) {
1222
+ console.log(chalk.red(` Error: ${err.message}`));
1223
+ }
1224
+ }
1225
+ async function handleExpeditionSteer(client, expId, message, action) {
1226
+ try {
1227
+ const result = await client.steerExpedition(expId, message, action);
1228
+ if (!result || result.error) {
1229
+ console.log(chalk.red(` Error: ${result?.error || 'Failed to steer expedition'}`));
1230
+ return;
1231
+ }
1232
+ const verb = action === 'hint' ? 'hint sent' : 'message sent';
1233
+ console.log(chalk.green(` ✓ ${verb} to expedition ${result.expedition_id}`));
1234
+ refreshPrompt();
1235
+ }
1236
+ catch (err) {
1237
+ console.log(chalk.red(` Error: ${err.message}`));
1238
+ }
1239
+ }
1240
+ async function handleExpeditionWatch(client, expId) {
1241
+ try {
1242
+ console.log(chalk.cyan(` Watching expedition ${expId}... (Ctrl+C to stop)\n`));
1243
+ let eventCount = 0;
1244
+ for await (const event of client.streamExpeditionEvents(expId)) {
1245
+ eventCount++;
1246
+ if (event.type === 'error') {
1247
+ console.log(chalk.red(` Error: ${event.data.error}`));
1248
+ break;
1249
+ }
1250
+ // Print event summaries
1251
+ const ts = new Date().toLocaleTimeString();
1252
+ const typeIcon = event.type === 'EXPEDITION_COMPLETED' ? chalk.green('✓')
1253
+ : event.type === 'EXPEDITION_FAILED' ? chalk.red('✗')
1254
+ : '•';
1255
+ console.log(` ${ts} ${typeIcon} ${event.type}`);
1256
+ if (event.data.message) {
1257
+ console.log(` ${chalk.dim(event.data.message)}`);
1258
+ }
1259
+ if (event.type === 'EXPEDITION_COMPLETED' || event.type === 'EXPEDITION_FAILED') {
1260
+ console.log(chalk.cyan(`\n Expedition ${event.data.status || 'done'}`));
1261
+ break;
1262
+ }
1263
+ }
1264
+ if (eventCount === 0) {
1265
+ console.log(chalk.yellow(' No events received (expedition may not exist or is already done).'));
1266
+ }
1267
+ }
1268
+ catch (err) {
1269
+ if (err.name === 'AbortError' || err.message?.includes('abort')) {
1270
+ console.log(chalk.yellow('\n Watch cancelled.'));
1271
+ }
1272
+ else {
1273
+ console.log(chalk.red(` Error: ${err.message}`));
1274
+ }
1275
+ }
1276
+ }
1277
+ function formatExpeditionStatus(status) {
1278
+ const normalized = (status || '').toLowerCase();
1279
+ if (normalized === 'completed' || normalized === 'done')
1280
+ return chalk.green(status);
1281
+ if (normalized === 'failed' || normalized === 'error')
1282
+ return chalk.red(status);
1283
+ if (normalized === 'running' || normalized === 'active')
1284
+ return chalk.blue(status);
1285
+ return chalk.yellow(status);
1092
1286
  }
1093
1287
  /* ── Background forge launcher ──────────────────────────────── */
1094
1288
  function launchBgForge(client, args) {
@@ -13,14 +13,34 @@
13
13
  * connect banner and the TUI share one implementation.
14
14
  */
15
15
  import chalk from 'chalk';
16
+ /** Loopback/private hosts serve the internal self-signed CA. Bun's `fetch`
17
+ * ignores NODE_EXTRA_CA_CERTS (set in main.ts), so an https probe to an internal
18
+ * service — notably Identity at :8115, which is https-ONLY (http → 400) — fails
19
+ * cert validation and gets false-flagged DOWN even though it's up. Health probes
20
+ * carry no secrets, so accept the internal cert for private hosts; PUBLIC remote
21
+ * endpoints (idp/gateway, real valid certs) keep strict TLS validation. */
22
+ function insecureTlsFor(url) {
23
+ try {
24
+ const h = new URL(url).hostname;
25
+ const isPrivate = h === 'localhost' || h === '127.0.0.1' || h === '::1' ||
26
+ /^10\./.test(h) || /^192\.168\./.test(h) ||
27
+ /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
28
+ h.endsWith('.local') || h.endsWith('.internal');
29
+ // `tls` is Bun's per-request option (the shipped binary is Bun-compiled);
30
+ // Node's fetch ignores it harmlessly.
31
+ return isPrivate ? { tls: { rejectUnauthorized: false } } : {};
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ }
16
37
  /** Probe a health endpoint, tolerating http↔https and a single cold-start miss. */
17
38
  export async function probeHealth(url, timeoutMs = 4000) {
18
39
  const tryFetch = async (u) => {
19
40
  try {
20
41
  const r = await fetch(u, {
21
42
  signal: AbortSignal.timeout(timeoutMs),
22
- // @ts-ignore — Node 18+ supports this for self-signed certs
23
- ...(u.startsWith('https') ? { dispatcher: undefined } : {}),
43
+ ...insecureTlsFor(u),
24
44
  });
25
45
  return r.ok;
26
46
  }
@@ -16,6 +16,7 @@ export function createTuiRenderer(surface, sessionId, prompt) {
16
16
  let completePrinted = false;
17
17
  let terminalSeen = false; // complete/error/timeout/interrupt reached us
18
18
  let answerCheckpoint = null; // OUTPUT offset where the answer text begins
19
+ let pendingFollowups = []; // stashed on suggested_followups; rendered AFTER complete
19
20
  const traceEvents = [];
20
21
  const toolCalls = [];
21
22
  const thinking = [];
@@ -145,6 +146,16 @@ export function createTuiRenderer(surface, sessionId, prompt) {
145
146
  // (replaceOutputFrom) instead of leaving the weak first pass on screen.
146
147
  // Matches renderer.ts (the plain REPL) which does `content = data.answer`.
147
148
  content = String(text);
149
+ // If this eager segment printed a header ("💬 Initial …") but never
150
+ // streamed any tokens (the engine returned the answer as one event,
151
+ // e.g. the search fastpath), show it NOW rather than waiting for
152
+ // 'complete' — an interrupted/timed-out stream would otherwise leave
153
+ // the user staring at just the preamble. 'complete' re-renders via
154
+ // replaceOutputFrom(answerCheckpoint).
155
+ if (!tokenStreamed && answerCheckpoint == null) {
156
+ answerCheckpoint = surface.markCheckpoint();
157
+ surface.appendOutput(String(text));
158
+ }
148
159
  }
149
160
  break;
150
161
  }
@@ -259,10 +270,36 @@ export function createTuiRenderer(surface, sessionId, prompt) {
259
270
  }
260
271
  const ms = d.duration_ms != null ? `${(Number(d.duration_ms) / 1000).toFixed(1)}s` : '';
261
272
  surface.outputLine(chalk.dim(`── ${[agent, model, ms].filter(Boolean).join(' · ')}`));
273
+ // Follow-up chips AFTER the answer (complete just rewrote the answer
274
+ // region via replaceOutputFrom, so they couldn't be printed earlier).
275
+ if (pendingFollowups.length) {
276
+ surface.outputLine(chalk.dim(' Next →'));
277
+ pendingFollowups.forEach((t, i) => surface.outputLine(' ' + chalk.cyan(`[${i + 1}]`) + ' ' + chalk.dim(String(t))));
278
+ surface.setPendingFollowups(pendingFollowups); // enable digit-select for next input
279
+ }
262
280
  autoOpenImagesFromText(content); // pop generated images in the OS viewer
263
281
  surface.finishTraceTurn('done');
264
282
  break;
265
283
  }
284
+ case 'suggested_followups': {
285
+ // Stash now; render in 'complete' (after the answer is finalised).
286
+ const items = Array.isArray(d.followups) ? d.followups.map((x) => String(x)) : [];
287
+ if (items.length)
288
+ pendingFollowups = items;
289
+ break;
290
+ }
291
+ case 'notebook_ready': {
292
+ // A clarified plan was turned into a notebook. formatTrace drops this
293
+ // (no message/stage field), so surface the id or the user never knows.
294
+ const id = String(d.notebook_id || d.plan_id || '').trim();
295
+ surface.outputLine(chalk.cyan(` 📓 Notebook ready${id ? `: ${id}` : ''}`));
296
+ break;
297
+ }
298
+ case 'stream_warning':
299
+ // 60s+ of stream silence (e.g. reasoning model cold-start). Reassure
300
+ // the user the turn isn't dead.
301
+ surface.traceLine(chalk.yellow(` ⏳ ${d.message || 'still working — model may be warming up…'}`));
302
+ break;
266
303
  case 'heartbeat':
267
304
  case 'keepalive':
268
305
  case 'debug':
@@ -10,7 +10,7 @@
10
10
  * for those; everything else runs in-pane.
11
11
  */
12
12
  import { execSync } from 'node:child_process';
13
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
13
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
14
  import { dirname } from 'node:path';
15
15
  import chalk from 'chalk';
16
16
  import { setActiveConfig } from '../config.js';
@@ -21,6 +21,9 @@ import { collectArgs } from '../interactive.js';
21
21
  import { gatherStatus, formatStatusLines } from '../status-banner.js';
22
22
  import { setJobNotifier, listJobs, getJob, cancelJob, runningCount, launchChatJob, launchForgeJob, launchSwarmJob, launchCommandJob, formatJobLine, formatJobOutput, } from '../jobs.js';
23
23
  import { configureRemoteSync, recordTurn, loadSession, buildContextSummary } from '../session-store.js';
24
+ import { openLocalImage } from '../renderer.js';
25
+ import { loadDoc, renderDocLines, resolveDocPath } from '../doc-view.js';
26
+ import { imageToAnsi, imageDimensions } from '../image-preview.js';
24
27
  import { createTuiScreen } from './screen.js';
25
28
  import { createTuiRenderer } from './controller.js';
26
29
  import { setTuiActive } from '../crash-reporter.js';
@@ -106,6 +109,11 @@ export async function startTuiRepl(client, config) {
106
109
  let histIdx = history.length;
107
110
  let activeAbort = null;
108
111
  let processing = false;
112
+ // Type-ahead queue: messages typed while a turn is streaming. Run as fresh
113
+ // turns the instant the current one finishes (Claude-Code style) instead of
114
+ // being dropped into a steer that oneshot/trivial turns silently discard.
115
+ let queuedInputs = [];
116
+ let draining = false;
109
117
  let sigintCount = 0;
110
118
  let sigintTimer = null;
111
119
  let gpuTag = '';
@@ -295,8 +303,13 @@ export async function startTuiRepl(client, config) {
295
303
  }
296
304
  sigintCount++;
297
305
  if (sigintCount >= 2) {
306
+ const q = queuedInputs.length;
298
307
  setTuiActive(null);
299
308
  surface.destroy();
309
+ // Write AFTER destroy so it lands in the restored terminal, not the
310
+ // torn-down blessed screen — otherwise the user never sees it.
311
+ if (q)
312
+ process.stderr.write(`\n⚠ Discarded ${q} queued message(s).\n`);
300
313
  process.exit(0);
301
314
  }
302
315
  surface.setStatus('Ctrl+C again to exit');
@@ -387,18 +400,54 @@ export async function startTuiRepl(client, config) {
387
400
  relayStatus('sent · agents may reply…');
388
401
  return;
389
402
  }
390
- // Steer an active stream.
403
+ // A turn is in flight. cancel/stop/abort interrupt it; anything else is
404
+ // queued and run as a fresh turn the moment this one finishes. (The old
405
+ // fire-and-forget steer was silently dropped on oneshot/trivial turns —
406
+ // which is every plain question — so the message just vanished.)
391
407
  if (activeAbort && processing) {
392
408
  const lower = clean.toLowerCase();
393
409
  if (lower === 'cancel' || lower === 'stop' || lower === '@abort') {
394
410
  onInterrupt();
395
411
  return;
396
412
  }
397
- surface.traceLine(chalk.yellow(` 🔄 steer: ${clean}`));
398
- client.steer(config.sessionId, clean, 'append').catch(() => { });
413
+ queuedInputs.push(clean);
414
+ surface.traceLine(chalk.cyan(` ⏳ queued (${queuedInputs.length}) sends after this turn`));
399
415
  return;
400
416
  }
401
- await runChat(clean, explicitBg);
417
+ // Bare digit (1-9) → expand to the Nth follow-up suggestion from the last turn.
418
+ const _pick = clean.match(/^([1-9])$/);
419
+ if (_pick) {
420
+ const fups = surface.getPendingFollowups();
421
+ const idx = parseInt(_pick[1], 10) - 1;
422
+ if (idx >= 0 && idx < fups.length) {
423
+ const chosen = fups[idx];
424
+ surface.setPendingFollowups([]); // consume
425
+ surface.outputLine(chalk.dim(` → ${chosen}`));
426
+ await runChatDraining(chosen, explicitBg);
427
+ return;
428
+ }
429
+ }
430
+ surface.setPendingFollowups([]); // any new message invalidates last turn's chips
431
+ await runChatDraining(clean, explicitBg);
432
+ }
433
+ /** Run a chat turn, then drain any messages the user typed while it streamed,
434
+ * running each as its own turn in order. The `draining` guard keeps two
435
+ * rapid submits from spinning up overlapping drain loops. */
436
+ async function runChatDraining(input, bg = false) {
437
+ await runChat(input, bg);
438
+ if (draining)
439
+ return;
440
+ draining = true;
441
+ try {
442
+ while (queuedInputs.length && !processing) {
443
+ const next = queuedInputs.shift();
444
+ surface.outputLine(chalk.green('› ') + chalk.dim('(queued) ') + next);
445
+ await runChat(next, false);
446
+ }
447
+ }
448
+ finally {
449
+ draining = false;
450
+ }
402
451
  }
403
452
  function showHelp() {
404
453
  surface.outputLine(chalk.bold('Keys: ') + chalk.dim('Edit: ←/→ move · Ctrl+A/E line start/end · Ctrl+W del word · Ctrl+U/K kill · Home/End (empty line=scroll) | Panes: wheel/PgUp scroll · Ctrl+←/→ resize split · Ctrl+T trace · Ctrl+E expand all · click thread | Ctrl+C abort/exit · Tab complete'));
@@ -424,9 +473,76 @@ export async function startTuiRepl(client, config) {
424
473
  }
425
474
  surface.focusInput();
426
475
  }
427
- function handleJobs(args) {
476
+ async function handleJobs(args) {
428
477
  const parts = args.trim().split(/\s+/);
429
478
  const sub = parts[0] || '';
479
+ // ── Cloud (durable expedition) jobs — mirror the classic REPL surface ──
480
+ if (sub === 'cloud') {
481
+ const expId = parts[1];
482
+ if (!expId) {
483
+ const result = await client.listExpeditions();
484
+ if (!result || result.error) {
485
+ surface.outputLine(chalk.red(` Error: ${result?.error || 'Failed to list expeditions'}`));
486
+ return;
487
+ }
488
+ const exps = result.expeditions || [];
489
+ if (!exps.length) {
490
+ surface.outputLine(chalk.dim(' No cloud expeditions.'));
491
+ return;
492
+ }
493
+ surface.outputLine(chalk.bold(' Cloud Expeditions (Durable Jobs)'));
494
+ for (const e of exps) {
495
+ surface.outputLine(` ${chalk.bold(String(e.id).slice(0, 8))} ${e.status || '?'} ${(e.title || '').slice(0, 44)}`);
496
+ }
497
+ surface.outputLine(chalk.dim(' /jobs cloud <id> · /jobs steer <id> <msg> · /jobs watch <id>'));
498
+ return;
499
+ }
500
+ const status = await client.getExpeditionStatus(expId);
501
+ if (!status || status.error) {
502
+ surface.outputLine(chalk.red(` Expedition not found: ${expId}`));
503
+ return;
504
+ }
505
+ surface.outputLine(` ${chalk.bold(expId)} status: ${status.status || '?'}`);
506
+ const tasks = await client.getExpeditionTasks(expId);
507
+ for (const t of (tasks?.tasks || [])) {
508
+ const out = (t.result_summary || t.error || '').slice(0, 500);
509
+ surface.outputLine(` • ${t.title || t.id}: ${t.status}${out ? `\n ${out}` : ''}`);
510
+ }
511
+ return;
512
+ }
513
+ if (sub === 'steer' || sub === 'hint') {
514
+ const expId = parts[1];
515
+ const msg = parts.slice(2).join(' ');
516
+ if (!expId || !msg) {
517
+ surface.outputLine(chalk.yellow(`Usage: /jobs ${sub} <id> <message>`));
518
+ return;
519
+ }
520
+ const r = await client.steerExpedition(expId, msg, sub === 'hint' ? 'hint' : 'append');
521
+ surface.outputLine(r?.ok ? chalk.green(` Steered ${expId} (${sub}).`) : chalk.red(` Steer failed: ${r?.error || r?.detail || 'unknown'}`));
522
+ return;
523
+ }
524
+ if (sub === 'watch') {
525
+ const expId = parts[1];
526
+ if (!expId) {
527
+ surface.outputLine(chalk.yellow('Usage: /jobs watch <id>'));
528
+ return;
529
+ }
530
+ surface.outputLine(chalk.dim(` Watching ${expId}…`));
531
+ try {
532
+ for await (const ev of client.streamExpeditionEvents(expId)) {
533
+ const d = ev.data || {};
534
+ const line = d.message || d.error || ev.type;
535
+ if (line)
536
+ surface.outputLine(` [${ev.type}] ${line}`);
537
+ if (ev.type === 'error' || d.status === 'completed' || d.status === 'failed')
538
+ break;
539
+ }
540
+ }
541
+ catch (err) {
542
+ surface.outputLine(chalk.red(` Watch error: ${err.message}`));
543
+ }
544
+ return;
545
+ }
430
546
  if (sub === 'cancel' || sub === 'kill') {
431
547
  const id = Number(parts[1]);
432
548
  if (!id) {
@@ -625,6 +741,89 @@ export async function startTuiRepl(client, config) {
625
741
  surface.outputLine(chalk.dim(' 📡 Left relay — back to agent chat.'));
626
742
  idleStatus();
627
743
  }
744
+ /** `/view <path>` — open a file in the in-TUI document viewer. Markdown/code/
745
+ * text render in a full-screen scrollable overlay; PNG images render inline as
746
+ * half-blocks on the real terminal (via runDetached, for true 24-bit colour);
747
+ * other image formats open in the OS viewer. */
748
+ async function handleView(args) {
749
+ const path = args.trim();
750
+ if (!path) {
751
+ surface.outputLine(chalk.yellow('Usage: /view <file>') + chalk.dim(' — markdown, code, text, or a PNG image'));
752
+ surface.focusInput();
753
+ return;
754
+ }
755
+ const doc = loadDoc(path);
756
+ if (doc.kind === 'missing' || doc.kind === 'binary') {
757
+ surface.outputLine(chalk.yellow(` ${doc.note || doc.kind}: `) + chalk.dim(doc.absPath));
758
+ surface.focusInput();
759
+ return;
760
+ }
761
+ if (doc.kind === 'image') {
762
+ const cols = Math.max(20, (process.stdout.columns || 100) - 4);
763
+ const ansi = imageToAnsi(doc.absPath, Math.min(cols, 120), 50);
764
+ if (ansi) {
765
+ const dims = imageDimensions(doc.absPath);
766
+ await surface.runDetached(`view ${doc.title}`, () => {
767
+ process.stdout.write(chalk.dim(` ${doc.title}${dims ? ` ${dims.width}×${dims.height}` : ''}\n\n`));
768
+ for (const l of ansi)
769
+ process.stdout.write(' ' + l + '\n');
770
+ });
771
+ idleStatus();
772
+ }
773
+ else {
774
+ openLocalImage(doc.absPath);
775
+ surface.outputLine(chalk.dim(` 🖼 ${doc.ext.replace('.', '').toUpperCase()} opened in OS viewer (only PNG renders inline): `) + chalk.dim(doc.absPath));
776
+ surface.focusInput();
777
+ }
778
+ return;
779
+ }
780
+ const width = (process.stdout.columns || 100);
781
+ const { lines, fallbackOpenPath } = renderDocLines(doc, width);
782
+ if (fallbackOpenPath)
783
+ openLocalImage(fallbackOpenPath);
784
+ await surface.showViewer(doc.title, lines);
785
+ idleStatus();
786
+ }
787
+ /** `/edit <path>` — open a file in the in-TUI editor (notepad/vim-lite). Opens
788
+ * an empty buffer for a non-existent path (create-on-save). Refuses image/
789
+ * binary files. Ctrl+S writes the file; Esc/Ctrl+Q closes. */
790
+ async function handleEdit(args) {
791
+ const path = args.trim();
792
+ if (!path) {
793
+ surface.outputLine(chalk.yellow('Usage: /edit <file>') + chalk.dim(' — opens the in-shell editor (^S save · ^Q quit)'));
794
+ surface.focusInput();
795
+ return;
796
+ }
797
+ const absPath = resolveDocPath(path);
798
+ let initial = '';
799
+ if (existsSync(absPath)) {
800
+ const doc = loadDoc(path);
801
+ if (doc.kind === 'image' || doc.kind === 'binary') {
802
+ surface.outputLine(chalk.yellow(` cannot edit a ${doc.kind} file: `) + chalk.dim(absPath));
803
+ surface.focusInput();
804
+ return;
805
+ }
806
+ initial = doc.text ?? '';
807
+ }
808
+ else {
809
+ surface.outputLine(chalk.dim(` new file: ${absPath}`));
810
+ }
811
+ const title = absPath.split(/[/\\]/).pop() || path;
812
+ await surface.showEditor(title, initial, async (text) => {
813
+ try {
814
+ const dir = dirname(absPath);
815
+ if (!existsSync(dir))
816
+ mkdirSync(dir, { recursive: true });
817
+ writeFileSync(absPath, text, 'utf-8');
818
+ return null;
819
+ }
820
+ catch (e) {
821
+ return e?.message || 'write failed';
822
+ }
823
+ });
824
+ surface.outputLine(chalk.dim(` edited ${absPath}`));
825
+ idleStatus();
826
+ }
628
827
  async function runCommand(input, bg = false) {
629
828
  const sp = input.indexOf(' ');
630
829
  const name = sp > 0 ? input.slice(1, sp) : input.slice(1);
@@ -643,8 +842,16 @@ export async function startTuiRepl(client, config) {
643
842
  showHelp();
644
843
  return;
645
844
  }
845
+ if (name === 'view' || name === 'open' || name === 'cat') {
846
+ await handleView(args);
847
+ return;
848
+ }
849
+ if (name === 'edit' || name === 'e' || name === 'nano') {
850
+ await handleEdit(args);
851
+ return;
852
+ }
646
853
  if (name === 'jobs') {
647
- handleJobs(args);
854
+ await handleJobs(args);
648
855
  return;
649
856
  }
650
857
  if (name === 'relay') {
@@ -774,7 +981,9 @@ export async function startTuiRepl(client, config) {
774
981
  surface.traceLine(chalk.yellow(' ⚠ LLM pool busy (0 slots) — sending anyway'));
775
982
  }
776
983
  }
777
- catch { /* */ }
984
+ catch {
985
+ surface.traceLine(chalk.yellow(' ⚠ pre-flight check failed (backend slow or down) — sending anyway'));
986
+ }
778
987
  surface.outputLine(chalk.green('› ') + input);
779
988
  surface.setStatus('working…');
780
989
  // Open a fresh, collapsible trace thread for this turn (older turns collapse).
@@ -842,8 +1051,11 @@ export async function startTuiRepl(client, config) {
842
1051
  aborted = true;
843
1052
  surface.outputLine(chalk.dim(' (interrupted)'));
844
1053
  }
845
- else
846
- surface.outputLine(chalk.red(` Error: ${err?.message || err}`));
1054
+ else {
1055
+ // First line only, bounded — never spray a raw stack/JSON at the user.
1056
+ const raw = typeof err?.message === 'string' ? err.message : String(err);
1057
+ surface.outputLine(chalk.red(` Error: ${raw.split('\n')[0].slice(0, 200)}`));
1058
+ }
847
1059
  renderer.finish(aborted);
848
1060
  break;
849
1061
  }