@sparkelf/dsh-group-chat 0.5.0 → 0.5.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
@@ -601,6 +601,173 @@ const textOfContent = (content) => {
601
601
  if (!Array.isArray(content)) return "";
602
602
  return content.filter((block) => typeof block === "object" && block !== null).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("");
603
603
  };
604
+ //#endregion
605
+ //#region src/session-id.ts
606
+ /**
607
+ * The room/session binding, shared by both halves of the plugin.
608
+ *
609
+ * The browser half needs it to open a room's Session; the host half needs it to create and
610
+ * recognize one. It lives in its own module because the two halves are built for different
611
+ * runtimes: importing it from the host entry would drag the whole server side into the client
612
+ * bundle, which the build's platform guard refuses — correctly, since none of it can run there.
613
+ *
614
+ * @module @sparkelf/dsh-group-chat/session-id
615
+ */
616
+ /**
617
+ * Every room session id starts with this, so a room's session is recognizable after a restart
618
+ * and an ordinary session never matches.
619
+ *
620
+ * The binding lives in the id rather than in `SessionHeader` because the header carries no
621
+ * free-form metadata field.
622
+ */
623
+ const ROOM_SESSION_PREFIX = "group-room-";
624
+ /** Deterministic session id for a room. */
625
+ const roomSessionId = (roomId) => `${ROOM_SESSION_PREFIX}${roomId}`;
626
+ /** Reads the room a session is bound to, or `undefined` for an ordinary session. */
627
+ const roomIdOfSession = (session) => {
628
+ if (typeof session !== "object" || session === null) return void 0;
629
+ const id = session.id;
630
+ if (typeof id !== "string" || !id.startsWith("group-room-")) return void 0;
631
+ const roomId = id.slice(11);
632
+ return roomId.length > 0 ? roomId : void 0;
633
+ };
634
+ //#endregion
635
+ //#region src/session-maintenance.ts
636
+ /**
637
+ * Maintenance for the sessions a room owns in one workspace.
638
+ *
639
+ * A room has exactly one canonical Session, addressed by {@link roomSessionId}. Before
640
+ * this module existed, a host restart could instead leave behind throwaway
641
+ * `session-<uuid>` records in the room's project directory: the controller's
642
+ * `create({ sessionId })` mints a new Session whenever the named one has already
643
+ * started a turn, and the room code treated that minted identity as a failed open and
644
+ * retried on every discovery poll. The result was thousands of idle Session
645
+ * directories, all with the same `cwd`, which slowed every `session/list`.
646
+ *
647
+ * The cleanup endpoint therefore works on the *physical* project directory rather
648
+ * than on a plugin-owned index: legacy garbage was never indexed, and a workspace
649
+ * upgraded from an affected build must be able to find it. Candidate selection is
650
+ * intentionally narrow — only minted `session-<uuid>` records, only in the named
651
+ * directory, never the canonical room Session, never another room's Session, and
652
+ * never a subagent child. A cleanup run cannot delete an ordinary named Session or a
653
+ * conversation someone deliberately created.
654
+ *
655
+ * @module @sparkelf/dsh-group-chat/session-maintenance
656
+ */
657
+ /**
658
+ * A Session identity minted by the controller for a conversation that asked for a
659
+ * different named id. Only this shape is ever a cleanup candidate.
660
+ */
661
+ const isMintedSessionId = (sessionId) => /^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(sessionId);
662
+ /** Whether a stored Session is a throwaway record this room's cleanup may touch. */
663
+ const isRoomSubSession = (record, directory, primarySessionId, protectedSessionIds = /* @__PURE__ */ new Set()) => {
664
+ const header = record.header;
665
+ if (header.cwd !== directory) return false;
666
+ if (header.id === primarySessionId) return false;
667
+ if (protectedSessionIds.has(header.id)) return false;
668
+ if (header.id.startsWith("group-room-")) return false;
669
+ if (header.origin === "subagent" || header.parentSession !== void 0) return false;
670
+ return isMintedSessionId(header.id);
671
+ };
672
+ /**
673
+ * The JSONL backend's readable project directory name.
674
+ *
675
+ * Mirrors the storage layout documented by `dsh-session-persistence-jsonl`: separators
676
+ * collapse to one `-`, unsafe code units use `~XXXX`, and the result is bounded for
677
+ * filesystem component limits. The plugin needs the physical directory only to purge;
678
+ * a backend whose layout differs must keep its `root` unset so purge is refused.
679
+ */
680
+ const roomProjectKey = (cwd) => {
681
+ if (cwd.length === 0) throw new Error("cannot encode an empty project path");
682
+ let readable = "";
683
+ let separatorRun = false;
684
+ for (let index = 0; index < cwd.length; index += 1) {
685
+ const code = cwd.charCodeAt(index);
686
+ const character = String.fromCharCode(code);
687
+ if (character === "/" || character === "\\" || character === ":") {
688
+ if (!separatorRun) readable += "-";
689
+ separatorRun = true;
690
+ } else if (character !== "~" && /^[A-Za-z0-9._-]$/u.test(character)) {
691
+ readable += character;
692
+ separatorRun = false;
693
+ } else {
694
+ readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
695
+ separatorRun = false;
696
+ }
697
+ }
698
+ return `--${(readable.replace(/^-+/u, "") || "root").slice(0, 251)}--`;
699
+ };
700
+ /** The JSONL backend's escaped single path segment. */
701
+ const sessionIdSegment = (sessionId) => {
702
+ if (sessionId.length === 0) throw new Error("cannot encode an empty session id");
703
+ if (sessionId === ".") return "~002E";
704
+ if (sessionId === "..") return "~002E~002E";
705
+ let out = "";
706
+ for (let index = 0; index < sessionId.length; index += 1) {
707
+ const code = sessionId.charCodeAt(index);
708
+ const character = String.fromCharCode(code);
709
+ if (character !== "~" && /^[A-Za-z0-9._-]$/u.test(character)) out += character;
710
+ else out += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
711
+ }
712
+ return out;
713
+ };
714
+ /** The physical directory one stored Session owns, validated to stay under `root`. */
715
+ const roomSessionDirectory = (root, directory, sessionId) => {
716
+ const rootPath = resolve(root);
717
+ const target = resolve(join(rootPath, roomProjectKey(directory), sessionIdSegment(sessionId)));
718
+ if (target === rootPath || !target.startsWith(`${rootPath}${sep}`)) throw new Error(`session directory escapes the configured root: ${sessionId}`);
719
+ return target;
720
+ };
721
+ /**
722
+ * Find and, when asked, archive or delete a room's throwaway Sessions.
723
+ *
724
+ * `dryRun` defaults to true and `purge` to false, so the safe call reports before it
725
+ * changes anything. An archive run writes DSH's official archive marker, which removes
726
+ * the Sessions from grouping surfaces without touching their files; a purge run removes
727
+ * the physical directories and leaves the canonical room Session alone.
728
+ */
729
+ const cleanupRoomSessions = async (input) => {
730
+ const dryRun = input.dryRun !== false;
731
+ const purge = !dryRun && input.purge === true;
732
+ const mode = dryRun ? "report" : purge ? "purge" : "archive";
733
+ if (purge && (input.persistence.root === void 0 || input.persistence.root.length === 0)) throw new Error("the session backend exposes no physical root; purge is unavailable");
734
+ const protectedIds = new Set(input.protectedSessionIds ?? []);
735
+ const records = await input.persistence.list();
736
+ const candidates = records.filter((record) => isRoomSubSession(record, input.directory, input.primarySessionId, protectedIds)).map((record) => record.header.id);
737
+ if (mode === "archive" && input.archive === void 0) throw new Error("the workspace registry exposes no archive marker; archive is unavailable");
738
+ const archived = [];
739
+ const removed = [];
740
+ const errors = [];
741
+ if (mode !== "report") for (const sessionId of candidates) try {
742
+ if (mode === "archive") {
743
+ await input.archive?.archiveSession(sessionId, { stopActivity: true });
744
+ archived.push(sessionId);
745
+ continue;
746
+ }
747
+ const target = roomSessionDirectory(input.persistence.root, input.directory, sessionId);
748
+ await rm(target, {
749
+ recursive: true,
750
+ force: true
751
+ });
752
+ removed.push(sessionId);
753
+ } catch (error) {
754
+ errors.push({
755
+ sessionId,
756
+ message: error instanceof Error ? error.message : String(error)
757
+ });
758
+ }
759
+ return {
760
+ roomId: input.roomId,
761
+ directory: input.directory,
762
+ primarySessionId: input.primarySessionId,
763
+ mode,
764
+ scanned: records.length,
765
+ candidates,
766
+ archived,
767
+ removed,
768
+ errors
769
+ };
770
+ };
604
771
  /**
605
772
  * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object.
606
773
  *
@@ -8841,36 +9008,6 @@ const openRoomStream = (options) => {
8841
9008
  };
8842
9009
  };
8843
9010
  //#endregion
8844
- //#region src/session-id.ts
8845
- /**
8846
- * The room/session binding, shared by both halves of the plugin.
8847
- *
8848
- * The browser half needs it to open a room's Session; the host half needs it to create and
8849
- * recognize one. It lives in its own module because the two halves are built for different
8850
- * runtimes: importing it from the host entry would drag the whole server side into the client
8851
- * bundle, which the build's platform guard refuses — correctly, since none of it can run there.
8852
- *
8853
- * @module @sparkelf/dsh-group-chat/session-id
8854
- */
8855
- /**
8856
- * Every room session id starts with this, so a room's session is recognizable after a restart
8857
- * and an ordinary session never matches.
8858
- *
8859
- * The binding lives in the id rather than in `SessionHeader` because the header carries no
8860
- * free-form metadata field.
8861
- */
8862
- const ROOM_SESSION_PREFIX = "group-room-";
8863
- /** Deterministic session id for a room. */
8864
- const roomSessionId = (roomId) => `${ROOM_SESSION_PREFIX}${roomId}`;
8865
- /** Reads the room a session is bound to, or `undefined` for an ordinary session. */
8866
- const roomIdOfSession = (session) => {
8867
- if (typeof session !== "object" || session === null) return void 0;
8868
- const id = session.id;
8869
- if (typeof id !== "string" || !id.startsWith("group-room-")) return void 0;
8870
- const roomId = id.slice(11);
8871
- return roomId.length > 0 ? roomId : void 0;
8872
- };
8873
- //#endregion
8874
9011
  //#region src/index.ts
8875
9012
  const name = "group-chat";
8876
9013
  /**
@@ -8987,8 +9124,99 @@ const modelKeyOf = (room) => [
8987
9124
  room.model ?? "",
8988
9125
  room.reasoningEffort ?? ""
8989
9126
  ].join("|");
9127
+ /** Whether two selections address the same route and effort. */
9128
+ const sameModelSelection = (left, right) => left.provider === right.provider && left.model === right.model && (left.reasoningEffort ?? "") === (right.reasoningEffort ?? "");
9129
+ /**
9130
+ * Apply a room's model without changing the user's deployment default.
9131
+ *
9132
+ * `sessionController.selectModel` saves the selection as `agent-default-model` in the
9133
+ * background. That is right for a user choosing in the composer, but a room applying
9134
+ * its stored model (especially on startup) would otherwise overwrite the user's last
9135
+ * explicit choice. Capture the default first and restore it after the background save
9136
+ * has had a chance to land.
9137
+ */
9138
+ const selectRoomModel = async (ctx, request) => {
9139
+ const defaultModel = ctx.agentDefaultModel;
9140
+ const before = defaultModel?.currentSelection();
9141
+ const changesDefault = before !== void 0 && !sameModelSelection(before, {
9142
+ provider: request.provider,
9143
+ model: request.model,
9144
+ ...request.reasoningEffort === void 0 ? {} : { reasoningEffort: request.reasoningEffort }
9145
+ });
9146
+ await ctx.sessionController.selectModel(request);
9147
+ if (!changesDefault || before === void 0 || defaultModel === void 0) return;
9148
+ await new Promise((resolve) => setTimeout(resolve, 60));
9149
+ try {
9150
+ await defaultModel.saveSelection(before);
9151
+ } catch {}
9152
+ };
9153
+ /** Whether a controller failure is the precise "this session is not stored" answer. */
9154
+ const isSessionNotFound = (error) => typeof error === "object" && error !== null && error.code === "session/not-found";
9155
+ /**
9156
+ * Read an optional host service without making it a required injection.
9157
+ *
9158
+ * The direct property is the usual path on a fully composed host. `ctx.get` is the fallback
9159
+ * for a host that exposes a lazily mounted service as a registry entry only; tests and
9160
+ * minimal hosts may provide neither, which must read as "service unavailable" rather than
9161
+ * as a crash while opening a room.
9162
+ */
9163
+ const hostService = (ctx, key) => {
9164
+ try {
9165
+ const direct = ctx[key];
9166
+ if (direct !== void 0) return direct;
9167
+ } catch {}
9168
+ const get = ctx.get;
9169
+ if (typeof get !== "function") return void 0;
9170
+ try {
9171
+ return get.call(ctx, key);
9172
+ } catch {
9173
+ return;
9174
+ }
9175
+ };
9176
+ /**
9177
+ * The model route a Session's own log records, or `''` when it has never selected one.
9178
+ *
9179
+ * A resolved room Session keeps the selection it was created with; a restarted host must
9180
+ * not silently leave it on a room whose configured model has since changed. Reading the
9181
+ * last `model/selection` event avoids a write on every open.
9182
+ */
9183
+ const sessionModelKey = (session) => {
9184
+ const events = session.snapshotEvents();
9185
+ for (let index = events.length - 1; index >= 0; index -= 1) {
9186
+ const event = events[index];
9187
+ if (event?.type !== "model/selection") continue;
9188
+ if (typeof event.data?.provider !== "string" || typeof event.data?.model !== "string") continue;
9189
+ return [
9190
+ event.data.provider,
9191
+ event.data.model,
9192
+ typeof event.data.reasoningEffort === "string" ? event.data.reasoningEffort : ""
9193
+ ].join("|");
9194
+ }
9195
+ return "";
9196
+ };
9197
+ /**
9198
+ * Resolves the DSH session backing one room.
9199
+ *
9200
+ * A room has exactly one Session, addressed by {@link roomSessionId}. Opening it must
9201
+ * therefore be a resolve-or-create, never a create alone:
9202
+ *
9203
+ * - `sessionController.create({ sessionId })` reuses the named Session only while it is
9204
+ * **blank**. A room that has ever run a turn — every room does, because of the warm-up
9205
+ * turn — is no longer blank, so the controller mints a fresh `session-<uuid>` in the
9206
+ * same cwd instead of returning the room's Session.
9207
+ * - Checking only `ctx.sessions.get()` misses a persisted Session after a host restart,
9208
+ * because the registry is process-local. The first open would then mint a replacement;
9209
+ * the plugin's lookup by the deterministic id still returns nothing, the open fails, and
9210
+ * the three-second discovery poll repeats the mint forever. That is how one room grew to
9211
+ * 16,004 Session directories.
9212
+ *
9213
+ * Resolving first also fixes the archived case: it loads the real Session and lets the
9214
+ * room unarchive it before running a turn, instead of creating an unrelated replacement.
9215
+ */
8990
9216
  var RoomSessions = class {
8991
9217
  ctx;
9218
+ /** One in-flight open per room, so two discovery passes cannot race the same identity. */
9219
+ pending = /* @__PURE__ */ new Map();
8992
9220
  constructor(ctx) {
8993
9221
  this.ctx = ctx;
8994
9222
  }
@@ -8996,36 +9224,105 @@ var RoomSessions = class {
8996
9224
  return this.ctx.sessions.get(roomSessionId(roomId));
8997
9225
  }
8998
9226
  /**
8999
- * Open a room's session, creating it through the controller on first use.
9227
+ * Open a room's persisted Session, or create it on the one occasion it is absent.
9000
9228
  *
9001
- * Creation goes through the controller rather than the session registry because only
9002
- * the controller persists the session and binds its agent. A registry-only session is
9003
- * an in-memory handle that cannot be resumed, so nothing would ever run in the room.
9229
+ * @param roomId - the room the session belongs to.
9230
+ * @param room - the room's configured scope and model.
9231
+ * @returns the canonical room Session, with an agent bound to it.
9004
9232
  */
9005
9233
  async ensure(roomId, room) {
9006
9234
  const existing = this.existing(roomId);
9007
9235
  if (existing !== void 0) return existing;
9236
+ const pending = this.pending.get(roomId);
9237
+ if (pending !== void 0) return pending;
9238
+ const opening = this.open(roomId, room).finally(() => {
9239
+ if (this.pending.get(roomId) === opening) this.pending.delete(roomId);
9240
+ });
9241
+ this.pending.set(roomId, opening);
9242
+ return opening;
9243
+ }
9244
+ async open(roomId, room) {
9008
9245
  const sessionId = roomSessionId(roomId);
9009
9246
  const directory = resolveRoomDirectory(roomId, room.cwd);
9010
9247
  await mkdir(directory, { recursive: true });
9011
- await this.ctx.sessionController.create({
9248
+ const resumed = await this.resolvePersisted(sessionId, directory);
9249
+ if (resumed !== void 0) {
9250
+ await this.installRoomModel(resumed, room, false);
9251
+ return resumed;
9252
+ }
9253
+ const created = await this.ctx.sessionController.create({
9012
9254
  sessionId,
9013
9255
  cwd: directory,
9014
9256
  ...room.agentPreset === void 0 ? {} : { agentPreset: room.agentPreset }
9015
9257
  });
9016
- if (room.modelProvider !== void 0 && room.model !== void 0) await this.ctx.sessionController.selectModel({
9017
- sessionId,
9018
- provider: room.modelProvider,
9258
+ if (created.sessionId !== sessionId) {
9259
+ await this.archiveReplacement(created.sessionId);
9260
+ throw new Error(`group-chat asked for room session ${sessionId} but the controller opened ${created.sessionId}`);
9261
+ }
9262
+ const session = this.existing(roomId);
9263
+ if (session === void 0) throw new Error(`group-chat could not open a session for room ${roomId}`);
9264
+ await this.installRoomModel(session, room, true);
9265
+ return session;
9266
+ }
9267
+ /** Load a persisted room Session, or `undefined` only when the controller denies it exists. */
9268
+ async resolvePersisted(sessionId, directory) {
9269
+ let resolved;
9270
+ try {
9271
+ resolved = await this.ctx.sessionController.resolveAgent(sessionId);
9272
+ } catch (error) {
9273
+ if (isSessionNotFound(error)) return void 0;
9274
+ throw error;
9275
+ }
9276
+ if ("agent" in resolved) {
9277
+ const session = resolved.agent.session;
9278
+ if (session.id !== sessionId) throw new Error(`group-chat resolved room session ${sessionId} as ${session.id}`);
9279
+ if (session.header.cwd !== void 0 && session.header.cwd !== directory) this.ctx.logger?.warn?.("[group-chat] room session directory differs from the room configuration; keeping the existing history", sessionId, session.header.cwd, directory);
9280
+ await this.unarchiveRoomSession(sessionId);
9281
+ return session;
9282
+ }
9283
+ if (isSessionNotFound(resolved.error)) return void 0;
9284
+ throw new Error(`group-chat could not resolve room session ${sessionId}: ${resolved.error instanceof Error ? resolved.error.message : String(resolved.error)}`);
9285
+ }
9286
+ /** Install the room's model when the Session's log does not already carry it. */
9287
+ async installRoomModel(session, room, strict) {
9288
+ if (room.modelProvider === void 0 || room.model === void 0) return;
9289
+ const target = {
9290
+ modelProvider: room.modelProvider,
9019
9291
  model: room.model,
9020
9292
  ...room.reasoningEffort === void 0 ? {} : { reasoningEffort: room.reasoningEffort }
9021
- });
9022
- const created = this.existing(roomId);
9023
- if (created === void 0) throw new Error(`group-chat could not open a session for room ${roomId}`);
9024
- return created;
9293
+ };
9294
+ if (sessionModelKey(session) === modelKeyOf(target)) return;
9295
+ try {
9296
+ await selectRoomModel(this.ctx, {
9297
+ sessionId: session.id,
9298
+ provider: room.modelProvider,
9299
+ model: room.model,
9300
+ ...room.reasoningEffort === void 0 ? {} : { reasoningEffort: room.reasoningEffort }
9301
+ });
9302
+ } catch (error) {
9303
+ if (strict) throw error;
9304
+ this.ctx.logger?.warn?.("[group-chat] keeping the room session on its existing model", session.id, error);
9305
+ }
9306
+ }
9307
+ /** The workspace registry's archive surface, looked up lazily because it is optional. */
9308
+ get archiveRegistry() {
9309
+ return hostService(this.ctx, "workspaceRegistry");
9310
+ }
9311
+ /** Hide a replacement the controller minted instead of honoring the room id. */
9312
+ async archiveReplacement(sessionId) {
9313
+ try {
9314
+ await this.archiveRegistry?.archiveSession(sessionId, { stopActivity: true });
9315
+ } catch {}
9316
+ }
9317
+ /** Lift an archive marker from the room's canonical Session before its first turn. */
9318
+ async unarchiveRoomSession(sessionId) {
9319
+ try {
9320
+ await this.archiveRegistry?.unarchiveSession?.(sessionId);
9321
+ } catch {}
9025
9322
  }
9026
9323
  };
9027
9324
  /** The height bounds the region enforces, exported so the client and host agree on them. */
9028
- const SIDEBAR_HEIGHT_MIN = 120;
9325
+ const SIDEBAR_HEIGHT_MIN = 80;
9029
9326
  const SIDEBAR_HEIGHT_MAX = 1200;
9030
9327
  /** Reads the preferences file, treating every failure as "nothing saved yet". */
9031
9328
  const readPreferences = async (env = process.env) => {
@@ -9045,7 +9342,7 @@ const readPreferences = async (env = process.env) => {
9045
9342
  }
9046
9343
  };
9047
9344
  /** Clamps a requested height into the range the region can actually draw. */
9048
- const clampSidebarHeight = (value) => Math.min(SIDEBAR_HEIGHT_MAX, Math.max(120, Math.round(value)));
9345
+ const clampSidebarHeight = (value) => Math.min(SIDEBAR_HEIGHT_MAX, Math.max(80, Math.round(value)));
9049
9346
  /**
9050
9347
  * Writes the preferences file atomically.
9051
9348
  *
@@ -9336,7 +9633,7 @@ const activate = (ctx, target) => {
9336
9633
  if (room.modelProvider === null || room.modelProvider === void 0) return;
9337
9634
  if (room.model === null || room.model === void 0) return;
9338
9635
  try {
9339
- await ctx.sessionController.selectModel({
9636
+ await selectRoomModel(ctx, {
9340
9637
  sessionId: session.id,
9341
9638
  provider: room.modelProvider,
9342
9639
  model: room.model,
@@ -9560,6 +9857,56 @@ const activate = (ctx, target) => {
9560
9857
  const url = new URL(request.url ?? "/", "http://dsh.internal");
9561
9858
  const route = url.pathname.slice(19).replace(/^\/+/u, "");
9562
9859
  try {
9860
+ if (request.method === "GET" && route === "attachment-materialize") {
9861
+ const attachmentId = url.searchParams.get("attachmentId") ?? "";
9862
+ const name = url.searchParams.get("name") ?? "attachment";
9863
+ const bytes = Number(url.searchParams.get("bytes") ?? "0");
9864
+ if (!attachmentId.startsWith("sha256:") || !Number.isSafeInteger(bytes) || bytes <= 0) throw new RouteRefusal("attachment-materialize requires a sha256 attachmentId and byte count", 400);
9865
+ const attachments = hostService(ctx, "attachments");
9866
+ if (attachments === void 0) throw new RouteRefusal("attachment store is unavailable", 501);
9867
+ const chunks = [];
9868
+ for await (const chunk of attachments.readFileStream({
9869
+ attachmentId,
9870
+ name,
9871
+ bytes
9872
+ })) chunks.push(chunk);
9873
+ const safeName = basename(name).replace(/[^\p{L}\p{N}._-]+/gu, "_") || "attachment";
9874
+ const directory = join(resolveWorkspaceRoot(), ".dsh-attachment-preview");
9875
+ await mkdir(directory, { recursive: true });
9876
+ const relativePath = ".dsh-attachment-preview/" + attachmentId.slice(7, 19) + "-" + safeName;
9877
+ await writeFile(join(resolveWorkspaceRoot(), relativePath), Buffer.concat(chunks));
9878
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
9879
+ response.end(JSON.stringify({
9880
+ path: relativePath,
9881
+ name: safeName
9882
+ }));
9883
+ return;
9884
+ }
9885
+ if (request.method === "GET" && route === "attachment-preview") {
9886
+ const attachmentId = url.searchParams.get("attachmentId") ?? "";
9887
+ const name = url.searchParams.get("name") ?? "attachment";
9888
+ const bytes = Number(url.searchParams.get("bytes") ?? "0");
9889
+ if (!attachmentId.startsWith("sha256:") || !Number.isSafeInteger(bytes) || bytes <= 0) throw new RouteRefusal("attachment-preview requires a sha256 attachmentId and byte count", 400);
9890
+ const attachments = hostService(ctx, "attachments");
9891
+ if (attachments === void 0) throw new RouteRefusal("attachment store is unavailable", 501);
9892
+ const chunks = [];
9893
+ for await (const chunk of attachments.readFileStream({
9894
+ attachmentId,
9895
+ name,
9896
+ bytes
9897
+ })) chunks.push(chunk);
9898
+ const body = Buffer.concat(chunks);
9899
+ const extension = name.toLowerCase().split(".").pop() ?? "";
9900
+ const contentType = extension === "png" ? "image/png" : extension === "jpg" || extension === "jpeg" ? "image/jpeg" : extension === "gif" ? "image/gif" : extension === "webp" ? "image/webp" : extension === "pdf" ? "application/pdf" : extension === "md" ? "text/markdown; charset=utf-8" : extension === "json" ? "application/json; charset=utf-8" : extension === "txt" || extension === "log" ? "text/plain; charset=utf-8" : "application/octet-stream";
9901
+ response.writeHead(200, {
9902
+ "content-type": contentType,
9903
+ "content-length": String(body.length),
9904
+ "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(name)}`,
9905
+ "cache-control": "private, no-store"
9906
+ });
9907
+ response.end(body);
9908
+ return;
9909
+ }
9563
9910
  const payload = await readJsonBody(request);
9564
9911
  const body = await handleRoomRequest(route, request.method ?? "GET", payload, url, backend, runtimes, closeRoom, ctx, pendingQuotes);
9565
9912
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
@@ -9626,6 +9973,44 @@ const activate = (ctx, target) => {
9626
9973
  const handleRoomRequest = async (route, httpMethod, payload, url, backend, runtimes, closeRoom, ctx, pendingQuotes) => {
9627
9974
  const [resource, roomId, ...rest] = route.split("/").filter((part) => part.length > 0);
9628
9975
  const action = rest.join("/");
9976
+ /**
9977
+ * Batch maintenance for the throwaway Sessions an older build minted under a room.
9978
+ *
9979
+ * Report is the default; archiving needs `dryRun: false`, and deleting additionally needs
9980
+ * `purge: true` plus `confirm` equal to the room id. The canonical room Session is never a
9981
+ * candidate, and neither is another room's Session or a subagent child.
9982
+ */
9983
+ const cleanupMatch = /^rooms\/([^/]+)\/sessions\/cleanup$/u.exec(route);
9984
+ if (cleanupMatch !== null && cleanupMatch[1] !== void 0) {
9985
+ if (httpMethod !== "POST") throw new RouteRefusal("session cleanup accepts POST", 405);
9986
+ const targetRoomId = decodeURIComponent(cleanupMatch[1]);
9987
+ let room;
9988
+ try {
9989
+ room = (await backend.listRooms()).find((candidate) => candidate.id === targetRoomId);
9990
+ } catch {}
9991
+ const requestedDirectory = payload?.["directory"];
9992
+ if (requestedDirectory !== void 0 && (typeof requestedDirectory !== "string" || !isAbsolute(requestedDirectory))) throw new RouteRefusal("directory must be an absolute path", 400);
9993
+ const configuredDirectory = requestedDirectory ?? (room === void 0 ? void 0 : resolveRoomDirectory(targetRoomId, room.cwd));
9994
+ if (configuredDirectory === void 0) throw new RouteRefusal("room is no longer listed; pass the room directory explicitly to clean its session records", 404);
9995
+ const directory = await realpath(configuredDirectory).catch(() => configuredDirectory);
9996
+ if (resolve(directory) === resolve(resolveWorkspaceRoot())) throw new RouteRefusal("refusing to clean sessions whose cwd is the workspace root", 400);
9997
+ const persistence = hostService(ctx, "sessionPersistence");
9998
+ if (persistence === void 0) throw new RouteRefusal("session persistence is unavailable on this host", 501);
9999
+ const archive = hostService(ctx, "workspaceRegistry");
10000
+ const dryRun = payload?.["dryRun"] !== false;
10001
+ const purge = payload?.["purge"] === true;
10002
+ if (purge && payload?.["confirm"] !== targetRoomId) throw new RouteRefusal("purging session directories requires confirm set to the room id", 400);
10003
+ return cleanupRoomSessions({
10004
+ roomId: targetRoomId,
10005
+ directory,
10006
+ primarySessionId: roomSessionId(targetRoomId),
10007
+ persistence,
10008
+ ...archive === void 0 ? {} : { archive },
10009
+ protectedSessionIds: [...runtimes.keys()].map((openRoomId) => roomSessionId(openRoomId)),
10010
+ dryRun,
10011
+ purge
10012
+ });
10013
+ }
9629
10014
  if (resource === "rooms" && roomId === "messages" && action === "search") {
9630
10015
  if (httpMethod !== "GET") throw new RouteRefusal("message search accepts GET", 405);
9631
10016
  const query = url.searchParams.get("query") ?? "";
@@ -10194,4 +10579,4 @@ const readJsonBody = async (req) => {
10194
10579
  */
10195
10580
  const MAX_UPLOAD_BYTES = 16777216;
10196
10581
  //#endregion
10197
- export { BackendError, Config, DEFAULT_CREDENTIAL_REF, GROUP_CHAT_ROUTE, GroupChatBackend, MANAGED_ENV, POLL_INTERVAL_MS, ROOM_SESSION_PREFIX, RoomEventTracker, SIDEBAR_HEIGHT_MAX, SIDEBAR_HEIGHT_MIN, activate, apply, clampSidebarHeight, inject, isAgentReply, isLocalUserMessage, isRuntimeInjectedData, isTransportFailure, isUserMessageData, lastRoomEvent, mentionedMemberIds, mentionsOf, messageId, name, publishableReply, readPreferences, resolveServerTarget, resolveWorkspaceRoot, roomIdOfSession, roomSessionId, senderId, shouldTriggerAgent, speakerPrefix, textOfContent, withSpeakerPrefix, writePreferences };
10582
+ export { BackendError, Config, DEFAULT_CREDENTIAL_REF, GROUP_CHAT_ROUTE, GroupChatBackend, MANAGED_ENV, POLL_INTERVAL_MS, ROOM_SESSION_PREFIX, RoomEventTracker, RoomSessions, SIDEBAR_HEIGHT_MAX, SIDEBAR_HEIGHT_MIN, activate, apply, clampSidebarHeight, handleRoomRequest, inject, isAgentReply, isLocalUserMessage, isRuntimeInjectedData, isTransportFailure, isUserMessageData, lastRoomEvent, mentionedMemberIds, mentionsOf, messageId, name, publishableReply, readPreferences, resolveServerTarget, resolveWorkspaceRoot, roomIdOfSession, roomSessionId, senderId, shouldTriggerAgent, speakerPrefix, textOfContent, withSpeakerPrefix, writePreferences };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-group-chat",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Human-agent group chat for DataOps-managed DSH workspaces: rooms broadcast to every member, and only a member's own @mention wakes that member's agent.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -40,11 +40,6 @@
40
40
  "cordis.patch.yml",
41
41
  "README.md"
42
42
  ],
43
- "scripts": {
44
- "build": "tsdown",
45
- "typecheck": "tsc --noEmit -p tsconfig.json",
46
- "test": "vitest run --root ../.. packages/group-chat/tests"
47
- },
48
43
  "repository": {
49
44
  "type": "git",
50
45
  "url": "git+https://github.com/SparkElf/dsh-plugins-plus.git",
@@ -96,5 +91,10 @@
96
91
  "@deepseek-ai/schemastery": "^3.18.2",
97
92
  "ws": "^8.18.0",
98
93
  "socket.io-client": "^4.8.1"
94
+ },
95
+ "scripts": {
96
+ "build": "tsdown",
97
+ "typecheck": "tsc --noEmit -p tsconfig.json",
98
+ "test": "vitest run --root ../.. packages/group-chat/tests"
99
99
  }
100
- }
100
+ }