dsh-phone-bridge 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +63 -0
- package/index.js +205 -9
- package/package.json +1 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KaiyeZeng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# dsh-phone-bridge
|
|
2
|
+
|
|
3
|
+
Drive an existing DeepSeek Harness **desktop** session from a chat app, and manage
|
|
4
|
+
sessions from the sidebar.
|
|
5
|
+
|
|
6
|
+
> **Status as of October 2026:** DSH ships no way to reach a running desktop session
|
|
7
|
+
> from a phone, and no delete button. This package fills both gaps. It is unofficial
|
|
8
|
+
> and may break when DSH changes.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```powershell
|
|
13
|
+
dsh plugin --profile desktop add dsh-phone-bridge
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then restart DSH. The package carries its own `dsh.bundle.patch`, so no config file
|
|
17
|
+
has to be edited by hand.
|
|
18
|
+
|
|
19
|
+
## What it adds
|
|
20
|
+
|
|
21
|
+
**Desktop UI** — a delete button on every session row (with a confirmation), and a
|
|
22
|
+
trash panel at the bottom of the sidebar where each entry can be restored or purged.
|
|
23
|
+
|
|
24
|
+
**HTTP routes** under `/phone-bridge/*`, for a chat-side client to call:
|
|
25
|
+
|
|
26
|
+
| Route | Purpose |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `GET /sessions` | list sessions, the same set the sidebar shows |
|
|
29
|
+
| `GET /search?q=` | search session message content |
|
|
30
|
+
| `POST /create` | create a session |
|
|
31
|
+
| `POST /rename` | set a session title |
|
|
32
|
+
| `POST /delete` | move a session to the trash |
|
|
33
|
+
| `GET /trash` | list the trash and its size |
|
|
34
|
+
| `POST /restore` | put a trashed session back |
|
|
35
|
+
| `POST /purge` | erase trashed data permanently |
|
|
36
|
+
| `GET /status` | running state, working directory, last activity |
|
|
37
|
+
| `POST /stop` | cancel the session's running turn |
|
|
38
|
+
| `GET /models` | the deployment model catalog |
|
|
39
|
+
| `POST /model` | switch the model a session uses |
|
|
40
|
+
|
|
41
|
+
Deleting **moves** a session to `~/.dsh/deleted-sessions/` instead of unlinking it,
|
|
42
|
+
so a delete stays recoverable and purging is a separate, explicit step.
|
|
43
|
+
|
|
44
|
+
## The chat side
|
|
45
|
+
|
|
46
|
+
This package is only the DSH half. To talk to it from WeChat, Telegram or Yuanbao
|
|
47
|
+
you also need the OpenClaw plugin from the same repository:
|
|
48
|
+
|
|
49
|
+
<https://github.com/KaiyeZeng/dsh-phone-bridge>
|
|
50
|
+
|
|
51
|
+
## Notes
|
|
52
|
+
|
|
53
|
+
- **Zero runtime dependencies.** Only `node:` builtins are imported, so the file can
|
|
54
|
+
be mounted straight from disk with `file://` and no `node_modules` directory.
|
|
55
|
+
- `/sessions` reads DSH's workspace registry rather than the disk, so the phone lists
|
|
56
|
+
what the desktop sidebar lists. Sessions that were never registered in a workspace
|
|
57
|
+
(subagent sessions, ones made before the registry existed) have files but no
|
|
58
|
+
sidebar row, and listing the disk alone made the phone show rows the desktop did
|
|
59
|
+
not have.
|
|
60
|
+
- The model catalog is read structurally. The exact shape is not a stable surface, so
|
|
61
|
+
a change there shortens the list instead of crashing.
|
|
62
|
+
|
|
63
|
+
MIT licensed.
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
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) =>
|
|
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
|
-
//
|
|
355
|
-
//
|
|
356
|
-
projections: item.projections?.values
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
"client.js",
|
|
19
19
|
"cordis.patch.yml",
|
|
20
20
|
"README.md",
|
|
21
|
-
"README.zh.md",
|
|
22
21
|
"LICENSE"
|
|
23
22
|
],
|
|
24
23
|
"keywords": [
|