@devmarketplacenpm/devmp 0.1.1-beta.5

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.
@@ -0,0 +1,1269 @@
1
+ "use strict";
2
+
3
+ const os = require("os");
4
+ const path = require("path");
5
+ const { randomUUID } = require("crypto");
6
+ const { stdin, stdout } = require("process");
7
+ const { createScreen } = require("./tty/screen");
8
+ const { displayWidth, wrap } = require("./tty/ansi");
9
+ const { createComposer } = require("./tty/composer");
10
+ const { createMarkdownStream } = require("./markdown");
11
+ const { expandMentions } = require("./mentions");
12
+ const { findProjectInstructionsFile } = require("./instructions");
13
+ const { completeInput } = require("./completion");
14
+ const { connectInteractiveSession } = require("./interactive-tunnel");
15
+ const { loadConversation, saveConversation } = require("./session");
16
+ const { createCheckpointManager } = require("./checkpoints");
17
+ const { checkpointDiff, diffStats, styleDiffLines } = require("./diff");
18
+ const { getGitInfo } = require("./git");
19
+ const { collectStatus, renderStatusCard, renderDoctor } = require("./status");
20
+ const { setApprovalDriver, clearApprovalDriver } = require("./prompt");
21
+ const { startDeviceAuth, pollDeviceAuth } = require("./api");
22
+ const { openBrowser } = require("./browser");
23
+ const {
24
+ color,
25
+ fmt,
26
+ SPINNER_FRAMES,
27
+ formatNumber,
28
+ formatDuration,
29
+ } = require("./ui");
30
+
31
+ const MODES = new Set(["auto", "chat", "plan", "act"]);
32
+ const PROVIDERS = new Set(["openai", "anthropic", "ollama"]);
33
+ const PERMISSIONS = ["ask", "edits", "commands", "auto"];
34
+ const SPINNER_INTERVAL_MS = 80;
35
+ // Review budgets: enough to judge the change, not so much that the prompt
36
+ // scrolls off the screen before you can answer it.
37
+ const DIFF_MAX_LINES = 60;
38
+ const CREATE_PREVIEW_LINES = 16;
39
+ // Below this, the live per-file lines are recap enough on their own.
40
+ const FOOTER_PATH_RECAP_THRESHOLD = 6;
41
+
42
+ /** Home-relative path, so a turn footer does not wrap on a long absolute one. */
43
+ function shortPath(value) {
44
+ const home = os.homedir();
45
+ return value.startsWith(home) ? `~${value.slice(home.length)}` : value;
46
+ }
47
+
48
+ /** Browser target for the device flow; signup lands on register first. */
49
+ function authUrl(config, userCode, mode) {
50
+ if (mode === "signup") {
51
+ const url = new URL(`${config.frontendBaseUrl}/register`);
52
+ url.searchParams.set("callbackUrl", `/cli-auth?code=${userCode}`);
53
+ url.searchParams.set("cli", "1");
54
+ return url.toString();
55
+ }
56
+ const url = new URL(`${config.frontendBaseUrl}/cli-auth`);
57
+ url.searchParams.set("code", userCode);
58
+ url.searchParams.set("cli", "1");
59
+ return url.toString();
60
+ }
61
+
62
+ const ACTIVITY_LABELS = {
63
+ list_files: "Scanning workspace",
64
+ search_files: "Searching",
65
+ search_marketplace: "Searching marketplace",
66
+ read_repo: "Reading gig source",
67
+ read_file: "Reading",
68
+ write_file: "Writing",
69
+ append_file: "Appending",
70
+ edit_file: "Editing",
71
+ delete_file: "Requesting delete",
72
+ move_file: "Requesting move",
73
+ run_command: "Running",
74
+ start_background_command: "Starting background job",
75
+ check_background_command: "Checking background job",
76
+ stop_background_command: "Stopping background job",
77
+ finish_project: "Finalizing",
78
+ };
79
+
80
+ const OUTCOME_STYLES = {
81
+ created: (value) => color.green(value),
82
+ updated: (value) => color.yellow(value),
83
+ deleted: (value) => color.red(value),
84
+ moved: (value) => color.yellow(value),
85
+ skipped: (value) => color.dim(value),
86
+ };
87
+
88
+ function permissionSummary(value) {
89
+ if (value === "auto") return "auto edits + commands";
90
+ if (value === "edits") return "auto edits, ask commands";
91
+ if (value === "commands") return "ask edits, auto commands";
92
+ return "review every edit and command";
93
+ }
94
+
95
+ /**
96
+ * Render the marketplace gigs the agent found. This is the CLI's counterpart to
97
+ * the web's gig cards, and the reason the CLI is not just another coding agent:
98
+ * the user sees the real catalog entries their build is grounded in, priced and
99
+ * linked, while it happens.
100
+ *
101
+ * Lines are returned rather than printed — the screen owns every write.
102
+ */
103
+ function gigCardLines(query, gigs, columns) {
104
+ const width = Math.max(20, columns || 80);
105
+ const INDENT = " "; // 9 columns, clearing the ` #<id> ` gutter
106
+
107
+ // Every line is clipped against its own prefix, not one shared budget: the
108
+ // screen counts rows by where text wraps, so a single over-wide line
109
+ // desynchronizes its erase arithmetic for the rest of the turn.
110
+ const fit = (text, budget) => {
111
+ const value = String(text);
112
+ if (budget < 2) return "";
113
+ return displayWidth(value) > budget
114
+ ? `${value.slice(0, Math.max(1, budget - 1))}…`
115
+ : value;
116
+ };
117
+
118
+ const heading = `${gigs.length} marketplace gig${gigs.length === 1 ? "" : "s"}`;
119
+ // ` ◆ ` + heading, then ` for "<query>"`.
120
+ const queryBudget = width - displayWidth(heading) - 4 - 7;
121
+ const clippedQuery = queryBudget >= 8 ? fit(query, queryBudget) : "";
122
+
123
+ const lines = [
124
+ "",
125
+ ` ${color.cyan("◆")} ${color.bold(heading)}${
126
+ clippedQuery ? color.dim(` for "${clippedQuery}"`) : ""
127
+ }`,
128
+ ];
129
+
130
+ for (const gig of gigs) {
131
+ const id = `#${gig.id}`;
132
+ lines.push(
133
+ ` ${color.cyan(id)} ${color.bold(
134
+ fit(gig.title, width - displayWidth(id) - 6)
135
+ )}`
136
+ );
137
+
138
+ const meta = [
139
+ typeof gig.price === "number" ? `$${gig.price}` : null,
140
+ typeof gig.ratings === "number" && gig.ratings > 0
141
+ ? `★ ${gig.ratings.toFixed(1)}`
142
+ : null,
143
+ typeof gig.score === "number" ? `match ${gig.score.toFixed(2)}` : null,
144
+ ]
145
+ .filter(Boolean)
146
+ .join(" · ");
147
+ const detail = [
148
+ Array.isArray(gig.tech) && gig.tech.length ? gig.tech.join(", ") : null,
149
+ meta || null,
150
+ ]
151
+ .filter(Boolean)
152
+ .join(" ");
153
+
154
+ const detailBudget = width - INDENT.length;
155
+ if (detail) lines.push(`${INDENT}${color.dim(fit(detail, detailBudget))}`);
156
+ if (gig.summary) {
157
+ lines.push(`${INDENT}${color.dim(fit(gig.summary, detailBudget))}`);
158
+ }
159
+ // A clipped URL is a broken URL — drop it rather than print one that
160
+ // cannot be opened.
161
+ if (gig.url && displayWidth(gig.url) <= detailBudget) {
162
+ lines.push(`${INDENT}${color.dim(gig.url)}`);
163
+ }
164
+ }
165
+
166
+ lines.push("");
167
+ return lines;
168
+ }
169
+
170
+ function helpLines(columns = process.stdout.columns || 80) {
171
+ const rows = [
172
+ ["/help", "Show this help"],
173
+ ["/status", "Account, tokens, workspace, model and mode"],
174
+ ["/doctor", "Diagnose environment and API connectivity"],
175
+ ["/login [signup]", "Re-authenticate without leaving the session"],
176
+ ["/mode auto|chat|plan|act", "How the next turns behave"],
177
+ ["/plan [prompt]", "Toggle plan mode, or plan one prompt"],
178
+ ["/model [provider] [id]", "Change provider/model for later turns"],
179
+ ["/permissions [ask|edits|commands|auto]", "Edit and command approvals"],
180
+ ["/diff", "Show the latest agent turn diff"],
181
+ ["/undo", "Safely restore the latest agent checkpoint"],
182
+ ["/jobs [stop <id>|all]", "List or stop background processes"],
183
+ ["/gigs [query]", "Marketplace gigs from this session, or search"],
184
+ ["/init [update]", "Write an AGENTS.md so later turns know this project"],
185
+ ["/new", "Clear this workspace conversation"],
186
+ ["/compact", "Summarize earlier turns to free up context"],
187
+ ["/resume", "Show the automatically resumed session"],
188
+ ["/quit", "Exit the session"],
189
+ ];
190
+ const width = Math.max(...rows.map(([name]) => name.length));
191
+ // `/permissions [ask|edits|commands|auto]` is 38 columns wide, which leaves
192
+ // almost nothing for the description on an 80-column terminal. Descriptions
193
+ // used to run past the edge and wrap to column 0, breaking the alignment that
194
+ // makes a two-column list readable at all. Wrap them under themselves, and on
195
+ // a terminal too narrow for two columns give the description its own row.
196
+ const available = Math.max(0, columns - width - 4);
197
+ const stacked = available < 24;
198
+ const descWidth = stacked ? Math.max(20, columns - 6) : available;
199
+
200
+ return [
201
+ ...fmt.section("Session commands"),
202
+ ...rows.flatMap(([name, description]) => {
203
+ const wrapped = wrap(description, descWidth);
204
+ if (stacked) {
205
+ return [
206
+ ` ${color.cyan(name)}`,
207
+ ...wrapped.map((piece) => ` ${color.dim(piece)}`),
208
+ ];
209
+ }
210
+ return wrapped.map((piece, i) =>
211
+ i === 0
212
+ ? ` ${color.cyan(name.padEnd(width))} ${color.dim(piece)}`
213
+ : ` ${" ".repeat(width)} ${color.dim(piece)}`
214
+ );
215
+ }),
216
+ "",
217
+ ...fmt.section("Keys"),
218
+ ...[
219
+ ["Tab", "Complete a /command or an @path"],
220
+ ["@path", "Attach a file to the prompt — the agent reads it directly"],
221
+ ["Enter", "Send · while the agent works, queue a follow-up"],
222
+ ["Ctrl-J", "Newline without sending"],
223
+ ["↑ / ↓", "Earlier prompts, including from previous sessions"],
224
+ ["Esc", "Clear the input, or the queue"],
225
+ ["Ctrl-C", "Cancel the turn · twice on an empty line to exit"],
226
+ ].flatMap(([keys, description]) => {
227
+ const keyWidth = 8;
228
+ const wrapped = wrap(description, Math.max(20, columns - keyWidth - 6));
229
+ return wrapped.map((piece, i) =>
230
+ i === 0
231
+ ? ` ${color.cyan(keys.padEnd(keyWidth))} ${color.dim(piece)}`
232
+ : ` ${" ".repeat(keyWidth)} ${color.dim(piece)}`
233
+ );
234
+ }),
235
+ "",
236
+ color.dim(" Paste multi-line text freely — it stays one prompt."),
237
+ ];
238
+ }
239
+
240
+ async function runInteractiveShell({
241
+ session,
242
+ rootDir: requestedRoot,
243
+ provider,
244
+ model,
245
+ maxFiles,
246
+ yes,
247
+ allowCommands,
248
+ config,
249
+ }) {
250
+ if (!stdin.isTTY || !stdout.isTTY) {
251
+ throw new Error(
252
+ '`devmp` interactive mode needs a terminal. Use `devmp run "…"` in scripts.'
253
+ );
254
+ }
255
+
256
+ // Declared before anything can call stop(): the composer starts reading
257
+ // stdin well before the rest of this function has run, so a Ctrl-D during
258
+ // startup used to reach stop() while these were still in their dead zone and
259
+ // crash with a raw ReferenceError. stop() touches all three, so all three are
260
+ // declared here, before anything can call it.
261
+ let stopped = false;
262
+ let resolveExit;
263
+ const exited = new Promise((resolve) => {
264
+ resolveExit = resolve;
265
+ });
266
+
267
+ const rootDir = path.resolve(requestedRoot || process.cwd());
268
+ const checkpoints = createCheckpointManager({ rootDir });
269
+ const saved = (await loadConversation(session.apiBaseUrl, rootDir)) || {};
270
+ const state = {
271
+ sessionId: saved.sessionId,
272
+ history: Array.isArray(saved.history) ? saved.history : [],
273
+ mode: MODES.has(saved.mode) ? saved.mode : "auto",
274
+ provider: provider || saved.provider,
275
+ model: model || saved.model,
276
+ permissions:
277
+ yes && allowCommands
278
+ ? "auto"
279
+ : yes
280
+ ? "edits"
281
+ : allowCommands
282
+ ? "commands"
283
+ : PERMISSIONS.includes(saved.permissions)
284
+ ? saved.permissions
285
+ : "ask",
286
+ /** Gigs the agent surfaced this session, newest search first — `/gigs`
287
+ * reads this. Deliberately in-memory: it is turn context, not a
288
+ * conversation transcript, and a stale price on resume would mislead. */
289
+ gigs: [],
290
+ gigQuery: null,
291
+ /** Prompt lines for up-arrow recall. Persisted, so the shell remembers
292
+ * what you asked it yesterday. */
293
+ inputHistory: Array.isArray(saved.inputHistory) ? saved.inputHistory : [],
294
+ };
295
+
296
+ const screen = createScreen({ stream: stdout });
297
+
298
+ const persist = () =>
299
+ saveConversation(session.apiBaseUrl, rootDir, {
300
+ sessionId: state.sessionId,
301
+ history: state.history,
302
+ mode: state.mode,
303
+ provider: state.provider,
304
+ model: state.model,
305
+ permissions: state.permissions,
306
+ inputHistory: state.inputHistory,
307
+ }).catch(() => undefined);
308
+
309
+ // ── live turn state ──────────────────────────────────────────────────────
310
+ // `active` is non-null exactly while a turn is in flight. Everything the
311
+ // spinner line shows is read from it, so there is one place to look when the
312
+ // status line and reality disagree.
313
+ let active = null;
314
+ let spinnerTick = 0;
315
+ let spinnerTimer = null;
316
+
317
+ function statusLines() {
318
+ if (!active) return [];
319
+ const frame = SPINNER_FRAMES[spinnerTick % SPINNER_FRAMES.length];
320
+ const parts = [color.cyan(frame), active.label];
321
+ parts.push(color.dim(formatDuration(Date.now() - active.startedAt)));
322
+ if (active.detail) parts.push(color.dim(`· ${active.detail}`));
323
+ if (active.tokens) {
324
+ parts.push(color.dim(`· ${formatNumber(active.tokens)} tokens`));
325
+ }
326
+ const partial = active.markdown ? active.markdown.partial() : [];
327
+ return [...partial, "", ` ${parts.join(" ")}`];
328
+ }
329
+
330
+ /**
331
+ * Join hints most-useful-first, dropping the tail rather than wrapping. A
332
+ * hint row that wraps costs a line of transcript on every redraw, and the
333
+ * least useful hint is the one worth losing on a narrow terminal.
334
+ */
335
+ function fitHints(pieces) {
336
+ const budget = Math.max(20, screen.columns - 4);
337
+ const kept = [];
338
+ let used = 0;
339
+ for (const piece of pieces) {
340
+ const cost = kept.length ? piece.length + 3 : piece.length;
341
+ if (used + cost > budget) break;
342
+ kept.push(piece);
343
+ used += cost;
344
+ }
345
+ return ` ${(kept.length ? kept : [pieces[0]]).join(" · ")}`;
346
+ }
347
+
348
+ function hintLines() {
349
+ if (active) {
350
+ return [
351
+ color.dim(
352
+ fitHints([
353
+ "ctrl-c cancels",
354
+ "enter queues a follow-up",
355
+ "esc clears the queue",
356
+ ])
357
+ ),
358
+ ];
359
+ }
360
+ if (composer.interruptArmed) {
361
+ return [color.yellow(" press ctrl-c again to exit")];
362
+ }
363
+ return [
364
+ color.dim(
365
+ fitHints([
366
+ "/help for commands",
367
+ "tab completes",
368
+ "@file attaches a file",
369
+ "ctrl-c twice to exit",
370
+ ])
371
+ ),
372
+ ];
373
+ }
374
+
375
+ const composer = createComposer({
376
+ screen,
377
+ statusLines,
378
+ hintLines,
379
+ promptLabel: () => "›",
380
+ placeholder: "Describe a task, ask a question, or /help",
381
+ onSubmit: (value) => void handleSubmit(value),
382
+ onCancel: () => {
383
+ // Some waits are not turns (connecting, logging in) and own their own
384
+ // abort; ask them to stop rather than sending a turn cancellation.
385
+ if (active?.abort) {
386
+ active.abort();
387
+ active.label = "Cancelling";
388
+ composer.refresh();
389
+ return;
390
+ }
391
+ // Ctrl-C before the tunnel exists can only mean "give up connecting".
392
+ if (!tunnel) {
393
+ stop();
394
+ return;
395
+ }
396
+ if (!active?.turnId) return;
397
+ tunnel.cancel(active.turnId);
398
+ active.label = "Cancelling";
399
+ composer.refresh();
400
+ },
401
+ onExit: () => stop(),
402
+ // A big paste is otherwise silent: the box scrolls rather than growing, so
403
+ // without this there is nothing to confirm the whole thing arrived.
404
+ completer: (value, caret) => completeInput(rootDir, value, caret),
405
+ initialHistory: state.inputHistory,
406
+ onHistoryChange: (list) => {
407
+ state.inputHistory = list;
408
+ void persist();
409
+ },
410
+ onPaste: (lines) => {
411
+ if (lines < 4) return;
412
+ screen.write(
413
+ color.dim(` pasted ${lines} lines — enter to send, esc to discard`)
414
+ );
415
+ },
416
+ });
417
+
418
+ function startSpinner() {
419
+ if (spinnerTimer) return;
420
+ spinnerTimer = setInterval(() => {
421
+ spinnerTick += 1;
422
+ composer.refresh();
423
+ }, SPINNER_INTERVAL_MS);
424
+ spinnerTimer.unref?.();
425
+ }
426
+
427
+ function stopSpinner() {
428
+ if (!spinnerTimer) return;
429
+ clearInterval(spinnerTimer);
430
+ spinnerTimer = null;
431
+ }
432
+
433
+ // ── connect ──────────────────────────────────────────────────────────────
434
+ // No standalone banner: the status card's title already carries it, and on
435
+ // the expired-session retry path the shell runs twice, which printed it twice.
436
+ composer.start();
437
+ active = { label: "Connecting", startedAt: Date.now(), markdown: null };
438
+ startSpinner();
439
+
440
+ let tunnel;
441
+ // The composer accepts input from the moment it starts, which is before the
442
+ // tunnel has finished connecting. Keeping the in-flight connect lets a prompt
443
+ // typed during that window wait for it instead of reaching an undefined
444
+ // tunnel — typing early is a normal thing to do while "Connecting" is on
445
+ // screen, and it used to end the turn with a TypeError.
446
+ let connecting;
447
+ let statusInfo;
448
+ // Mutable: `/login` replaces the credentials mid-session.
449
+ let currentSession = session;
450
+
451
+ /** Open a tunnel for the given credentials, keeping the conversation id and
452
+ * visible history so a reconnect resumes rather than starting over. */
453
+ const openTunnel = (forSession) =>
454
+ connectInteractiveSession({
455
+ session: forSession,
456
+ rootDir,
457
+ sessionId: state.sessionId,
458
+ history: state.history,
459
+ yes: state.permissions === "edits" || state.permissions === "auto",
460
+ allowCommands:
461
+ state.permissions === "commands" || state.permissions === "auto",
462
+ checkpoint: checkpoints,
463
+ onSession: (next) => {
464
+ state.sessionId = next.sessionId;
465
+ state.history = next.history;
466
+ void persist();
467
+ },
468
+ // Command output must go through the screen, never straight to stdout,
469
+ // or it lands inside the live region and desyncs the row accounting.
470
+ onNotice: (text) => screen.write(text),
471
+ });
472
+
473
+ connecting = Promise.all([
474
+ openTunnel(currentSession),
475
+ collectStatus({ session: currentSession, rootDir }),
476
+ ]);
477
+ try {
478
+ [tunnel, statusInfo] = await connecting;
479
+ active = null;
480
+ stopSpinner();
481
+ } catch (error) {
482
+ // The terminal is already in raw mode with a live region painted; hand it
483
+ // back before the error propagates or the user's shell is left wrecked.
484
+ active = null;
485
+ stopSpinner();
486
+ composer.stop();
487
+ screen.close();
488
+ throw error;
489
+ }
490
+
491
+ screen.writeLines(renderStatusCard(statusInfo, { rootDir, env: config?.env, inShell: true }));
492
+ screen.write(
493
+ tunnel.session.resumed
494
+ ? fmt.success(
495
+ `Conversation resumed · ${Math.floor(
496
+ (state.history?.length || 0) / 2
497
+ )} previous turn(s)`
498
+ )
499
+ : fmt.success("Interactive session ready.")
500
+ );
501
+
502
+ // Approvals are raised deep inside the executor and command runner, which
503
+ // have no handle on the shell — the driver is how they reach this screen.
504
+ setApprovalDriver({
505
+ showText: (text) => screen.write(` ${text}`),
506
+ showDiff: (diff, { isCreate, path } = {}) => {
507
+ const { added } = diffStats(diff);
508
+ if (isCreate) {
509
+ // Show the shape of a new file, not all of it.
510
+ screen.write(
511
+ ` ${color.green("create")} ${path} ${color.dim(`(${added} lines)`)}`
512
+ );
513
+ screen.writeLines(
514
+ styleDiffLines(diff, { indent: " ", maxLines: CREATE_PREVIEW_LINES })
515
+ );
516
+ return;
517
+ }
518
+ screen.writeLines(
519
+ styleDiffLines(diff, { indent: " ", maxLines: DIFF_MAX_LINES })
520
+ );
521
+ },
522
+ choose: ({ question, hint, choices, defaultValue }) =>
523
+ composer.question({ question, hint, choices, defaultValue }),
524
+ });
525
+
526
+ function stop() {
527
+ if (stopped) return;
528
+ stopped = true;
529
+ resolveExit();
530
+ }
531
+
532
+ // ── turns ────────────────────────────────────────────────────────────────
533
+
534
+ /** Commit any half-finished markdown line before writing something else, so
535
+ * narration and tool activity land in the order they actually happened. */
536
+ function flushPartial() {
537
+ if (!active?.markdown) return;
538
+ const rest = active.markdown.flush();
539
+ if (rest.length) screen.writeLines(rest);
540
+ }
541
+
542
+ function renderDoneLines(event, sawText) {
543
+ const lines = [];
544
+ const files = Array.isArray(event.files) ? event.files : [];
545
+ const changes = Array.isArray(event.localChanges) ? event.localChanges : [];
546
+
547
+ if (!sawText && event.summary) lines.push(` ${event.summary}`);
548
+
549
+ if (changes.length) {
550
+ // Each path was already announced live as it was applied; re-listing them
551
+ // all here just doubles the output. Recap the count, and only spell out
552
+ // paths when there were too many to have followed in the stream.
553
+ lines.push(
554
+ fmt.success(`${changes.length} reviewed change(s) in ${shortPath(rootDir)}`)
555
+ );
556
+ if (changes.length > FOOTER_PATH_RECAP_THRESHOLD) {
557
+ for (const change of changes) {
558
+ const style = OUTCOME_STYLES[change.kind] || color.dim;
559
+ const detail =
560
+ change.kind === "moved"
561
+ ? `${change.fromPath} → ${change.path}`
562
+ : change.path;
563
+ lines.push(` ${style(String(change.kind).padEnd(8))} ${detail}`);
564
+ }
565
+ }
566
+ if (event.checkpoint) {
567
+ lines.push(
568
+ color.dim(
569
+ ` checkpoint ${event.checkpoint.id.slice(0, 8)} · /diff or /undo`
570
+ )
571
+ );
572
+ }
573
+ } else if (files.length) {
574
+ lines.push(
575
+ fmt.success(`${files.length} file(s) changed in ${shortPath(rootDir)}`)
576
+ );
577
+ for (const file of files) lines.push(` ${color.dim("changed")} ${file}`);
578
+ }
579
+
580
+ const commands = Array.isArray(event.executedCommands)
581
+ ? event.executedCommands
582
+ : [];
583
+ if (commands.length) {
584
+ lines.push(fmt.line("Commands", commandTally(commands)));
585
+ }
586
+
587
+ if (event.checkpointError) {
588
+ lines.push(fmt.warn(`Checkpoint warning: ${event.checkpointError}`));
589
+ }
590
+ if (event.partial) {
591
+ lines.push(fmt.warn("The turn stopped before the agent reported completion."));
592
+ }
593
+
594
+ const used = event.usage
595
+ ? Number(event.usage.inputTokens || 0) + Number(event.usage.outputTokens || 0)
596
+ : 0;
597
+ const elapsed = active ? Date.now() - active.startedAt : 0;
598
+ const footer = [];
599
+ if (used) footer.push(`${formatNumber(used)} tokens`);
600
+ if (elapsed) footer.push(formatDuration(elapsed));
601
+ if (footer.length) lines.push(color.dim(` ${footer.join(" · ")}`));
602
+
603
+ return lines;
604
+ }
605
+
606
+ async function runTurn(promptText) {
607
+ const turnId = randomUUID();
608
+ const markdown = createMarkdownStream({
609
+ width: () => screen.columns,
610
+ indent: " ",
611
+ });
612
+ active = {
613
+ turnId,
614
+ startedAt: Date.now(),
615
+ label: "Thinking",
616
+ detail: null,
617
+ tokens: 0,
618
+ markdown,
619
+ };
620
+ let sawText = false;
621
+
622
+ screen.writeLines(["", `${color.cyan("›")} ${color.bold(promptText)}`, ""]);
623
+ composer.setBusy(true);
624
+ startSpinner();
625
+
626
+ if (!tunnel) {
627
+ // Typed before the connection came up: wait for it rather than fail.
628
+ await connecting?.catch(() => undefined);
629
+ }
630
+ if (!tunnel) {
631
+ stopSpinner();
632
+ composer.setBusy(false);
633
+ active = null;
634
+ screen.writeLines([fmt.fail("Not connected to the agent yet — try again.")]);
635
+ return;
636
+ }
637
+
638
+ const mentioned = await expandMentions(rootDir, promptText).catch(() => ({
639
+ prompt: promptText,
640
+ attached: [],
641
+ }));
642
+ if (mentioned.attached.length) {
643
+ screen.writeLines([
644
+ color.dim(
645
+ ` attached ${mentioned.attached.map((f) => f.path).join(", ")}`,
646
+ ),
647
+ ]);
648
+ }
649
+
650
+ const terminal = await tunnel.runTurn({
651
+ turnId,
652
+ prompt: mentioned.prompt,
653
+ mode: state.mode,
654
+ provider: state.provider,
655
+ model: state.model,
656
+ maxFiles,
657
+ onEvent: (event) => {
658
+ if (event.type === "text") {
659
+ sawText = true;
660
+ active.label = "Responding";
661
+ const committed = markdown.push(event.delta);
662
+ if (committed.length) screen.writeLines(committed);
663
+ composer.refresh();
664
+ return;
665
+ }
666
+ if (event.type === "activity") {
667
+ flushPartial();
668
+ active.label = "Working";
669
+ active.detail = event.detail
670
+ ? `${ACTIVITY_LABELS[event.tool] || event.tool} ${event.detail}`
671
+ : ACTIVITY_LABELS[event.tool] || event.tool;
672
+ screen.write(
673
+ ` ${color.dim("•")} ${ACTIVITY_LABELS[event.tool] || event.tool}${
674
+ event.detail ? ` ${color.cyan(event.detail)}` : ""
675
+ }`
676
+ );
677
+ return;
678
+ }
679
+ if (event.type === "gigs") {
680
+ flushPartial();
681
+ const gigs = Array.isArray(event.gigs) ? event.gigs : [];
682
+ if (!gigs.length) return;
683
+ state.gigs = gigs;
684
+ state.gigQuery = event.query || null;
685
+ screen.writeLines(
686
+ gigCardLines(event.query || "", gigs, screen.columns)
687
+ );
688
+ composer.refresh();
689
+ return;
690
+ }
691
+ if (event.type === "file-local") {
692
+ flushPartial();
693
+ const style = OUTCOME_STYLES[event.outcome] || color.yellow;
694
+ screen.write(
695
+ ` ${style(event.outcome)} ${
696
+ event.fromPath ? `${event.fromPath} → ` : ""
697
+ }${event.path}`
698
+ );
699
+ return;
700
+ }
701
+ if (event.type === "usage" && event.usage) {
702
+ active.tokens =
703
+ Number(event.usage.inputTokens || 0) +
704
+ Number(event.usage.outputTokens || 0);
705
+ composer.refresh();
706
+ }
707
+ },
708
+ });
709
+
710
+ flushPartial();
711
+ stopSpinner();
712
+
713
+ if (terminal.type === "done") {
714
+ screen.writeLines(renderDoneLines(terminal, sawText));
715
+ const git = await getGitInfo(rootDir).catch(() => null);
716
+ if (git?.isRepo) {
717
+ screen.write(
718
+ fmt.line("Git", `${git.branch} · ${git.changed} changed path(s)`)
719
+ );
720
+ }
721
+ } else if (terminal.type === "cancelled") {
722
+ screen.write(fmt.warn("Turn cancelled. You can continue the conversation."));
723
+ if (terminal.localChanges?.length) {
724
+ screen.write(
725
+ fmt.warn(
726
+ `${terminal.localChanges.length} partial change(s) checkpointed; use /diff or /undo.`
727
+ )
728
+ );
729
+ }
730
+ } else {
731
+ screen.write(fmt.fail(terminal.message || "The turn failed."));
732
+ if (terminal.localChanges?.length) {
733
+ screen.write(
734
+ fmt.warn(
735
+ `${terminal.localChanges.length} partial change(s) checkpointed; use /diff or /undo.`
736
+ )
737
+ );
738
+ }
739
+ }
740
+
741
+ active = null;
742
+ composer.setBusy(false);
743
+ }
744
+
745
+ // ── re-authentication ────────────────────────────────────────────────────
746
+
747
+ /**
748
+ * Run the device flow without leaving the session, then rebuild the tunnel on
749
+ * the new credentials. The WebSocket authenticated with the old token, so a
750
+ * fresh token is only useful once the socket is reopened with it; the
751
+ * conversation id and visible history carry across, so the session resumes.
752
+ */
753
+ async function reauthenticate(mode) {
754
+ if (active) {
755
+ screen.write(fmt.warn("Finish or cancel the current turn first."));
756
+ return;
757
+ }
758
+
759
+ const authConfig = {
760
+ apiBaseUrl: currentSession.apiBaseUrl,
761
+ frontendBaseUrl:
762
+ currentSession.frontendBaseUrl || config?.frontendBaseUrl,
763
+ };
764
+ if (!authConfig.frontendBaseUrl) {
765
+ screen.write(fmt.fail("No frontend URL configured; run `devmp login`."));
766
+ return;
767
+ }
768
+
769
+ const controller = new AbortController();
770
+ active = {
771
+ label: mode === "signup" ? "Waiting for signup" : "Waiting for approval",
772
+ startedAt: Date.now(),
773
+ markdown: null,
774
+ // Ctrl-C during the wait aborts the poll rather than cancelling a turn.
775
+ abort: () => controller.abort(),
776
+ };
777
+ composer.setBusy(true);
778
+ startSpinner();
779
+
780
+ try {
781
+ const start = await startDeviceAuth(authConfig);
782
+ const url = authUrl(authConfig, start.userCode, mode);
783
+ screen.writeLines([
784
+ "",
785
+ ` ${color.dim("Code")} ${color.bold(color.cyan(start.userCode))}`,
786
+ ` ${color.dim("URL ")} ${url}`,
787
+ color.dim(" Approve this in your browser. Ctrl-C to cancel."),
788
+ ]);
789
+ if (!openBrowser(url)) {
790
+ screen.write(fmt.warn("Could not open the browser — use the URL above."));
791
+ }
792
+
793
+ const next = await pollDeviceAuth(authConfig, start, {
794
+ signal: controller.signal,
795
+ });
796
+
797
+ // Point the new session at the same backend this shell is already
798
+ // talking to, so a re-login cannot silently move the workspace.
799
+ currentSession = { ...next, apiBaseUrl: currentSession.apiBaseUrl };
800
+
801
+ tunnel.close();
802
+ tunnel = await openTunnel(currentSession);
803
+ screen.write(
804
+ fmt.success(
805
+ `Signed in as ${currentSession.user?.email || "your account"}.`
806
+ )
807
+ );
808
+
809
+ const info = await collectStatus({ session: currentSession, rootDir });
810
+ screen.writeLines(
811
+ renderStatusCard(info, { rootDir, env: config?.env, inShell: true })
812
+ );
813
+ } catch (error) {
814
+ screen.write(fmt.fail(error?.message || "Login failed."));
815
+ screen.write(
816
+ color.dim(" The previous session is unchanged. Try /login again.")
817
+ );
818
+ } finally {
819
+ active = null;
820
+ stopSpinner();
821
+ composer.setBusy(false);
822
+ }
823
+ }
824
+
825
+ // ── slash commands ───────────────────────────────────────────────────────
826
+
827
+ /** Returns the prompt to run, or null when the input was fully handled. */
828
+ async function handleCommand(input) {
829
+ const [command, ...args] = input.split(/\s+/);
830
+ const rest = args.join(" ").trim();
831
+
832
+ switch (command) {
833
+ case "/quit":
834
+ case "/exit":
835
+ stop();
836
+ return null;
837
+
838
+ case "/help":
839
+ screen.writeLines(helpLines());
840
+ return null;
841
+
842
+ case "/status": {
843
+ const info = await collectStatus({ session: currentSession, rootDir });
844
+ screen.writeLines(
845
+ renderStatusCard(info, { rootDir, env: config?.env, inShell: true })
846
+ );
847
+ const latest = await checkpoints.latest();
848
+ screen.writeLines([
849
+ fmt.line("Session", state.sessionId || "new"),
850
+ fmt.line("Mode", state.mode),
851
+ fmt.line("Provider", state.provider || "server default"),
852
+ fmt.line("Model", state.model || "provider default"),
853
+ fmt.line("Permissions", permissionSummary(state.permissions)),
854
+ fmt.line("Destructive", "always ask for delete/move"),
855
+ fmt.line(
856
+ "History",
857
+ `${Math.floor((state.history?.length || 0) / 2)} turn(s)`
858
+ ),
859
+ fmt.line(
860
+ "Checkpoint",
861
+ latest
862
+ ? `${latest.id.slice(0, 8)} · ${latest.status}${
863
+ latest.undoneAt ? " (undone)" : ""
864
+ }`
865
+ : "none"
866
+ ),
867
+ ]);
868
+ const running = tunnel.listJobs().filter((job) => job.running);
869
+ if (running.length) {
870
+ screen.write(
871
+ fmt.line(
872
+ "Jobs",
873
+ `${running.length} running · ${color.dim("/jobs")}`
874
+ )
875
+ );
876
+ }
877
+ return null;
878
+ }
879
+
880
+ case "/doctor":
881
+ screen.writeLines(
882
+ await renderDoctor({
883
+ config,
884
+ session: currentSession,
885
+ rootDir,
886
+ inShell: true,
887
+ })
888
+ );
889
+ return null;
890
+
891
+ case "/login":
892
+ await reauthenticate(rest === "signup" ? "signup" : "login");
893
+ return null;
894
+
895
+ case "/diff": {
896
+ const latest = await checkpoints.latest();
897
+ if (!latest) {
898
+ screen.write(fmt.warn("No agent checkpoint exists for this workspace yet."));
899
+ return null;
900
+ }
901
+ screen.writeLines(
902
+ fmt.section(
903
+ `Checkpoint ${latest.id.slice(0, 8)}${
904
+ latest.undoneAt ? " (undone)" : ""
905
+ }`
906
+ )
907
+ );
908
+ const diff = checkpointDiff(latest);
909
+ if (diff) screen.writeLines(styleDiffLines(diff));
910
+ else screen.write(fmt.warn("This checkpoint has no remaining file differences."));
911
+ return null;
912
+ }
913
+
914
+ case "/undo": {
915
+ const result = await checkpoints.undoLatest();
916
+ if (result.ok) {
917
+ screen.write(
918
+ fmt.success(
919
+ `Restored checkpoint ${result.checkpoint.id.slice(0, 8)} (${
920
+ result.checkpoint.changes.length
921
+ } change(s)).`
922
+ )
923
+ );
924
+ } else if (result.reason === "conflict") {
925
+ screen.write(
926
+ fmt.warn("Undo stopped because these files changed after the agent turn:")
927
+ );
928
+ screen.writeLines(result.conflicts.map((file) => ` ${file}`));
929
+ screen.write(
930
+ color.dim(" Review or preserve those edits before trying again.")
931
+ );
932
+ } else {
933
+ screen.write(fmt.warn("There is no agent checkpoint to undo."));
934
+ }
935
+ return null;
936
+ }
937
+
938
+ case "/jobs": {
939
+ const jobs = tunnel.listJobs();
940
+ const [action, target] = args;
941
+
942
+ if (action && action !== "stop") {
943
+ screen.write(
944
+ fmt.warn(`/jobs has no "${action}" action. Use /jobs or /jobs stop <id>.`)
945
+ );
946
+ return null;
947
+ }
948
+
949
+ if (action === "stop") {
950
+ if (!target) {
951
+ screen.write(fmt.warn("Usage: /jobs stop <jobId>, or /jobs stop all."));
952
+ return null;
953
+ }
954
+ if (target === "all") {
955
+ const running = jobs.filter((job) => job.running);
956
+ if (!running.length) {
957
+ screen.write(color.dim(" No running background jobs."));
958
+ return null;
959
+ }
960
+ for (const job of running) tunnel.stopJob(job.jobId);
961
+ screen.write(
962
+ fmt.success(
963
+ `Stopping ${running.length} background job${
964
+ running.length === 1 ? "" : "s"
965
+ }.`
966
+ )
967
+ );
968
+ return null;
969
+ }
970
+ // Job ids are eight hex characters; nobody types those in full.
971
+ const matches = jobs.filter((job) => job.jobId.startsWith(target));
972
+ if (matches.length > 1) {
973
+ screen.write(
974
+ fmt.warn(
975
+ `"${target}" matches ${matches.length} jobs: ${matches
976
+ .map((job) => job.jobId)
977
+ .join(", ")}.`
978
+ )
979
+ );
980
+ return null;
981
+ }
982
+ const id = matches[0]?.jobId ?? target;
983
+ const result = tunnel.stopJob(id);
984
+ screen.write(
985
+ result.found
986
+ ? result.stopped
987
+ ? fmt.success(`Stopping background job ${id}.`)
988
+ : fmt.warn(`Job ${id} had already exited.`)
989
+ : fmt.warn(`No background job with id ${target}.`)
990
+ );
991
+ return null;
992
+ }
993
+ if (!jobs.length) {
994
+ screen.write(
995
+ color.dim(" No background jobs. The agent starts these itself.")
996
+ );
997
+ return null;
998
+ }
999
+ screen.writeLines(fmt.section("Background jobs"));
1000
+ screen.writeLines(
1001
+ jobs.map((job) => {
1002
+ const state_ = job.stopping
1003
+ ? color.yellow("stopping")
1004
+ : job.running
1005
+ ? color.green("running")
1006
+ : color.dim(`exited ${job.exitCode ?? "?"}`);
1007
+ return ` ${color.cyan(job.jobId)} ${state_} ${job.command}`;
1008
+ })
1009
+ );
1010
+ screen.write(color.dim(" /jobs stop <id> · /jobs stop all"));
1011
+ return null;
1012
+ }
1013
+
1014
+ case "/gigs": {
1015
+ // With a query this becomes an ordinary turn: the agent owns retrieval,
1016
+ // so asking it to search is both simpler and better than bolting a
1017
+ // second search path onto the client.
1018
+ if (rest) {
1019
+ return (
1020
+ `Search the DevMarketplace catalog for: ${rest}. ` +
1021
+ `Show me what matches and which one you would build on, and why.`
1022
+ );
1023
+ }
1024
+ if (!state.gigs.length) {
1025
+ screen.write(
1026
+ color.dim(
1027
+ " No marketplace gigs yet this session. Ask for something to build, or /gigs <what you need>."
1028
+ )
1029
+ );
1030
+ return null;
1031
+ }
1032
+ screen.writeLines(
1033
+ gigCardLines(state.gigQuery || "", state.gigs, screen.columns)
1034
+ );
1035
+ screen.write(
1036
+ color.dim(" Ask me to build on one by id, e.g. “use #" +
1037
+ state.gigs[0].id + " as the base”.")
1038
+ );
1039
+ return null;
1040
+ }
1041
+
1042
+ case "/init": {
1043
+ // Reading AGENTS.md is already wired in; there was no way to write one.
1044
+ // It is the single highest-leverage file in the workspace: every later
1045
+ // turn is shaped by it.
1046
+ const existing = await findProjectInstructionsFile(rootDir);
1047
+ if (existing && !rest) {
1048
+ screen.write(
1049
+ fmt.warn(`${existing} already exists — I will not overwrite it.`)
1050
+ );
1051
+ screen.write(
1052
+ color.dim(
1053
+ " Use /init update to have me revise it against the current code."
1054
+ )
1055
+ );
1056
+ return null;
1057
+ }
1058
+ const verb = existing
1059
+ ? `Update ${existing} so it matches the code as it is now`
1060
+ : "Write an AGENTS.md at the workspace root";
1061
+ return (
1062
+ `${verb}. First read enough of this project to describe it ` +
1063
+ "accurately — do not guess. The file is instructions for a coding " +
1064
+ "agent working here, so keep it short and specific: what this " +
1065
+ "project is, how to run it, how to run the tests, the conventions " +
1066
+ "worth following, and anything that would surprise someone new. " +
1067
+ "Leave out anything you could not verify from the code."
1068
+ );
1069
+ }
1070
+
1071
+ case "/resume":
1072
+ screen.write(
1073
+ fmt.success(
1074
+ `Session ${state.sessionId} is active and resumes automatically for this workspace.`
1075
+ )
1076
+ );
1077
+ return null;
1078
+
1079
+ case "/new":
1080
+ await tunnel.reset();
1081
+ state.history = [];
1082
+ await persist();
1083
+ screen.write(
1084
+ fmt.success("Conversation context cleared. Workspace files were not changed.")
1085
+ );
1086
+ return null;
1087
+
1088
+ case "/compact": {
1089
+ const before = state.history.length;
1090
+ const result = await tunnel.compact();
1091
+ await persist();
1092
+ screen.write(
1093
+ result.compacted
1094
+ ? fmt.success(
1095
+ `Context compacted — ${before} → ${result.messages} messages. ` +
1096
+ "Earlier turns are kept as a summary."
1097
+ )
1098
+ : fmt.warn(
1099
+ "Nothing to compact yet — the conversation is still short enough to keep in full."
1100
+ )
1101
+ );
1102
+ return null;
1103
+ }
1104
+
1105
+ case "/mode":
1106
+ if (!MODES.has(rest)) {
1107
+ screen.write(fmt.warn("Use /mode auto, /mode chat, /mode plan, or /mode act."));
1108
+ } else {
1109
+ state.mode = rest;
1110
+ await persist();
1111
+ screen.write(fmt.success(`Mode set to ${state.mode}.`));
1112
+ }
1113
+ return null;
1114
+
1115
+ case "/plan":
1116
+ if (!rest) {
1117
+ state.mode = state.mode === "plan" ? "auto" : "plan";
1118
+ await persist();
1119
+ screen.write(fmt.success(`Mode set to ${state.mode}.`));
1120
+ return null;
1121
+ }
1122
+ state.mode = "plan";
1123
+ await persist();
1124
+ return rest;
1125
+
1126
+ case "/model":
1127
+ if (!args.length) {
1128
+ screen.writeLines([
1129
+ fmt.line("Provider", state.provider || "server default"),
1130
+ fmt.line("Model", state.model || "provider default"),
1131
+ ]);
1132
+ } else if (PROVIDERS.has(args[0])) {
1133
+ state.provider = args[0];
1134
+ state.model = args.slice(1).join(" ") || undefined;
1135
+ await persist();
1136
+ screen.write(
1137
+ fmt.success(
1138
+ `Provider set to ${state.provider}${
1139
+ state.model ? ` (${state.model})` : ""
1140
+ }.`
1141
+ )
1142
+ );
1143
+ } else {
1144
+ state.model = rest;
1145
+ await persist();
1146
+ screen.write(fmt.success(`Model set to ${state.model}.`));
1147
+ }
1148
+ return null;
1149
+
1150
+ case "/permissions":
1151
+ if (!rest) {
1152
+ screen.write(fmt.line("Permissions", permissionSummary(state.permissions)));
1153
+ } else if (!PERMISSIONS.includes(rest)) {
1154
+ screen.write(fmt.warn("Use /permissions ask, edits, commands, or auto."));
1155
+ } else {
1156
+ state.permissions = rest;
1157
+ tunnel.setPermissions({
1158
+ overwriteAll: rest === "edits" || rest === "auto",
1159
+ commandsAll: rest === "commands" || rest === "auto",
1160
+ });
1161
+ await persist();
1162
+ screen.write(fmt.success(`Permissions set to ${rest}.`));
1163
+ if (rest === "auto") {
1164
+ screen.write(
1165
+ fmt.warn("Edits and commands are auto-approved; delete/move still ask.")
1166
+ );
1167
+ }
1168
+ }
1169
+ return null;
1170
+
1171
+ default:
1172
+ screen.write(fmt.warn(`Unknown session command: ${command}. Type /help.`));
1173
+ return null;
1174
+ }
1175
+ }
1176
+
1177
+ let draining = false;
1178
+
1179
+ /** Run queued prompts one at a time until the queue empties. */
1180
+ async function drainQueue() {
1181
+ if (draining) return;
1182
+ draining = true;
1183
+ try {
1184
+ let next = composer.shiftQueued();
1185
+ while (next && !stopped) {
1186
+ await dispatch(next);
1187
+ next = composer.shiftQueued();
1188
+ }
1189
+ } finally {
1190
+ draining = false;
1191
+ }
1192
+ }
1193
+
1194
+ async function dispatch(value) {
1195
+ if (value.startsWith("/")) {
1196
+ const inlinePrompt = await handleCommand(value);
1197
+ if (!inlinePrompt) return;
1198
+ await runTurn(inlinePrompt);
1199
+ return;
1200
+ }
1201
+ await runTurn(value);
1202
+ }
1203
+
1204
+ async function handleSubmit(value) {
1205
+ if (stopped) return;
1206
+ try {
1207
+ await dispatch(value);
1208
+ await drainQueue();
1209
+ } catch (error) {
1210
+ active = null;
1211
+ stopSpinner();
1212
+ composer.setBusy(false);
1213
+ screen.write(fmt.fail(error?.message || String(error)));
1214
+ }
1215
+ }
1216
+
1217
+ composer.refresh();
1218
+
1219
+ try {
1220
+ await exited;
1221
+ } finally {
1222
+ stopSpinner();
1223
+ clearApprovalDriver();
1224
+ // Say what is about to be killed — a dev server disappearing silently on
1225
+ // exit is the kind of thing you debug for ten minutes.
1226
+ const running = tunnel.listJobs().filter((job) => job.running);
1227
+ composer.stop();
1228
+ screen.close();
1229
+ await tunnel.close();
1230
+ await persist();
1231
+ for (const job of running) {
1232
+ console.log(fmt.warn(`Stopped background job ${job.jobId}: ${job.command}`));
1233
+ }
1234
+ console.log("");
1235
+ }
1236
+ }
1237
+
1238
+ /**
1239
+ * One-line summary of what a turn's commands did. A background job has no exit
1240
+ * code yet — that is the point of it — so it is counted as started, never as
1241
+ * failed. Counting it as a failure told people their dev server had crashed
1242
+ * while it was in fact running fine.
1243
+ */
1244
+ function commandTally(commands) {
1245
+ const started = commands.filter((item) => item.background);
1246
+ const finished = commands.filter((item) => !item.background);
1247
+ const passed = finished.filter((item) => item.exitCode === 0).length;
1248
+ const failed = finished.filter(
1249
+ (item) => !item.refused && item.exitCode !== 0
1250
+ ).length;
1251
+ const refused = finished.filter((item) => item.refused).length;
1252
+
1253
+ const parts = [];
1254
+ if (finished.length) {
1255
+ parts.push(
1256
+ `${passed} passed${failed ? `, ${failed} failed` : ""}${
1257
+ refused ? `, ${refused} skipped` : ""
1258
+ }`
1259
+ );
1260
+ }
1261
+ if (started.length) {
1262
+ parts.push(
1263
+ `${started.length} background job${started.length === 1 ? "" : "s"} started`
1264
+ );
1265
+ }
1266
+ return parts.join(" · ");
1267
+ }
1268
+
1269
+ module.exports = { runInteractiveShell, helpLines, gigCardLines, commandTally };