codsh-bundle 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,12 +1,13 @@
1
- import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join, parse } from "node:path";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
7
+ import { admitEncodedImages, isImageAdmissionError } from "@deepseek-ai/dsh-attachment";
7
8
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
8
9
  import { SessionId } from "@deepseek-ai/dsh-session";
9
- import { homedir } from "node:os";
10
+ import { homedir, tmpdir } from "node:os";
10
11
  import stringWidth from "string-width";
11
12
  import { readdirSync } from "node:fs";
12
13
  import { createInterface } from "node:readline";
@@ -795,6 +796,7 @@ const CONTROLS = {
795
796
  "": { kind: "expand-output" },
796
797
  "": { kind: "toggle-todos" },
797
798
  "": { kind: "kill-input" },
799
+ "": { kind: "paste-image" },
798
800
  "": { kind: "kill-word" }
799
801
  };
800
802
  /** Decodes terminal bytes into keys, holding partial sequences between reads. */
@@ -2225,6 +2227,158 @@ var TerminalConsole = class {
2225
2227
  }
2226
2228
  };
2227
2229
 
2230
+ //#endregion
2231
+ //#region src/clipboard-image.ts
2232
+ /** The most a clipboard image may be; beyond this the read reports none. */
2233
+ const MAX_CLIPBOARD_IMAGE_BYTES = 64 * 1024 * 1024;
2234
+ /**
2235
+ * Run one command, capturing binary stdout.
2236
+ * @param command - the executable.
2237
+ * @param args - its arguments.
2238
+ * @returns stdout as bytes, or undefined on any failure or empty output.
2239
+ */
2240
+ function run$1(command, args) {
2241
+ return new Promise((resolve) => {
2242
+ execFile(command, [...args], {
2243
+ encoding: "buffer",
2244
+ maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES,
2245
+ timeout: 1e4
2246
+ }, (error, stdout) => {
2247
+ if (error !== null || stdout.length === 0) resolve(void 0);
2248
+ else resolve(stdout);
2249
+ });
2250
+ });
2251
+ }
2252
+ /**
2253
+ * What image type these bytes are, from their magic numbers.
2254
+ *
2255
+ * Sniffed rather than taken from the reader's word: the store verifies the
2256
+ * declared type against the decoded bytes and refuses a mismatch, so lying
2257
+ * here would only defer the failure to a worse moment.
2258
+ * @param data - the bytes.
2259
+ * @returns the media type, or undefined for anything that is not an image.
2260
+ */
2261
+ function sniffImageType(data) {
2262
+ if (data.length < 12) return void 0;
2263
+ if (data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "image/png";
2264
+ if (data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
2265
+ if (data[0] === 71 && data[1] === 73 && data[2] === 70 && data[3] === 56) return "image/gif";
2266
+ if (data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
2267
+ }
2268
+ /** The clipboard's image on macOS: AppleScript writes the PNG to a file. */
2269
+ async function readDarwin() {
2270
+ const dir = await mkdtemp(join(tmpdir(), "codsh-clip-"));
2271
+ const file = join(dir, "clipboard.png");
2272
+ try {
2273
+ const script = [
2274
+ "-e",
2275
+ "set png_data to (the clipboard as «class PNGf»)",
2276
+ "-e",
2277
+ `set fp to open for access POSIX file "${file}" with write permission`,
2278
+ "-e",
2279
+ "write png_data to fp",
2280
+ "-e",
2281
+ "close access fp"
2282
+ ];
2283
+ if (!await new Promise((resolve) => {
2284
+ execFile("osascript", script, { timeout: 1e4 }, (error) => void resolve(error === null));
2285
+ })) return void 0;
2286
+ return await readFile(file).catch(() => void 0);
2287
+ } finally {
2288
+ await rm(dir, {
2289
+ recursive: true,
2290
+ force: true
2291
+ });
2292
+ }
2293
+ }
2294
+ /** The clipboard's image on Linux, Wayland first, X11 as the fallback. */
2295
+ async function readLinux(env) {
2296
+ if (env.WAYLAND_DISPLAY !== void 0) {
2297
+ const offered$1 = (await run$1("wl-paste", ["-l"]))?.toString("utf8").match(/image\/(?:png|jpeg|webp|gif)/u)?.[0];
2298
+ if (offered$1 === void 0) return void 0;
2299
+ return run$1("wl-paste", ["-t", offered$1]);
2300
+ }
2301
+ const offered = (await run$1("xclip", [
2302
+ "-selection",
2303
+ "clipboard",
2304
+ "-t",
2305
+ "TARGETS",
2306
+ "-o"
2307
+ ]))?.toString("utf8").match(/image\/(?:png|jpeg|webp|gif)/u)?.[0];
2308
+ if (offered === void 0) return void 0;
2309
+ return run$1("xclip", [
2310
+ "-selection",
2311
+ "clipboard",
2312
+ "-t",
2313
+ offered,
2314
+ "-o"
2315
+ ]);
2316
+ }
2317
+ /** The clipboard's image on Windows: PowerShell saves it as a PNG file. */
2318
+ async function readWin32() {
2319
+ const dir = await mkdtemp(join(tmpdir(), "codsh-clip-"));
2320
+ const file = join(dir, "clipboard.png");
2321
+ try {
2322
+ const script = `$img = Get-Clipboard -Format Image; if ($img) { $img.Save('${file.replaceAll("\\", "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png) }`;
2323
+ if (!await new Promise((resolve) => {
2324
+ execFile("powershell", [
2325
+ "-NoProfile",
2326
+ "-Command",
2327
+ script
2328
+ ], { timeout: 1e4 }, (error) => void resolve(error === null));
2329
+ })) return void 0;
2330
+ return await readFile(file).catch(() => void 0);
2331
+ } finally {
2332
+ await rm(dir, {
2333
+ recursive: true,
2334
+ force: true
2335
+ });
2336
+ }
2337
+ }
2338
+ /**
2339
+ * Pixel dimensions from the image header, best effort.
2340
+ *
2341
+ * sharp decodes properly, but it is a native module and the flash line does
2342
+ * not justify failing a paste over it — an unreadable header simply reports
2343
+ * no dimensions.
2344
+ * @param data - the image bytes.
2345
+ * @returns width and height, or undefined.
2346
+ */
2347
+ async function probeDimensions(data) {
2348
+ try {
2349
+ const { default: sharp } = await import("sharp");
2350
+ const meta = await sharp(data).metadata();
2351
+ if (typeof meta.width === "number" && typeof meta.height === "number") return {
2352
+ width: meta.width,
2353
+ height: meta.height
2354
+ };
2355
+ return;
2356
+ } catch {
2357
+ return;
2358
+ }
2359
+ }
2360
+ /**
2361
+ * The image on the system clipboard, or undefined when it holds none.
2362
+ *
2363
+ * `CODSH_CLIPBOARD_IMAGE_CMD` overrides the platform reader with a shell
2364
+ * command whose stdout is the image bytes — the seam the tests use, exactly
2365
+ * as `CODSH_CLIPBOARD=osc52` keeps the write path off the real clipboard.
2366
+ * @param env - the environment, for the override and the display probes.
2367
+ * @returns the image with its sniffed type and dimensions, or undefined.
2368
+ */
2369
+ async function readClipboardImage(env) {
2370
+ const override = env.CODSH_CLIPBOARD_IMAGE_CMD;
2371
+ const data = override !== void 0 && override !== "" ? await run$1(env.SHELL ?? "/bin/sh", ["-c", override]) : process.platform === "darwin" ? await readDarwin() : process.platform === "win32" ? await readWin32() : await readLinux(env);
2372
+ if (data === void 0) return void 0;
2373
+ const mediaType = sniffImageType(data);
2374
+ if (mediaType === void 0) return void 0;
2375
+ return {
2376
+ data,
2377
+ mediaType,
2378
+ ...await probeDimensions(data)
2379
+ };
2380
+ }
2381
+
2228
2382
  //#endregion
2229
2383
  //#region src/editor.ts
2230
2384
  /** Longest run of history the editor keeps for one session. */
@@ -2451,9 +2605,11 @@ var Editor = class {
2451
2605
  backspace() {
2452
2606
  if (this.column > 0) {
2453
2607
  const cells = points(this.line());
2454
- cells.splice(this.column - 1, 1);
2608
+ const token = /\[Image #\d+\]$/u.exec(cells.slice(0, this.column).join(""));
2609
+ const width = token === null ? 1 : points(token[0]).length;
2610
+ cells.splice(this.column - width, width);
2455
2611
  this.setLine(cells.join(""));
2456
- this.column -= 1;
2612
+ this.column -= width;
2457
2613
  } else if (this.row > 0) {
2458
2614
  const previous = this.lines[this.row - 1] ?? "";
2459
2615
  const current = this.line();
@@ -2991,6 +3147,15 @@ var Prompt = class {
2991
3147
  * not be lost; the queue is what a line reader provides for free.
2992
3148
  */
2993
3149
  queued = [];
3150
+ /** Images pasted into the box, by the number their `[Image #N]` token wears. */
3151
+ pendingImages = /* @__PURE__ */ new Map();
3152
+ /** Numbers are never reused within a session: a recalled token must not
3153
+ * silently pick up a different image. */
3154
+ imageCounter = 0;
3155
+ /** The images belonging to the line the last read handed out. */
3156
+ submittedImages = [];
3157
+ /** Whether a clipboard read is already in flight; a second Ctrl+V waits. */
3158
+ pastingImage = false;
2994
3159
  /** The working indicator shown under the box. */
2995
3160
  hint;
2996
3161
  /** A short-lived notice that borrows the hint row, e.g. the copy toast. */
@@ -3150,7 +3315,10 @@ var Prompt = class {
3150
3315
  read(signal) {
3151
3316
  if (!this.console.readsKeys) return this.console.readLine(signal);
3152
3317
  const typedAhead = this.queued.shift();
3153
- if (typedAhead !== void 0) return Promise.resolve(typedAhead);
3318
+ if (typedAhead !== void 0) {
3319
+ this.submittedImages = typedAhead.images;
3320
+ return Promise.resolve(typedAhead.text);
3321
+ }
3154
3322
  if (this.console.finished) return Promise.resolve(void 0);
3155
3323
  this.reading = true;
3156
3324
  this.render();
@@ -3158,6 +3326,7 @@ var Prompt = class {
3158
3326
  const settle = (text) => {
3159
3327
  this.pending = void 0;
3160
3328
  this.reading = false;
3329
+ this.submittedImages = text === void 0 ? [] : this.claimImages(text);
3161
3330
  resolve(text);
3162
3331
  };
3163
3332
  const onAbort = () => {
@@ -3297,12 +3466,19 @@ var Prompt = class {
3297
3466
  this.render();
3298
3467
  return;
3299
3468
  }
3469
+ if (key.kind === "paste-image") {
3470
+ this.pasteImage();
3471
+ return;
3472
+ }
3300
3473
  const action = this.editor.handle(key);
3301
3474
  switch (action.kind) {
3302
3475
  case "submit": {
3303
3476
  const waiting = this.pending;
3304
3477
  if (waiting === void 0) {
3305
- this.queued.push(action.text);
3478
+ this.queued.push({
3479
+ text: action.text,
3480
+ images: this.claimImages(action.text)
3481
+ });
3306
3482
  break;
3307
3483
  }
3308
3484
  waiting.dispose();
@@ -3327,6 +3503,81 @@ var Prompt = class {
3327
3503
  this.render();
3328
3504
  }
3329
3505
  /**
3506
+ * Read the clipboard and attach its image behind an `[Image #N]` token.
3507
+ *
3508
+ * The read shells out and takes real time, so it runs off the key handler;
3509
+ * a second Ctrl+V during it is dropped rather than raced. Numbers count up
3510
+ * for the whole session — a token in a recalled line must never quietly
3511
+ * name a different image than the one it was minted for.
3512
+ */
3513
+ async pasteImage() {
3514
+ const read = this.handlers.readClipboardImage;
3515
+ if (read === void 0 || this.pastingImage) return;
3516
+ this.pastingImage = true;
3517
+ try {
3518
+ const found = await read();
3519
+ if (found === void 0) {
3520
+ this.setFlash(this.theme.dim(" no image in the clipboard"));
3521
+ return;
3522
+ }
3523
+ this.imageCounter += 1;
3524
+ const id = this.imageCounter;
3525
+ const pending = {
3526
+ id,
3527
+ image: {
3528
+ mediaType: found.mediaType,
3529
+ data: found.data.toString("base64"),
3530
+ name: `Pasted image #${id}`
3531
+ }
3532
+ };
3533
+ if (found.width !== void 0) pending.width = found.width;
3534
+ if (found.height !== void 0) pending.height = found.height;
3535
+ this.pendingImages.set(id, pending);
3536
+ this.editor.handle({
3537
+ kind: "paste",
3538
+ text: `[Image #${id}]`
3539
+ });
3540
+ const size = found.width !== void 0 && found.height !== void 0 ? ` (${found.width}×${found.height} ${found.mediaType.slice(6)})` : "";
3541
+ this.setFlash(this.theme.dim(` ✓ image #${id} attached${size}`));
3542
+ } finally {
3543
+ this.pastingImage = false;
3544
+ this.render();
3545
+ }
3546
+ }
3547
+ /**
3548
+ * The images a submitted line actually references, in token order.
3549
+ *
3550
+ * The tokens are the source of truth: a token the person deleted drops its
3551
+ * image, a token duplicated by editing still names one attachment once.
3552
+ * Claimed images leave the pending pool, so a token recalled from history
3553
+ * later submits as plain text rather than resurrecting consumed bytes.
3554
+ * @param text - the submitted line.
3555
+ * @returns the referenced images, ready to ride the submission.
3556
+ */
3557
+ claimImages(text) {
3558
+ const images = [];
3559
+ for (const match of text.matchAll(/\[Image #(\d+)\]/gu)) {
3560
+ const id = Number(match[1]);
3561
+ const pending = this.pendingImages.get(id);
3562
+ if (pending === void 0) continue;
3563
+ this.pendingImages.delete(id);
3564
+ images.push(pending);
3565
+ }
3566
+ return images;
3567
+ }
3568
+ /**
3569
+ * The images belonging to the line the last read returned.
3570
+ *
3571
+ * A drain, in the transcript's `take*` idiom: the caller reads the line,
3572
+ * then takes its images exactly once.
3573
+ * @returns the images in token order, empty for a plain line.
3574
+ */
3575
+ takeAttachments() {
3576
+ const images = this.submittedImages;
3577
+ this.submittedImages = [];
3578
+ return images;
3579
+ }
3580
+ /**
3330
3581
  * The todo readout's rows: the one in flight, or the whole list once opened.
3331
3582
  * @param columns - display columns available.
3332
3583
  * @returns the rows, empty when no list is live.
@@ -3365,7 +3616,7 @@ var Prompt = class {
3365
3616
  rows.push(...box.rows);
3366
3617
  }
3367
3618
  if (this.queued.length > 0) {
3368
- const preview = this.queued[0] ?? "";
3619
+ const preview = this.queued[0]?.text ?? "";
3369
3620
  const more = this.queued.length > 1 ? ` (+${this.queued.length - 1} more)` : "";
3370
3621
  rows.push(this.theme.dim(truncate(` ↳ queued: ${preview.split("\n")[0] ?? ""}${more}`, columns)));
3371
3622
  }
@@ -3888,6 +4139,13 @@ var TerminalQuestions = class {
3888
4139
  * from idea to shipped, verified code — a research-grounded interview, a
3889
4140
  * confirmed spec (gate 1), an approved plan (gate 2), then autonomous landing
3890
4141
  * until the spec's acceptance criteria pass.
4142
+ *
4143
+ * The spec FILE is the workflow's memory, not the conversation: the approved
4144
+ * plan is written into it, its Status line names the phase, its checkboxes
4145
+ * are the progress, and a bare /ship offers to resume whatever it finds
4146
+ * unfinished. Conversations get interrupted, compacted, and cleared; the file
4147
+ * survives all three, which is what makes the landing reliable rather than
4148
+ * merely well-intentioned.
3891
4149
  */
3892
4150
  /** The `/ship` prompt body; `$ARGUMENTS` is the typed one-sentence requirement. */
3893
4151
  const SHIP_PROMPT = `Run the /ship workflow: take the one-sentence requirement below from idea to shipped, verified code in this repository. The requirement, exactly as typed:
@@ -3896,17 +4154,17 @@ const SHIP_PROMPT = `Run the /ship workflow: take the one-sentence requirement b
3896
4154
  $ARGUMENTS
3897
4155
  </idea>
3898
4156
 
3899
- If the idea between the <idea> tags is empty, that is not an error: before anything else, ask for the one-sentence requirement with ask_user_question, and use the answer as the idea for the rest of this workflow.
4157
+ If the idea between the <idea> tags is empty, that is not an error. First look for unfinished work: scan the repository's spec directory (docs/specs/, or the repo's own design-document convention) for a spec whose Status line is not \`shipped\` — a bare /ship most likely means "carry on", so offer through ask_user_question to resume that spec from the phase its Status names, with everything below applying from that phase onward. Only when there is nothing to resume, ask for the one-sentence requirement with ask_user_question and use the answer as the idea. Images accompanying the command — [Image #N] tokens, <pasted-image> context, attached image blocks — are part of the requirement: a mockup or a screenshot is requirements material, so read it and cite what it shows in the interview.
3900
4158
 
3901
4159
  Phase 1 — grounded interview. Research before you ask: read the repository layout, the docs, and the code paths the idea touches, so every question is informed by what actually exists. Then interrogate the idea with ask_user_question, one focused question per call, never a batch. Cover, as far as they are genuinely open: who this is for and what success looks like, scope and explicit non-goals, constraints (compatibility, performance, security, dependencies), edge cases and failure behavior, and how the result should be verified. Prefer concrete options grounded in what you found over open-ended prompts. Do not ask what inspection can answer — where code lives or how current behavior works is yours to find out. Stop when answers stop changing the design; do not pad the interview to look thorough.
3902
4160
 
3903
- Phase 2 — the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion is objectively checkablenamed tests, commands with expected output, observable behavior. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.
4161
+ Phase 2 — the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion names the exact command that proves it and the output that counts as passing the final phase runs those commands verbatim, so a criterion without a command is not finished. Give the file a \`Status:\` line (interviewing, confirmed, planned, landing, shipped) and keep it current at every phase change: it is what lets an interrupted /ship resume instead of starting over. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.
3904
4162
 
3905
- Phase 3 — the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Write no implementation code before this gate passes, and do not use todo_write before it either — it tracks landing, not the interview.
4163
+ Phase 3 — the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Once approved, write the plan into the spec file as a \`## Plan\` section with one checkbox per milestone — an approved plan lives on disk, not in a conversation that can be compacted or lost. Then, still before any implementation code, establish the ground: check the working tree is clean (uncommitted unrelated changes are the user's to decide about — ask), and run the plan's proof commands once, recording the baseline in the spec. A baseline that is already red changes what "green" will mean, so surface it here rather than discovering it under your own diff. Write no implementation code before this gate passes, and do not use todo_write before it either — it tracks landing, not the interview.
3906
4164
 
3907
- Phase 4 — landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, and fix until green before moving on. If it is larger — four or more substantially independent milestones, or work you expect to exceed what one session can hold — the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the durable source of truth, instructs each round to read the spec and plan from disk, pick up the next unfinished milestone, implement and test it, and record progress in the workspace, and defines completion as every acceptance criterion in the spec passing.
4165
+ Phase 4 — landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Either way the spec file — not this conversation — is the working memory: re-read it before starting each milestone, tick the milestone's checkbox and update Status as you go, and commit after each milestone turns green — small commits are the progress that survives a crash and the history a reviewer can walk. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, fix until green, then commit before moving on. If it is larger — four or more substantially independent milestones, or work you expect to exceed what one session can hold — the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the single source of truth, instructs each round to read the spec from disk (plan, checkboxes, baseline), pick the first unchecked milestone, implement and test it, then commit and tick its checkbox, and defines completion as every acceptance criterion in the spec passing. Bound the loop: budget about three rounds per milestone, and instruct it to stop and report rather than continue past two consecutive rounds that tick nothing.
3908
4166
 
3909
- Phase 5 — done means verified. The workflow ends only when every acceptance criterion passes with you actually running the named tests and commands and reading their real output. Never report a result you did not run, and never weaken a criterion to make it pass; if one cannot be met, say so plainly and why. When a decision changes mid-flight, update the spec file first so the file on disk stays the truth. Close with a short honest report: what shipped, what was verified and how, and anything left open.
4167
+ Phase 5 — done means verified. The workflow ends only when every acceptance criterion passes with you actually running its named command and reading the real output. After a Ralph loop returns, run every proof command again yourself — the loop's word is a report, not a verification. Never report a result you did not run, and never weaken a criterion to make it pass; if one cannot be met, say so plainly and why. When a decision changes mid-flight, update the spec file first so the file on disk stays the truth. Set Status to shipped only after that final run, and close with an honest report listing each criterion, the command that proved it, and what it printed — plus anything left open.
3910
4168
 
3911
4169
  If the session is in plan mode, the plan-mode rules win: nothing here authorizes writes while it is active. Tell the user this workflow needs to write the spec file and ask them to leave plan mode before continuing past the interview.`;
3912
4170
 
@@ -3994,6 +4252,166 @@ var Spinner = class {
3994
4252
  }
3995
4253
  };
3996
4254
 
4255
+ //#endregion
4256
+ //#region src/vision.ts
4257
+ /** How long one description may take before the paste falls back to file-only. */
4258
+ const VISION_TIMEOUT_MS = 3e4;
4259
+ /**
4260
+ * The one instruction the sidecar gets.
4261
+ *
4262
+ * It is the eyes for a model that has none, so completeness beats brevity and
4263
+ * verbatim beats summary: a truncated error message or a paraphrased line of
4264
+ * code is exactly the part the coding agent needed.
4265
+ */
4266
+ const VISION_PROMPT = "You are the eyes for a text-only coding agent. Describe this image precisely and completely. Transcribe ALL visible text, code, commands, error messages, numbers and labels verbatim. When it shows a UI, terminal, diagram or chart, describe its structure and layout so the agent can reason about it. Do not speculate beyond what is visible.";
4267
+ /**
4268
+ * The sidecar from the environment, or undefined when none is configured.
4269
+ * @param env - the process environment.
4270
+ * @returns the config when both the base URL and the model are set.
4271
+ */
4272
+ function visionConfigFromEnv(env) {
4273
+ const baseUrl = env.CODSH_VISION_BASE_URL;
4274
+ const model = env.CODSH_VISION_MODEL;
4275
+ if (baseUrl === void 0 || baseUrl === "" || model === void 0 || model === "") return void 0;
4276
+ const config = {
4277
+ baseUrl: baseUrl.replace(/\/$/u, ""),
4278
+ model
4279
+ };
4280
+ const key = env.CODSH_VISION_API_KEY;
4281
+ if (key !== void 0 && key !== "") config.apiKey = key;
4282
+ return config;
4283
+ }
4284
+ /**
4285
+ * Ask the sidecar what an image shows.
4286
+ * @param image - the encoded image.
4287
+ * @param config - which endpoint and model to ask.
4288
+ * @param signal - cancels the request, on top of the built-in timeout.
4289
+ * @returns the description.
4290
+ * @throws on timeout, a non-2xx answer, or an answer with no text.
4291
+ */
4292
+ async function describeImage(image, config, signal) {
4293
+ const timeout = AbortSignal.timeout(VISION_TIMEOUT_MS);
4294
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
4295
+ method: "POST",
4296
+ headers: {
4297
+ "content-type": "application/json",
4298
+ ...config.apiKey === void 0 ? {} : { authorization: `Bearer ${config.apiKey}` }
4299
+ },
4300
+ body: JSON.stringify({
4301
+ model: config.model,
4302
+ messages: [{
4303
+ role: "user",
4304
+ content: [{
4305
+ type: "image_url",
4306
+ image_url: { url: `data:${image.mediaType};base64,${image.data}` }
4307
+ }, {
4308
+ type: "text",
4309
+ text: VISION_PROMPT
4310
+ }]
4311
+ }]
4312
+ }),
4313
+ signal: signal === void 0 ? timeout : AbortSignal.any([signal, timeout])
4314
+ });
4315
+ if (!response.ok) throw new Error(`vision endpoint answered ${response.status}`);
4316
+ const text = (await response.json()).choices?.[0]?.message?.content?.trim();
4317
+ if (text === void 0 || text === "") throw new Error("vision endpoint answered without text");
4318
+ return text;
4319
+ }
4320
+ /**
4321
+ * The upstream store's default admission limits, for use when no store is
4322
+ * mounted: the sidecar payload is bounded by the same line either way.
4323
+ */
4324
+ const DEFAULT_IMAGE_LIMITS = {
4325
+ maxImageBytes: 3.5 * 1024 * 1024,
4326
+ maxImagesPerMessage: 20,
4327
+ maxMessageImageBytes: 100 * 1024 * 1024,
4328
+ maxImagePixels: 4e7,
4329
+ maxImageDimension: 2e3,
4330
+ mediaTypes: [
4331
+ "image/png",
4332
+ "image/jpeg",
4333
+ "image/webp",
4334
+ "image/gif"
4335
+ ]
4336
+ };
4337
+ /** File extension per media type, for the saved copy's name. */
4338
+ const EXTENSIONS = {
4339
+ "image/png": "png",
4340
+ "image/jpeg": "jpg",
4341
+ "image/webp": "webp",
4342
+ "image/gif": "gif"
4343
+ };
4344
+ /**
4345
+ * Save a pasted image where the agent's tools can reach it.
4346
+ *
4347
+ * The original bytes, never a downscaled copy — the person may want the asset
4348
+ * itself committed. Content-addressed under the dsh home so a repeated paste
4349
+ * dedupes, nothing lands in the workspace uninvited, and the path stays valid
4350
+ * for `--resume`.
4351
+ * @param image - the encoded image.
4352
+ * @returns the absolute path of the saved file.
4353
+ */
4354
+ async function savePastedImage(image) {
4355
+ const data = Buffer.from(image.data, "base64");
4356
+ const digest = createHash("sha256").update(data).digest("hex").slice(0, 12);
4357
+ const dir = dshHomePath("attachments", "pasted");
4358
+ await mkdir(dir, { recursive: true });
4359
+ const path = join(dir, `${digest}.${EXTENSIONS[image.mediaType]}`);
4360
+ await writeFile(path, data);
4361
+ return path;
4362
+ }
4363
+ /**
4364
+ * The context block a pasted image contributes on a text-only route.
4365
+ *
4366
+ * The same XMLish convention the `!` passthrough uses: the model reads the
4367
+ * path (its tools can open the file), the dimensions, and — when the sidecar
4368
+ * ran — the description standing in for sight.
4369
+ * @param id - the `[Image #N]` number the person's text references.
4370
+ * @param image - the encoded image.
4371
+ * @param at - where the file was saved and what is known about it.
4372
+ * @returns the block text.
4373
+ */
4374
+ function pastedImageBlock(id, image, at) {
4375
+ const size = at.width !== void 0 && at.height !== void 0 ? ` dimensions="${at.width}x${at.height}"` : "";
4376
+ const body = at.description === void 0 ? "" : `\n<description>\n${at.description}\n</description>`;
4377
+ return `<pasted-image id="${id}" media="${image.mediaType}"${size} path="${at.path}">${body}\n</pasted-image>`;
4378
+ }
4379
+ /**
4380
+ * Shrink an image until the attachment store will admit it.
4381
+ *
4382
+ * Retina screenshots exceed the deployed routes' 2000-pixel side limit as a
4383
+ * matter of course, and refusing them would make the feature useless on the
4384
+ * machines most likely to use it. The downscale re-encodes as PNG; a copy
4385
+ * still over the byte limit falls back to JPEG, which is what a photograph
4386
+ * that big actually is.
4387
+ * @param image - the encoded image.
4388
+ * @param limits - the store's admission limits.
4389
+ * @returns the image, downscaled only when it had to be.
4390
+ */
4391
+ async function fitWithinLimits(image, limits) {
4392
+ const data = Buffer.from(image.data, "base64");
4393
+ const { default: sharp } = await import("sharp");
4394
+ const meta = await sharp(data).metadata();
4395
+ if (!(Math.max(meta.width ?? 0, meta.height ?? 0) > limits.maxImageDimension || data.length > limits.maxImageBytes || (meta.width ?? 0) * (meta.height ?? 0) > limits.maxImagePixels)) return image;
4396
+ const png = await sharp(data).resize({
4397
+ width: limits.maxImageDimension,
4398
+ height: limits.maxImageDimension,
4399
+ fit: "inside",
4400
+ withoutEnlargement: true
4401
+ }).png().toBuffer();
4402
+ if (png.length <= limits.maxImageBytes) return {
4403
+ ...image,
4404
+ mediaType: "image/png",
4405
+ data: png.toString("base64")
4406
+ };
4407
+ const jpeg = await sharp(png).jpeg({ quality: 80 }).toBuffer();
4408
+ return {
4409
+ ...image,
4410
+ mediaType: "image/jpeg",
4411
+ data: jpeg.toString("base64")
4412
+ };
4413
+ }
4414
+
3997
4415
  //#endregion
3998
4416
  //#region src/streaming.ts
3999
4417
  /** Accumulates assistant text deltas into rendered lines. */
@@ -4088,6 +4506,32 @@ function visibleText(content) {
4088
4506
  return content.filter((block) => block.type === "text").map((block) => block.text).join("");
4089
4507
  }
4090
4508
  /**
4509
+ * One dim line per image a user message carried, in place of pixels.
4510
+ *
4511
+ * An image block's bytes cannot render here, and a `<pasted-image>` context
4512
+ * block is the pipeline talking to the model — pages of description would
4513
+ * bury the words the person typed. Either becomes a line saying what rode
4514
+ * along and what became of it.
4515
+ * @param content - the message's blocks.
4516
+ * @param theme - styling for the meta lines.
4517
+ * @returns the lines, empty for a text-only message.
4518
+ */
4519
+ function imageMetaLines(content, theme) {
4520
+ const lines = [];
4521
+ for (const block of content) if (block.type === "image") {
4522
+ const { width, height, mediaType } = block.attachment;
4523
+ lines.push(theme.dim(` [image · ${width}×${height} ${mediaType.slice(6)} · sent to the model]`));
4524
+ } else if (block.type === "text" && block.text.startsWith("<pasted-image ")) {
4525
+ const id = /id="(\d+)"/u.exec(block.text)?.[1] ?? "?";
4526
+ const dims = /dimensions="(\d+)x(\d+)"/u.exec(block.text);
4527
+ const media = /media="image\/(\w+)"/u.exec(block.text)?.[1] ?? "image";
4528
+ const size = dims === null ? "" : ` · ${dims[1]}×${dims[2]}`;
4529
+ const fate = block.text.includes("<description>") ? "described" : "saved to file";
4530
+ lines.push(theme.dim(` [image #${id}${size} ${media} · ${fate}]`));
4531
+ }
4532
+ return lines;
4533
+ }
4534
+ /**
4091
4535
  * The left rules the transcript draws down a block's edge, one per kind.
4092
4536
  *
4093
4537
  * A rule is how a segment shows where it starts and ends without a frame or a
@@ -4261,10 +4705,12 @@ var Transcript = class {
4261
4705
  case "user/message": {
4262
4706
  if (event.data.source.kind !== "user") return [];
4263
4707
  this.rule = rules.user;
4264
- const [first = "", ...rest] = visibleText(event.data.content).split("\n");
4708
+ const [first = "", ...rest] = event.data.content.filter((block) => block.type === "text").filter((block) => !block.text.startsWith("<pasted-image ")).map((block) => block.text).join("").split("\n");
4709
+ const meta = imageMetaLines(event.data.content, theme);
4265
4710
  return [
4266
4711
  `${theme.user("›")} ${first}`,
4267
4712
  ...rest.map((line) => ` ${line}`),
4713
+ ...meta,
4268
4714
  ""
4269
4715
  ];
4270
4716
  }
@@ -4656,20 +5102,16 @@ function replay(session, transcript, io, theme) {
4656
5102
  if (summary !== void 0) io.console.foldRecent(lines.length, summary, FOLD_LABELS.answer);
4657
5103
  }
4658
5104
  }
4659
- /**
4660
- * Run one conversation turn and wait for the agent to go idle.
4661
- * @param agent - the live agent.
4662
- * @param text - the person's message.
4663
- * @param working - the indicator to run while the turn does.
4664
- * @param source - the message source; a canned prompt is plugin-sourced so the
4665
- * transcript echoes the command that ran it, not its whole body.
4666
- */
4667
- async function turn(agent, text, working, source = { kind: "user" }) {
5105
+ async function turn(agent, text, working, source = { kind: "user" }, extra) {
4668
5106
  agent.followup(createUserMessage({
4669
- content: [{
4670
- type: "text",
4671
- text
4672
- }],
5107
+ content: [
5108
+ ...extra?.leading ?? [],
5109
+ {
5110
+ type: "text",
5111
+ text
5112
+ },
5113
+ ...extra?.trailing ?? []
5114
+ ],
4673
5115
  source
4674
5116
  }));
4675
5117
  working?.start();
@@ -4688,7 +5130,7 @@ async function turn(agent, text, working, source = { kind: "user" }) {
4688
5130
  * @param theme - styling for the command's report.
4689
5131
  * @param signal - cancels the command when the person interrupts.
4690
5132
  */
4691
- async function runCommand(ctx, agent, line, io, theme, signal) {
5133
+ async function runCommand(ctx, agent, line, io, theme, signal, images = []) {
4692
5134
  const commands = ctx.get("commands");
4693
5135
  if (commands === void 0) {
4694
5136
  io.console.write(theme.error(" commands are unavailable in this composition"));
@@ -4701,7 +5143,7 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
4701
5143
  io.console.write("");
4702
5144
  return;
4703
5145
  }
4704
- const execution = await commands.execute(agent, line, [], signal);
5146
+ const execution = await commands.execute(agent, line, images, signal);
4705
5147
  if (execution === void 0) {
4706
5148
  io.console.write(theme.error(` unknown command: ${line}`));
4707
5149
  return;
@@ -4918,11 +5360,33 @@ async function run(ctx, config, io) {
4918
5360
  }))).flat().map((entry) => ({
4919
5361
  provider: entry.provider,
4920
5362
  id: entry.id,
4921
- name: entry.name
5363
+ name: entry.name,
5364
+ ...entry.inputModalities === void 0 ? {} : { inputModalities: entry.inputModalities }
4922
5365
  }));
4923
5366
  };
4924
5367
  refreshModelCatalog();
4925
5368
  /**
5369
+ * Whether the current route explicitly accepts image input.
5370
+ *
5371
+ * Exact-model metadata, not the advisory catalog, owns this decision. The
5372
+ * catalog is fetched in the background and can be empty or stale while a
5373
+ * saved route is already usable. The same explicit-true test the adapter
5374
+ * applies before throwing UNSUPPORTED_CONTENT remains fail-closed: absent
5375
+ * modalities or a failed resolution get the text fallback, which degrades,
5376
+ * where the block path would crash the turn.
5377
+ */
5378
+ const routeAcceptsImages = async () => {
5379
+ const current = selection.current;
5380
+ if (current === void 0) return false;
5381
+ const llm = ctx.get("llm");
5382
+ if (llm === void 0) return false;
5383
+ try {
5384
+ return (await llm.resolveModelInfo(current.provider, current.model)).inputModalities?.includes("image") === true;
5385
+ } catch {
5386
+ return false;
5387
+ }
5388
+ };
5389
+ /**
4926
5390
  * Resolve a /model argument to a selection.
4927
5391
  * @param typed - a bare model id, or an explicit `provider/model`.
4928
5392
  * @returns the selection, or an error message naming what is available.
@@ -5036,7 +5500,8 @@ async function run(ctx, config, io) {
5036
5500
  },
5037
5501
  expandOutput: () => {
5038
5502
  if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
5039
- }
5503
+ },
5504
+ readClipboardImage: () => readClipboardImage(process.env)
5040
5505
  }, "Ask anything · / for commands · @ for files · ⇧Tab plan mode");
5041
5506
  let turnBaseTokens = 0;
5042
5507
  const spinner = new Spinner({
@@ -5497,13 +5962,70 @@ async function run(ctx, config, io) {
5497
5962
  * than only in the status line.
5498
5963
  * @param text - the person's message.
5499
5964
  */
5500
- const answer = async (text, source) => {
5965
+ /**
5966
+ * Turn pasted images into what this turn's message can carry.
5967
+ *
5968
+ * Three exits, decided by the route. An image-capable model gets the images
5969
+ * as first-class blocks through the durable store — the runtime's own path.
5970
+ * A text-only model gets each image saved as a file plus, when the vision
5971
+ * sidecar is configured, a description standing in for sight; both ride the
5972
+ * same message so they persist for `--resume`. A failure never loses the
5973
+ * turn: it flashes, and the text still goes.
5974
+ * @param images - the submission's pasted images, in token order.
5975
+ * @returns blocks around the text, or undefined when there is nothing extra.
5976
+ */
5977
+ const prepareImages = async (images) => {
5978
+ if (images.length === 0) return void 0;
5979
+ const store = ctx.get("attachments");
5980
+ const limits = store?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
5981
+ if (await routeAcceptsImages() && store !== void 0) try {
5982
+ return {
5983
+ leading: (await admitEncodedImages(store, await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits))))).map((attachment) => ({
5984
+ type: "image",
5985
+ attachment
5986
+ })),
5987
+ trailing: []
5988
+ };
5989
+ } catch (error) {
5990
+ const reason = error instanceof Error ? error.message : String(error);
5991
+ prompt.setFlash(theme.error(truncate(` image dropped: ${reason}`, io.console.columns)));
5992
+ if (!isImageAdmissionError(error)) return void 0;
5993
+ }
5994
+ const vision = visionConfigFromEnv(process.env);
5995
+ const trailing = [];
5996
+ for (const pending of images) {
5997
+ const at = { path: await savePastedImage(pending.image) };
5998
+ if (pending.width !== void 0) at.width = pending.width;
5999
+ if (pending.height !== void 0) at.height = pending.height;
6000
+ if (vision !== void 0) {
6001
+ prompt.setHint(theme.dim(` ✻ describing image #${pending.id} with ${vision.model}…`));
6002
+ try {
6003
+ at.description = await describeImage(await fitWithinLimits(pending.image, limits), vision);
6004
+ } catch (error) {
6005
+ const reason = error instanceof Error ? error.message : String(error);
6006
+ prompt.setFlash(theme.error(truncate(` image #${pending.id}: description failed (${reason}) — attached as file only`, io.console.columns)));
6007
+ } finally {
6008
+ prompt.setHint(void 0);
6009
+ }
6010
+ }
6011
+ trailing.push({
6012
+ type: "text",
6013
+ text: pastedImageBlock(pending.id, pending.image, at)
6014
+ });
6015
+ }
6016
+ return {
6017
+ leading: [],
6018
+ trailing
6019
+ };
6020
+ };
6021
+ const answer = async (text, source, images = []) => {
6022
+ const extra = await prepareImages(images);
5501
6023
  const before = totalTokens(facts(branch).usage) ?? 0;
5502
6024
  turnBaseTokens = before;
5503
6025
  const started = performance.now();
5504
6026
  io.console.setTitle(`⚡ dsh code — ${basename(cwd)}`);
5505
6027
  try {
5506
- await turn(live.agent, text, spinner, source);
6028
+ await turn(live.agent, text, spinner, source, extra);
5507
6029
  } finally {
5508
6030
  io.console.setTitle(`dsh code — ${basename(cwd)}`);
5509
6031
  }
@@ -5581,11 +6103,13 @@ async function run(ctx, config, io) {
5581
6103
  }
5582
6104
  const line = await prompt.read();
5583
6105
  if (line === void 0) break;
6106
+ const images = prompt.takeAttachments();
5584
6107
  io.console.collapseFolds();
5585
6108
  const trimmed = line.trim();
5586
6109
  if (trimmed === "") continue;
5587
6110
  if (trimmed === "/exit" || trimmed === "/quit") break;
5588
6111
  if (trimmed.startsWith("!")) {
6112
+ if (images.length > 0) prompt.setFlash(theme.dim(" images do not ride ! commands — send them with a prompt"));
5589
6113
  const command = trimmed.slice(1).trim();
5590
6114
  if (command !== "") await passthrough(command);
5591
6115
  continue;
@@ -5597,14 +6121,14 @@ async function run(ctx, config, io) {
5597
6121
  await answer(INIT_PROMPT, {
5598
6122
  kind: "plugin",
5599
6123
  plugin: "coding-cli"
5600
- });
6124
+ }, images);
5601
6125
  continue;
5602
6126
  }
5603
6127
  if (name$1 === "ship") {
5604
6128
  await answer(expandTemplate(SHIP_PROMPT, rest.trim()), {
5605
6129
  kind: "plugin",
5606
6130
  plugin: "coding-cli"
5607
- });
6131
+ }, images);
5608
6132
  continue;
5609
6133
  }
5610
6134
  const canned = customByName.get(name$1);
@@ -5612,18 +6136,23 @@ async function run(ctx, config, io) {
5612
6136
  await answer(expandTemplate(canned.template, rest.trim()), {
5613
6137
  kind: "plugin",
5614
6138
  plugin: "coding-cli"
5615
- });
6139
+ }, images);
5616
6140
  continue;
5617
6141
  }
6142
+ let batch = [];
6143
+ if (images.length > 0) if (await routeAcceptsImages()) {
6144
+ const limits = ctx.get("attachments")?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
6145
+ batch = await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits)));
6146
+ } else prompt.setFlash(theme.error(" this model does not accept images with commands — they were dropped"));
5618
6147
  running = new AbortController();
5619
6148
  try {
5620
- await runCommand(ctx, live.agent, trimmed, io, theme, running.signal);
6149
+ await runCommand(ctx, live.agent, trimmed, io, theme, running.signal, batch);
5621
6150
  } finally {
5622
6151
  running = void 0;
5623
6152
  }
5624
6153
  continue;
5625
6154
  }
5626
- await answer(trimmed);
6155
+ await answer(trimmed, void 0, images);
5627
6156
  }
5628
6157
  await sessions.flush(live.agent.session);
5629
6158
  try {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Reading an image off the system clipboard.
3
+ *
4
+ * An image never arrives through the terminal: bracketed paste is text by
5
+ * construction, and a screenshot sitting on the clipboard has no byte channel
6
+ * into stdin at all. So — exactly as Claude Code does — Ctrl+V asks the
7
+ * platform for the clipboard's image directly: `osascript` on macOS,
8
+ * `wl-paste`/`xclip` on Linux, PowerShell on Windows. A machine without the
9
+ * helper, or a clipboard holding text, reads as "no image" rather than an
10
+ * error, the same silent tolerance the clipboard WRITE path has.
11
+ * @module codsh-bundle/src/clipboard-image
12
+ */
13
+ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment/types';
14
+ /** An image read off the clipboard, plus what the flash line wants to say. */
15
+ export interface ClipboardImage {
16
+ /** The raw bytes. */
17
+ data: Buffer;
18
+ /** The type the bytes actually are, sniffed rather than trusted. */
19
+ mediaType: ImageMediaType;
20
+ /** Pixel width, when the header could be read. */
21
+ width?: number;
22
+ /** Pixel height, when the header could be read. */
23
+ height?: number;
24
+ }
25
+ /**
26
+ * What image type these bytes are, from their magic numbers.
27
+ *
28
+ * Sniffed rather than taken from the reader's word: the store verifies the
29
+ * declared type against the decoded bytes and refuses a mismatch, so lying
30
+ * here would only defer the failure to a worse moment.
31
+ * @param data - the bytes.
32
+ * @returns the media type, or undefined for anything that is not an image.
33
+ */
34
+ export declare function sniffImageType(data: Buffer): ImageMediaType | undefined;
35
+ /**
36
+ * The image on the system clipboard, or undefined when it holds none.
37
+ *
38
+ * `CODSH_CLIPBOARD_IMAGE_CMD` overrides the platform reader with a shell
39
+ * command whose stdout is the image bytes — the seam the tests use, exactly
40
+ * as `CODSH_CLIPBOARD=osc52` keeps the write path off the real clipboard.
41
+ * @param env - the environment, for the override and the display probes.
42
+ * @returns the image with its sniffed type and dimensions, or undefined.
43
+ */
44
+ export declare function readClipboardImage(env: Record<string, string | undefined>): Promise<ClipboardImage | undefined>;
@@ -71,6 +71,8 @@ export type Key = {
71
71
  } | {
72
72
  kind: 'paste';
73
73
  text: string;
74
+ } | {
75
+ kind: 'paste-image';
74
76
  } | {
75
77
  kind: 'mouse-down';
76
78
  row: number;
@@ -8,6 +8,8 @@
8
8
  * pipe and draws nothing. Callers ask for the next submission either way.
9
9
  * @module codsh-bundle/src/prompt
10
10
  */
11
+ import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types';
12
+ import type { ClipboardImage } from './clipboard-image.ts';
11
13
  import type { TerminalConsole } from './console.ts';
12
14
  import type { EditorSources } from './editor.ts';
13
15
  import type { SelectOutcome, SelectSpec } from './selector.ts';
@@ -25,6 +27,27 @@ export interface PromptHandlers {
25
27
  shiftTab?(): void;
26
28
  /** Ctrl-O: show the last clipped tool output in full. */
27
29
  expandOutput?(): void;
30
+ /**
31
+ * Ctrl-V: the system clipboard's image, or undefined for none.
32
+ *
33
+ * Injected rather than imported so the pure-module tests can hand the
34
+ * prompt a fixture instead of a machine's clipboard.
35
+ */
36
+ readClipboardImage?(): Promise<ClipboardImage | undefined>;
37
+ }
38
+ /**
39
+ * One pasted image awaiting its submission.
40
+ *
41
+ * The wire form the runtime admits, plus the dimensions the paste already
42
+ * probed — the submission pipeline says them back without re-decoding.
43
+ */
44
+ export interface PendingImage {
45
+ /** The number the `[Image #N]` token wears, for context that names it back. */
46
+ id: number;
47
+ /** Base64 bytes and their sniffed media type. */
48
+ image: EncodedImageAttachment;
49
+ width?: number;
50
+ height?: number;
28
51
  }
29
52
  /** Drives the input box and answers reads and selections. */
30
53
  export declare class Prompt {
@@ -43,6 +66,15 @@ export declare class Prompt {
43
66
  * not be lost; the queue is what a line reader provides for free.
44
67
  */
45
68
  private readonly queued;
69
+ /** Images pasted into the box, by the number their `[Image #N]` token wears. */
70
+ private readonly pendingImages;
71
+ /** Numbers are never reused within a session: a recalled token must not
72
+ * silently pick up a different image. */
73
+ private imageCounter;
74
+ /** The images belonging to the line the last read handed out. */
75
+ private submittedImages;
76
+ /** Whether a clipboard read is already in flight; a second Ctrl+V waits. */
77
+ private pastingImage;
46
78
  /** The working indicator shown under the box. */
47
79
  private hint;
48
80
  /** A short-lived notice that borrows the hint row, e.g. the copy toast. */
@@ -164,6 +196,34 @@ export declare class Prompt {
164
196
  * @param key - the decoded keystroke.
165
197
  */
166
198
  private onKey;
199
+ /**
200
+ * Read the clipboard and attach its image behind an `[Image #N]` token.
201
+ *
202
+ * The read shells out and takes real time, so it runs off the key handler;
203
+ * a second Ctrl+V during it is dropped rather than raced. Numbers count up
204
+ * for the whole session — a token in a recalled line must never quietly
205
+ * name a different image than the one it was minted for.
206
+ */
207
+ private pasteImage;
208
+ /**
209
+ * The images a submitted line actually references, in token order.
210
+ *
211
+ * The tokens are the source of truth: a token the person deleted drops its
212
+ * image, a token duplicated by editing still names one attachment once.
213
+ * Claimed images leave the pending pool, so a token recalled from history
214
+ * later submits as plain text rather than resurrecting consumed bytes.
215
+ * @param text - the submitted line.
216
+ * @returns the referenced images, ready to ride the submission.
217
+ */
218
+ private claimImages;
219
+ /**
220
+ * The images belonging to the line the last read returned.
221
+ *
222
+ * A drain, in the transcript's `take*` idiom: the caller reads the line,
223
+ * then takes its images exactly once.
224
+ * @returns the images in token order, empty for a plain line.
225
+ */
226
+ takeAttachments(): PendingImage[];
167
227
  /**
168
228
  * The todo readout's rows: the one in flight, or the whole list once opened.
169
229
  * @param columns - display columns available.
@@ -3,6 +3,13 @@
3
3
  * from idea to shipped, verified code — a research-grounded interview, a
4
4
  * confirmed spec (gate 1), an approved plan (gate 2), then autonomous landing
5
5
  * until the spec's acceptance criteria pass.
6
+ *
7
+ * The spec FILE is the workflow's memory, not the conversation: the approved
8
+ * plan is written into it, its Status line names the phase, its checkboxes
9
+ * are the progress, and a bare /ship offers to resume whatever it finds
10
+ * unfinished. Conversations get interrupted, compacted, and cleared; the file
11
+ * survives all three, which is what makes the landing reliable rather than
12
+ * merely well-intentioned.
6
13
  */
7
14
  /** The `/ship` prompt body; `$ARGUMENTS` is the typed one-sentence requirement. */
8
- export declare const SHIP_PROMPT = "Run the /ship workflow: take the one-sentence requirement below from idea to shipped, verified code in this repository. The requirement, exactly as typed:\n\n<idea>\n$ARGUMENTS\n</idea>\n\nIf the idea between the <idea> tags is empty, that is not an error: before anything else, ask for the one-sentence requirement with ask_user_question, and use the answer as the idea for the rest of this workflow.\n\nPhase 1 \u2014 grounded interview. Research before you ask: read the repository layout, the docs, and the code paths the idea touches, so every question is informed by what actually exists. Then interrogate the idea with ask_user_question, one focused question per call, never a batch. Cover, as far as they are genuinely open: who this is for and what success looks like, scope and explicit non-goals, constraints (compatibility, performance, security, dependencies), edge cases and failure behavior, and how the result should be verified. Prefer concrete options grounded in what you found over open-ended prompts. Do not ask what inspection can answer \u2014 where code lives or how current behavior works is yours to find out. Stop when answers stop changing the design; do not pad the interview to look thorough.\n\nPhase 2 \u2014 the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion is objectively checkable \u2014 named tests, commands with expected output, observable behavior. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.\n\nPhase 3 \u2014 the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Write no implementation code before this gate passes, and do not use todo_write before it either \u2014 it tracks landing, not the interview.\n\nPhase 4 \u2014 landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, and fix until green before moving on. If it is larger \u2014 four or more substantially independent milestones, or work you expect to exceed what one session can hold \u2014 the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the durable source of truth, instructs each round to read the spec and plan from disk, pick up the next unfinished milestone, implement and test it, and record progress in the workspace, and defines completion as every acceptance criterion in the spec passing.\n\nPhase 5 \u2014 done means verified. The workflow ends only when every acceptance criterion passes with you actually running the named tests and commands and reading their real output. Never report a result you did not run, and never weaken a criterion to make it pass; if one cannot be met, say so plainly and why. When a decision changes mid-flight, update the spec file first so the file on disk stays the truth. Close with a short honest report: what shipped, what was verified and how, and anything left open.\n\nIf the session is in plan mode, the plan-mode rules win: nothing here authorizes writes while it is active. Tell the user this workflow needs to write the spec file and ask them to leave plan mode before continuing past the interview.";
15
+ export declare const SHIP_PROMPT = "Run the /ship workflow: take the one-sentence requirement below from idea to shipped, verified code in this repository. The requirement, exactly as typed:\n\n<idea>\n$ARGUMENTS\n</idea>\n\nIf the idea between the <idea> tags is empty, that is not an error. First look for unfinished work: scan the repository's spec directory (docs/specs/, or the repo's own design-document convention) for a spec whose Status line is not `shipped` \u2014 a bare /ship most likely means \"carry on\", so offer through ask_user_question to resume that spec from the phase its Status names, with everything below applying from that phase onward. Only when there is nothing to resume, ask for the one-sentence requirement with ask_user_question and use the answer as the idea. Images accompanying the command \u2014 [Image #N] tokens, <pasted-image> context, attached image blocks \u2014 are part of the requirement: a mockup or a screenshot is requirements material, so read it and cite what it shows in the interview.\n\nPhase 1 \u2014 grounded interview. Research before you ask: read the repository layout, the docs, and the code paths the idea touches, so every question is informed by what actually exists. Then interrogate the idea with ask_user_question, one focused question per call, never a batch. Cover, as far as they are genuinely open: who this is for and what success looks like, scope and explicit non-goals, constraints (compatibility, performance, security, dependencies), edge cases and failure behavior, and how the result should be verified. Prefer concrete options grounded in what you found over open-ended prompts. Do not ask what inspection can answer \u2014 where code lives or how current behavior works is yours to find out. Stop when answers stop changing the design; do not pad the interview to look thorough.\n\nPhase 2 \u2014 the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion names the exact command that proves it and the output that counts as passing \u2014 the final phase runs those commands verbatim, so a criterion without a command is not finished. Give the file a `Status:` line (interviewing, confirmed, planned, landing, shipped) and keep it current at every phase change: it is what lets an interrupted /ship resume instead of starting over. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.\n\nPhase 3 \u2014 the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Once approved, write the plan into the spec file as a `## Plan` section with one checkbox per milestone \u2014 an approved plan lives on disk, not in a conversation that can be compacted or lost. Then, still before any implementation code, establish the ground: check the working tree is clean (uncommitted unrelated changes are the user's to decide about \u2014 ask), and run the plan's proof commands once, recording the baseline in the spec. A baseline that is already red changes what \"green\" will mean, so surface it here rather than discovering it under your own diff. Write no implementation code before this gate passes, and do not use todo_write before it either \u2014 it tracks landing, not the interview.\n\nPhase 4 \u2014 landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Either way the spec file \u2014 not this conversation \u2014 is the working memory: re-read it before starting each milestone, tick the milestone's checkbox and update Status as you go, and commit after each milestone turns green \u2014 small commits are the progress that survives a crash and the history a reviewer can walk. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, fix until green, then commit before moving on. If it is larger \u2014 four or more substantially independent milestones, or work you expect to exceed what one session can hold \u2014 the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the single source of truth, instructs each round to read the spec from disk (plan, checkboxes, baseline), pick the first unchecked milestone, implement and test it, then commit and tick its checkbox, and defines completion as every acceptance criterion in the spec passing. Bound the loop: budget about three rounds per milestone, and instruct it to stop and report rather than continue past two consecutive rounds that tick nothing.\n\nPhase 5 \u2014 done means verified. The workflow ends only when every acceptance criterion passes with you actually running its named command and reading the real output. After a Ralph loop returns, run every proof command again yourself \u2014 the loop's word is a report, not a verification. Never report a result you did not run, and never weaken a criterion to make it pass; if one cannot be met, say so plainly and why. When a decision changes mid-flight, update the spec file first so the file on disk stays the truth. Set Status to shipped only after that final run, and close with an honest report listing each criterion, the command that proved it, and what it printed \u2014 plus anything left open.\n\nIf the session is in plan mode, the plan-mode rules win: nothing here authorizes writes while it is active. Tell the user this workflow needs to write the spec file and ask them to leave plan mode before continuing past the interview.";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * What a pasted image becomes when the model cannot see.
3
+ *
4
+ * DeepSeek Vision routes receive first-class image blocks before this module
5
+ * is involved. For text-only routes such as Flash and Pro, codsh gives an
6
+ * image two honest lives: it is always saved to a stable file the agent's
7
+ * tools can touch — inspect, commit, embed. And when a vision sidecar is
8
+ * configured (`CODSH_VISION_*`: any OpenAI-compatible multimodal endpoint),
9
+ * the image is also described into text the model can actually read: everything in it
10
+ * transcribed, structure narrated. Both ride the same message the person
11
+ * sent, so they persist in durable history and survive `--resume`.
12
+ * @module codsh-bundle/src/vision
13
+ */
14
+ import type { EncodedImageAttachment, ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment/types';
15
+ /** A vision sidecar: an OpenAI-compatible endpoint that can see. */
16
+ export interface VisionConfig {
17
+ /** The API base, e.g. `https://open.bigmodel.cn/api/paas/v4`. */
18
+ baseUrl: string;
19
+ /** Bearer token; absent for endpoints that need none (local ollama). */
20
+ apiKey?: string;
21
+ /** The multimodal model to ask. */
22
+ model: string;
23
+ }
24
+ /**
25
+ * The sidecar from the environment, or undefined when none is configured.
26
+ * @param env - the process environment.
27
+ * @returns the config when both the base URL and the model are set.
28
+ */
29
+ export declare function visionConfigFromEnv(env: Record<string, string | undefined>): VisionConfig | undefined;
30
+ /**
31
+ * Ask the sidecar what an image shows.
32
+ * @param image - the encoded image.
33
+ * @param config - which endpoint and model to ask.
34
+ * @param signal - cancels the request, on top of the built-in timeout.
35
+ * @returns the description.
36
+ * @throws on timeout, a non-2xx answer, or an answer with no text.
37
+ */
38
+ export declare function describeImage(image: EncodedImageAttachment, config: VisionConfig, signal?: AbortSignal): Promise<string>;
39
+ /**
40
+ * The upstream store's default admission limits, for use when no store is
41
+ * mounted: the sidecar payload is bounded by the same line either way.
42
+ */
43
+ export declare const DEFAULT_IMAGE_LIMITS: ImageAttachmentLimits;
44
+ /**
45
+ * Save a pasted image where the agent's tools can reach it.
46
+ *
47
+ * The original bytes, never a downscaled copy — the person may want the asset
48
+ * itself committed. Content-addressed under the dsh home so a repeated paste
49
+ * dedupes, nothing lands in the workspace uninvited, and the path stays valid
50
+ * for `--resume`.
51
+ * @param image - the encoded image.
52
+ * @returns the absolute path of the saved file.
53
+ */
54
+ export declare function savePastedImage(image: EncodedImageAttachment): Promise<string>;
55
+ /**
56
+ * The context block a pasted image contributes on a text-only route.
57
+ *
58
+ * The same XMLish convention the `!` passthrough uses: the model reads the
59
+ * path (its tools can open the file), the dimensions, and — when the sidecar
60
+ * ran — the description standing in for sight.
61
+ * @param id - the `[Image #N]` number the person's text references.
62
+ * @param image - the encoded image.
63
+ * @param at - where the file was saved and what is known about it.
64
+ * @returns the block text.
65
+ */
66
+ export declare function pastedImageBlock(id: number, image: EncodedImageAttachment, at: {
67
+ path: string;
68
+ width?: number;
69
+ height?: number;
70
+ description?: string;
71
+ }): string;
72
+ /**
73
+ * Shrink an image until the attachment store will admit it.
74
+ *
75
+ * Retina screenshots exceed the deployed routes' 2000-pixel side limit as a
76
+ * matter of course, and refusing them would make the feature useless on the
77
+ * machines most likely to use it. The downscale re-encodes as PNG; a copy
78
+ * still over the byte limit falls back to JPEG, which is what a photograph
79
+ * that big actually is.
80
+ * @param image - the encoded image.
81
+ * @param limits - the store's admission limits.
82
+ * @returns the image, downscaled only when it had to be.
83
+ */
84
+ export declare function fitWithinLimits(image: EncodedImageAttachment, limits: ImageAttachmentLimits): Promise<EncodedImageAttachment>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "codsh-bundle",
3
3
  "description": "The codsh runtime: the interactive TTY surface and code-cli agent preset, installed into a dsh profile. Users install codsh-cli (the launcher) — this package is what it registers.",
4
- "version": "0.5.0",
4
+ "version": "0.6.1",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "main": "lib/index.js",
@@ -38,59 +38,61 @@
38
38
  }
39
39
  },
40
40
  "dependencies": {
41
- "@deepseek-ai/dsh-agent-instructions": "^0.1.0-rc.8",
42
- "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.8",
43
- "@deepseek-ai/dsh-cmdline": "^0.1.0-rc.8",
44
- "@deepseek-ai/dsh-code-runtime-worker-thread": "^0.1.0-rc.8",
45
- "@deepseek-ai/dsh-command-compact": "^0.1.0-rc.8",
46
- "@deepseek-ai/dsh-compaction-basic": "^0.1.0-rc.8",
47
- "@deepseek-ai/dsh-compaction-tool-result-pruner": "^0.1.0-rc.8",
48
- "@deepseek-ai/dsh-lsp": "^0.1.0-rc.8",
49
- "@deepseek-ai/dsh-lsp-stdio": "^0.1.0-rc.8",
50
- "@deepseek-ai/dsh-persona": "^0.1.0-rc.8",
51
- "@deepseek-ai/dsh-plan-mode": "^0.1.0-rc.8",
52
- "@deepseek-ai/dsh-skill-filesystem": "^0.1.0-rc.8",
53
- "@deepseek-ai/dsh-terminal": "^0.1.0-rc.8",
54
- "@deepseek-ai/dsh-terminal-bash": "^0.1.0-rc.8",
55
- "@deepseek-ai/dsh-tool-ask-user": "^0.1.0-rc.8",
56
- "@deepseek-ai/dsh-tool-bash": "^0.1.0-rc.8",
57
- "@deepseek-ai/dsh-tool-fs": "^0.1.0-rc.8",
58
- "@deepseek-ai/dsh-tool-fs-search": "^0.1.0-rc.8",
59
- "@deepseek-ai/dsh-tool-goal": "^0.1.0-rc.8",
60
- "@deepseek-ai/dsh-tool-jobs": "^0.1.0-rc.8",
61
- "@deepseek-ai/dsh-tool-lsp": "^0.1.0-rc.8",
62
- "@deepseek-ai/dsh-tool-pwsh": "^0.1.0-rc.8",
63
- "@deepseek-ai/dsh-tool-ralph": "^0.1.0-rc.8",
64
- "@deepseek-ai/dsh-tool-skill": "^0.1.0-rc.8",
65
- "@deepseek-ai/dsh-tool-subagent": "^0.1.0-rc.8",
66
- "@deepseek-ai/dsh-tool-subagent-control": "^0.1.0-rc.8",
67
- "@deepseek-ai/dsh-tool-terminal": "^0.1.0-rc.8",
68
- "@deepseek-ai/dsh-tool-todo": "^0.1.0-rc.8",
69
- "@deepseek-ai/dsh-tool-web": "^0.1.0-rc.8",
70
- "@deepseek-ai/dsh-tool-workflow": "^0.1.0-rc.8",
71
- "@deepseek-ai/dsh-workflow-worker-thread": "^0.1.0-rc.8",
41
+ "@deepseek-ai/dsh-agent-instructions": "^0.1.1-rc.2",
42
+ "@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
43
+ "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
44
+ "@deepseek-ai/dsh-cmdline": "^0.1.1-rc.2",
45
+ "@deepseek-ai/dsh-code-runtime-worker-thread": "^0.1.1-rc.2",
46
+ "@deepseek-ai/dsh-command-compact": "^0.1.1-rc.2",
47
+ "@deepseek-ai/dsh-compaction-basic": "^0.1.1-rc.2",
48
+ "@deepseek-ai/dsh-compaction-tool-result-pruner": "^0.1.1-rc.2",
49
+ "@deepseek-ai/dsh-lsp": "^0.1.1-rc.2",
50
+ "@deepseek-ai/dsh-lsp-stdio": "^0.1.1-rc.2",
51
+ "@deepseek-ai/dsh-persona": "^0.1.1-rc.2",
52
+ "@deepseek-ai/dsh-plan-mode": "^0.1.1-rc.2",
53
+ "@deepseek-ai/dsh-skill-filesystem": "^0.1.1-rc.2",
54
+ "@deepseek-ai/dsh-terminal": "^0.1.1-rc.2",
55
+ "@deepseek-ai/dsh-terminal-bash": "^0.1.1-rc.2",
56
+ "@deepseek-ai/dsh-tool-ask-user": "^0.1.1-rc.2",
57
+ "@deepseek-ai/dsh-tool-bash": "^0.1.1-rc.2",
58
+ "@deepseek-ai/dsh-tool-fs": "^0.1.1-rc.2",
59
+ "@deepseek-ai/dsh-tool-fs-search": "^0.1.1-rc.2",
60
+ "@deepseek-ai/dsh-tool-goal": "^0.1.1-rc.2",
61
+ "@deepseek-ai/dsh-tool-jobs": "^0.1.1-rc.2",
62
+ "@deepseek-ai/dsh-tool-lsp": "^0.1.1-rc.2",
63
+ "@deepseek-ai/dsh-tool-pwsh": "^0.1.1-rc.2",
64
+ "@deepseek-ai/dsh-tool-ralph": "^0.1.1-rc.2",
65
+ "@deepseek-ai/dsh-tool-skill": "^0.1.1-rc.2",
66
+ "@deepseek-ai/dsh-tool-subagent": "^0.1.1-rc.2",
67
+ "@deepseek-ai/dsh-tool-subagent-control": "^0.1.1-rc.2",
68
+ "@deepseek-ai/dsh-tool-terminal": "^0.1.1-rc.2",
69
+ "@deepseek-ai/dsh-tool-todo": "^0.1.1-rc.2",
70
+ "@deepseek-ai/dsh-tool-web": "^0.1.1-rc.2",
71
+ "@deepseek-ai/dsh-tool-workflow": "^0.1.1-rc.2",
72
+ "@deepseek-ai/dsh-workflow-worker-thread": "^0.1.1-rc.2",
72
73
  "@deepseek-ai/schemastery": "^3.18.1",
73
74
  "commander": "^15.0.0",
74
75
  "diff": "^9.0.0",
76
+ "sharp": "^0.35.3",
75
77
  "string-width": "^8.2.2"
76
78
  },
77
79
  "peerDependencies": {
78
80
  "@deepseek-ai/cordis": "^4.0.1",
79
81
  "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
80
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
81
- "@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.8",
82
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
83
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
84
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
85
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
86
- "@deepseek-ai/dsh-permission-presets": "^0.1.0-rc.8",
87
- "@deepseek-ai/dsh-session": "^0.1.0-rc.8",
88
- "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.8",
89
- "@deepseek-ai/dsh-session-query": "^0.1.0-rc.8",
90
- "@deepseek-ai/dsh-token-meter": "^0.1.0-rc.8",
91
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
92
- "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.8",
93
- "@deepseek-ai/dsh-user-questions": "^0.1.0-rc.8"
82
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
83
+ "@deepseek-ai/dsh-agent-default-model": "^0.1.1-rc.2",
84
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
85
+ "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
86
+ "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
87
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
88
+ "@deepseek-ai/dsh-permission-presets": "^0.1.1-rc.2",
89
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
90
+ "@deepseek-ai/dsh-session-projection": "^0.1.1-rc.2",
91
+ "@deepseek-ai/dsh-session-query": "^0.1.1-rc.2",
92
+ "@deepseek-ai/dsh-token-meter": "^0.1.1-rc.2",
93
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
94
+ "@deepseek-ai/dsh-user-approval": "^0.1.1-rc.2",
95
+ "@deepseek-ai/dsh-user-questions": "^0.1.1-rc.2"
94
96
  },
95
97
  "engines": {
96
98
  "node": ">=22.19"