@deepseek-ai/dsh-api-session-controller 0.1.3-alpha.2 → 0.1.5-alpha.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
@@ -15,6 +15,9 @@ import { assertNever } from "@deepseek-ai/dsh-util-values";
15
15
  import { Deque } from "@deepseek-ai/dsh-deque";
16
16
  import { z as z$1 } from "zod";
17
17
  import { isUserInvocable } from "@deepseek-ai/dsh-skill";
18
+ import { isAbsolute } from "node:path";
19
+ import { FsError } from "@deepseek-ai/dsh-fs";
20
+ import mime from "mime-types";
18
21
  //#region lib/types/agent.js
19
22
  /** Agent activation, composition, and model-selection policy owned by API Session. */
20
23
  var __addDisposableResource$4 = function(env, value, async) {
@@ -349,14 +352,14 @@ var ApiSessionAgentController = class {
349
352
  */
350
353
  async composeAgent(presetId) {
351
354
  const presets = this.ctx.get("agentPresets");
352
- if (presets === void 0) return { setup: (agentCtx) => {
353
- this.installSelection(agentCtx);
355
+ if (presets === void 0) return { setup: (_agentCtx, agent) => {
356
+ this.installSelection(agent);
354
357
  } };
355
358
  const resolvedId = (await presets.resolve(presetId)).id;
356
359
  return {
357
360
  agentPreset: resolvedId,
358
- setup: async (agentCtx) => {
359
- this.installSelection(agentCtx);
361
+ setup: async (agentCtx, agent) => {
362
+ this.installSelection(agent);
360
363
  await presets.mount(agentCtx, resolvedId);
361
364
  }
362
365
  };
@@ -456,9 +459,7 @@ var ApiSessionAgentController = class {
456
459
  model
457
460
  };
458
461
  }
459
- installSelection(agentCtx) {
460
- const agent = agentCtx.agent;
461
- if (agent === void 0) throw new Error("api-session: Agent setup has no scoped Agent");
462
+ installSelection(agent) {
462
463
  this.selectionFor(agent);
463
464
  }
464
465
  /**
@@ -543,6 +544,9 @@ var __disposeResources$3 = (function(SuppressedError) {
543
544
  var e = new Error(message);
544
545
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
545
546
  });
547
+ function hasPromptContent(content) {
548
+ return content.some((part) => part.type !== "text" || part.text.trim().length > 0);
549
+ }
546
550
  /** Implements Session business commands delegated by the Session Controller Remote service. */
547
551
  var SessionCommandController = class {
548
552
  ctx;
@@ -724,11 +728,12 @@ var SessionCommandController = class {
724
728
  }
725
729
  }
726
730
  /**
727
- * Admit one browser prompt after explicit Agent resume and image validation.
731
+ * Reject empty content, then admit one prompt after Agent and attachment validation.
728
732
  * @param request - Session identity, prompt content, source metadata, and delivery mode.
729
733
  * @returns acknowledgement that the Agent accepted the prompt.
730
734
  */
731
735
  async prompt(request) {
736
+ if (!hasPromptContent(request.content)) throw new RemoteError("gateway/bad-request", "prompt content must include non-whitespace text or an attachment", {});
732
737
  const clientTimeZone = request.clientTimeZone === void 0 ? void 0 : canonicalClientTimeZone(request.clientTimeZone);
733
738
  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
739
  const agent = await this.resolveAgent(request.sessionId);
@@ -814,7 +819,10 @@ var SessionCommandController = class {
814
819
  * @returns acknowledgement that the queue mutation was applied.
815
820
  */
816
821
  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" });
822
+ if (request.action.kind === "edit") {
823
+ 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" });
824
+ if (!hasPromptContent(request.action.content)) throw new RemoteError("gateway/bad-request", "queue edit content must include non-whitespace text", {});
825
+ }
818
826
  const agent = this.ctx.agents.get(request.sessionId);
819
827
  if (agent === void 0) throw new RemoteError("session/queue-item-not-found", "queued item is no longer pending", { itemId: request.itemId });
820
828
  if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
@@ -989,9 +997,6 @@ var SessionControlController = class {
989
997
  /** @param ctx - Host context carrying live Agent, projection, and jobs services. */
990
998
  constructor(ctx) {
991
999
  this.ctx = ctx;
992
- ctx.on("session/event", (session, event) => {
993
- this.onSessionEvent(session, event);
994
- });
995
1000
  ctx.sessionProjections.onChanged((session, key, value, seq) => {
996
1001
  this.broadcast({
997
1002
  type: "projection",
@@ -1000,6 +1005,13 @@ var SessionControlController = class {
1000
1005
  value,
1001
1006
  seq
1002
1007
  });
1008
+ if (key !== "inbox") return;
1009
+ if (this.ctx.agents.get(session.id)?.session !== session) return;
1010
+ this.broadcast({
1011
+ type: "queue",
1012
+ sessionId: session.id,
1013
+ items: queueItemsFromInbox(value)
1014
+ });
1003
1015
  });
1004
1016
  ctx.inject(["jobs"], (jobsCtx) => {
1005
1017
  jobsCtx.jobs.onJobsChanged((owner) => {
@@ -1065,16 +1077,6 @@ var SessionControlController = class {
1065
1077
  }
1066
1078
  return blocks;
1067
1079
  }
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
1080
  onJobsChanged(owner) {
1079
1081
  if (owner !== void 0) {
1080
1082
  this.broadcast({
@@ -1139,12 +1141,14 @@ var ControlQueue = class {
1139
1141
  }
1140
1142
  }
1141
1143
  };
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) => ({
1144
+ function queueItems(agent) {
1145
+ return queueItemsFromInbox({
1146
+ "next-turn": agent.inbox.nextTurn,
1147
+ "next-step": agent.inbox.nextStep
1148
+ });
1149
+ }
1150
+ function queueItemsFromInbox(inbox) {
1151
+ return [...inbox["next-turn"].map((message) => ({
1148
1152
  id: message.id,
1149
1153
  placement: "queued",
1150
1154
  ...promptRpcId(message),
@@ -1152,7 +1156,7 @@ function queueItems(agent, splice) {
1152
1156
  id: message.id,
1153
1157
  content: message.content
1154
1158
  }
1155
- })), ...project("next-step").map((message) => ({
1159
+ })), ...inbox["next-step"].map((message) => ({
1156
1160
  id: message.id,
1157
1161
  placement: message.source.kind === "user" ? "steering" : "context",
1158
1162
  ...promptRpcId(message),
@@ -2297,6 +2301,79 @@ let SessionSkillCatalog = (() => {
2297
2301
  };
2298
2302
  })();
2299
2303
  //#endregion
2304
+ //#region lib/types/media-references.js
2305
+ /**
2306
+ * Authenticated GET/HEAD /api/file reads bounded file responses through
2307
+ * the composed filesystem provider. Paths and MIME types do not restrict access;
2308
+ * the connection service authenticates requests before this handler.
2309
+ * @module @deepseek-ai/dsh-api-session-controller/media-references
2310
+ */
2311
+ const BASE_HEADERS = {
2312
+ "Cache-Control": "private, no-store",
2313
+ "X-Content-Type-Options": "nosniff",
2314
+ "Content-Security-Policy": "sandbox; default-src 'none'"
2315
+ };
2316
+ async function serveFile(request, fs, maxBytes) {
2317
+ const fail = (status, text) => new Response(request.method === "HEAD" ? null : text, {
2318
+ status,
2319
+ headers: BASE_HEADERS
2320
+ });
2321
+ const path = new URL(request.url).searchParams.get("path");
2322
+ if (path === null || path.length === 0) return fail(400, "missing path");
2323
+ if (path.includes("\0") || !isAbsolute(path)) return fail(400, "absolute path required");
2324
+ try {
2325
+ const target = await fs.resolve(path, { signal: request.signal });
2326
+ const mediaType = mime.lookup(target.displayPath) || "application/octet-stream";
2327
+ const headers = {
2328
+ ...BASE_HEADERS,
2329
+ "Content-Type": mediaType
2330
+ };
2331
+ if (request.method === "HEAD") {
2332
+ const info = await fs.stat(target, request.signal);
2333
+ if (info === void 0) return fail(404, "not found");
2334
+ if (info.type !== "file") return fail(403, "not a regular file");
2335
+ if (info.size !== void 0) {
2336
+ if (info.size > maxBytes) return fail(413, "file exceeds byte limit");
2337
+ headers["Content-Length"] = String(info.size);
2338
+ }
2339
+ return new Response(null, { headers });
2340
+ }
2341
+ const bytes = await fs.readBytes(target, request.signal, maxBytes);
2342
+ headers["Content-Length"] = String(bytes.byteLength);
2343
+ return new Response(bytes.slice(), { headers });
2344
+ } catch (error) {
2345
+ if (!(error instanceof FsError)) throw error;
2346
+ return fail({
2347
+ FS_NOT_FOUND: 404,
2348
+ FS_NOT_REGULAR_FILE: 403,
2349
+ FS_PERMISSION_DENIED: 403,
2350
+ FS_SANDBOX_DENIED: 403,
2351
+ FS_TOO_LARGE: 413,
2352
+ FS_ABORTED: 499
2353
+ }[error.code] ?? 500, error.code);
2354
+ }
2355
+ }
2356
+ /**
2357
+ * File-display contribution. The connection service supplies authentication;
2358
+ * `ctx.fs` supplies the execution world's paths, reads, and access policy.
2359
+ */
2360
+ const SessionMediaReferences = {
2361
+ inject: [
2362
+ "connection",
2363
+ "fs",
2364
+ "attachments"
2365
+ ],
2366
+ apply(ctx) {
2367
+ const maxBytes = ctx.attachments.imageLimits.maxImageBytes;
2368
+ ctx.effect(() => ctx.connection.fetch.register({
2369
+ path: "/api/file",
2370
+ methods: ["GET", "HEAD"],
2371
+ requestBody: "buffered",
2372
+ fetch: (request) => serveFile(request, ctx.fs, maxBytes)
2373
+ }), "session-controller: /api/file");
2374
+ }
2375
+ };
2376
+ //#endregion
2300
2377
  //#region lib/types/index.js
2301
2378
  /** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
2302
2379
  var __runInitializers = function(thisArg, initializers, value) {
@@ -2664,6 +2741,7 @@ let SessionController = (() => {
2664
2741
  this.openPath = internals.openPath ?? openNativePath;
2665
2742
  this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== void 0 || canOpenNativePath()));
2666
2743
  ctx.plugin(SessionFileReferences);
2744
+ ctx.plugin(SessionMediaReferences);
2667
2745
  ctx.plugin(SessionSkillCatalog);
2668
2746
  ctx.on("session/created", (session) => {
2669
2747
  ctx.emit("api-session/added", this.listState.summaryFor(session));