dsh-phone-bridge 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +205 -9
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -221,6 +221,9 @@ function createHandler(ctx) {
221
221
  const DSH_HOME = join(homedir(), ".dsh");
222
222
  const SESSIONS_ROOT = join(DSH_HOME, "sessions");
223
223
  const PROJCACHE_ROOT = join(DSH_HOME, "storages", "session_projcache", "sessions");
224
+ // The desktop sidebar renders this registry, so the /sessions route reads it too
225
+ // in order to list the same rows the desktop does.
226
+ const WORKSPACE_FILE = join(DSH_HOME, "storages", "workspace.json");
224
227
  // TRASH_ROOT is assigned in apply() from Config, because it is the one path a
225
228
  // deployment may reasonably want to move somewhere else.
226
229
 
@@ -254,6 +257,49 @@ function listOnDiskSessionIds() {
254
257
  return ids;
255
258
  }
256
259
 
260
+ // Pull just the title out of a session's projection bag.
261
+ //
262
+ // The bag also carries token counts, context pressure, the turn outline and the
263
+ // subagent catalog. On a long session that is tens of kilobytes, and a session
264
+ // list or a status line needs none of it - the title is the only part that makes
265
+ // a session identifiable. The key is not guaranteed across versions, so probe
266
+ // the plausible names the same way the phone side does.
267
+ function titleFromProjections(values) {
268
+ for (const key of ["title", "sessionTitle", "name", "label"]) {
269
+ const value = values?.[key];
270
+ if (typeof value === "string" && value.trim()) return value.trim();
271
+ if (value && typeof value === "object" && typeof value.title === "string" && value.title.trim()) {
272
+ return value.title.trim();
273
+ }
274
+ }
275
+ return "";
276
+ }
277
+
278
+ // The ids the desktop sidebar would actually list.
279
+ //
280
+ // The sidebar renders DSH's workspace registry (workspaces[<id>].sessionIds),
281
+ // not the disk. Sessions that were never registered there - subagent sessions,
282
+ // ones made before the registry existed - have files but no row. Listing the
283
+ // disk alone therefore shows the phone rows the desktop does not have, which
284
+ // reads as "the phone still shows sessions I deleted".
285
+ //
286
+ // Returns null when the registry cannot be read, so the caller can fall back to
287
+ // the disk list instead of hiding everything.
288
+ function readRegisteredSessionIds() {
289
+ try {
290
+ const parsed = JSON.parse(readFileSync(WORKSPACE_FILE, "utf8"));
291
+ const ids = new Set();
292
+ for (const workspace of Object.values(parsed?.tables?.workspaces ?? {})) {
293
+ for (const id of workspace?.sessionIds ?? []) ids.add(id);
294
+ }
295
+ // Archived sessions live in their own sidebar section, not the main list.
296
+ for (const id of parsed?.global?.archivedSessionIds ?? []) ids.delete(id);
297
+ return ids;
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
257
303
  // Move rather than unlink. DSH exposes no delete API, and an irreversible
258
304
  // deletion triggered from a chat message is a bad trade. A session becomes
259
305
  // invisible to DSH the instant its directory leaves the sessions root, so
@@ -337,23 +383,34 @@ export function apply(ctx, config = {}) {
337
383
  const sessionController = ctx.get("sessionController");
338
384
  if (!sessionController) throw new Error("sessionController is unavailable");
339
385
  const listing = await sessionController.list({}, new AbortController().signal);
340
- // Hide sessions whose directory is gone: sessionController.list() keeps a
341
- // cached phantom after a delete (its cache is not evicted on directory
342
- // removal), and the phone would otherwise still offer a session that no
343
- // longer opens. Running sessions are exempt because they may be created
344
- // but not yet written to disk.
386
+ // Two filters, both about agreeing with the desktop sidebar:
387
+ // - the registry filter drops sessions the sidebar would not list at all
388
+ // (never registered in a workspace: subagent sessions, pre-registry
389
+ // ones). Without it the phone shows rows the desktop does not have,
390
+ // which reads as "the phone still has the sessions I deleted".
391
+ // - the disk filter drops cached phantoms: sessionController.list()
392
+ // keeps returning a session after its directory is moved to the trash,
393
+ // because that cache is only rebuilt on restart.
394
+ // Running sessions bypass both: they can exist before being materialized
395
+ // or registered.
345
396
  const onDisk = listOnDiskSessionIds();
397
+ const registered = readRegisteredSessionIds();
346
398
  const sessions = (listing?.items ?? [])
347
- .filter((item) => item.running || onDisk.has(item.sessionId))
399
+ .filter((item) => {
400
+ if (item.running) return true;
401
+ if (!onDisk.has(item.sessionId)) return false;
402
+ if (registered !== null && !registered.has(item.sessionId)) return false;
403
+ return true;
404
+ })
348
405
  .map((item) => ({
349
406
  sessionId: item.sessionId,
350
407
  updatedAt: item.updatedAt,
351
408
  running: Boolean(item.running),
352
409
  blank: Boolean(item.blank),
353
410
  cwd: item.cwd ?? "",
354
- // Projection values carry derived per-session data such as the
355
- // generated title, which is what makes a session identifiable.
356
- projections: item.projections?.values ?? null,
411
+ // Only the title is sent: the full projection bag runs to tens of
412
+ // kilobytes per session on a long one, and the phone lists these.
413
+ projections: { title: titleFromProjections(item.projections?.values) },
357
414
  }));
358
415
  sendJson(res, 200, { ok: true, sessions });
359
416
  } catch (error) {
@@ -683,6 +740,145 @@ export function apply(ctx, config = {}) {
683
740
  },
684
741
  }));
685
742
 
743
+ // What is the session doing right now. Everything here comes from list(),
744
+ // which is cheap and does not activate a cold session - asking for status
745
+ // should never wake one up.
746
+ disposers.push(ctx.webServer.register({
747
+ kind: "exact",
748
+ path: `${ROUTE_PATH}/status`,
749
+ handler: async (req, res) => {
750
+ try {
751
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
752
+ const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
753
+ const sessionController = ctx.get("sessionController");
754
+ if (!sessionController) throw new Error("sessionController is unavailable");
755
+ const signal = new AbortController().signal;
756
+ const listing = await sessionController.list({}, signal);
757
+ const items = listing?.items ?? [];
758
+ const running = items.filter((item) => item.running);
759
+
760
+ let detail = null;
761
+ if (sessionId) {
762
+ const found = items.find((item) => item.sessionId === sessionId);
763
+ detail = {
764
+ sessionId,
765
+ known: Boolean(found),
766
+ running: Boolean(found?.running),
767
+ blank: Boolean(found?.blank),
768
+ cwd: found?.cwd ?? "",
769
+ updatedAt: found?.updatedAt ?? null,
770
+ projections: { title: titleFromProjections(found?.projections?.values) },
771
+ };
772
+ }
773
+
774
+ sendJson(res, 200, {
775
+ ok: true,
776
+ runningCount: running.length,
777
+ runningIds: running.map((item) => item.sessionId),
778
+ detail,
779
+ });
780
+ } catch (error) {
781
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
782
+ }
783
+ },
784
+ }));
785
+
786
+ // Stop whatever the session is doing. cancel() takes { kind } internally and
787
+ // keeps the inbox, so the user's queued messages are not silently dropped.
788
+ disposers.push(ctx.webServer.register({
789
+ kind: "exact",
790
+ path: `${ROUTE_PATH}/stop`,
791
+ handler: async (req, res) => {
792
+ try {
793
+ if (req.method !== "POST") {
794
+ sendJson(res, 405, { ok: false, error: "POST only" });
795
+ return;
796
+ }
797
+ const raw = await readBody(req);
798
+ let payload;
799
+ try {
800
+ payload = JSON.parse(raw || "{}");
801
+ } catch {
802
+ sendJson(res, 400, { ok: false, error: "invalid json" });
803
+ return;
804
+ }
805
+ const sessionId = String(payload.sessionId ?? "").trim();
806
+ if (!sessionId) {
807
+ sendJson(res, 400, { ok: false, error: "sessionId is required" });
808
+ return;
809
+ }
810
+ const sessionController = ctx.get("sessionController");
811
+ if (!sessionController) throw new Error("sessionController is unavailable");
812
+ if (typeof sessionController.cancel !== "function") {
813
+ throw new Error("this DSH build does not expose cancel");
814
+ }
815
+ const result = await sessionController.cancel({ sessionId });
816
+ sendJson(res, 200, { ok: true, accepted: result?.accepted !== false });
817
+ } catch (error) {
818
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
819
+ }
820
+ },
821
+ }));
822
+
823
+ // The model catalog is a deployment-wide read, so the phone can fetch it once
824
+ // and then switch models by index without carrying provider ids around.
825
+ disposers.push(ctx.webServer.register({
826
+ kind: "exact",
827
+ path: `${ROUTE_PATH}/models`,
828
+ handler: async (_req, res) => {
829
+ try {
830
+ const sessionController = ctx.get("sessionController");
831
+ if (!sessionController) throw new Error("sessionController is unavailable");
832
+ if (typeof sessionController.modelCatalog !== "function") {
833
+ throw new Error("this DSH build does not expose modelCatalog");
834
+ }
835
+ sendJson(res, 200, { ok: true, catalog: (await sessionController.modelCatalog()) ?? null });
836
+ } catch (error) {
837
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
838
+ }
839
+ },
840
+ }));
841
+
842
+ // Switch the model a session uses. selectModel() resumes the session, so this
843
+ // is the one route here with a deliberate side effect.
844
+ disposers.push(ctx.webServer.register({
845
+ kind: "exact",
846
+ path: `${ROUTE_PATH}/model`,
847
+ handler: async (req, res) => {
848
+ try {
849
+ if (req.method !== "POST") {
850
+ sendJson(res, 405, { ok: false, error: "POST only" });
851
+ return;
852
+ }
853
+ const raw = await readBody(req);
854
+ let payload;
855
+ try {
856
+ payload = JSON.parse(raw || "{}");
857
+ } catch {
858
+ sendJson(res, 400, { ok: false, error: "invalid json" });
859
+ return;
860
+ }
861
+ const sessionId = String(payload.sessionId ?? "").trim();
862
+ const provider = String(payload.provider ?? "").trim();
863
+ const model = String(payload.model ?? "").trim();
864
+ if (!sessionId || !provider || !model) {
865
+ sendJson(res, 400, { ok: false, error: "sessionId, provider and model are required" });
866
+ return;
867
+ }
868
+ const sessionController = ctx.get("sessionController");
869
+ if (!sessionController) throw new Error("sessionController is unavailable");
870
+ if (typeof sessionController.selectModel !== "function") {
871
+ throw new Error("this DSH build does not expose selectModel");
872
+ }
873
+ const request = { sessionId, provider, model };
874
+ if (payload.reasoningEffort) request.reasoningEffort = payload.reasoningEffort;
875
+ sendJson(res, 200, { ok: true, selection: (await sessionController.selectModel(request)) ?? null });
876
+ } catch (error) {
877
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
878
+ }
879
+ },
880
+ }));
881
+
686
882
  disposers.push(ctx.webServer.register({
687
883
  kind: "exact",
688
884
  path: ROUTE_PATH,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-phone-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Drive an existing DSH desktop session from a chat app, plus a sidebar delete button and trash panel",
5
5
  "type": "module",
6
6
  "main": "index.js",