dsh-cindy-host 0.1.20

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 (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Fold a DSH session row into the wire shape the Cindy controller stores.
3
+ *
4
+ * THE CONTRACT IS FLAT. `local-db:sessions:list` returns `RemoteSession[]` —
5
+ * one flat session object per row, identity and status at the top level — and
6
+ * `local-db:sessions:get` returns exactly one such object. The controller does
7
+ * NOT unwrap anything: it hands the invoke result straight to
8
+ * `remoteSessionStore.setDeviceSessions`, which de-dupes on `session.id`.
9
+ *
10
+ * `RemoteSessionListItem` (the `{ session: {...}, subtitle, detail, … }` view
11
+ * model) is built *inside* the controller's own renderers
12
+ * (`maker-shared/sessionList.ts`, `mobileHome.ts`) and must never cross the wire.
13
+ * An earlier revision of this file emitted that view model, citing a
14
+ * `mobileMakerTransport.listSessions()` that does not exist in the Cindy tree;
15
+ * the result was a silently empty list — `id`, `status` and `deviceLinkDeviceId`
16
+ * all arrived as `undefined`, so the controller's status filter, device filter,
17
+ * and de-dupe key each discarded every row without raising anything.
18
+ *
19
+ * Only the wire shape belongs here. Anything the controller derives for display
20
+ * (subtitle, detail, live activity, pending counts, schedule info) is its own
21
+ * business and is deliberately absent.
22
+ */
23
+
24
+ import { toCindyPermissionMode } from './host-models.js';
25
+
26
+ /** Cindy wire aliases for the one DSH harness; never use these to route a model. */
27
+ export function agentKindForModel({ model, provider, catalog } = {}) {
28
+ let resolvedModel = typeof model === 'string' && model.trim() !== '' ? model.trim() : null;
29
+ let resolvedProvider = typeof provider === 'string' && provider.trim() !== '' ? provider.trim() : null;
30
+ if (resolvedModel === null && resolvedProvider === null) {
31
+ const fallback = catalog?.default;
32
+ resolvedModel = typeof fallback?.model === 'string' && fallback.model !== '' ? fallback.model : null;
33
+ resolvedProvider = typeof fallback?.provider === 'string' && fallback.provider !== '' ? fallback.provider : null;
34
+ } else if (resolvedProvider === null) {
35
+ // The default route applies only to its own model. A model shared by two
36
+ // providers cannot be classified without the session's source.
37
+ if (catalog?.default?.model === resolvedModel) {
38
+ resolvedProvider = catalog.default.provider ?? null;
39
+ } else {
40
+ const matches = (Array.isArray(catalog?.groups) ? catalog.groups : [])
41
+ .filter((group) => Array.isArray(group?.models) && group.models.some((candidate) => candidate?.id === resolvedModel));
42
+ if (matches.length === 1) resolvedProvider = matches[0].id;
43
+ }
44
+ }
45
+ // Pi is reserved for a confirmed DeepSeek source, even if its model is unknown.
46
+ if (typeof resolvedProvider === 'string' && /^deepseek(?:$|[-_./])/i.test(resolvedProvider)) return 'pi';
47
+ if (resolvedModel === null || typeof resolvedProvider !== 'string' || resolvedProvider === '') return 'claude-code';
48
+ if (/^(?:gpt(?:$|[-_./])|chatgpt(?:$|[-_./]))/i.test(resolvedModel)) return 'codex';
49
+ return 'claude-code';
50
+ }
51
+ const DSH_MODEL = 'pi';
52
+ const DSH_EFFORT = 'default';
53
+ const DSH_PERMISSION_MODE = 'default';
54
+ const UNTITLED = 'Untitled DSH task';
55
+ const MAX_TITLE = 160;
56
+
57
+ /** Trim a title the way the local read model does, so both surfaces agree. */
58
+ function titleOf(value) {
59
+ const title = typeof value === 'string' ? value.trim() : '';
60
+ return title.slice(0, MAX_TITLE) || UNTITLED;
61
+ }
62
+
63
+ /**
64
+ * Build one wire session row.
65
+ * @param item - a source row: `{ id, title, running, updatedAt, createdAt, cwd, blank }`.
66
+ * @param options - injectable clock and this Host's relay identity.
67
+ * @returns the flat `RemoteSession` the controller stores.
68
+ */
69
+ export function toCindySessionListRow(item, { now = () => new Date(), device, defaultModel, catalog } = {}) {
70
+ const id = String(item.id);
71
+ const updatedAt = item.updatedAt ?? now().toISOString();
72
+ const createdAt = item.createdAt ?? updatedAt;
73
+ const title = titleOf(item.title);
74
+ const workingDir = typeof item.cwd === 'string' && item.cwd !== '' ? item.cwd : null;
75
+
76
+ return {
77
+ id,
78
+ title,
79
+ createdAt,
80
+ updatedAt,
81
+ // `'active' | 'archived' | 'deleted'`; the controller's default status filter
82
+ // is `'active'`, so this field decides whether a row is visible at all.
83
+ //
84
+ // DSH has no archived or deleted state, so the value can only come from this
85
+ // Host's own flags (`session-flags.js`), folded onto the source row before it
86
+ // reaches here. A row that carries none is active, which is what every row was
87
+ // before the flags existed.
88
+ status: item.status === 'archived' || item.status === 'deleted' ? item.status : 'active',
89
+ agentKind: agentKindForModel({ model: item.model ?? defaultModel, provider: item.providerId, catalog }),
90
+ // The controller looks the current model up by id in the catalog it fetched
91
+ // (`availableModels.find(item => item.id === session.model)`), so a
92
+ // placeholder here leaves the picker with nothing selected even when the
93
+ // catalog is right. The session's own selection wins; the Host default is
94
+ // the honest fallback while it is unknown.
95
+ model: typeof item.model === 'string' && item.model !== ''
96
+ ? item.model
97
+ : (typeof defaultModel === 'string' && defaultModel !== '' ? defaultModel : DSH_MODEL),
98
+ // The source that serves that model, when the session recorded one. The
99
+ // controller keeps `providerId` beside the model and derives its new-chat draft
100
+ // runtime from the most recent session (`pickRecentSessionRuntime`), so a row that
101
+ // drops it hands the next conversation a model with no source. Absent — not null —
102
+ // when nothing recorded one: the controller reads a missing field as "the Host's
103
+ // default route", which is exactly what such a session runs.
104
+ ...(typeof item.providerId === 'string' && item.providerId !== '' ? { providerId: item.providerId } : {}),
105
+ // The session's own effort, which DSH carries as part of its model
106
+ // selection. The placeholder is only for a session that has not chosen yet:
107
+ // answering "default" after a successful `maker:set-effort` would make the
108
+ // write look like it never landed.
109
+ effort: typeof item.effort === 'string' && item.effort !== '' ? item.effort : DSH_EFFORT,
110
+ // The session's own preset when the list carried the `permissions`
111
+ // projection, so the composer shows the mode actually in force rather than a
112
+ // placeholder that no picker entry matches.
113
+ permissionMode: typeof item.permissionMode === 'string' && item.permissionMode !== ''
114
+ ? toCindyPermissionMode(item.permissionMode)
115
+ : DSH_PERMISSION_MODE,
116
+ fastMode: false,
117
+ workingDir,
118
+ // The controller groups by this: `workspaceKind === 'dialogue' || !workingDir`
119
+ // puts a row in the "chats" bucket, everything else under its project.
120
+ workspaceKind: workingDir === null ? 'dialogue' : 'project',
121
+ // A blank session has no user turn yet; the controller shows it as a draft.
122
+ preview: null,
123
+ userSendAt: typeof item.userSendAt === 'string' && item.userSendAt !== '' ? item.userSendAt : null,
124
+ _count: Number.isFinite(item.messageCount) && item.messageCount >= 0
125
+ ? { messages: Math.floor(item.messageCount) }
126
+ : null,
127
+ // Not part of the stored row's contract, but harmless and honest: the
128
+ // controller reads running state from `maker:list-active`, not from here.
129
+ running: item.running === true,
130
+ // Which device this row came from. The controller's home aggregates sessions
131
+ // from every linked computer and filters with
132
+ // `canonicalDeviceId ?? deviceLinkDeviceId === selectedDeviceId`
133
+ // (`maker-shared/mobileHome.ts`), so a row that names neither is dropped the
134
+ // moment the user selects this Host — which is the only way to see it.
135
+ deviceLinkDeviceId: device?.deviceId ?? null,
136
+ deviceLinkDeviceName: device?.deviceName ?? null,
137
+ // Present only when a controller pinned this session here (the flag store's
138
+ // doing, not DSH's). The controller reads a missing field as "not pinned" — and
139
+ // on a write reply it reads a missing field as "keep what I have", which is
140
+ // exactly how an unpin stays applied without this row asserting `null`.
141
+ ...(typeof item.pinnedAt === 'string' && item.pinnedAt !== '' ? { pinnedAt: item.pinnedAt } : {}),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Fold a whole listing.
147
+ * @param items - source rows.
148
+ * @param options - injectable clock and this Host's relay identity.
149
+ * @returns the flat array the controller stores.
150
+ */
151
+ export function toCindySessionList(items, options = {}) {
152
+ const list = Array.isArray(items) ? items : [];
153
+ return list.map((item) => toCindySessionListRow(item, options));
154
+ }
155
+
156
+ /**
157
+ * Build the `maker:list-active` answer.
158
+ *
159
+ * This channel has its own shape — `[{ sessionId, isTurnRunning }]` — and is
160
+ * neither a session row nor a list item. Answering it with session rows makes
161
+ * the controller skip every entry, which shows up only as a running badge that
162
+ * never lights up.
163
+ * @param items - source rows.
164
+ * @returns one entry per session that is currently in a turn.
165
+ */
166
+ export function toCindyActiveSessions(items) {
167
+ const list = Array.isArray(items) ? items : [];
168
+ return list
169
+ .filter((item) => item?.running === true)
170
+ .map((item) => ({ sessionId: String(item.id), isTurnRunning: true }));
171
+ }
@@ -0,0 +1,6 @@
1
+ export class ConsoleSink {
2
+ constructor(out = console.log) { this.out = out; }
3
+ async replaceConversations(items) { this.out(JSON.stringify({ channel: "dsh:conversations", items }, null, 2)); }
4
+ async appendActivity(item) { this.out(JSON.stringify({ channel: "dsh:activity", item }, null, 2)); }
5
+ async markStale() { this.out(JSON.stringify({ channel: "dsh:projection", state: "stale" }, null, 2)); }
6
+ }
@@ -0,0 +1,26 @@
1
+ export const ACTIVITY_KINDS = new Set(["session-added", "session-removed", "session-status", "session-subscribed"]);
2
+
3
+ export function toConversationRow(session) {
4
+ return {
5
+ sessionId: String(session.id),
6
+ title: safeTitle(session.title),
7
+ phase: session.running ? "running" : session.pendingInteraction ? "waiting" : "idle",
8
+ updatedAt: session.updatedAt || new Date().toISOString(),
9
+ };
10
+ }
11
+
12
+ export function toActivityItem(event) {
13
+ if (!ACTIVITY_KINDS.has(event.kind)) return null;
14
+ return {
15
+ sessionId: String(event.sessionId),
16
+ sequence: Number(event.sequence),
17
+ kind: event.kind,
18
+ phase: event.phase || "unknown",
19
+ occurredAt: event.occurredAt || new Date().toISOString(),
20
+ };
21
+ }
22
+
23
+ function safeTitle(value) {
24
+ const title = typeof value === "string" ? value.trim() : "";
25
+ return title.slice(0, 160) || "Untitled DSH task";
26
+ }
@@ -0,0 +1,128 @@
1
+ import { createRequire } from 'node:module';
2
+ import { createHash } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { resolve as resolvePath } from 'node:path';
5
+
6
+ const SERVICE = 'DSH Cindy Host';
7
+
8
+ /**
9
+ * `keytar` is a **native** module, so it is loaded lazily and its absence is not fatal.
10
+ *
11
+ * Measured on a clean install (a packed tarball into a fresh `DSH_HOME`): pnpm 10 does not run
12
+ * dependency build scripts by default, so `keytar.node` was never built, and a top-level import
13
+ * here made the whole plugin fail to load — the profile would not boot over a *credential store*
14
+ * it may never need. An optional capability must not be able to break mounting, so the module is
15
+ * required the moment it is used and a failure degrades to "no credential store": a read answers
16
+ * "no stored session" (the card asks for a login), and a write throws a named error instead of
17
+ * pretending to have saved.
18
+ *
19
+ * @returns the keytar module, or null when it cannot be loaded on this machine.
20
+ */
21
+ let keytarModule;
22
+ function keytar() {
23
+ if (keytarModule === undefined) {
24
+ try {
25
+ keytarModule = createRequire(import.meta.url)('keytar');
26
+ } catch {
27
+ keytarModule = null;
28
+ }
29
+ }
30
+ return keytarModule;
31
+ }
32
+
33
+ /** Whether a credential store is usable here, for callers that want to say so. */
34
+ export function credentialStoreAvailable() {
35
+ return keytar() !== null;
36
+ }
37
+
38
+ /** The error a write raises when this machine has no usable credential store. */
39
+ export class CredentialStoreUnavailableError extends Error {
40
+ constructor() {
41
+ super('this machine has no usable credential store (keytar is not built; on pnpm 10 approve the keytar build script)');
42
+ this.code = 'CREDENTIAL_STORE_UNAVAILABLE';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The credential-store entry the default installation has always used.
48
+ *
49
+ * The OS keeps these per (service, account), and the account is what separates two DSH
50
+ * installations on one machine — not a folder, because there is no folder: one machine,
51
+ * one credential store.
52
+ */
53
+ export const DEFAULT_ACCOUNT = 'session-v1';
54
+
55
+ /** DSH's own default home: `~/.dsh` (the same precedence `dsh-home-paths` documents). */
56
+ export function defaultDshHome() {
57
+ return resolvePath(homedir(), '.dsh');
58
+ }
59
+
60
+ /** Compare two home paths the way the filesystem would: `~` expanded, case-insensitive on Windows. */
61
+ function sameHome(left, right) {
62
+ const normalize = (value) => {
63
+ const expanded = value.startsWith('~')
64
+ ? resolvePath(homedir(), value.slice(1).replace(/^[\\/]+/, ''))
65
+ : resolvePath(value);
66
+ const trimmed = expanded.replace(/[\\/]+$/, '');
67
+ return process.platform === 'win32' ? trimmed.toLowerCase() : trimmed;
68
+ };
69
+ return normalize(left) === normalize(right);
70
+ }
71
+
72
+ /**
73
+ * Which credential entry this process owns.
74
+ *
75
+ * The rule is **"is this the default home"**, not **"is `DSH_HOME` set"**. That
76
+ * distinction is not academic: this deployment sets `DSH_HOME=C:\Users\<user>\.dsh`
77
+ * explicitly — the default home, spelled out — and a rule keyed on the variable being
78
+ * present sent the real installation looking for an entry that had never existed, so it
79
+ * booted with no session and would not connect, offering a login form for an account that
80
+ * was already signed in. Measured right after a restart: `no stored session`.
81
+ *
82
+ * `DSH_HOME` naming a *different* home is a separate installation and does get its own
83
+ * entry.
84
+ *
85
+ * Why scoping exists at all: the relay's device identity comes from the *login* —
86
+ * `hello-ack` echoes a `deviceId` that the stored credential also carries — so two
87
+ * installations sharing one entry present the **same** device id. The relay enforces one
88
+ * connection per device (the client's own code: `4409: 同 deviceId 的新连接顶掉了本连接`),
89
+ * and with a reconnect ladder on both sides that is not a brief overlap but a kicking
90
+ * loop, with the controller's session list caught in the middle. A scoped credential gives
91
+ * each installation its own device, its own login, and therefore its own task list.
92
+ *
93
+ * @param env - environment to read (injectable for tests).
94
+ * @param defaultHome - the home treated as the default installation.
95
+ * @returns the keytar account name for this process.
96
+ */
97
+ export function credentialAccount(env = process.env, defaultHome = defaultDshHome()) {
98
+ const home = typeof env?.DSH_HOME === 'string' ? env.DSH_HOME.trim() : '';
99
+ if (home === '' || sameHome(home, defaultHome)) return DEFAULT_ACCOUNT;
100
+ // A digest, not the path: this string is written into the OS credential store, and a
101
+ // Windows path with backslashes and a drive letter has no business being there.
102
+ const digest = createHash('sha256').update(home).digest('hex').slice(0, 12);
103
+ return `${DEFAULT_ACCOUNT}@${digest}`;
104
+ }
105
+
106
+ export async function loadSession(ports = {}) {
107
+ const account = ports.account ?? credentialAccount();
108
+ const store = keytar();
109
+ if (store === null) return null; // no store here: "no stored session", never a crash
110
+ const value = await store.getPassword(SERVICE, account);
111
+ return value ? JSON.parse(value) : null;
112
+ }
113
+
114
+ export async function saveSession(session, ports = {}) {
115
+ const account = ports.account ?? credentialAccount();
116
+ const store = keytar();
117
+ if (store === null) throw new CredentialStoreUnavailableError();
118
+ await store.setPassword(SERVICE, account, JSON.stringify(session));
119
+ }
120
+
121
+ export async function clearSession(ports = {}) {
122
+ const account = ports.account ?? credentialAccount();
123
+ const store = keytar();
124
+ // Clearing what does not exist is the outcome the caller wanted, so a missing store is not an
125
+ // error here — unlike a save, which must not report success it cannot deliver.
126
+ if (store === null) return;
127
+ await store.deletePassword(SERVICE, account);
128
+ }
package/src/demo.js ADDED
@@ -0,0 +1,9 @@
1
+ import { ConsoleSink } from "./console-sink.js";
2
+ import { FixtureDshSource } from "./fixture-source.js";
3
+ import { ReadOnlyProjection } from "./projection.js";
4
+
5
+ const source = new FixtureDshSource();
6
+ const projection = new ReadOnlyProjection(new ConsoleSink());
7
+ await projection.start(source);
8
+ await source.emit({ sessionId: "dsh-host-demo", sequence: 1, kind: "session-status", phase: "running", occurredAt: "2026-09-16T05:46:00.000Z" });
9
+ await source.emit({ sessionId: "remote-research", sequence: 2, kind: "session-added", phase: "waiting", occurredAt: "2026-09-16T05:46:01.000Z" });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Read the running subset from DSH's live registry, never the persisted corpus.
3
+ *
4
+ * Services are resolved for each call so unload/reload cannot leave cached running
5
+ * flags behind. `null` means this runtime lacks the required API; an empty array
6
+ * is authoritative. Actual read failures propagate to the caller's legacy fallback.
7
+ *
8
+ * DSH's session-controller summary uses Agent.status, Agent.session identity,
9
+ * sessionListMetadata.blank (falling back to Session.seq), and lastPromptAt.
10
+ * Keep the same facts here without controller.list(), persistence, or log copies.
11
+ * snapshot() is current, unlike cachedSnapshot(); a first touch may still lazily
12
+ * materialize in-memory projections, but only for eligible running roots.
13
+ */
14
+ export function readActiveSessionSummaries(ctx) {
15
+ if (typeof ctx?.get !== 'function') return null;
16
+ const agents = ctx.get('agents');
17
+ const sessions = ctx.get('sessions');
18
+ const projections = ctx.get('sessionProjections');
19
+ if (typeof agents?.list !== 'function' || typeof agents?.get !== 'function'
20
+ || typeof sessions?.get !== 'function' || typeof projections?.snapshot !== 'function') {
21
+ return null;
22
+ }
23
+
24
+ const listed = agents.list();
25
+ if (!Array.isArray(listed)) throw new TypeError('agents.list() must return an array');
26
+ const result = [];
27
+ for (const agent of listed) {
28
+ if (agent?.status !== 'running') continue;
29
+ const id = agent.id;
30
+ if (agents.get(id) !== agent) continue;
31
+ const session = sessions.get(id);
32
+ if (!session || agent.session !== session) continue;
33
+ const header = session.header;
34
+ if (!header || header.id !== id || header.origin === 'subagent'
35
+ || header.parentSession != null || typeof header.cwd !== 'string' || !header.cwd) continue;
36
+
37
+ const snapshot = projections.snapshot(session, ['sessionListMetadata']);
38
+ const metadata = snapshot.values.sessionListMetadata;
39
+ const blank = metadata?.blank ?? session.seq === 0;
40
+ if (blank === true) continue;
41
+ // A service may synchronously trigger lifecycle work while deriving a view.
42
+ // Never publish an entry replaced or detached during that read.
43
+ if (agents.get(id) !== agent || agent.status !== 'running'
44
+ || sessions.get(id) !== session || agent.session !== session) continue;
45
+ result.push({
46
+ sessionId: id,
47
+ updatedAt: Math.max(header.createdAt, metadata?.lastPromptAt ?? 0),
48
+ agentAvailable: true,
49
+ running: true,
50
+ blank,
51
+ cwd: header.cwd,
52
+ ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }),
53
+ ...(header.origin === undefined ? {} : { origin: header.origin }),
54
+ projections: snapshot,
55
+ });
56
+ }
57
+ result.sort((left, right) => right.updatedAt - left.updatedAt);
58
+ return result;
59
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Host-only integration seam. A DSH profile bundle constructs the public
3
+ * InProcessApiClient and supplies the public events API. This standalone demo
4
+ * never imports DSH nested dependencies.
5
+ */
6
+ export class DshHostSource {
7
+ constructor(client) {
8
+ this.client = client;
9
+ this.controller = null;
10
+ }
11
+
12
+ async listSessions() {
13
+ const response = await this.client.sessions.list({});
14
+ if (!response.result.ok) throw new Error('DSH session list failed');
15
+ return response.result.value.items.map((session) => ({
16
+ id: session.sessionId,
17
+ running: Boolean(session.running),
18
+ // session.list intentionally carries no title or pending-interaction payload.
19
+ updatedAt: new Date(session.updatedAt).toISOString(),
20
+ }));
21
+ }
22
+
23
+ onEvent(listener) {
24
+ this.controller = new AbortController();
25
+ const signal = this.controller.signal;
26
+ Promise.all([this.consumeHost(signal, listener), this.consumeMux(signal, listener)])
27
+ .catch(() => { if (!signal.aborted) listener({ kind: "stream-failed" }); });
28
+ return () => this.controller.abort();
29
+ }
30
+
31
+ async consumeHost(signal, listener) {
32
+ for await (const frame of this.client.events.host({}, signal)) {
33
+ const item = hostFrame(frame.payload);
34
+ if (item) await listener(item);
35
+ }
36
+ }
37
+
38
+ async consumeMux(signal, listener) {
39
+ for await (const frame of this.client.events.mux({}, signal)) {
40
+ const item = muxFrame(frame.payload);
41
+ if (item) await listener(item);
42
+ }
43
+ }
44
+ }
45
+
46
+ function hostFrame(frame) {
47
+ if (frame.type === 'stream/error') return { kind: 'stream-failed' };
48
+ if (frame.type === 'host/session-added') return summary(frame, 'session-added');
49
+ if (frame.type === 'host/session-removed') return summary(frame, 'session-removed');
50
+ if (frame.type === 'host/session-status') return summary(frame, 'session-status');
51
+ return null;
52
+ }
53
+
54
+ function summary(frame, kind) {
55
+ return { sessionId: frame.sessionId, sequence: 0, kind, phase: frame.running ? 'running' : 'idle' };
56
+ }
57
+
58
+ function muxFrame(frame) {
59
+ if (frame.type === 'stream/error') return { kind: 'stream-failed' };
60
+ if (frame.type !== 'session/subscribed') return null;
61
+ return { sessionId: frame.sessionId, sequence: frame.lastSeq || 0, kind: 'session-subscribed', phase: 'subscribed' };
62
+ }