@deepseek-ai/dsh-api-session-controller 0.1.3-alpha.2 → 0.1.5-alpha.2

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,6 +1,7 @@
1
+ import { hostname } from "node:os";
1
2
  import z from "@deepseek-ai/schemastery";
2
3
  import { AssistantStreamAccumulator, ReasoningEffortId, assistantStreamChunks, createUserMessage, errorChain, freezeMessage } from "@deepseek-ai/dsh-llm";
3
- import { canOpenNativePath, openNativePath } from "@deepseek-ai/dsh-native-command";
4
+ import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from "@deepseek-ai/dsh-native-command";
4
5
  import { Remote, RemoteError, TypertRemoteService, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
5
6
  import { mkdir } from "node:fs/promises";
6
7
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
@@ -15,6 +16,9 @@ import { assertNever } from "@deepseek-ai/dsh-util-values";
15
16
  import { Deque } from "@deepseek-ai/dsh-deque";
16
17
  import { z as z$1 } from "zod";
17
18
  import { isUserInvocable } from "@deepseek-ai/dsh-skill";
19
+ import { isAbsolute } from "node:path";
20
+ import { FsError } from "@deepseek-ai/dsh-fs";
21
+ import mime from "mime-types";
18
22
  //#region lib/types/agent.js
19
23
  /** Agent activation, composition, and model-selection policy owned by API Session. */
20
24
  var __addDisposableResource$4 = function(env, value, async) {
@@ -349,14 +353,14 @@ var ApiSessionAgentController = class {
349
353
  */
350
354
  async composeAgent(presetId) {
351
355
  const presets = this.ctx.get("agentPresets");
352
- if (presets === void 0) return { setup: (agentCtx) => {
353
- this.installSelection(agentCtx);
356
+ if (presets === void 0) return { setup: (_agentCtx, agent) => {
357
+ this.installSelection(agent);
354
358
  } };
355
359
  const resolvedId = (await presets.resolve(presetId)).id;
356
360
  return {
357
361
  agentPreset: resolvedId,
358
- setup: async (agentCtx) => {
359
- this.installSelection(agentCtx);
362
+ setup: async (agentCtx, agent) => {
363
+ this.installSelection(agent);
360
364
  await presets.mount(agentCtx, resolvedId);
361
365
  }
362
366
  };
@@ -456,9 +460,7 @@ var ApiSessionAgentController = class {
456
460
  model
457
461
  };
458
462
  }
459
- installSelection(agentCtx) {
460
- const agent = agentCtx.agent;
461
- if (agent === void 0) throw new Error("api-session: Agent setup has no scoped Agent");
463
+ installSelection(agent) {
462
464
  this.selectionFor(agent);
463
465
  }
464
466
  /**
@@ -543,6 +545,9 @@ var __disposeResources$3 = (function(SuppressedError) {
543
545
  var e = new Error(message);
544
546
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
545
547
  });
548
+ function hasPromptContent(content) {
549
+ return content.some((part) => part.type !== "text" || part.text.trim().length > 0);
550
+ }
546
551
  /** Implements Session business commands delegated by the Session Controller Remote service. */
547
552
  var SessionCommandController = class {
548
553
  ctx;
@@ -724,11 +729,12 @@ var SessionCommandController = class {
724
729
  }
725
730
  }
726
731
  /**
727
- * Admit one browser prompt after explicit Agent resume and image validation.
732
+ * Reject empty content, then admit one prompt after Agent and attachment validation.
728
733
  * @param request - Session identity, prompt content, source metadata, and delivery mode.
729
734
  * @returns acknowledgement that the Agent accepted the prompt.
730
735
  */
731
736
  async prompt(request) {
737
+ if (!hasPromptContent(request.content)) throw new RemoteError("gateway/bad-request", "prompt content must include non-whitespace text or an attachment", {});
732
738
  const clientTimeZone = request.clientTimeZone === void 0 ? void 0 : canonicalClientTimeZone(request.clientTimeZone);
733
739
  if (request.clientTimeZone !== void 0 && clientTimeZone === void 0) throw new RemoteError("session/invalid-time-zone", "clientTimeZone must be UTC or a valid IANA Area/Location name", { value: request.clientTimeZone });
734
740
  const agent = await this.resolveAgent(request.sessionId);
@@ -814,7 +820,10 @@ var SessionCommandController = class {
814
820
  * @returns acknowledgement that the queue mutation was applied.
815
821
  */
816
822
  updateQueue(request) {
817
- if (request.action.kind === "edit" && request.action.content.some((block) => block.type !== "text")) throw new RemoteError("session/attachment-invalid", "queue edits accept text content only", { reason: "QUEUE_EDIT_NON_TEXT" });
823
+ if (request.action.kind === "edit") {
824
+ if (request.action.content.some((block) => block.type !== "text")) throw new RemoteError("session/attachment-invalid", "queue edits accept text content only", { reason: "QUEUE_EDIT_NON_TEXT" });
825
+ if (!hasPromptContent(request.action.content)) throw new RemoteError("gateway/bad-request", "queue edit content must include non-whitespace text", {});
826
+ }
818
827
  const agent = this.ctx.agents.get(request.sessionId);
819
828
  if (agent === void 0) throw new RemoteError("session/queue-item-not-found", "queued item is no longer pending", { itemId: request.itemId });
820
829
  if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
@@ -989,9 +998,6 @@ var SessionControlController = class {
989
998
  /** @param ctx - Host context carrying live Agent, projection, and jobs services. */
990
999
  constructor(ctx) {
991
1000
  this.ctx = ctx;
992
- ctx.on("session/event", (session, event) => {
993
- this.onSessionEvent(session, event);
994
- });
995
1001
  ctx.sessionProjections.onChanged((session, key, value, seq) => {
996
1002
  this.broadcast({
997
1003
  type: "projection",
@@ -1000,6 +1006,13 @@ var SessionControlController = class {
1000
1006
  value,
1001
1007
  seq
1002
1008
  });
1009
+ if (key !== "inbox") return;
1010
+ if (this.ctx.agents.get(session.id)?.session !== session) return;
1011
+ this.broadcast({
1012
+ type: "queue",
1013
+ sessionId: session.id,
1014
+ items: queueItemsFromInbox(value)
1015
+ });
1003
1016
  });
1004
1017
  ctx.inject(["jobs"], (jobsCtx) => {
1005
1018
  jobsCtx.jobs.onJobsChanged((owner) => {
@@ -1065,16 +1078,6 @@ var SessionControlController = class {
1065
1078
  }
1066
1079
  return blocks;
1067
1080
  }
1068
- onSessionEvent(session, event) {
1069
- if (event.type !== "agent/inbox/spliced") return;
1070
- const agent = this.ctx.agents.get(session.id);
1071
- if (agent?.session !== session) return;
1072
- this.broadcast({
1073
- type: "queue",
1074
- sessionId: session.id,
1075
- items: queueItems(agent, event.data)
1076
- });
1077
- }
1078
1081
  onJobsChanged(owner) {
1079
1082
  if (owner !== void 0) {
1080
1083
  this.broadcast({
@@ -1139,12 +1142,14 @@ var ControlQueue = class {
1139
1142
  }
1140
1143
  }
1141
1144
  };
1142
- function queueItems(agent, splice) {
1143
- const project = (target) => {
1144
- const messages = target === "next-turn" ? agent.inbox.nextTurn : agent.inbox.nextStep;
1145
- return splice?.target === target ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted) : messages;
1146
- };
1147
- return [...project("next-turn").map((message) => ({
1145
+ function queueItems(agent) {
1146
+ return queueItemsFromInbox({
1147
+ "next-turn": agent.inbox.nextTurn,
1148
+ "next-step": agent.inbox.nextStep
1149
+ });
1150
+ }
1151
+ function queueItemsFromInbox(inbox) {
1152
+ return [...inbox["next-turn"].map((message) => ({
1148
1153
  id: message.id,
1149
1154
  placement: "queued",
1150
1155
  ...promptRpcId(message),
@@ -1152,7 +1157,7 @@ function queueItems(agent, splice) {
1152
1157
  id: message.id,
1153
1158
  content: message.content
1154
1159
  }
1155
- })), ...project("next-step").map((message) => ({
1160
+ })), ...inbox["next-step"].map((message) => ({
1156
1161
  id: message.id,
1157
1162
  placement: message.source.kind === "user" ? "steering" : "context",
1158
1163
  ...promptRpcId(message),
@@ -2297,6 +2302,79 @@ let SessionSkillCatalog = (() => {
2297
2302
  };
2298
2303
  })();
2299
2304
  //#endregion
2305
+ //#region lib/types/media-references.js
2306
+ /**
2307
+ * Authenticated GET/HEAD /api/file reads bounded file responses through
2308
+ * the composed filesystem provider. Paths and MIME types do not restrict access;
2309
+ * the connection service authenticates requests before this handler.
2310
+ * @module @deepseek-ai/dsh-api-session-controller/media-references
2311
+ */
2312
+ const BASE_HEADERS = {
2313
+ "Cache-Control": "private, no-store",
2314
+ "X-Content-Type-Options": "nosniff",
2315
+ "Content-Security-Policy": "sandbox; default-src 'none'"
2316
+ };
2317
+ async function serveFile(request, fs, maxBytes) {
2318
+ const fail = (status, text) => new Response(request.method === "HEAD" ? null : text, {
2319
+ status,
2320
+ headers: BASE_HEADERS
2321
+ });
2322
+ const path = new URL(request.url).searchParams.get("path");
2323
+ if (path === null || path.length === 0) return fail(400, "missing path");
2324
+ if (path.includes("\0") || !isAbsolute(path)) return fail(400, "absolute path required");
2325
+ try {
2326
+ const target = await fs.resolve(path, { signal: request.signal });
2327
+ const mediaType = mime.lookup(target.displayPath) || "application/octet-stream";
2328
+ const headers = {
2329
+ ...BASE_HEADERS,
2330
+ "Content-Type": mediaType
2331
+ };
2332
+ if (request.method === "HEAD") {
2333
+ const info = await fs.stat(target, request.signal);
2334
+ if (info === void 0) return fail(404, "not found");
2335
+ if (info.type !== "file") return fail(403, "not a regular file");
2336
+ if (info.size !== void 0) {
2337
+ if (info.size > maxBytes) return fail(413, "file exceeds byte limit");
2338
+ headers["Content-Length"] = String(info.size);
2339
+ }
2340
+ return new Response(null, { headers });
2341
+ }
2342
+ const bytes = await fs.readBytes(target, request.signal, maxBytes);
2343
+ headers["Content-Length"] = String(bytes.byteLength);
2344
+ return new Response(bytes.slice(), { headers });
2345
+ } catch (error) {
2346
+ if (!(error instanceof FsError)) throw error;
2347
+ return fail({
2348
+ FS_NOT_FOUND: 404,
2349
+ FS_NOT_REGULAR_FILE: 403,
2350
+ FS_PERMISSION_DENIED: 403,
2351
+ FS_SANDBOX_DENIED: 403,
2352
+ FS_TOO_LARGE: 413,
2353
+ FS_ABORTED: 499
2354
+ }[error.code] ?? 500, error.code);
2355
+ }
2356
+ }
2357
+ /**
2358
+ * File-display contribution. The connection service supplies authentication;
2359
+ * `ctx.fs` supplies the execution world's paths, reads, and access policy.
2360
+ */
2361
+ const SessionMediaReferences = {
2362
+ inject: [
2363
+ "connection",
2364
+ "fs",
2365
+ "attachments"
2366
+ ],
2367
+ apply(ctx) {
2368
+ const maxBytes = ctx.attachments.imageLimits.maxImageBytes;
2369
+ ctx.effect(() => ctx.connection.fetch.register({
2370
+ path: "/api/file",
2371
+ methods: ["GET", "HEAD"],
2372
+ requestBody: "buffered",
2373
+ fetch: (request) => serveFile(request, ctx.fs, maxBytes)
2374
+ }), "session-controller: /api/file");
2375
+ }
2376
+ };
2377
+ //#endregion
2300
2378
  //#region lib/types/index.js
2301
2379
  /** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
2302
2380
  var __runInitializers = function(thisArg, initializers, value) {
@@ -2636,6 +2714,7 @@ let SessionController = (() => {
2636
2714
  history;
2637
2715
  listState;
2638
2716
  openPath;
2717
+ revealPath;
2639
2718
  canOpenPath;
2640
2719
  promotions = /* @__PURE__ */ new Set();
2641
2720
  /**
@@ -2662,8 +2741,10 @@ let SessionController = (() => {
2662
2741
  });
2663
2742
  this.listState = new ApiSessionList(ctx);
2664
2743
  this.openPath = internals.openPath ?? openNativePath;
2744
+ this.revealPath = internals.revealPath ?? revealNativePath;
2665
2745
  this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== void 0 || canOpenNativePath()));
2666
2746
  ctx.plugin(SessionFileReferences);
2747
+ ctx.plugin(SessionMediaReferences);
2667
2748
  ctx.plugin(SessionSkillCatalog);
2668
2749
  ctx.on("session/created", (session) => {
2669
2750
  ctx.emit("api-session/added", this.listState.summaryFor(session));
@@ -2784,6 +2865,18 @@ let SessionController = (() => {
2784
2865
  return this.canOpenPath();
2785
2866
  }
2786
2867
  /**
2868
+ * Describe the serving desktop for authenticated file-action routes.
2869
+ * @returns Host name, configured availability, and platform-specific file-manager behavior.
2870
+ */
2871
+ workspaceDesktop() {
2872
+ const fileManager = nativeFileManager();
2873
+ return {
2874
+ name: hostname(),
2875
+ available: fileManager !== null && this.canOpenPath(),
2876
+ fileManager
2877
+ };
2878
+ }
2879
+ /**
2787
2880
  * Open one path prepared by a Session-aware caller on the Host desktop.
2788
2881
  * @param request - path after best-effort Session workspace resolution.
2789
2882
  * @param signal - caller lifetime; abort terminates the native command.
@@ -2794,7 +2887,8 @@ let SessionController = (() => {
2794
2887
  if (request.path.length === 0) throw new RemoteError("gateway/bad-request", "session.openWorkspacePath requires a non-empty path", {});
2795
2888
  signal.throwIfAborted();
2796
2889
  try {
2797
- await this.openPath(request.path, signal);
2890
+ if (request.action === "reveal") await this.revealPath(request.path, signal);
2891
+ else await this.openPath(request.path, signal);
2798
2892
  return { opened: true };
2799
2893
  } catch (error) {
2800
2894
  if (signal.aborted) throw new RemoteError("gateway/cancelled", "path open was aborted", {});