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
package/src/login.js ADDED
@@ -0,0 +1,31 @@
1
+ import { loginWithPhone } from "./cindy-login.js";
2
+ import { saveSession } from "./credential-store.js";
3
+
4
+ if (process.argv.includes("--help")) {
5
+ console.log("Usage: CINDY_AUTH_BASE_URL=https://auth.example npm run login");
6
+ process.exit(0);
7
+ }
8
+
9
+ const authBaseUrl = process.env.CINDY_AUTH_BASE_URL || readFlag("--auth-base-url");
10
+ const clientType = process.env.CINDY_CLIENT_TYPE || "desktop";
11
+
12
+ try {
13
+ const session = await loginWithPhone({ authBaseUrl, clientType });
14
+ // Persist so the settings card sees the same session the terminal just made.
15
+ // Best-effort: a host with no OS credential store still gets a usable handle.
16
+ try {
17
+ await saveSession(session);
18
+ } catch {
19
+ console.error("Cindy logged in, but the OS credential store is unavailable; the settings card will not see this session");
20
+ }
21
+ // Deliberately expose only the non-secret local device handle.
22
+ console.log(JSON.stringify({ status: "authenticated", deviceId: session.deviceId }, null, 2));
23
+ } catch (error) {
24
+ console.error(error instanceof Error ? error.message : "Cindy login failed");
25
+ process.exitCode = 1;
26
+ }
27
+
28
+ function readFlag(name) {
29
+ const index = process.argv.indexOf(name);
30
+ return index >= 0 ? process.argv[index + 1] : undefined;
31
+ }
package/src/logout.js ADDED
@@ -0,0 +1,3 @@
1
+ import { clearSession } from './auth-session.js';
2
+ await clearSession();
3
+ console.log('{"status":"signed-out"}');
@@ -0,0 +1,6 @@
1
+ export class ProjectionReadModelSink {
2
+ constructor(model, onActivity = () => {}) { this.model = model; this.onActivity = onActivity; }
3
+ async replaceConversations(rows) { this.model.rows = new Map(rows.map((row) => [row.sessionId, row])); }
4
+ async appendActivity(item) { this.onActivity(item); }
5
+ async markStale() {}
6
+ }
@@ -0,0 +1,43 @@
1
+ import { toActivityItem, toConversationRow } from "./contracts.js";
2
+
3
+ export class ReadOnlyProjection {
4
+ constructor(sink) {
5
+ this.sink = sink;
6
+ this.source = null;
7
+ this.unsubscribe = null;
8
+ this.rebasing = false;
9
+ }
10
+
11
+ async start(source) {
12
+ this.source = source;
13
+ await this.rebaseline();
14
+ this.unsubscribe = source.onEvent((event) => this.apply(event));
15
+ }
16
+
17
+ async stop() {
18
+ if (this.unsubscribe) this.unsubscribe();
19
+ this.unsubscribe = null;
20
+ }
21
+
22
+ async apply(event) {
23
+ if (event.kind === "stream-failed") return this.recover();
24
+ const item = toActivityItem(event);
25
+ if (item) await this.sink.appendActivity(item);
26
+ }
27
+
28
+ async recover() {
29
+ if (this.rebasing) return;
30
+ this.rebasing = true;
31
+ try {
32
+ await this.sink.markStale();
33
+ await this.rebaseline();
34
+ } finally {
35
+ this.rebasing = false;
36
+ }
37
+ }
38
+
39
+ async rebaseline() {
40
+ const sessions = await this.source.listSessions();
41
+ await this.sink.replaceConversations(sessions.map(toConversationRow));
42
+ }
43
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The three session actions DSH itself has no concept of: archive, delete, pin.
3
+ *
4
+ * A controller's session list offers 删除 / 归档 / 置顶 / 重命名, and all four are
5
+ * the *same* write (`local-db:sessions:patch-meta`, see
6
+ * `Cindy/apps/desktop/src/renderer/lib/sessionService.ts` and
7
+ * `Cindy/apps/mobile/src/session/useSessionListActions.ts`). Only 重命名 has a DSH
8
+ * counterpart — DSH sessions are a log-backed store with no archived, hidden or
9
+ * pinned state — so this module is the Host's own bookkeeping for the other three.
10
+ *
11
+ * Three consequences are deliberate and must stay visible:
12
+ *
13
+ * 1. **删除 hides, it does not destroy.** The session is removed from what this
14
+ * Host's controllers see; DSH's log and the desk's own DSH UI are untouched. A
15
+ * Host that deleted DSH data on a phone tap would be a far worse failure than a
16
+ * hidden row, and the user can always restore from the archive view.
17
+ * 2. **The flags outlive the process.** They live in this plugin's settings section
18
+ * (`sessionFlags`), so an archived session does not silently reappear on the
19
+ * next start — a hide that forgets is worse than no hide at all.
20
+ * 3. **An unknown session id can still be flagged.** The controller's write is
21
+ * authoritative about what it wants; its own cached row is what it is editing.
22
+ */
23
+
24
+ /** The statuses the controller's filter understands. */
25
+ const STATUSES = new Set(['active', 'archived', 'deleted']);
26
+
27
+ /**
28
+ * Normalize one stored entry, dropping anything malformed.
29
+ *
30
+ * Settings survive upgrades and hand edits, so a bad entry must cost one row's
31
+ * flag rather than the whole list.
32
+ */
33
+ function normalizeEntry(value) {
34
+ if (value === null || typeof value !== 'object') return null;
35
+ const entry = {};
36
+ if (typeof value.status === 'string' && STATUSES.has(value.status) && value.status !== 'active') entry.status = value.status;
37
+ // An empty string is not a pin. Settings round-trips can hand one back (a `null`
38
+ // written by an older build becomes `''` in YAML), and keeping it would leave an
39
+ // entry behind for every session the user ever unpinned.
40
+ if (typeof value.pinnedAt === 'string' && value.pinnedAt !== '') entry.pinnedAt = value.pinnedAt;
41
+ return Object.keys(entry).length === 0 ? null : entry;
42
+ }
43
+
44
+ /**
45
+ * Build the flag store over a settings-backed snapshot.
46
+ *
47
+ * @param options - `initial` is the settings section's value; `persist` is awaited
48
+ * with the whole snapshot after every change (the settings service owns the write,
49
+ * so this module never touches storage itself).
50
+ * @returns the flag store the channel layer and the row projection share.
51
+ */
52
+ export function createSessionFlags({ initial, persist } = {}) {
53
+ /** sessionId -> `{ status?: 'archived'|'deleted', pinnedAt?: string }`. */
54
+ const entries = new Map();
55
+
56
+ /**
57
+ * Adopt the settings section's value as the truth.
58
+ *
59
+ * Called whenever settings change (including once at start). It deliberately does
60
+ * **not** persist: the settings service is the writer here, and a replace that
61
+ * wrote back would loop through `scope.watch`.
62
+ */
63
+ function replace(value) {
64
+ entries.clear();
65
+ if (value === null || typeof value !== 'object') return;
66
+ for (const [sessionId, raw] of Object.entries(value)) {
67
+ const entry = normalizeEntry(raw);
68
+ if (entry !== null && sessionId !== '') entries.set(sessionId, entry);
69
+ }
70
+ }
71
+
72
+ replace(initial);
73
+
74
+ /** The stored flags for one session, or undefined when it carries none. */
75
+ function flagsFor(sessionId) {
76
+ return entries.get(String(sessionId));
77
+ }
78
+
79
+ /** The whole store, in the shape the settings section stores. */
80
+ function snapshot() {
81
+ const out = {};
82
+ for (const [sessionId, entry] of entries) out[sessionId] = { ...entry };
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Record one metadata write and hand back what it did.
88
+ *
89
+ * @param sessionId - the session the controller is editing.
90
+ * @param patch - the controller's `{ status?, pinnedAt? }`, verbatim.
91
+ * @returns `{ status, pinnedAt }` — the effective values the reply row must carry.
92
+ * `pinnedAt` is null for an unpinned session, and the caller echoes it only when
93
+ * the controller asked for it (an unpin is a real patch: it is what clears the
94
+ * pin on every other device).
95
+ */
96
+ async function apply(sessionId, patch) {
97
+ const id = String(sessionId);
98
+ const next = { ...(entries.get(id) ?? {}) };
99
+
100
+ if (patch.status !== undefined) {
101
+ if (typeof patch.status !== 'string' || !STATUSES.has(patch.status)) throw new Error(`unsupported session status ${String(patch.status)}`);
102
+ // `active` is the absence of a flag, not a flag: restoring a session must
103
+ // leave nothing behind, or the store would grow an entry per restored row.
104
+ if (patch.status === 'active') delete next.status;
105
+ else next.status = patch.status;
106
+ }
107
+
108
+ if (patch.pinnedAt !== undefined) {
109
+ // `null` is the controller's unpin — `swipeActionPatch('archive')` sends
110
+ // `{ status: 'archived', pinnedAt: null }`, and 取消置顶 sends the same field
111
+ // alone. Deleting the key is the whole unpin: this store is the *only* source
112
+ // of a pin (DSH has none), so there is no stale value to come back.
113
+ if (patch.pinnedAt === null || patch.pinnedAt === '') delete next.pinnedAt;
114
+ else if (typeof patch.pinnedAt === 'string') next.pinnedAt = patch.pinnedAt;
115
+ else throw new Error('pinnedAt must be an ISO string or null');
116
+ }
117
+
118
+ if (Object.keys(next).length === 0) entries.delete(id);
119
+ else entries.set(id, next);
120
+
121
+ if (typeof persist === 'function') await persist(snapshot());
122
+
123
+ return { status: next.status ?? 'active', pinnedAt: next.pinnedAt ?? null };
124
+ }
125
+
126
+ /**
127
+ * Fold the flags onto one source row.
128
+ *
129
+ * The source row is deliberately **not** copied when it carries no flags: the
130
+ * overwhelming majority of rows do not, and `toCindySessionListRow` is the single
131
+ * place that decides the wire shape.
132
+ */
133
+ function project(row) {
134
+ if (row === null || typeof row !== 'object') return row;
135
+ const entry = entries.get(String(row.id));
136
+ if (entry === undefined) return row;
137
+ const next = { ...row };
138
+ if (entry.status !== undefined) next.status = entry.status;
139
+ if (typeof entry.pinnedAt === 'string' && entry.pinnedAt !== '') next.pinnedAt = entry.pinnedAt;
140
+ return next;
141
+ }
142
+
143
+ function projectAll(rows) {
144
+ const list = Array.isArray(rows) ? rows : [];
145
+ return list.map((row) => project(row));
146
+ }
147
+
148
+ /**
149
+ * Whether a row is hidden from this Host's controllers.
150
+ *
151
+ * Archived and deleted both drop out of the live surfaces (`maker:list-active`):
152
+ * a row the phone does not show must not be able to light its running badge.
153
+ */
154
+ function isHidden(sessionId) {
155
+ const entry = entries.get(String(sessionId));
156
+ return entry?.status === 'archived' || entry?.status === 'deleted';
157
+ }
158
+
159
+ return { replace, apply, project, projectAll, isHidden, snapshot, flagsFor };
160
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Send one list-level push to the controllers holding the `sessions` topic.
3
+ *
4
+ * @param ws - the relay socket.
5
+ * @param subscribers - the devices holding the topic.
6
+ * @param channel - the push channel.
7
+ * @param payload - the channel's payload.
8
+ * @param record - called with the channel and its watcher count.
9
+ *
10
+ * `record` exists because these pushes bypass the session-scoped path that keeps
11
+ * `recentPushes`/`pushTotals`. Without it the list-level pushes — the very ones a
12
+ * session list's running badge depends on — were invisible in diagnostics, so
13
+ * "did we tell the controller the turn ended?" could not be answered at all.
14
+ */
15
+ export function publish(ws, subscribers, channel, payload, record) {
16
+ if (typeof record === 'function') record(channel, subscribers.size, payload);
17
+ for (const dst of subscribers) ws.send(JSON.stringify({ v: 1, kind: 'push', dst, payload: { channel, payload } }));
18
+ }
19
+
20
+ /**
21
+ * The controller's live-activity phase for one lifecycle item, or null.
22
+ *
23
+ * The mapping is by **kind as well as phase**, because DSH reuses `idle` for two
24
+ * different facts: a turn that just finished (`session-status`) and a session
25
+ * that has simply never run (`session-added`). Only the first is a completion.
26
+ *
27
+ * The fallback matters more than it looks. This used to end in `: 'running'`, so
28
+ * every phase it did not name — `idle` above all — announced a running turn. The
29
+ * controller sets `isRunning: true` for `running` and only converges it to false
30
+ * for `completed`/`error`, so a finished turn left the session list spinning
31
+ * until something else happened to correct it.
32
+ *
33
+ * @param item - one lifecycle item from the projection sink.
34
+ * @returns the controller's phase, or null when the item claims nothing.
35
+ */
36
+ export function activityPhaseFor(item) {
37
+ if (item === null || typeof item !== 'object') return null;
38
+ // A session appearing or a subscription opening says nothing about a turn.
39
+ if (item.kind === 'session-added' || item.kind === 'session-removed' || item.kind === 'session-subscribed') return null;
40
+ switch (item.phase) {
41
+ case 'waiting': return 'needs-interaction';
42
+ case 'running': return 'running';
43
+ case 'completed': return 'completed';
44
+ case 'error': return 'error';
45
+ // A turn that ended: the only thing that tells the controller to stop.
46
+ case 'idle': return 'completed';
47
+ // `active` is DSH's bare "something happened" tick, not a statement that a
48
+ // turn is live — `api-session/status` is that authority. Claiming `running`
49
+ // for it meant a tick arriving *after* a turn ended told the controller the
50
+ // session was busy again, with no later terminal event to undo it: the
51
+ // spinner never stopped. An unknown phase is the same kind of non-claim.
52
+ default: return null;
53
+ }
54
+ }
55
+
56
+ export function publishActivity(ws, subscribers, item, record) {
57
+ const phase = activityPhaseFor(item);
58
+ // An item this Host cannot place is not a claim about the session's state.
59
+ if (phase === null) return;
60
+ publish(ws, subscribers, 'local-db:sessions:activity', { sessionId: item.sessionId, phase, compactDetail: '', attention: phase === 'needs-interaction' }, record);
61
+ }
62
+
63
+ /**
64
+ * Publish one lifecycle item to the list subscribers.
65
+ * @param ws - the relay socket.
66
+ * @param subscribers - the devices holding the `sessions` topic.
67
+ * @param item - one lifecycle item from the projection sink.
68
+ * @param record - called with each channel and its watcher count.
69
+ */
70
+ export function publishLifecycle(ws, subscribers, item, record) {
71
+ if (item.kind === 'session-added') publish(ws, subscribers, 'local-db:sessions:created', { sessionId: item.sessionId }, record);
72
+ else if (item.kind === 'session-status') publish(ws, subscribers, 'local-db:sessions:patched', { sessionId: item.sessionId, patch: { updatedAt: item.occurredAt } }, record);
73
+ publishActivity(ws, subscribers, item, record);
74
+ }
75
+
76
+ /**
77
+ * The per-session turn event that starts and stops the controller's spinner.
78
+ *
79
+ * The controller clears "thinking" on a terminal `maker:event` and on nothing
80
+ * else: `maker:status-changed` only retires a *closed* session, and the
81
+ * list-level activity push belongs to the `sessions` topic, which a controller
82
+ * viewing a single session does not hold. Without this, a finished turn left the
83
+ * phone spinning forever — the answer had arrived and the composer still said
84
+ * 思考中.
85
+ *
86
+ * `maker:event` is session-scoped, so it must go to `session:<id>` watchers
87
+ * rather than to the list subscribers.
88
+ * @param item - one lifecycle item from the projection sink.
89
+ * @returns the channel and payload, or null when the item says nothing about a turn.
90
+ */
91
+ /** Cindy status events require both the running flag and a displayable string. */
92
+ export function runningStatusEvent(status = 'running') {
93
+ return { type: 'status', data: { isRunning: true, status } };
94
+ }
95
+
96
+ export function turnEventFor(item) {
97
+ const sessionId = item?.sessionId;
98
+ if (typeof sessionId !== 'string' || sessionId === '') return null;
99
+ if (item.kind !== 'session-status') return null;
100
+ switch (item.phase) {
101
+ // A turn (or a wait for the user) is live.
102
+ case 'running':
103
+ return { channel: 'maker:event', payload: { sessionId, event: runningStatusEvent() } };
104
+ case 'waiting':
105
+ return { channel: 'maker:event', payload: { sessionId, event: runningStatusEvent('waiting on input') } };
106
+ // A failure is unambiguous.
107
+ case 'error':
108
+ return { channel: 'maker:event', payload: { sessionId, event: { type: 'done' } } };
109
+ // `idle` is deliberately NOT an ending here.
110
+ //
111
+ // This flag flaps: `api-session/status` reports `running: false` for a moment
112
+ // between a prompt being accepted and its turn really starting, so treating it
113
+ // as the end sent a `done` **31 ms after a live turn began** — the controller
114
+ // finalized its streaming rows and stopped its timer while the agent was still
115
+ // generating. Real endings come from DSH's own `turn/end` event (driven by the
116
+ // plugin), with the durable-message reconcile as the fallback if one is lost.
117
+ //
118
+ // `active` is the same kind of non-claim: it is a bare "something happened"
119
+ // tick, not a statement about a turn.
120
+ default:
121
+ return null;
122
+ }
123
+ }
@@ -0,0 +1,6 @@
1
+ import { toConversationRow } from './contracts.js';
2
+ export class SessionReadModel {
3
+ constructor() { this.rows = new Map(); }
4
+ replace(sessions) { this.rows = new Map(sessions.map((session) => { const row = toConversationRow(session); return [row.sessionId, row]; })); }
5
+ list() { return [...this.rows.values()]; }
6
+ }