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.
- package/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map DSH's model catalog onto the capability payload the Cindy controller reads.
|
|
3
|
+
*
|
|
4
|
+
* The controller validates every field before it will use any of it
|
|
5
|
+
* (`normalizeMobileAgentCapabilities` in `maker-shared/agentCapabilities.ts`), and
|
|
6
|
+
* two of its names are counter-intuitive enough to be worth stating outright:
|
|
7
|
+
*
|
|
8
|
+
* - the display label is read from **`displayName`**, not from `label`;
|
|
9
|
+
* - plan mode is read from **`planMode.supported`**, an object, not from a
|
|
10
|
+
* boolean of the same meaning.
|
|
11
|
+
*
|
|
12
|
+
* Getting either wrong does not fail loudly: the field is simply dropped, the
|
|
13
|
+
* picker loses its list (or its label), and the surface looks like a Host that
|
|
14
|
+
* offers nothing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One `MobileChoiceOption`, in the wire shape the controller validates. */
|
|
18
|
+
function toChoiceOption(id, name) {
|
|
19
|
+
if (typeof id !== 'string' || id === '') return null;
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
displayName: typeof name === 'string' && name !== '' ? name : id,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One `MobileModelOption`.
|
|
28
|
+
* @param model - a DSH `ModelCatalogModel`.
|
|
29
|
+
* @returns the wire option, or null when the model has no usable id.
|
|
30
|
+
*/
|
|
31
|
+
function toModelOption(model) {
|
|
32
|
+
if (typeof model?.id !== 'string' || model.id === '') return null;
|
|
33
|
+
const reasons = Array.isArray(model?.reasoning?.efforts) ? model.reasoning.efforts : [];
|
|
34
|
+
const efforts = [];
|
|
35
|
+
const effortDisplayNames = {};
|
|
36
|
+
for (const effort of reasons) {
|
|
37
|
+
if (typeof effort?.id !== 'string' || effort.id === '') continue;
|
|
38
|
+
efforts.push(effort.id);
|
|
39
|
+
effortDisplayNames[effort.id] = typeof effort.name === 'string' && effort.name !== '' ? effort.name : effort.id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
id: model.id,
|
|
44
|
+
displayName: typeof model.name === 'string' && model.name !== '' ? model.name : model.id,
|
|
45
|
+
...(typeof model.description === 'string' && model.description !== '' ? { description: model.description } : {}),
|
|
46
|
+
efforts,
|
|
47
|
+
effortDisplayNames,
|
|
48
|
+
defaultEffort: typeof model?.reasoning?.defaultEffort === 'string' && model.reasoning.defaultEffort !== ''
|
|
49
|
+
? model.reasoning.defaultEffort
|
|
50
|
+
: null,
|
|
51
|
+
// DSH has no per-model fast mode, so the controller must not offer one.
|
|
52
|
+
supportsFastMode: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every model the catalog offers, in provider order.
|
|
58
|
+
* @param catalog - a DSH `ModelCatalog`.
|
|
59
|
+
* @returns the controller's `availableModels`.
|
|
60
|
+
*/
|
|
61
|
+
export function toAvailableModels(catalog) {
|
|
62
|
+
const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
|
|
63
|
+
const models = [];
|
|
64
|
+
for (const group of groups) {
|
|
65
|
+
for (const model of Array.isArray(group?.models) ? group.models : []) {
|
|
66
|
+
const option = toModelOption(model);
|
|
67
|
+
if (option !== null) models.push(option);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return models;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The flat effort list the controller falls back to.
|
|
75
|
+
*
|
|
76
|
+
* DSH declares effort per model route, so this is the union — deduplicated in
|
|
77
|
+
* first-seen order, which keeps the list stable across catalog reads.
|
|
78
|
+
* @param availableModels - the options `toAvailableModels` produced.
|
|
79
|
+
* @returns the controller's `effortLevels`.
|
|
80
|
+
*/
|
|
81
|
+
export function toEffortLevels(availableModels) {
|
|
82
|
+
const seen = new Map();
|
|
83
|
+
for (const model of availableModels) {
|
|
84
|
+
for (const effort of Array.isArray(model?.efforts) ? model.efforts : []) {
|
|
85
|
+
if (seen.has(effort)) continue;
|
|
86
|
+
const option = toChoiceOption(effort, model?.effortDisplayNames?.[effort]);
|
|
87
|
+
if (option !== null) seen.set(effort, option);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...seen.values()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The capability payload for one agent kind.
|
|
95
|
+
* @param catalog - a DSH `ModelCatalog`, or undefined when none loaded.
|
|
96
|
+
* @returns the payload `maker:get-capabilities` answers with.
|
|
97
|
+
*/
|
|
98
|
+
export function toAgentCapabilities(catalog) {
|
|
99
|
+
const availableModels = toAvailableModels(catalog);
|
|
100
|
+
return {
|
|
101
|
+
availableModels,
|
|
102
|
+
effortLevels: toEffortLevels(availableModels),
|
|
103
|
+
// This Host enforces no permission-mode vocabulary of its own; DSH's
|
|
104
|
+
// presets are not the controller's list, so the honest answer is empty.
|
|
105
|
+
permissionModes: [],
|
|
106
|
+
hasFastMode: false,
|
|
107
|
+
// Read as an object by the controller; a boolean here is silently ignored.
|
|
108
|
+
planMode: { supported: false },
|
|
109
|
+
supportsSessionAgentSwitch: false,
|
|
110
|
+
supportsModelWindowSwitchGuard: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The model id a session row should carry.
|
|
116
|
+
*
|
|
117
|
+
* The controller matches its current model with
|
|
118
|
+
* `availableModels.find(item => item.id === session.model)`, so a row whose
|
|
119
|
+
* `model` is a placeholder selects nothing and the picker shows no current model
|
|
120
|
+
* even when the catalog is correct.
|
|
121
|
+
* @param options - the catalog default and the session's recorded selection.
|
|
122
|
+
* @returns the model id, or null when the catalogue names none.
|
|
123
|
+
*/
|
|
124
|
+
export function modelIdFor({ catalog, selection }) {
|
|
125
|
+
const selected = selection?.model;
|
|
126
|
+
if (typeof selected === 'string' && selected !== '') return selected;
|
|
127
|
+
const fallback = catalog?.default?.model;
|
|
128
|
+
return typeof fallback === 'string' && fallback !== '' ? fallback : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Cindy's permission vocabulary mapped onto DSH's sandbox presets. */
|
|
132
|
+
const CINDY_PERMISSION_BY_DSH = new Map([
|
|
133
|
+
['read-only', { id: 'ask', displayName: 'Ask' }],
|
|
134
|
+
['workspace-write', { id: 'acceptEdits', displayName: 'Accept edits' }],
|
|
135
|
+
['danger-full-access', { id: 'bypassPermissions', displayName: 'Full access' }],
|
|
136
|
+
]);
|
|
137
|
+
const DSH_PERMISSION_BY_CINDY = new Map([...CINDY_PERMISSION_BY_DSH].map(([dsh, cindy]) => [cindy.id, dsh]));
|
|
138
|
+
|
|
139
|
+
/** Translate one DSH permission preset to the public Cindy option id. */
|
|
140
|
+
export function toCindyPermissionMode(value) {
|
|
141
|
+
return CINDY_PERMISSION_BY_DSH.get(value)?.id ?? 'ask';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Translate one Cindy permission choice back to the DSH preset name. */
|
|
145
|
+
export function toDshPermissionMode(value) {
|
|
146
|
+
return DSH_PERMISSION_BY_CINDY.get(value) ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Advertise only DSH presets that have an exact Cindy control meaning. */
|
|
150
|
+
export function toPermissionOptions(names) {
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const options = [];
|
|
153
|
+
for (const name of Array.isArray(names) ? names : []) {
|
|
154
|
+
const option = CINDY_PERMISSION_BY_DSH.get(name);
|
|
155
|
+
if (option === undefined || seen.has(option.id)) continue;
|
|
156
|
+
seen.add(option.id);
|
|
157
|
+
options.push({ ...option });
|
|
158
|
+
}
|
|
159
|
+
return options;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Project DSH's provider-grouped catalog into Cindy desktop's ProviderView wire shape.
|
|
164
|
+
* Routing credentials never cross this boundary; every Cindy harness alias reaches the
|
|
165
|
+
* same DSH catalog, so each provider exposes the same models under all aliases.
|
|
166
|
+
*/
|
|
167
|
+
export function toProviderList(catalog, agentKinds = []) {
|
|
168
|
+
const routable = new Set(Array.isArray(catalog?.routableProviders) ? catalog.routableProviders : []);
|
|
169
|
+
const agents = [...new Set((Array.isArray(agentKinds) ? agentKinds : []).filter((kind) => typeof kind === 'string' && kind !== ''))];
|
|
170
|
+
const providers = [];
|
|
171
|
+
for (const group of Array.isArray(catalog?.groups) ? catalog.groups : []) {
|
|
172
|
+
if (typeof group?.id !== 'string' || group.id === '') continue;
|
|
173
|
+
const models = [];
|
|
174
|
+
for (const model of Array.isArray(group.models) ? group.models : []) {
|
|
175
|
+
if (typeof model?.id !== 'string' || model.id === '') continue;
|
|
176
|
+
const contextWindow = Number(model.contextWindow);
|
|
177
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) continue;
|
|
178
|
+
const efforts = Array.isArray(model?.reasoning?.efforts)
|
|
179
|
+
? model.reasoning.efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '')
|
|
180
|
+
: [];
|
|
181
|
+
const defaultEffort = typeof model?.reasoning?.defaultEffort === 'string'
|
|
182
|
+
&& efforts.includes(model.reasoning.defaultEffort)
|
|
183
|
+
? model.reasoning.defaultEffort
|
|
184
|
+
: (efforts[0] ?? null);
|
|
185
|
+
models.push({
|
|
186
|
+
id: model.id,
|
|
187
|
+
name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id,
|
|
188
|
+
contextWindow,
|
|
189
|
+
efforts,
|
|
190
|
+
defaultEffort,
|
|
191
|
+
supportsFastMode: false,
|
|
192
|
+
defaultEnabled: true,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (models.length === 0) continue;
|
|
196
|
+
providers.push({
|
|
197
|
+
id: group.id,
|
|
198
|
+
name: typeof group.name === 'string' && group.name !== '' ? group.name : group.id,
|
|
199
|
+
agents,
|
|
200
|
+
connected: routable.has(group.id),
|
|
201
|
+
models: Object.fromEntries(agents.map((agent) => [agent, models.map((model) => ({ ...model }))])),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return { providers, providerOrder: providers.map((provider) => provider.id) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The new-session defaults DSH can state without owning Cindy's draft preferences. */
|
|
208
|
+
export function toNewMakerDefaults(catalog) {
|
|
209
|
+
const providerId = typeof catalog?.default?.provider === 'string' && catalog.default.provider !== ''
|
|
210
|
+
? catalog.default.provider
|
|
211
|
+
: null;
|
|
212
|
+
const modelId = typeof catalog?.default?.model === 'string' && catalog.default.model !== ''
|
|
213
|
+
? catalog.default.model
|
|
214
|
+
: null;
|
|
215
|
+
if (modelId === null) return {};
|
|
216
|
+
const group = (Array.isArray(catalog?.groups) ? catalog.groups : []).find((entry) => entry?.id === providerId);
|
|
217
|
+
const model = (Array.isArray(group?.models) ? group.models : []).find((entry) => entry?.id === modelId);
|
|
218
|
+
const efforts = Array.isArray(model?.reasoning?.efforts)
|
|
219
|
+
? model.reasoning.efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '')
|
|
220
|
+
: [];
|
|
221
|
+
const effort = typeof model?.reasoning?.defaultEffort === 'string' && efforts.includes(model.reasoning.defaultEffort)
|
|
222
|
+
? model.reasoning.defaultEffort
|
|
223
|
+
: (efforts[0] ?? undefined);
|
|
224
|
+
return {
|
|
225
|
+
model: modelId,
|
|
226
|
+
modelChosenByUser: false,
|
|
227
|
+
...(effort === undefined ? {} : { effort }),
|
|
228
|
+
fastMode: false,
|
|
229
|
+
providerId,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's `/` and `@` palettes, in the controller's vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Three channels feed them and they are easy to conflate:
|
|
5
|
+
*
|
|
6
|
+
* - `maker:list-agent-commands` — the agent's own slash commands, e.g. `/compact`;
|
|
7
|
+
* - `maker:list-agent-skills` — user-invocable skills, listed separately so the
|
|
8
|
+
* palette can offer them without pretending they are built-ins;
|
|
9
|
+
* - `maker:scan-at-resources` — the `@` menu, which lists paths, not commands.
|
|
10
|
+
*
|
|
11
|
+
* `maker:list-desktop-commands` is a fourth: commands that only the controlling
|
|
12
|
+
* desktop can run (Cindy's `/learn`). This Host owns none, and answering with an
|
|
13
|
+
* empty *success* is correct there — unlike the provider catalog, an empty list
|
|
14
|
+
* here means "nothing to offer", not "there is no such feature".
|
|
15
|
+
*
|
|
16
|
+
* Every list result is an envelope (`{ success, commands|skills|items }`), not a
|
|
17
|
+
* bare array: the controller reads the envelope, so an array would be dropped in
|
|
18
|
+
* silence.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Map one DSH skill source onto the controller's `'user' | 'skill'`.
|
|
23
|
+
*
|
|
24
|
+
* DSH names where a skill came from; the controller only asks whether the user
|
|
25
|
+
* put it there.
|
|
26
|
+
*/
|
|
27
|
+
function skillSourceOf(source) {
|
|
28
|
+
return source === 'user-dsh' || source === 'user-agents' || source === 'custom' ? 'user' : 'skill';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the `maker:list-agent-skills` answer.
|
|
33
|
+
*
|
|
34
|
+
* Only skills a human may invoke are listed: `invocation.userInvocable` is DSH's
|
|
35
|
+
* own statement that a skill belongs in a human-facing catalog, and offering one
|
|
36
|
+
* that says no would put an entry in the palette that cannot run.
|
|
37
|
+
*
|
|
38
|
+
* @param summaries - `ctx.skills.list()` output.
|
|
39
|
+
* @returns the controller's `MobileAgentSkillListResult`.
|
|
40
|
+
*/
|
|
41
|
+
export function toAgentSkills(summaries) {
|
|
42
|
+
const skills = [];
|
|
43
|
+
for (const skill of Array.isArray(summaries) ? summaries : []) {
|
|
44
|
+
if (typeof skill?.name !== 'string' || skill.name === '') continue;
|
|
45
|
+
if (skill?.invocation?.userInvocable !== true) continue;
|
|
46
|
+
skills.push({
|
|
47
|
+
kind: 'agent-skill',
|
|
48
|
+
name: skill.name,
|
|
49
|
+
description: typeof skill.description === 'string' ? skill.description : undefined,
|
|
50
|
+
source: skillSourceOf(skill.source),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { success: true, skills };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the `maker:list-agent-commands` / `maker:list-desktop-commands` answer.
|
|
58
|
+
* @param descriptors - `ctx.commands.list(agent)` output, or nothing.
|
|
59
|
+
* @param kind - the controller's command kind for this channel.
|
|
60
|
+
* @returns the controller's command list result.
|
|
61
|
+
*/
|
|
62
|
+
export function toAgentCommands(descriptors, kind = 'agent-builtin') {
|
|
63
|
+
const commands = [];
|
|
64
|
+
for (const command of Array.isArray(descriptors) ? descriptors : []) {
|
|
65
|
+
if (typeof command?.name !== 'string' || command.name === '') continue;
|
|
66
|
+
commands.push({
|
|
67
|
+
kind,
|
|
68
|
+
name: command.name,
|
|
69
|
+
description: typeof command.description === 'string' ? command.description : '',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return { success: true, commands };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Sort entries the way a palette reads: directories first, then by name. */
|
|
76
|
+
function compareEntries(a, b) {
|
|
77
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
78
|
+
return a.name.localeCompare(b.name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the `maker:scan-at-resources` answer for one working directory.
|
|
83
|
+
*
|
|
84
|
+
* The `@` menu lists what a message can reference, so this is a shallow listing
|
|
85
|
+
* of one directory rather than a tree: the controller re-scans with a deeper
|
|
86
|
+
* `relPath` when the user descends, and a tree would be both slower and larger
|
|
87
|
+
* than the palette can show.
|
|
88
|
+
*
|
|
89
|
+
* @param entries - directory entries: `{ name, kind }` where `kind` is `'dir'` or `'file'`.
|
|
90
|
+
* @param options - the base directory the entries are relative to, a cap, and a query filter.
|
|
91
|
+
* @returns the controller's `MobileAtResourceScanResult`.
|
|
92
|
+
*/
|
|
93
|
+
export function toAtResources(entries, { workingDir = '', cap = 200, query = '' } = {}) {
|
|
94
|
+
const wanted = typeof query === 'string' ? query.trim().toLowerCase() : '';
|
|
95
|
+
const items = [];
|
|
96
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
97
|
+
if (typeof entry?.name !== 'string' || entry.name === '') continue;
|
|
98
|
+
const isDir = entry.kind === 'dir';
|
|
99
|
+
if (wanted !== '' && !entry.name.toLowerCase().includes(wanted)) continue;
|
|
100
|
+
items.push({
|
|
101
|
+
type: isDir ? 'dir' : 'file',
|
|
102
|
+
name: entry.name,
|
|
103
|
+
// Relative to the working directory, which is the coordinate the
|
|
104
|
+
// controller sends back when the user picks the entry.
|
|
105
|
+
relPath: entry.name,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
items.sort(compareEntries);
|
|
109
|
+
const limit = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : 200;
|
|
110
|
+
return { success: true, items: items.slice(0, limit), truncated: items.length > limit };
|
|
111
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every push channel this Host sends, and the code that sends it.
|
|
3
|
+
*
|
|
4
|
+
* This list lives in `src` rather than in a tool because two callers need the same
|
|
5
|
+
* answer and had no way to share it: `tools/channel-audit.mjs` classifies the push
|
|
6
|
+
* side of the wire (what the phone handles, what this Host sends, what it declines
|
|
7
|
+
* and why), and `tools/acceptance.mjs` reports *coverage* — which of these channels a
|
|
8
|
+
* run actually produced. A second copy would drift, and the drift would be invisible:
|
|
9
|
+
* a channel added to the Host and forgotten here simply stops being audited.
|
|
10
|
+
*
|
|
11
|
+
* The value is the evidence for the claim, not a restatement of the code: each entry
|
|
12
|
+
* names the function that emits the frame, so a reader can check the pairing.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-cindy-host/host-push-channels
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Push channel → the emitting path in this Host. */
|
|
18
|
+
export const HOST_PUSH_CHANNELS = new Map([
|
|
19
|
+
['maker:event', 'host.js announceTurnRunning/announceTurnIdle (turn boundaries)'],
|
|
20
|
+
['local-db:messages:created', 'host.js pushSessionMessage (live fold)'],
|
|
21
|
+
// The history view's own invalidation. The controller handles this channel *before* any
|
|
22
|
+
// store logic (`DeviceLinkContext`: `historyView?.invalidate(); return;`), and it is what
|
|
23
|
+
// refreshes a session the controller is already inside — without it a new message only
|
|
24
|
+
// appeared after re-entering the session (报:重新进入会话之后对话就出来了). The audit
|
|
25
|
+
// used to record this channel as deliberately unsent because the view it invalidates was
|
|
26
|
+
// the one this Host refused; the Host serves the view now, so the reason expired with it.
|
|
27
|
+
['maker:history-view-changed', 'host.js pushSessionMessage (the view a session is showing)'],
|
|
28
|
+
['maker:input:projection', 'host.js pushInputProjection (queue changes)'],
|
|
29
|
+
['maker:goal:status-changed', 'host.js pushGoalStatus + applyControlFrame (goal projection)'],
|
|
30
|
+
['maker:interaction-request', 'host.js askApproval/askUserQuestion'],
|
|
31
|
+
['maker:interaction-dismissed', 'host.js onDismissed (answered, timed out, withdrawn)'],
|
|
32
|
+
['local-db:sessions:created', 'session-publisher.publishLifecycle'],
|
|
33
|
+
['local-db:sessions:patched', 'session-publisher.publishLifecycle'],
|
|
34
|
+
['local-db:sessions:activity', 'session-publisher.publishActivity'],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export default HOST_PUSH_CHANNELS;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnect policy for the relay socket.
|
|
3
|
+
*
|
|
4
|
+
* The reference is the Cindy device-link **client** (`packages/device-link/src/client.ts`),
|
|
5
|
+
* which never treats a lost socket as a terminal state: it keeps a `connecting` status
|
|
6
|
+
* and retries with exponential backoff, which is why a Cindy client "从来没有掉线过".
|
|
7
|
+
* This Host used to do the opposite — drop the socket, report `failed`, and wait for a
|
|
8
|
+
* person to press reconnect. On a phone-controlled Host that is a silent outage: the
|
|
9
|
+
* card says 断线 and the handset simply has no DSH in its device list until someone
|
|
10
|
+
* walks over to the desktop.
|
|
11
|
+
*
|
|
12
|
+
* The numbers are the client's defaults, so both ends of the same link recover on the
|
|
13
|
+
* same schedule:
|
|
14
|
+
*
|
|
15
|
+
* | constant | value | meaning |
|
|
16
|
+
* | --- | --- | --- |
|
|
17
|
+
* | `RECONNECT_BASE_MS` | 1s | first retry |
|
|
18
|
+
* | `RECONNECT_MAX_MS` | 30s | cap, so retries continue forever at ≤30s |
|
|
19
|
+
* | `RECONNECT_STABLE_RESET_MS` | 10s | stably online this long resets the ladder |
|
|
20
|
+
*
|
|
21
|
+
* One deliberate omission: the client's separate **congestion** ladder
|
|
22
|
+
* (`congestionBackoffBaseMs`/`congestionStableResetMs`) is driven by its reliable
|
|
23
|
+
* transport's backpressure signal (`transport-timeout-close-v1`), and this Host has no
|
|
24
|
+
* such layer — inventing a second ladder without that signal would only make the delay
|
|
25
|
+
* harder to predict.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-cindy-host/host-reconnect
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** First retry delay, matching the Cindy client. */
|
|
31
|
+
export const RECONNECT_BASE_MS = 1_000;
|
|
32
|
+
/** Longest gap between retries: retries continue indefinitely at this ceiling. */
|
|
33
|
+
export const RECONNECT_MAX_MS = 30_000;
|
|
34
|
+
/** How long a connection must hold before the attempt counter is forgotten. */
|
|
35
|
+
export const RECONNECT_STABLE_RESET_MS = 10_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Delay before the next attempt.
|
|
39
|
+
*
|
|
40
|
+
* Exponential from the base, capped, then multiplied by a **downward** jitter
|
|
41
|
+
* (0.7×–1.0×): many hosts that lose the same relay at the same moment must not come
|
|
42
|
+
* back in lockstep and re-congest it. Non-finite inputs are clamped rather than
|
|
43
|
+
* propagated — a `NaN` delay would silently become an immediate retry storm.
|
|
44
|
+
*
|
|
45
|
+
* @param options - `attempt` (0 for the first retry), the two bounds, and `random` in
|
|
46
|
+
* `[0, 1]` (injected so a test can pin the curve).
|
|
47
|
+
* @returns milliseconds to wait.
|
|
48
|
+
*/
|
|
49
|
+
export function computeReconnectDelayMs({ attempt, reconnectBaseMs = RECONNECT_BASE_MS, reconnectMaxMs = RECONNECT_MAX_MS, random = 0 } = {}) {
|
|
50
|
+
const step = Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0;
|
|
51
|
+
const roll = Number.isFinite(random) ? Math.min(Math.max(random, 0), 1) : 0;
|
|
52
|
+
const base = Math.min(reconnectBaseMs * 2 ** step, reconnectMaxMs);
|
|
53
|
+
return Math.round(base * (0.7 + roll * 0.3));
|
|
54
|
+
}
|