deepseek-cli-mode 0.0.0-stage → 1.0.0

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,1298 @@
1
+ // dsh-cli runner — an interactive, Claude-Code-style driver for DeepSeek Harness.
2
+ //
3
+ // Loaded by the `dsh-cli` profile as a Cordis plugin (name: "./runner.js"), this
4
+ // plugin keeps ONE live Agent across many turns in a single process, so the
5
+ // session log, tool state, approvals, and the model's request prefix stay warm
6
+ // between messages — real multi-turn continuity, unlike the one-shot
7
+ // `headless` profile.
8
+ //
9
+ // Modes (read from the launcher's cmdlineArgs):
10
+ // deepseek interactive REPL (offers to resume the last session)
11
+ // deepseek "task..." one-shot: run the task, print the answer, exit
12
+ // deepseek -r | --resume resume the most recent session in this directory
13
+ // deepseek -r <id> resume a specific session
14
+ // deepseek -c | --continue continue the most recent session, no prompting
15
+ // deepseek --list list recent sessions in this directory
16
+ // deepseek -y | --yes auto-approve actions that would ask
17
+ import { randomUUID } from "node:crypto";
18
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
19
+ import { basename, join, resolve as resolvePath } from "node:path";
20
+ import { createInterface } from "node:readline";
21
+ import { zstdDecompressSync } from "node:zlib";
22
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
23
+ import { SessionId } from "@deepseek-ai/dsh-session";
24
+ import { installModelSelection } from "@deepseek-ai/dsh-agent";
25
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
26
+
27
+ /** Stable Cordis plugin name. */
28
+ export const name = "cli-runner";
29
+ /** Core services required before a turn can start. */
30
+ export const inject = ["agents", "agentDefaultModel", "sessionPersistence"];
31
+
32
+ const VERSION = process.env.DSH_CLI_VERSION ?? "0.1.0";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Terminal helpers
36
+ // ---------------------------------------------------------------------------
37
+ const tty = process.stdout.isTTY && !process.env.NO_COLOR;
38
+ const showReasoning = process.env.DSH_CLI_REASONING === "1";
39
+ const paint = (fn, s) => (tty ? fn(s) : s);
40
+ const ansi = {
41
+ dim: (s) => `\x1b[2m${s}\x1b[22m`,
42
+ bold: (s) => `\x1b[1m${s}\x1b[22m`,
43
+ italic: (s) => `\x1b[3m${s}\x1b[23m`,
44
+ cyan: (s) => `\x1b[36m${s}\x1b[39m`,
45
+ green: (s) => `\x1b[32m${s}\x1b[39m`,
46
+ yellow: (s) => `\x1b[33m${s}\x1b[39m`,
47
+ red: (s) => `\x1b[31m${s}\x1b[39m`,
48
+ magenta: (s) => `\x1b[35m${s}\x1b[39m`,
49
+ blue: (s) => `\x1b[94m${s}\x1b[39m`,
50
+ };
51
+
52
+ let out = (s) => process.stdout.write(s);
53
+ let err = (s) => process.stderr.write(s);
54
+
55
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
56
+
57
+ function truncate(s, max) {
58
+ const text = String(s);
59
+ return text.length <= max ? text : text.slice(0, max - 1) + "…";
60
+ }
61
+
62
+ /** Error text with an optional stack for DSH_CLI_DEBUG=1. */
63
+ function describeError(error) {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ if (process.env.DSH_CLI_DEBUG === "1" && error instanceof Error && error.stack) return `${message}\n${error.stack}`;
66
+ return message;
67
+ }
68
+
69
+ /** Compact token counts for the per-turn usage line. */
70
+ function formatTokens(n) {
71
+ if (!Number.isFinite(n) || n <= 0) return "0";
72
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
73
+ return String(n);
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Markdown-lite line formatter (tty only; piped output stays raw)
78
+ // ---------------------------------------------------------------------------
79
+ function createFormatter() {
80
+ let inFence = false;
81
+ return function format(line) {
82
+ if (!tty) return line;
83
+ const trimmed = line.trimStart();
84
+ if (trimmed.startsWith("```")) {
85
+ inFence = !inFence;
86
+ return paint(ansi.dim, paint(ansi.cyan, line));
87
+ }
88
+ if (inFence) return paint(ansi.cyan, line);
89
+ let text = line;
90
+ // Headings
91
+ text = text.replace(/^(\s*)#{1,6}\s+(.*)$/, (_, lead, body) => lead + paint(ansi.bold, body));
92
+ // Bold, inline code
93
+ text = text.replace(/\*\*([^*\n]+)\*\*/g, (_, body) => paint(ansi.bold, body));
94
+ text = text.replace(/`([^`\n]+)`/g, (_, body) => paint(ansi.cyan, body));
95
+ return text;
96
+ };
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Tool-call previews
101
+ // ---------------------------------------------------------------------------
102
+ const TOOL_ICONS = {
103
+ read: "◇",
104
+ write: "✎",
105
+ edit: "✎",
106
+ str_replace_editor: "✎",
107
+ bash: "$",
108
+ pwsh: "$",
109
+ glob: "⌕",
110
+ grep: "⌕",
111
+ web_search: "◎",
112
+ web_fetch: "◎",
113
+ todo_write: "☑",
114
+ subagent: "⑂",
115
+ goal: "◈",
116
+ skill: "◆",
117
+ present: "▣",
118
+ ask_user_question: "?",
119
+ };
120
+
121
+ function previewToolCall(name, rawArgs) {
122
+ let args;
123
+ try {
124
+ args = JSON.parse(rawArgs);
125
+ } catch {
126
+ return truncate(rawArgs ?? "", 80);
127
+ }
128
+ if (args === null || typeof args !== "object") return "";
129
+ const pick = args.path ?? args.file_path ?? args.filePath ?? args.command ?? args.cmd
130
+ ?? args.pattern ?? args.query ?? args.url ?? args.description ?? args.task ?? args.id;
131
+ if (typeof pick !== "string") {
132
+ const json = JSON.stringify(args);
133
+ return json && json !== "{}" ? truncate(json, 80) : "";
134
+ }
135
+ return truncate(pick.replace(/\s+/g, " "), 90);
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Rendering
140
+ // ---------------------------------------------------------------------------
141
+ /**
142
+ * @param {object} options
143
+ * @param {(text: string) => void} options.write where transcript text goes
144
+ * @param {boolean} options.capture collect assistant text instead of printing it
145
+ */
146
+ function createRenderer({ write, capture }) {
147
+ const format = createFormatter();
148
+ let buffer = ""; // partial text line
149
+ let reasoning = ""; // this attempt's reasoning text
150
+ let lastReasoning = ""; // across the whole session, for /thinking
151
+ let streamedThisTurn = false;
152
+ let lineDirty = false; // something was written without a trailing newline
153
+ let lastAssistantText = "";
154
+ let lastTurnKind = undefined;
155
+ let lastTodos = [];
156
+ let turnUsage = { input: 0, output: 0 };
157
+
158
+ const endLine = () => {
159
+ if (lineDirty) {
160
+ write("\n");
161
+ lineDirty = false;
162
+ }
163
+ };
164
+
165
+ const feed = (text) => {
166
+ if (capture) return;
167
+ buffer += text;
168
+ let index;
169
+ while ((index = buffer.indexOf("\n")) !== -1) {
170
+ const line = buffer.slice(0, index);
171
+ buffer = buffer.slice(index + 1);
172
+ write(format(line) + "\n");
173
+ lineDirty = false;
174
+ }
175
+ if (buffer !== "") {
176
+ lineDirty = true;
177
+ }
178
+ };
179
+
180
+ const flushText = () => {
181
+ if (buffer !== "") {
182
+ if (!capture) {
183
+ write(format(buffer) + "\n");
184
+ lineDirty = false;
185
+ }
186
+ buffer = "";
187
+ }
188
+ };
189
+
190
+ return {
191
+ get lastReasoning() {
192
+ return lastReasoning;
193
+ },
194
+ get lastAssistantText() {
195
+ return lastAssistantText;
196
+ },
197
+ get lastTurnKind() {
198
+ return lastTurnKind;
199
+ },
200
+ get lastTodos() {
201
+ return lastTodos;
202
+ },
203
+ resetTurn() {
204
+ streamedThisTurn = false;
205
+ reasoning = "";
206
+ turnUsage = { input: 0, output: 0 };
207
+ },
208
+ /** Process one live assistant-stream frame. */
209
+ frame(frame) {
210
+ if (frame.type === "start") {
211
+ // A new attempt/step begins: never glue its text onto the previous line.
212
+ flushText();
213
+ endLine();
214
+ streamedThisTurn = false;
215
+ return;
216
+ }
217
+ if (frame.type === "chunk") {
218
+ const chunk = frame.chunk;
219
+ if (chunk.type === "text-delta") {
220
+ streamedThisTurn = true;
221
+ feed(chunk.text);
222
+ } else if (chunk.type === "reasoning-delta") {
223
+ reasoning += chunk.text;
224
+ if (showReasoning && !capture) {
225
+ write(paint(ansi.dim, paint(ansi.italic, chunk.text)));
226
+ lineDirty = true;
227
+ }
228
+ }
229
+ }
230
+ },
231
+ /** Process one durable session event. */
232
+ event(event) {
233
+ const data = event.data;
234
+ switch (event.type) {
235
+ case "tool/call": {
236
+ if (data.name === "todo_write") break; // the checklist below is the presentation
237
+ endLine();
238
+ const icon = TOOL_ICONS[data.name] ?? "⏺";
239
+ const preview = previewToolCall(data.name, data.arguments);
240
+ write(paint(ansi.dim, paint(ansi.italic, ` ${icon} ${data.name}${preview ? " " + preview : ""}`)) + "\n");
241
+ break;
242
+ }
243
+ case "tool/result": {
244
+ if (data.error) {
245
+ write(paint(ansi.dim, paint(ansi.italic, ` ✗ ${data.error.name}: ${data.error.code}`)) + "\n");
246
+ }
247
+ break;
248
+ }
249
+ case "assistant/message": {
250
+ const blocks = data.message?.content ?? [];
251
+ const text = blocks.filter((b) => b.type === "text").map((b) => b.text).join("");
252
+ if (text !== "") lastAssistantText = text;
253
+ if (data.usage) {
254
+ turnUsage.input += data.usage.inputTokens ?? 0;
255
+ turnUsage.output += data.usage.outputTokens ?? 0;
256
+ }
257
+ if (!streamedThisTurn) {
258
+ // Non-streaming adapter: print the assembled message.
259
+ if (!capture) feed(text);
260
+ }
261
+ break;
262
+ }
263
+ case "todo/write": {
264
+ const todos = data.todos ?? [];
265
+ const previous = new Map(lastTodos.map((todo) => [todo.content, todo.status]));
266
+ const firstRender = lastTodos.length === 0;
267
+ lastTodos = todos;
268
+ if (capture) break;
269
+ endLine();
270
+ flushText();
271
+ const done = todos.filter((todo) => todo.status === "completed").length;
272
+ write(paint(ansi.dim, ` ☑ todos ${done}/${todos.length}`) + "\n");
273
+ // Print the whole list once; later writes show only the items that moved.
274
+ const shown = firstRender ? todos : todos.filter((todo) => previous.get(todo.content) !== todo.status);
275
+ for (const todo of shown) {
276
+ const mark =
277
+ todo.status === "completed"
278
+ ? paint(ansi.green, "✓")
279
+ : todo.status === "in_progress"
280
+ ? paint(ansi.yellow, "▸")
281
+ : paint(ansi.dim, "☐");
282
+ const content = todo.status === "completed" ? paint(ansi.dim, todo.content) : todo.content;
283
+ write(` ${mark} ${content}\n`);
284
+ }
285
+ break;
286
+ }
287
+ case "turn/end": {
288
+ flushText();
289
+ endLine();
290
+ const kind = data.reason?.kind;
291
+ lastTurnKind = kind;
292
+ if (kind === "error") {
293
+ write(paint(ansi.red, ` ⚠ turn failed: ${data.reason.error?.message ?? "unknown error"}\n`));
294
+ } else if (kind === "max-tokens") {
295
+ write(paint(ansi.yellow, " ⚠ hit the output-token ceiling\n"));
296
+ } else if (kind === "aborted") {
297
+ write(paint(ansi.yellow, " ⏹ interrupted\n"));
298
+ }
299
+ if (reasoning !== "") lastReasoning = reasoning;
300
+ if (!showReasoning && reasoning.length > 0 && tty && !capture) {
301
+ write(paint(ansi.dim, paint(ansi.italic, ` · thought ${reasoning.length} chars (use /thinking)`)) + "\n");
302
+ }
303
+ if (!capture && tty && (turnUsage.input > 0 || turnUsage.output > 0)) {
304
+ write(paint(ansi.dim, ` · ${formatTokens(turnUsage.input)} in / ${formatTokens(turnUsage.output)} out`) + "\n");
305
+ }
306
+ write("\n");
307
+ streamedThisTurn = false;
308
+ reasoning = "";
309
+ break;
310
+ }
311
+ default:
312
+ break;
313
+ }
314
+ },
315
+ };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Session discovery — mirrors the JSONL persistence layout:
320
+ // $DSH_HOME/sessions/<project-key>/<session-id>/session.v4.jsonl.zstd
321
+ // ---------------------------------------------------------------------------
322
+ function projectKey(cwd) {
323
+ if (cwd.length === 0) throw new Error("cannot encode an empty project path");
324
+ let readable = "";
325
+ let separatorRun = false;
326
+ for (let i = 0; i < cwd.length; i++) {
327
+ const code = cwd.charCodeAt(i);
328
+ const ch = String.fromCharCode(code);
329
+ if (ch === "/" || ch === "\\" || ch === ":") {
330
+ if (!separatorRun) readable += "-";
331
+ separatorRun = true;
332
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
333
+ readable += ch;
334
+ separatorRun = false;
335
+ } else {
336
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
337
+ separatorRun = false;
338
+ }
339
+ }
340
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
341
+ }
342
+
343
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
344
+
345
+ /** Last few zstd frames of a session log, decoded to turn-lifecycle records. */
346
+ function readSessionTail(sessionDir) {
347
+ try {
348
+ const file = join(sessionDir, "session.v4.jsonl.zstd");
349
+ const buf = readFileSync(file);
350
+ const positions = [];
351
+ let at = 0;
352
+ while ((at = buf.indexOf(ZSTD_MAGIC, at)) !== -1) {
353
+ positions.push(at);
354
+ at += 4;
355
+ }
356
+ const events = [];
357
+ for (let i = Math.max(0, positions.length - 5); i < positions.length; i++) {
358
+ const start = positions[i];
359
+ const end = i + 1 < positions.length ? positions[i + 1] : buf.length;
360
+ let text;
361
+ try {
362
+ text = zstdDecompressSync(buf.subarray(start, end)).toString("utf8");
363
+ } catch {
364
+ continue;
365
+ }
366
+ for (const line of text.split("\n")) {
367
+ if (line === "") continue;
368
+ try {
369
+ const event = JSON.parse(line);
370
+ if (event.type === "turn/start" || event.type === "turn/end" || event.type === "step/start" || event.type === "tool/call") {
371
+ events.push({ type: event.type });
372
+ }
373
+ } catch {}
374
+ }
375
+ }
376
+ return events;
377
+ } catch {
378
+ return null;
379
+ }
380
+ }
381
+
382
+ /** Whether a session's log ends inside a turn (live elsewhere, or abandoned). */
383
+ function hasOpenTurn(sessionDir) {
384
+ const events = readSessionTail(sessionDir);
385
+ if (events === null || events.length === 0) return false;
386
+ let open = false;
387
+ for (const event of events) {
388
+ if (event.type === "turn/end") open = false;
389
+ else if (event.type === "turn/start" || event.type === "step/start" || event.type === "tool/call") open = true;
390
+ }
391
+ return open;
392
+ }
393
+
394
+ function listSessions(cwd = process.cwd()) {
395
+ const dir = join(resolveDshHome(), "sessions", projectKey(cwd));
396
+ let entries;
397
+ try {
398
+ entries = readdirSync(dir, { withFileTypes: true });
399
+ } catch {
400
+ return [];
401
+ }
402
+ const sessions = [];
403
+ for (const entry of entries) {
404
+ if (!entry.isDirectory() || !entry.name.startsWith("session-")) continue;
405
+ const sessionDir = join(dir, entry.name);
406
+ let newest = 0;
407
+ try {
408
+ for (const file of readdirSync(sessionDir)) {
409
+ const st = statSync(join(sessionDir, file));
410
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
411
+ }
412
+ } catch {
413
+ continue;
414
+ }
415
+ if (newest > 0) sessions.push({ id: entry.name, mtime: newest, openTurn: hasOpenTurn(sessionDir) });
416
+ }
417
+ sessions.sort((a, b) => b.mtime - a.mtime);
418
+ return sessions;
419
+ }
420
+
421
+ function timeAgo(ms) {
422
+ const diff = Date.now() - ms;
423
+ if (diff < 60000) return `${Math.max(1, Math.floor(diff / 1000))}s ago`;
424
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
425
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
426
+ return `${Math.floor(diff / 86400000)}d ago`;
427
+ }
428
+
429
+ function printSessionList(sessions, write = out) {
430
+ if (sessions.length === 0) {
431
+ write(paint(ansi.dim, " no sessions in this directory yet\n"));
432
+ return;
433
+ }
434
+ write(` Recent sessions in ${process.cwd()}:\n`);
435
+ sessions.slice(0, 10).forEach((session, index) => {
436
+ const running = session.openTurn ? paint(ansi.yellow, " ▶ running") : "";
437
+ write(` ${paint(ansi.cyan, String(index + 1))}) ${paint(ansi.dim, session.id)} (${paint(ansi.dim, timeAgo(session.mtime))})${running}\n`);
438
+ });
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Help / banner
443
+ // ---------------------------------------------------------------------------
444
+ function printBanner(model) {
445
+ const art = [
446
+ " ooo",
447
+ ' " """',
448
+ ' " "',
449
+ ' ___:____ " "|',
450
+ " ,' \\. \\//",
451
+ " | O \\___/ |",
452
+ "~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~",
453
+ ];
454
+ out("\n");
455
+ for (const line of art) out(paint(ansi.blue, line) + "\n");
456
+ out(`${paint(ansi.bold, "deepseek")} ${paint(ansi.dim, VERSION)} — DeepSeek Harness in your terminal\n`);
457
+ out(paint(ansi.dim, `model: ${model.provider}/${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ""}`) + "\n");
458
+ out(paint(ansi.dim, `cwd: ${process.cwd()}`) + "\n");
459
+ out(paint(ansi.dim, "type /help for commands, Ctrl+C to interrupt or exit") + "\n\n");
460
+ }
461
+
462
+ function printHelp(write = out) {
463
+ write(`deepseek ${VERSION} — DeepSeek Harness in your terminal.
464
+
465
+ Usage:
466
+ deepseek start an interactive session
467
+ deepseek "task text..." answer one task and exit (scriptable)
468
+ deepseek -r, --resume [id] resume a session (newest in this directory by default)
469
+ deepseek -c, --continue continue the newest session without prompting
470
+ deepseek --list list recent sessions in this directory
471
+ deepseek -y, --yes auto-approve actions that would ask
472
+ deepseek --model <id> override the model for this run
473
+ deepseek --reasoning <effort> override the reasoning effort (off|high|max)
474
+
475
+ Interactive commands:
476
+ /help this list (plus the Harness commands below)
477
+ /new start a fresh session
478
+ /sessions list recent sessions in this directory
479
+ /resume <id> switch to another session
480
+ /model [id] show or switch the model (e.g. /model deepseek-v4-pro)
481
+ /reasoning [off|high|max] show or set the reasoning effort
482
+ /permissions [p] show or switch the permission preset (native: /permission)
483
+ /skills list the skills discovered in this workspace
484
+ /jobs list this session's background jobs
485
+ /todos show the current todo list
486
+ /image <path> attach an image to the next message (or mention @photo.png)
487
+ /export [file] write this run's transcript as markdown
488
+ /thinking show the last turn's reasoning
489
+ /session print the current session id (for --resume)
490
+ /clear clear the screen
491
+ /quit, /exit leave (also Ctrl+C at an empty prompt)
492
+
493
+ Harness commands (shared with the Web and Desktop apps):
494
+ /compact, /goal, /plan, and any command a plugin registers.
495
+ Type /help inside a session to see the live list.
496
+ `);
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Argument parsing
501
+ // ---------------------------------------------------------------------------
502
+ function parseArgs(argv) {
503
+ const options = {
504
+ mode: "interactive",
505
+ resume: undefined,
506
+ continue: false,
507
+ list: false,
508
+ yes: false,
509
+ model: undefined,
510
+ reasoning: undefined,
511
+ help: false,
512
+ version: false,
513
+ task: undefined,
514
+ };
515
+ const rest = [];
516
+ for (let i = 0; i < argv.length; i++) {
517
+ const arg = argv[i];
518
+ if (arg === "--help" || arg === "-h") options.help = true;
519
+ else if (arg === "--version" || arg === "-v") options.version = true;
520
+ else if (arg === "--list" || arg === "--sessions") options.list = true;
521
+ else if (arg === "--continue" || arg === "-c") options.continue = true;
522
+ else if (arg === "--yes" || arg === "-y") options.yes = true;
523
+ else if (arg === "--model") options.model = argv[++i];
524
+ else if (arg === "--reasoning") options.reasoning = argv[++i];
525
+ else if (arg === "--resume" || arg === "-r") {
526
+ const next = argv[i + 1];
527
+ if (next !== undefined && !next.startsWith("-")) {
528
+ options.resume = next;
529
+ i++;
530
+ } else {
531
+ options.resume = true;
532
+ }
533
+ } else if (arg === "--") {
534
+ rest.push(...argv.slice(i + 1));
535
+ break;
536
+ } else if (arg.startsWith("-")) {
537
+ rest.push(arg);
538
+ } else {
539
+ rest.push(arg);
540
+ }
541
+ }
542
+ if (rest.length > 0) {
543
+ options.mode = "oneshot";
544
+ options.task = rest.join(" ");
545
+ }
546
+ return options;
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // Core driver
551
+ // ---------------------------------------------------------------------------
552
+ async function run(ctx, options, exit) {
553
+ await ctx.get("loader")?.await();
554
+ const agents = ctx.get("agents");
555
+ const defaultModel = ctx.get("agentDefaultModel");
556
+ const persistence = ctx.get("sessionPersistence");
557
+ if (agents === undefined || defaultModel === undefined || persistence === undefined) {
558
+ throw new Error("cli-runner: agents / agentDefaultModel / sessionPersistence services missing");
559
+ }
560
+ // Optional Harness services: each one adds native parity when the profile
561
+ // composes it, and every use is guarded so a narrower profile still runs.
562
+ const commands = ctx.get("commands");
563
+ const skills = ctx.get("skills");
564
+ const jobs = ctx.get("jobs");
565
+ const planMode = ctx.get("planMode");
566
+ const permissionPresets = ctx.get("permissionPresets");
567
+ const attachments = ctx.get("attachments");
568
+
569
+ if (options.list) {
570
+ printSessionList(listSessions());
571
+ exit(0);
572
+ return;
573
+ }
574
+
575
+ const selection = { ...defaultModel.currentSelection() };
576
+ if (options.model) selection.model = options.model;
577
+ if (options.reasoning) selection.reasoningEffort = options.reasoning;
578
+
579
+ const interactive = options.mode === "interactive";
580
+ const autoYes = options.yes || process.env.DSH_CLI_YES === "1";
581
+
582
+ let handle = null;
583
+ let busy = false;
584
+ let quitting = false;
585
+ let cancelSent = false;
586
+ let selectionRef = { current: { ...selection }, assembled: undefined };
587
+ let currentAsk = null;
588
+ let pendingLine = null;
589
+ const lineQueue = [];
590
+ let paused = false;
591
+ let lastInterrupt = 0;
592
+ let ready = false;
593
+ let stdinClosed = false;
594
+
595
+ const write = (text) => process.stdout.write(text);
596
+
597
+ // ---- line input ---------------------------------------------------------
598
+ // One-shot mode must not own stdin: a piped/closed stdin would deliver EOF
599
+ // and tear the runtime down before the task runs.
600
+ const rlInterface = interactive
601
+ ? createInterface({
602
+ input: process.stdin,
603
+ output: process.stdout,
604
+ terminal: process.stdin.isTTY,
605
+ historySize: 200,
606
+ removeHistoryDuplicates: true,
607
+ })
608
+ : null;
609
+ // Hold the input until the first agent is ready: buffered piped lines must
610
+ // not be consumed (and dropped) while the profile is still starting.
611
+ if (rlInterface) {
612
+ try {
613
+ rlInterface.pause();
614
+ paused = true;
615
+ } catch {}
616
+ }
617
+
618
+ function setPaused(value) {
619
+ if (!rlInterface || paused === value) return;
620
+ paused = value;
621
+ try {
622
+ if (value) rlInterface.pause();
623
+ else rlInterface.resume();
624
+ } catch {}
625
+ }
626
+
627
+ rlInterface?.on("line", (line) => {
628
+ if (pendingLine) {
629
+ const deliver = pendingLine;
630
+ pendingLine = null;
631
+ deliver(line);
632
+ } else {
633
+ lineQueue.push(line);
634
+ }
635
+ });
636
+
637
+ /**
638
+ * Ask one line. Resolves null when the question is interrupted (Ctrl+C) or
639
+ * stdin closes. A permanent listener queues ahead-of-time lines (piped input,
640
+ * or typing while the model works), so no line is ever dropped.
641
+ * @param {string} prompt
642
+ * @returns {Promise<string | null>}
643
+ */
644
+ function ask(prompt) {
645
+ return new Promise((resolve) => {
646
+ if (!rlInterface) {
647
+ resolve(null);
648
+ return;
649
+ }
650
+ if (lineQueue.length > 0) {
651
+ resolve(lineQueue.shift());
652
+ return;
653
+ }
654
+ if (stdinClosed) {
655
+ resolve(null);
656
+ return;
657
+ }
658
+ setPaused(false);
659
+ rlInterface.setPrompt(prompt);
660
+ rlInterface.prompt();
661
+ pendingLine = (value) => {
662
+ currentAsk = null;
663
+ if (busy) setPaused(true);
664
+ resolve(value);
665
+ };
666
+ currentAsk = {
667
+ done: (value) => {
668
+ const answer = pendingLine;
669
+ pendingLine = null;
670
+ answer?.(value);
671
+ },
672
+ };
673
+ });
674
+ }
675
+
676
+ function closeInput() {
677
+ try {
678
+ rlInterface?.close();
679
+ } catch {}
680
+ }
681
+
682
+ const shutdown = async (code) => {
683
+ if (quitting) return;
684
+ quitting = true;
685
+ closeInput();
686
+ if (handle) {
687
+ try {
688
+ await Promise.race([persistence.flush(), sleep(2000)]);
689
+ } catch {}
690
+ try {
691
+ await Promise.race([handle.dispose(), sleep(3000)]);
692
+ } catch {}
693
+ handle = null;
694
+ }
695
+ exit(code);
696
+ };
697
+
698
+ rlInterface?.on("SIGINT", handleInterrupt);
699
+ process.on("SIGINT", handleInterrupt);
700
+ rlInterface?.on("close", () => {
701
+ stdinClosed = true;
702
+ if (currentAsk) {
703
+ const done = currentAsk.done;
704
+ done(lineQueue.length > 0 ? lineQueue.shift() : null);
705
+ return;
706
+ }
707
+ if (quitting || busy) return;
708
+ if (lineQueue.length > 0) return; // the loop drains the buffered lines
709
+ if (ready) shutdown(0);
710
+ });
711
+
712
+ /** One Ctrl+C path shared by readline and the process signal. */
713
+ function handleInterrupt() {
714
+ const now = Date.now();
715
+ if (now - lastInterrupt < 150) return; // one physical keypress, one action
716
+ lastInterrupt = now;
717
+ if (currentAsk) {
718
+ const done = currentAsk.done;
719
+ process.stdout.write("\n");
720
+ done(null);
721
+ return;
722
+ }
723
+ if (busy) {
724
+ if (!cancelSent) {
725
+ cancelSent = true;
726
+ out(paint(ansi.yellow, "\n ⏹ cancelling…\n"));
727
+ try {
728
+ handle?.agent.cancel({ kind: "user" });
729
+ } catch {}
730
+ return;
731
+ }
732
+ shutdown(130);
733
+ return;
734
+ }
735
+ shutdown(0);
736
+ }
737
+
738
+ // ---- terminal questions for tools (plan mode, ask_user_question) ---------
739
+ // Answered per agent through the same `user-questions/request` waterfall the
740
+ // Web client uses, so plan review and ask_user_question behave identically.
741
+ const answerQuestions = async (request) => {
742
+ const answers = [];
743
+ const questions = request?.questions ?? [];
744
+ for (const question of questions) {
745
+ out("\n");
746
+ if (question.header) out(paint(ansi.bold, ` ${question.header}`) + "\n");
747
+ out(` ${question.question}\n`);
748
+ if (question.detail) out(paint(ansi.dim, question.detail.replace(/^/gm, " | ")) + "\n");
749
+ const options = question.options ?? [];
750
+ options.forEach((option, index) => {
751
+ out(` ${paint(ansi.cyan, String(index + 1))}) ${option.label}${option.description ? paint(ansi.dim, ` — ${option.description}`) : ""}\n`);
752
+ });
753
+ if (!interactive) {
754
+ answers.push({ id: question.id, selected: options[0] ? [options[0].label] : [], custom: options[0] ? undefined : "one-shot mode: no interactive answer" });
755
+ continue;
756
+ }
757
+ const answer = await ask(paint(ansi.green, options.length > 0 ? " choose: " : " answer: "));
758
+ const text = (answer ?? "").trim();
759
+ const index = Number.parseInt(text, 10) - 1;
760
+ if (options.length > 0 && index >= 0 && index < options.length) answers.push({ id: question.id, selected: [options[index].label] });
761
+ else answers.push({ id: question.id, selected: [], custom: text });
762
+ }
763
+ return { answers };
764
+ };
765
+
766
+ // ---- agent lifecycle ----------------------------------------------------
767
+ const makeAgentOptions = () => ({ ...selectionRef.current });
768
+
769
+ const wire = (agent) => {
770
+ agent.ctx.on("session/event", (session, event) => {
771
+ if (session === agent.session) renderer.event(event);
772
+ });
773
+ agent.ctx.on("agent/assistant-stream", (payload) => {
774
+ if (payload.agent === agent) renderer.frame(payload.frame);
775
+ });
776
+ agent.ctx.on("user-questions/request", async (request) => answerQuestions(request));
777
+ agent.ctx.on("approval/request", async (request) => {
778
+ const toolName = request.toolName ?? "tool";
779
+ if (autoYes) return "allowed-once";
780
+ if (request.reason) {
781
+ out(paint(ansi.dim, paint(ansi.italic, ` ⚠ ${toolName} needs approval: ${request.reason}`)) + "\n");
782
+ }
783
+ if (!interactive || !process.stdin.isTTY) return "rejected";
784
+ const answer = await ask(paint(ansi.yellow, ` ⚠ Allow ${paint(ansi.cyan, toolName)}? `) + paint(ansi.dim, "[y/N] "));
785
+ if (answer === null) return "rejected";
786
+ return /^y(es)?$/i.test(answer.trim()) ? "allowed-once" : "rejected";
787
+ });
788
+ };
789
+
790
+ const startAgent = async (sessionId, resumeId) => {
791
+ if (handle) {
792
+ try {
793
+ await Promise.race([handle.dispose(), sleep(3000)]);
794
+ } catch {}
795
+ handle = null;
796
+ }
797
+ selectionRef = { current: { ...selectionRef.current }, assembled: undefined };
798
+ const setup = (agentCtx) => {
799
+ installModelSelection(agentCtx, selectionRef);
800
+ };
801
+ const created = resumeId
802
+ ? await agents.resume({ resumeSessionId: SessionId(resumeId), agentOptions: makeAgentOptions(), setup })
803
+ : await agents.create({
804
+ sessionId,
805
+ meta: { cwd: process.cwd() },
806
+ agentOptions: makeAgentOptions(),
807
+ setup,
808
+ });
809
+ handle = created;
810
+ wire(created.agent);
811
+ await created.agent.whenIdle();
812
+ return created.agent;
813
+ };
814
+
815
+ const runTurn = async (agent, text) => {
816
+ const content = await buildUserContent(text);
817
+ transcript.push({ role: "user", text });
818
+ const previousAnswer = renderer.lastAssistantText;
819
+ busy = true;
820
+ cancelSent = false;
821
+ renderer.resetTurn();
822
+ setPaused(true);
823
+ try {
824
+ agent.followup(createUserMessage({ content, source: { kind: "user" } }));
825
+ await agent.whenIdle();
826
+ await persistence.flush();
827
+ } finally {
828
+ busy = false;
829
+ setPaused(false);
830
+ if (renderer.lastAssistantText && renderer.lastAssistantText !== previousAnswer) {
831
+ transcript.push({ role: "assistant", text: renderer.lastAssistantText });
832
+ }
833
+ }
834
+ };
835
+
836
+ const renderer = createRenderer({
837
+ write: (text) => {
838
+ if (options.mode !== "oneshot") write(text);
839
+ },
840
+ capture: options.mode === "oneshot",
841
+ });
842
+
843
+ const modelDesc = () => {
844
+ const current = selectionRef.current;
845
+ return `${current.provider}/${current.model}${current.reasoningEffort ? ` · ${current.reasoningEffort}` : ""}`;
846
+ };
847
+
848
+ // ---- parity helpers -----------------------------------------------------
849
+ const transcript = [];
850
+ const pendingImages = [];
851
+ const IMAGE_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
852
+
853
+ /** Image paths mentioned with `@…` in a message. */
854
+ function mentionedImages(text) {
855
+ const paths = [];
856
+ for (const match of text.matchAll(/(?:^|\s)@([^\s@]+)/g)) {
857
+ const extension = match[1].split(".").pop()?.toLowerCase() ?? "";
858
+ if (!(extension in IMAGE_TYPES)) continue;
859
+ const target = resolvePath(process.cwd(), match[1]);
860
+ if (existsSync(target)) paths.push(target);
861
+ }
862
+ return paths;
863
+ }
864
+
865
+ /** Build the user message: text (with @file inlining) plus attached images. */
866
+ async function buildUserContent(text) {
867
+ const expanded = expandMentions(text);
868
+ const images = [...pendingImages, ...mentionedImages(text)];
869
+ pendingImages.length = 0;
870
+ const blocks = [{ type: "text", text: expanded }];
871
+ for (const imagePath of [...new Set(images)]) {
872
+ if (attachments === undefined) {
873
+ out(paint(ansi.yellow, " ⚠ this profile has no attachment store; image ignored\n"));
874
+ continue;
875
+ }
876
+ try {
877
+ const extension = imagePath.split(".").pop()?.toLowerCase() ?? "";
878
+ const refs = await attachments.saveImages([
879
+ { data: readFileSync(imagePath), mediaType: IMAGE_TYPES[extension], name: basename(imagePath) },
880
+ ]);
881
+ if (refs[0] !== undefined) blocks.push({ type: "image", attachment: refs[0] });
882
+ } catch (error) {
883
+ out(paint(ansi.yellow, ` ⚠ cannot attach ${imagePath}: ${describeError(error)}`) + "\n");
884
+ }
885
+ }
886
+ if (blocks.length > 1) out(paint(ansi.dim, ` ▣ ${blocks.length - 1} image(s) attached`) + "\n");
887
+ return blocks;
888
+ }
889
+
890
+ /** Run one native Harness slash command; false when no such command exists. */
891
+ async function runNativeCommand(agent, line) {
892
+ if (commands === undefined) return false;
893
+ const controller = new AbortController();
894
+ try {
895
+ const execution = await commands.execute(agent, line, [], controller.signal);
896
+ if (execution === undefined) return false;
897
+ const result = execution.result;
898
+ if (result.kind === "error") out(paint(ansi.red, ` ✘ ${result.text ?? "command failed"}`) + "\n");
899
+ else if (result.text) out(paint(ansi.dim, ` ${result.text}`) + "\n");
900
+ else out(paint(ansi.dim, " ✓ done") + "\n");
901
+ return true;
902
+ } catch (error) {
903
+ out(paint(ansi.red, ` ✘ ${describeError(error)}`) + "\n");
904
+ return true;
905
+ }
906
+ }
907
+
908
+ /** List the Harness commands this profile registers (/compact, /goal, …). */
909
+ function printNativeCommands(agent) {
910
+ if (commands === undefined) return;
911
+ let list = [];
912
+ try {
913
+ list = commands.list(agent);
914
+ } catch {}
915
+ if (list.length === 0) return;
916
+ out(paint(ansi.bold, "Harness commands:") + "\n");
917
+ for (const entry of list) out(` /${entry.name.padEnd(12)} ${paint(ansi.dim, entry.description)}\n`);
918
+ }
919
+
920
+ /**
921
+ * Expand `@path` mentions into attached file text, so the model can read a
922
+ * file the same way a Web client attaches one.
923
+ */
924
+ function expandMentions(text) {
925
+ const mentions = [...new Set([...text.matchAll(/(?:^|\s)@([^\s@]+)/g)].map((match) => match[1]))];
926
+ if (mentions.length === 0) return text;
927
+ let extra = "";
928
+ for (const mention of mentions) {
929
+ try {
930
+ const target = resolvePath(process.cwd(), mention);
931
+ if (!existsSync(target)) continue;
932
+ const stat = statSync(target);
933
+ if (!stat.isFile()) continue;
934
+ if (stat.size > 200_000) {
935
+ extra += `\n\n--- attached: ${mention} (skipped, ${stat.size} bytes > 200 KB) ---\n`;
936
+ continue;
937
+ }
938
+ if (/\.(png|jpe?g|webp|gif)$/i.test(target)) continue; // handled as an image attachment
939
+ if (/\.(pdf|zip|exe|dll|bin)$/i.test(target)) {
940
+ extra += `\n\n--- attached: ${mention} (binary, not inlined) ---\n`;
941
+ continue;
942
+ }
943
+ const body = readFileSync(target, "utf8");
944
+ extra += `\n\n--- attached file: ${mention} ---\n${body}\n--- end ${mention} ---\n`;
945
+ } catch {}
946
+ }
947
+ return extra === "" ? text : text + extra;
948
+ }
949
+
950
+ /** Write the current run's transcript as markdown. */
951
+ function exportTranscript(agent, target) {
952
+ const lines = [
953
+ `# DeepSeek Harness session ${agent.session.id}`,
954
+ "",
955
+ `- model: ${modelDesc()}`,
956
+ `- cwd: ${process.cwd()}`,
957
+ `- exported: ${new Date().toISOString()}`,
958
+ "",
959
+ ];
960
+ if (transcript.length === 0) lines.push("_(no turns in this run)_", "");
961
+ for (const entry of transcript) {
962
+ lines.push(entry.role === "user" ? "## You" : "## Assistant", "", entry.text, "");
963
+ }
964
+ const file = target === "" ? `deepseek-${String(agent.session.id).replace(/^session-/, "").slice(0, 8)}.md` : target;
965
+ writeFileSync(file, lines.join("\n"), "utf8");
966
+ return file;
967
+ }
968
+
969
+ // ---- subagent progress --------------------------------------------------
970
+ // Root-scoped listeners: child agents created by this session are reported as
971
+ // one-line progress notes, like the Web UI's subagent cards.
972
+ ctx.on("agent/created", (payload) => {
973
+ try {
974
+ if (handle !== null && agents.isOwnedBy(payload.agent.id, handle.agent)) {
975
+ out(paint(ansi.dim, paint(ansi.italic, ` ↳ subagent ${String(payload.agent.id).slice(-8)} started`)) + "\n");
976
+ }
977
+ } catch {}
978
+ });
979
+ ctx.on("agent/disposed", (payload) => {
980
+ try {
981
+ if (handle !== null && agents.isOwnedBy(payload.agent.id, handle.agent)) {
982
+ out(paint(ansi.dim, paint(ansi.italic, ` ↳ subagent ${String(payload.agent.id).slice(-8)} finished`)) + "\n");
983
+ }
984
+ } catch {}
985
+ });
986
+
987
+ // ---- resolve a session to resume, if any --------------------------------
988
+ let resumeTarget = options.resume === true ? true : typeof options.resume === "string" ? options.resume : undefined;
989
+ if (options.continue) resumeTarget = true;
990
+
991
+ if (resumeTarget === true) {
992
+ const sessions = listSessions();
993
+ const candidate = sessions.find((session) => !session.openTurn);
994
+ if (candidate === undefined) {
995
+ out(paint(ansi.yellow, " no resumable sessions in this directory; starting fresh\n"));
996
+ resumeTarget = undefined;
997
+ } else {
998
+ resumeTarget = candidate.id;
999
+ }
1000
+ }
1001
+
1002
+ if (interactive && resumeTarget === undefined && process.stdin.isTTY) {
1003
+ const candidate = listSessions().find((session) => !session.openTurn);
1004
+ if (candidate !== undefined) {
1005
+ const answer = await ask(
1006
+ paint(ansi.yellow, ` Resume previous session (${candidate.id}, ${timeAgo(candidate.mtime)})? `) + paint(ansi.dim, "[y/N] ")
1007
+ );
1008
+ if (answer !== null && /^y(es)?$/i.test(answer.trim())) resumeTarget = candidate.id;
1009
+ }
1010
+ }
1011
+
1012
+ // ---- one-shot -----------------------------------------------------------
1013
+ if (options.mode === "oneshot") {
1014
+ const task = options.task?.trim();
1015
+ if (!task) {
1016
+ err("deepseek: no task given\n");
1017
+ await shutdown(1);
1018
+ return;
1019
+ }
1020
+ let agent;
1021
+ try {
1022
+ agent = resumeTarget ? await startAgent(undefined, resumeTarget) : await startAgent(SessionId(`session-${randomUUID()}`), undefined);
1023
+ } catch (error) {
1024
+ err(`deepseek: ${describeError(error)}\n`);
1025
+ await shutdown(1);
1026
+ return;
1027
+ }
1028
+ await runTurn(agent, task);
1029
+ const answer = renderer.lastAssistantText;
1030
+ if (answer !== "") process.stdout.write(answer.endsWith("\n") ? answer : answer + "\n");
1031
+ await shutdown(renderer.lastTurnKind === "completed" ? 0 : 1);
1032
+ return;
1033
+ }
1034
+
1035
+ // ---- interactive --------------------------------------------------------
1036
+ let agent;
1037
+ try {
1038
+ agent = resumeTarget ? await startAgent(undefined, resumeTarget) : await startAgent(SessionId(`session-${randomUUID()}`), undefined);
1039
+ } catch (error) {
1040
+ err(`deepseek: cannot start a session: ${error instanceof Error ? error.message : String(error)}\n`);
1041
+ await shutdown(1);
1042
+ return;
1043
+ }
1044
+
1045
+ ready = true;
1046
+ if (stdinClosed) {
1047
+ await shutdown(0);
1048
+ return;
1049
+ }
1050
+
1051
+ printBanner(selectionRef.current);
1052
+ if (resumeTarget) {
1053
+ out(paint(ansi.green, `▶ resumed `) + paint(ansi.dim, resumeTarget) + "\n");
1054
+ out(paint(ansi.dim, " (history is in the session log; continue the conversation below)") + "\n\n");
1055
+ } else {
1056
+ out(paint(ansi.dim, `session: ${agent.session.id} (resume with: deepseek --resume ${agent.session.id})`) + "\n\n");
1057
+ }
1058
+
1059
+ while (!quitting) {
1060
+ const line = await ask(paint(ansi.green, "❯ "));
1061
+ if (line === null) break;
1062
+ const text = line.trim();
1063
+ if (text === "") continue;
1064
+
1065
+ if (text.startsWith("/")) {
1066
+ const [command, ...rest] = text.split(/\s+/);
1067
+ const argument = rest.join(" ");
1068
+ switch (command) {
1069
+ case "/help":
1070
+ printHelp(write);
1071
+ printNativeCommands(agent);
1072
+ continue;
1073
+ case "/quit":
1074
+ case "/exit":
1075
+ await shutdown(0);
1076
+ return;
1077
+ case "/new": {
1078
+ out(paint(ansi.dim, " ↻ new session\n"));
1079
+ try {
1080
+ agent = await startAgent(SessionId(`session-${randomUUID()}`), undefined);
1081
+ out(paint(ansi.dim, ` session: ${agent.session.id}\n`));
1082
+ } catch (error) {
1083
+ out(paint(ansi.red, ` ✘ ${error instanceof Error ? error.message : String(error)}\n`));
1084
+ }
1085
+ continue;
1086
+ }
1087
+ case "/sessions":
1088
+ printSessionList(listSessions(), write);
1089
+ continue;
1090
+ case "/resume": {
1091
+ if (argument === "") {
1092
+ out(paint(ansi.yellow, " usage: /resume <session-id> (see /sessions)\n"));
1093
+ continue;
1094
+ }
1095
+ try {
1096
+ out(paint(ansi.dim, ` ↻ resuming ${argument}\n`));
1097
+ agent = await startAgent(undefined, argument);
1098
+ } catch (error) {
1099
+ out(paint(ansi.red, ` ✘ ${error instanceof Error ? error.message : String(error)}\n`));
1100
+ }
1101
+ continue;
1102
+ }
1103
+ case "/session":
1104
+ out(paint(ansi.dim, ` ${agent.session.id}\n`));
1105
+ continue;
1106
+ case "/model": {
1107
+ if (argument === "") {
1108
+ out(paint(ansi.dim, ` model: ${modelDesc()}\n`));
1109
+ continue;
1110
+ }
1111
+ const next = { ...selectionRef.current, model: argument };
1112
+ try {
1113
+ await defaultModel.saveSelection(next);
1114
+ selectionRef.current = next;
1115
+ out(paint(ansi.green, ` ✓ model set: ${next.provider}/${next.model} (next message)\n`));
1116
+ } catch (error) {
1117
+ out(paint(ansi.red, ` ✘ ${error instanceof Error ? error.message : String(error)}\n`));
1118
+ }
1119
+ continue;
1120
+ }
1121
+ case "/reasoning": {
1122
+ if (argument === "") {
1123
+ out(paint(ansi.dim, ` reasoning: ${selectionRef.current.reasoningEffort ?? "default"}\n`));
1124
+ continue;
1125
+ }
1126
+ const next = { ...selectionRef.current, reasoningEffort: argument === "default" ? undefined : argument };
1127
+ try {
1128
+ await defaultModel.saveSelection(next);
1129
+ selectionRef.current = next;
1130
+ out(paint(ansi.green, ` ✓ reasoning set: ${argument} (next message)\n`));
1131
+ } catch (error) {
1132
+ out(paint(ansi.red, ` ✘ ${error instanceof Error ? error.message : String(error)}\n`));
1133
+ }
1134
+ continue;
1135
+ }
1136
+ case "/thinking": {
1137
+ const reasoning = renderer.lastReasoning;
1138
+ if (reasoning === "") out(paint(ansi.dim, " no reasoning recorded yet\n"));
1139
+ else out(paint(ansi.dim, paint(ansi.italic, reasoning)) + "\n");
1140
+ continue;
1141
+ }
1142
+ case "/image": {
1143
+ if (argument === "") {
1144
+ out(paint(ansi.yellow, " usage: /image <path> (or mention @photo.png in a message)\n"));
1145
+ continue;
1146
+ }
1147
+ const target = resolvePath(process.cwd(), argument);
1148
+ if (!existsSync(target)) {
1149
+ out(paint(ansi.red, ` ✘ no such file: ${argument}\n`));
1150
+ continue;
1151
+ }
1152
+ pendingImages.push(target);
1153
+ out(paint(ansi.green, ` ▣ attached ${basename(target)} to the next message\n`));
1154
+ continue;
1155
+ }
1156
+ case "/todos": {
1157
+ const todos = renderer.lastTodos;
1158
+ if (todos.length === 0) {
1159
+ out(paint(ansi.dim, " no todo list in this session yet\n"));
1160
+ continue;
1161
+ }
1162
+ const done = todos.filter((todo) => todo.status === "completed").length;
1163
+ out(paint(ansi.dim, ` ☑ todos ${done}/${todos.length}`) + "\n");
1164
+ for (const todo of todos) {
1165
+ const mark = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "▸" : "☐";
1166
+ out(` ${mark} ${todo.content}\n`);
1167
+ }
1168
+ continue;
1169
+ }
1170
+ case "/skills": {
1171
+ if (skills === undefined) {
1172
+ out(paint(ansi.yellow, " this profile does not compose the skill registry\n"));
1173
+ continue;
1174
+ }
1175
+ try {
1176
+ const list = await skills.list();
1177
+ if (list.length === 0) out(paint(ansi.dim, " no skills discovered in this workspace\n"));
1178
+ else {
1179
+ out(paint(ansi.bold, "Skills:") + "\n");
1180
+ for (const skill of list) out(` ${paint(ansi.cyan, skill.name)} — ${paint(ansi.dim, skill.description)}\n`);
1181
+ }
1182
+ } catch (error) {
1183
+ out(paint(ansi.red, ` ✘ ${describeError(error)}\n`));
1184
+ }
1185
+ continue;
1186
+ }
1187
+ case "/jobs": {
1188
+ if (jobs === undefined) {
1189
+ out(paint(ansi.yellow, " this profile does not compose the job registry\n"));
1190
+ continue;
1191
+ }
1192
+ let list = [];
1193
+ try {
1194
+ list = jobs.list(agent.session.id);
1195
+ } catch (error) {
1196
+ out(paint(ansi.red, ` ✘ ${describeError(error)}\n`));
1197
+ continue;
1198
+ }
1199
+ if (list.length === 0) out(paint(ansi.dim, " no background jobs\n"));
1200
+ else {
1201
+ out(paint(ansi.bold, "Background jobs:") + "\n");
1202
+ for (const job of list) {
1203
+ const state = job.status === "running" ? paint(ansi.yellow, job.status) : paint(ansi.dim, job.status);
1204
+ out(` ${job.id} ${state} ${job.label}${job.progress ? paint(ansi.dim, ` · ${job.progress}`) : ""}\n`);
1205
+ }
1206
+ }
1207
+ continue;
1208
+ }
1209
+ case "/permissions": {
1210
+ if (permissionPresets === undefined) {
1211
+ out(paint(ansi.yellow, " this profile does not compose permission presets\n"));
1212
+ continue;
1213
+ }
1214
+ try {
1215
+ const catalog = permissionPresets.catalog();
1216
+ if (argument === "") {
1217
+ out(paint(ansi.bold, "Permission presets:") + "\n");
1218
+ for (const option of catalog.options) {
1219
+ out(` ${paint(ansi.cyan, option.value.padEnd(18))} ${option.name}${option.description ? paint(ansi.dim, ` — ${option.description}`) : ""}\n`);
1220
+ }
1221
+ out(paint(ansi.dim, ` default: ${catalog.defaultPreset}`) + "\n");
1222
+ } else {
1223
+ permissionPresets.set(agent.session, argument);
1224
+ out(paint(ansi.green, ` ✓ permissions: ${argument} (applies to the next tool call)\n`));
1225
+ }
1226
+ } catch (error) {
1227
+ out(paint(ansi.red, ` ✘ ${describeError(error)}\n`));
1228
+ }
1229
+ continue;
1230
+ }
1231
+ case "/export": {
1232
+ try {
1233
+ const file = exportTranscript(agent, argument);
1234
+ out(paint(ansi.green, ` ✓ exported ${transcript.length} message(s) to ${file}\n`));
1235
+ } catch (error) {
1236
+ out(paint(ansi.red, ` ✘ ${describeError(error)}\n`));
1237
+ }
1238
+ continue;
1239
+ }
1240
+ case "/clear":
1241
+ out("\x1b[2J\x1b[3J\x1b[H");
1242
+ continue;
1243
+ default: {
1244
+ // Anything the Harness itself registers (/compact, /goal, /plan, …)
1245
+ // runs through the shared command registry, so CLI and Web stay in sync.
1246
+ if (await runNativeCommand(agent, text)) continue;
1247
+ if (planMode !== undefined && command === "/plan") {
1248
+ try {
1249
+ const current = planMode.get(agent);
1250
+ const next = argument === "off" ? false : argument === "" ? !current.active : true;
1251
+ const outcome = planMode.set(agent, next);
1252
+ out(paint(ansi.green, ` ✓ plan mode ${next ? "on" : "off"} (${outcome})\n`));
1253
+ } catch (error) {
1254
+ out(paint(ansi.red, ` ✘ ${describeError(error)}\n`));
1255
+ }
1256
+ continue;
1257
+ }
1258
+ out(paint(ansi.yellow, ` unknown command ${command} — try /help\n`));
1259
+ continue;
1260
+ }
1261
+ }
1262
+ }
1263
+
1264
+ out("\n" + paint(ansi.green, "❯ ") + text + "\n\n");
1265
+ await runTurn(agent, text);
1266
+ }
1267
+
1268
+ await shutdown(0);
1269
+ }
1270
+
1271
+ // ---------------------------------------------------------------------------
1272
+ // Cordis plugin entry
1273
+ // ---------------------------------------------------------------------------
1274
+ export function apply(ctx) {
1275
+ const exit = ctx.get("appExit");
1276
+ if (exit === undefined) {
1277
+ throw new Error("cli-runner: the launcher must provide ctx.appExit before the tree mounts");
1278
+ }
1279
+ const argv = ctx.get("cmdlineArgs")?.get() ?? [];
1280
+ const options = parseArgs(argv);
1281
+ if (options.help) {
1282
+ printHelp();
1283
+ exit(0);
1284
+ return;
1285
+ }
1286
+ if (options.version) {
1287
+ console.log(VERSION);
1288
+ exit(0);
1289
+ return;
1290
+ }
1291
+ run(ctx, options, exit).catch((error) => {
1292
+ const detail = process.env.DSH_CLI_DEBUG === "1" && error instanceof Error ? `\n${error.stack}` : "";
1293
+ process.stderr.write(`deepseek: ${error instanceof Error ? error.message : String(error)}${detail}\n`);
1294
+ try {
1295
+ exit(1);
1296
+ } catch {}
1297
+ });
1298
+ }