@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +237 -2
- package/bin/pocket.js +31 -0
- package/cordis.patch.yml +6 -0
- package/index.js +360 -0
- package/lib/client.js +2453 -0
- package/package.json +91 -4
- package/src/advertise-host.js +69 -0
- package/src/approval-hook.js +50 -0
- package/src/client/ConfirmDialog.tsx +80 -0
- package/src/client/HostUiLauncher.tsx +101 -0
- package/src/client/ManagePanel.tsx +218 -0
- package/src/client/PhoneIcon.tsx +48 -0
- package/src/client/PocketSettings.tsx +13 -0
- package/src/client/api.ts +97 -0
- package/src/client/completion-sync.js +289 -0
- package/src/client/host-ui-styles.ts +62 -0
- package/src/client/index.ts +47 -0
- package/src/client/locales.ts +75 -0
- package/src/client/nav-icon.ts +31 -0
- package/src/client/qr.ts +15 -0
- package/src/client/styles.ts +76 -0
- package/src/completion-route.js +87 -0
- package/src/completion-tracker.js +95 -0
- package/src/devices.js +60 -0
- package/src/extra-ops.js +604 -0
- package/src/host-ui.js +466 -0
- package/src/instance-id.js +33 -0
- package/src/iroh.js +260 -0
- package/src/manage.js +122 -0
- package/src/mdns-advertise.js +75 -0
- package/src/office-preview.js +129 -0
- package/src/pairing-uri.js +42 -0
- package/src/pairing.js +34 -0
- package/src/parity-ops.js +250 -0
- package/src/server.js +761 -0
- package/src/settings-ops.js +387 -0
- package/src/task-events.js +77 -0
- package/src/task-fallback.js +177 -0
- package/src/token.js +18 -0
- package/src/transport-pref.js +47 -0
- package/src/trust.js +25 -0
- package/src/user-question-hook.js +93 -0
- package/src/web-route.js +116 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop settings Host surfaces for the phone: models providers, plugins,
|
|
3
|
+
* schedules, and model API-key writes.
|
|
4
|
+
*
|
|
5
|
+
* Data shapes and Host call patterns follow deepseek-harness client modules
|
|
6
|
+
* (ui-settings-models, ui-plugin-manager, ui-schedule, settings-controller).
|
|
7
|
+
* Mobile nav (list → full-screen detail) follows the dsh-mobile panel pattern
|
|
8
|
+
* (Apache-2.0; see docs/THIRD_PARTY_NOTICES.md) without copying WebView UI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const str = (v) => typeof v === 'string' && v.length > 0
|
|
12
|
+
const bad = (m) => Object.assign(new Error(m), { code: 'bad-request' })
|
|
13
|
+
|
|
14
|
+
/** Conventional credential ref for a provider route (ui-settings-models deriveKeyRef). */
|
|
15
|
+
export function deriveKeyRef(provider) {
|
|
16
|
+
return `${String(provider).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Walk a plain object by string path segments. */
|
|
20
|
+
export function getPath(value, path) {
|
|
21
|
+
let cur = value
|
|
22
|
+
for (const key of path ?? []) {
|
|
23
|
+
if (cur === undefined || cur === null || typeof cur !== 'object') return undefined
|
|
24
|
+
cur = cur[key]
|
|
25
|
+
}
|
|
26
|
+
return cur
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Join configurable directory with live routes (ui-settings-models joinProviderDirectory). */
|
|
30
|
+
export function joinProviderDirectory(registered, directory) {
|
|
31
|
+
const active = new Set((registered ?? []).map((p) => p.id))
|
|
32
|
+
const rows = (directory ?? []).map((entry) => ({
|
|
33
|
+
provider: entry.provider,
|
|
34
|
+
displayName: entry.displayName ?? entry.provider,
|
|
35
|
+
settingsNs: entry.settingsNs ?? '',
|
|
36
|
+
settingsPath: [...(entry.settingsPath ?? [])],
|
|
37
|
+
active: active.has(entry.provider),
|
|
38
|
+
...(entry.declared === undefined ? {} : { declared: entry.declared }),
|
|
39
|
+
...(entry.error === undefined ? {} : { error: entry.error }),
|
|
40
|
+
}))
|
|
41
|
+
const rank = (id) => (id === 'deepseek-account' ? 0 : id === 'deepseek-official' ? 1 : 2)
|
|
42
|
+
return rows.sort((a, b) => rank(a.provider) - rank(b.provider))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveSchemaNode(schema, raw) {
|
|
46
|
+
const uid = raw && typeof raw === 'object' ? raw.uid : raw
|
|
47
|
+
return uid !== undefined && schema?.refs ? schema.refs[uid] : raw
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read the Host's serialized Schemastery graph without loading client services. */
|
|
51
|
+
function schemaNodeAtPath(schema, path) {
|
|
52
|
+
const resolve = (raw) => resolveSchemaNode(schema, raw)
|
|
53
|
+
let node = resolve(schema)
|
|
54
|
+
for (const key of path) {
|
|
55
|
+
if (node?.type === 'object') node = resolve(node.dict?.[key])
|
|
56
|
+
else if (node?.type === 'dict' || node?.type === 'array') node = resolve(node.inner)
|
|
57
|
+
else return undefined
|
|
58
|
+
}
|
|
59
|
+
return node
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function protocolChoices(namespace) {
|
|
63
|
+
const schema = namespace?.schema
|
|
64
|
+
const node = schemaNodeAtPath(schema, ['providers', '\u0000probe', 'api'])
|
|
65
|
+
if (node?.type !== 'union' || !Array.isArray(node.list)) return []
|
|
66
|
+
return node.list.map((raw) => resolveSchemaNode(schema, raw)?.value)
|
|
67
|
+
.filter((value) => typeof value === 'string')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasPath(value, path) {
|
|
71
|
+
if (path.length === 0) return value !== undefined
|
|
72
|
+
const parent = getPath(value, path.slice(0, -1))
|
|
73
|
+
return parent !== null && typeof parent === 'object' && Object.hasOwn(parent, path.at(-1))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function projectCredentialInfo(info) {
|
|
77
|
+
if (!info || typeof info !== 'object') return { configured: false, writable: false }
|
|
78
|
+
return {
|
|
79
|
+
configured: Boolean(info.configured),
|
|
80
|
+
...(info.source === undefined ? {} : { source: info.source }),
|
|
81
|
+
writable: Boolean(info.writable),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function namespaceView(descriptor) {
|
|
86
|
+
return {
|
|
87
|
+
ns: descriptor.ns,
|
|
88
|
+
revision: descriptor.revision,
|
|
89
|
+
value: descriptor.value,
|
|
90
|
+
user: descriptor.user,
|
|
91
|
+
base: descriptor.base,
|
|
92
|
+
schema: descriptor.schema,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Models settings ops over llm + settings + credentials Host services.
|
|
98
|
+
* Session model picker stays on existing models.catalog / models.select.
|
|
99
|
+
*/
|
|
100
|
+
export function modelsSettingsOps({ llm, settings, settingsController, credentials }) {
|
|
101
|
+
const describeSettings = () => {
|
|
102
|
+
if (settingsController?.describe) return settingsController.describe()
|
|
103
|
+
if (!settings?.describe) throw Object.assign(new Error('settings unavailable'), { code: 'unavailable' })
|
|
104
|
+
const raw = settings.describe({ redactSecrets: true })
|
|
105
|
+
return {
|
|
106
|
+
writable: settings.writable !== false,
|
|
107
|
+
hasDocument: true,
|
|
108
|
+
namespaces: (Array.isArray(raw) ? raw : []).map(namespaceView),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const describeCreds = async (refs) => {
|
|
113
|
+
if (!credentials?.describe) return { values: {}, error: refs.length ? 'credentials unavailable' : null }
|
|
114
|
+
try {
|
|
115
|
+
const entries = await Promise.all(refs.map(async (ref) => [
|
|
116
|
+
ref, projectCredentialInfo(await credentials.describe(ref)),
|
|
117
|
+
]))
|
|
118
|
+
return { values: Object.fromEntries(entries), error: null }
|
|
119
|
+
} catch {
|
|
120
|
+
// Credential state enriches the page; an unavailable backend must not hide settings.
|
|
121
|
+
return { values: {}, error: 'credentials unavailable' }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
'models.providers': async () => {
|
|
127
|
+
if (!llm?.listProviders || !llm?.listConfigurableProviders) {
|
|
128
|
+
throw Object.assign(new Error('llm service unavailable'), { code: 'unavailable' })
|
|
129
|
+
}
|
|
130
|
+
const registered = llm.listProviders()
|
|
131
|
+
const directory = llm.listConfigurableProviders()
|
|
132
|
+
const providers = joinProviderDirectory(registered, directory)
|
|
133
|
+
const described = describeSettings()
|
|
134
|
+
const namespaces = new Map((described.namespaces ?? []).map((n) => [n.ns, n]))
|
|
135
|
+
const rows = providers.filter((entry) => namespaces.has(entry.settingsNs)).map((entry) => {
|
|
136
|
+
const ns = namespaces.get(entry.settingsNs)
|
|
137
|
+
const profile = getPath(ns.value, entry.settingsPath)
|
|
138
|
+
const configured = entry.settingsPath.length === 0 || profile !== undefined
|
|
139
|
+
const apiKeyEnv = entry.provider !== 'deepseek-account' && str(profile?.apiKeyEnv)
|
|
140
|
+
? profile.apiKeyEnv
|
|
141
|
+
: undefined
|
|
142
|
+
return {
|
|
143
|
+
...entry,
|
|
144
|
+
configured,
|
|
145
|
+
editable: described.writable !== false,
|
|
146
|
+
removable: entry.settingsPath.length > 0
|
|
147
|
+
&& hasPath(ns.user, entry.settingsPath) && !hasPath(ns.base, entry.settingsPath),
|
|
148
|
+
apiKeyEnv,
|
|
149
|
+
profile: profile && typeof profile === 'object' ? profile : undefined,
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
const account = rows.find((row) => row.provider === 'deepseek-account')
|
|
153
|
+
if (account) {
|
|
154
|
+
try {
|
|
155
|
+
account.accountAvailable = account.active && (await llm.listModels(account.provider)).length > 0
|
|
156
|
+
} catch {
|
|
157
|
+
account.accountAvailable = false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const refs = [...new Set(rows.filter((row) => row.provider !== 'deepseek-account')
|
|
161
|
+
.map((row) => row.apiKeyEnv ?? deriveKeyRef(row.provider)))]
|
|
162
|
+
const { values: credentialsMap, error: credentialError } = await describeCreds(refs)
|
|
163
|
+
return {
|
|
164
|
+
writable: described.writable !== false && typeof settingsController?.mutate === 'function',
|
|
165
|
+
namespaces: described.namespaces ?? [],
|
|
166
|
+
protocols: protocolChoices(namespaces.get('llm-pi-ai')),
|
|
167
|
+
credentialError,
|
|
168
|
+
rows: rows.filter((row) => row.provider !== 'deepseek-account' || row.accountAvailable).map((row) => ({
|
|
169
|
+
...row,
|
|
170
|
+
...(row.provider === 'deepseek-account' ? {} : row.apiKeyEnv
|
|
171
|
+
? { credential: credentialsMap[row.apiKeyEnv] }
|
|
172
|
+
: { derivedCredential: credentialsMap[deriveKeyRef(row.provider)] }),
|
|
173
|
+
})),
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
'models.providerGet': async (a) => {
|
|
178
|
+
if (!str(a.provider)) throw bad('provider required')
|
|
179
|
+
const listed = await modelsSettingsOps({ llm, settings, settingsController, credentials })['models.providers']()
|
|
180
|
+
const row = listed.rows.find((r) => r.provider === a.provider)
|
|
181
|
+
if (!row) throw Object.assign(new Error(`unknown provider: ${a.provider}`), { code: 'not-found' })
|
|
182
|
+
const namespace = listed.namespaces.find((ns) => ns.ns === row.settingsNs)
|
|
183
|
+
const modelsPath = [...row.settingsPath, 'models']
|
|
184
|
+
let catalog = []
|
|
185
|
+
let catalogError = null
|
|
186
|
+
if (row.active) {
|
|
187
|
+
try {
|
|
188
|
+
catalog = await llm.listModels(row.provider)
|
|
189
|
+
} catch {
|
|
190
|
+
catalogError = 'model catalog unavailable'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
writable: listed.writable, provider: row, namespace, protocols: listed.protocols,
|
|
195
|
+
catalog, catalogError,
|
|
196
|
+
inheritedModels: getPath(namespace?.base, modelsPath)
|
|
197
|
+
?? schemaNodeAtPath(namespace?.schema, modelsPath)?.meta?.default,
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
'models.settingsMutate': async (a) => {
|
|
202
|
+
if (!str(a.ns) || !Array.isArray(a.ops)) throw bad('ns and ops required')
|
|
203
|
+
if (!Number.isSafeInteger(a.expectedRevision) || a.expectedRevision < 0) throw bad('expectedRevision required')
|
|
204
|
+
const controller = settingsController
|
|
205
|
+
if (controller?.mutate) {
|
|
206
|
+
return controller.mutate(a.ns, a.ops, a.expectedRevision)
|
|
207
|
+
}
|
|
208
|
+
throw Object.assign(new Error('safe settings controller unavailable'), { code: 'unavailable' })
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
'models.discover': async (a, signal) => {
|
|
212
|
+
if (!str(a.settingsNs)) throw bad('settingsNs required')
|
|
213
|
+
if (!llm?.discoverModels) throw Object.assign(new Error('llm discovery unavailable'), { code: 'unavailable' })
|
|
214
|
+
const models = await llm.discoverModels(a.settingsNs, a.request ?? {}, signal)
|
|
215
|
+
return { models }
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Keep the official manager's business outcomes intact: an acknowledged RPC can
|
|
222
|
+
* still require restart, be overridden, or fail. Only advertise live methods.
|
|
223
|
+
*/
|
|
224
|
+
export function pluginsOps(pluginManager) {
|
|
225
|
+
if (!pluginManager) throw bad('pluginManager required')
|
|
226
|
+
const ops = {
|
|
227
|
+
'plugins.list': async () => {
|
|
228
|
+
const [bundles, plugins] = await Promise.all([
|
|
229
|
+
pluginManager.listBundles(),
|
|
230
|
+
pluginManager.listPlugins(),
|
|
231
|
+
])
|
|
232
|
+
return {
|
|
233
|
+
bundles: bundles ?? [],
|
|
234
|
+
plugins: plugins ?? [],
|
|
235
|
+
managementAvailable: true,
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
'plugins.setEnabled': async (a) => {
|
|
239
|
+
if (typeof a.enabled !== 'boolean') throw bad('enabled boolean required')
|
|
240
|
+
if (str(a.bundle) && (str(a.id) || str(a.entryId))) throw bad('choose bundle or id, not both')
|
|
241
|
+
if (str(a.id) && str(a.entryId) && a.id !== a.entryId) throw bad('conflicting plugin ids')
|
|
242
|
+
if (str(a.bundle)) {
|
|
243
|
+
return pluginManager.setBundleEnabled(a.bundle, a.enabled)
|
|
244
|
+
}
|
|
245
|
+
if (str(a.id) || str(a.entryId)) {
|
|
246
|
+
return pluginManager.setPluginEnabled(a.id ?? a.entryId, a.enabled)
|
|
247
|
+
}
|
|
248
|
+
throw bad('bundle or id required')
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
const methods = [
|
|
252
|
+
['plugins.install', 'installBundle', 'spec'],
|
|
253
|
+
['plugins.uninstall', 'removeBundle', 'bundle'],
|
|
254
|
+
['plugins.inspect', 'inspect', 'spec'],
|
|
255
|
+
['plugins.cancelInstall', 'cancelInstall', 'requestId'],
|
|
256
|
+
['plugins.waitForInstall', 'waitForInstall', 'requestId'],
|
|
257
|
+
]
|
|
258
|
+
for (const [op, method, field] of methods) {
|
|
259
|
+
if (typeof pluginManager[method] !== 'function') continue
|
|
260
|
+
ops[op] = async (a, signal) => {
|
|
261
|
+
if (!str(a[field]) || !a[field].trim()) throw bad(`${field} required`)
|
|
262
|
+
if (op === 'plugins.inspect') return pluginManager.inspect(a.spec.trim(), a.options, signal)
|
|
263
|
+
if (op === 'plugins.install') return pluginManager.installBundle(a.spec.trim(), a.options)
|
|
264
|
+
return pluginManager[method](a[field])
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (typeof pluginManager.registries === 'function') {
|
|
268
|
+
ops['plugins.registries'] = () => pluginManager.registries()
|
|
269
|
+
}
|
|
270
|
+
return ops
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Use the official redacted settings controller, including revision conflicts. */
|
|
274
|
+
export function pluginSettingsOps(settingsController) {
|
|
275
|
+
return {
|
|
276
|
+
'plugins.settingsDescribe': () => settingsController.describe(),
|
|
277
|
+
'plugins.settingsMutate': async (a) => {
|
|
278
|
+
if (!str(a.ns) || !Array.isArray(a.ops)) throw bad('ns and ops required')
|
|
279
|
+
if (!Number.isSafeInteger(a.expectedRevision) || a.expectedRevision < 0) {
|
|
280
|
+
throw bad('expectedRevision required')
|
|
281
|
+
}
|
|
282
|
+
return settingsController.mutate(a.ns, a.ops, a.expectedRevision)
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Built-in plugin inventory (settings → 内置插件), read-only roster plus enable
|
|
289
|
+
* when pluginManager can address the row.
|
|
290
|
+
*/
|
|
291
|
+
export function pluginInventoryOps(pluginInventory, pluginManager) {
|
|
292
|
+
return {
|
|
293
|
+
'plugins.inventory': async () => {
|
|
294
|
+
if (pluginInventory?.list) {
|
|
295
|
+
const snap = await pluginInventory.list()
|
|
296
|
+
return snap
|
|
297
|
+
}
|
|
298
|
+
if (pluginManager?.listPlugins) {
|
|
299
|
+
const plugins = await pluginManager.listPlugins()
|
|
300
|
+
return { entries: plugins, managementAvailable: true }
|
|
301
|
+
}
|
|
302
|
+
throw Object.assign(new Error('plugin inventory unavailable'), { code: 'unavailable' })
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Schedule / automation tasks. create() is Host in-process (not a Remote);
|
|
309
|
+
* catalog/list/update/delete/history are Remotes. Desktop has no pause toggle —
|
|
310
|
+
* inactive means ended after delivery.
|
|
311
|
+
*/
|
|
312
|
+
export function scheduleOps(schedule) {
|
|
313
|
+
if (!schedule) throw bad('schedule service required')
|
|
314
|
+
return {
|
|
315
|
+
'schedule.catalog': () => schedule.catalog(),
|
|
316
|
+
'schedule.list': async (a) => {
|
|
317
|
+
if (!str(a.sessionId)) throw bad('sessionId required')
|
|
318
|
+
return schedule.list({ sessionId: a.sessionId })
|
|
319
|
+
},
|
|
320
|
+
'schedule.create': async (a, signal) => {
|
|
321
|
+
if (!str(a.sessionId)) throw bad('sessionId required')
|
|
322
|
+
if (!str(a.title) || !str(a.prompt)) throw bad('title and prompt required')
|
|
323
|
+
if (typeof schedule.create !== 'function') {
|
|
324
|
+
throw Object.assign(new Error('schedule.create unavailable'), { code: 'unavailable' })
|
|
325
|
+
}
|
|
326
|
+
const request = {
|
|
327
|
+
title: a.title,
|
|
328
|
+
prompt: a.prompt,
|
|
329
|
+
...(a.after_seconds !== undefined ? { after_seconds: a.after_seconds } : {}),
|
|
330
|
+
...(a.at !== undefined ? { at: a.at } : {}),
|
|
331
|
+
...(a.every_seconds !== undefined ? { every_seconds: a.every_seconds } : {}),
|
|
332
|
+
...(a.daily !== undefined ? { daily: a.daily } : {}),
|
|
333
|
+
...(a.weekly !== undefined ? { weekly: a.weekly } : {}),
|
|
334
|
+
...(a.cron !== undefined ? { cron: a.cron } : {}),
|
|
335
|
+
}
|
|
336
|
+
return schedule.create(a.sessionId, request, signal)
|
|
337
|
+
},
|
|
338
|
+
'schedule.update': async (a, signal) => {
|
|
339
|
+
if (!str(a.sessionId) || !str(a.id)) throw bad('sessionId and id required')
|
|
340
|
+
if (!a.expected || typeof a.expected !== 'object') throw bad('expected record required')
|
|
341
|
+
return schedule.update({
|
|
342
|
+
sessionId: a.sessionId,
|
|
343
|
+
id: a.id,
|
|
344
|
+
expected: a.expected,
|
|
345
|
+
...(a.title !== undefined ? { title: a.title } : {}),
|
|
346
|
+
...(a.prompt !== undefined ? { prompt: a.prompt } : {}),
|
|
347
|
+
...(a.change !== undefined ? { change: a.change } : {}),
|
|
348
|
+
}, signal)
|
|
349
|
+
},
|
|
350
|
+
'schedule.delete': async (a, signal) => {
|
|
351
|
+
if (!str(a.sessionId) || !str(a.id)) throw bad('sessionId and id required')
|
|
352
|
+
return schedule.delete({ sessionId: a.sessionId, id: a.id }, signal)
|
|
353
|
+
},
|
|
354
|
+
'schedule.history': async (a) => {
|
|
355
|
+
if (!str(a.sessionId) || !str(a.id)) throw bad('sessionId and id required')
|
|
356
|
+
const limit = Number.isSafeInteger(a.limit) ? a.limit : 20
|
|
357
|
+
return schedule.history({
|
|
358
|
+
sessionId: a.sessionId,
|
|
359
|
+
id: a.id,
|
|
360
|
+
limit,
|
|
361
|
+
...(str(a.before) ? { before: a.before } : {}),
|
|
362
|
+
})
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Credential operations return only metadata; Host credential records stay private. */
|
|
368
|
+
export function credentialsOps({ credentials }) {
|
|
369
|
+
if (!credentials) throw bad('credentials service required')
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
'credentials.describe': async (a) => {
|
|
373
|
+
if (!str(a.ref)) throw bad('ref required')
|
|
374
|
+
return { ref: a.ref, ...projectCredentialInfo(await credentials.describe(a.ref)) }
|
|
375
|
+
},
|
|
376
|
+
'credentials.set': async (a) => {
|
|
377
|
+
if (!str(a.ref) || !str(a.value)) throw bad('ref and non-empty value required')
|
|
378
|
+
await credentials.set(a.ref, a.value)
|
|
379
|
+
return { ref: a.ref, ...projectCredentialInfo(await credentials.describe(a.ref)) }
|
|
380
|
+
},
|
|
381
|
+
'credentials.unset': async (a) => {
|
|
382
|
+
if (!str(a.ref)) throw bad('ref required')
|
|
383
|
+
await credentials.unset(a.ref)
|
|
384
|
+
return { ref: a.ref, ...projectCredentialInfo(await credentials.describe(a.ref)) }
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapted from saya-ch/dsh-mobile (Apache-2.0)
|
|
3
|
+
* Host-side task-completion fan-out for phone notifications.
|
|
4
|
+
*
|
|
5
|
+
* Watches the public `session/event` bus for completed root turns and hands
|
|
6
|
+
* them to a sink (normally the pocket WS server). Phones render the text
|
|
7
|
+
* locally — only opaque session and turn ids cross this boundary.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const TASK_EVENT_DEBOUNCE_MS = 1_000
|
|
11
|
+
|
|
12
|
+
function turnNumber(value) {
|
|
13
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function turnData(value) {
|
|
17
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
18
|
+
return { turn: 0, completed: false }
|
|
19
|
+
}
|
|
20
|
+
const reason = value.reason
|
|
21
|
+
const completed = typeof reason === 'string'
|
|
22
|
+
? reason === 'completed'
|
|
23
|
+
: reason !== null && typeof reason === 'object' && reason.kind === 'completed'
|
|
24
|
+
return { turn: turnNumber(value.turn), completed }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Subscribe to completed root turns. Subagent turns are skipped so one task
|
|
29
|
+
* announces once, and rapid turn boundaries collapse into a single event.
|
|
30
|
+
* @returns {() => void} disposer that also drops pending debounces
|
|
31
|
+
*/
|
|
32
|
+
export function watchTaskCompletions(ctx, options) {
|
|
33
|
+
const debounceMs = options.debounceMs ?? TASK_EVENT_DEBOUNCE_MS
|
|
34
|
+
const pending = new Map()
|
|
35
|
+
const disposeListener = ctx.on('session/event', (session, event) => {
|
|
36
|
+
if (event?.type !== 'turn/end') return
|
|
37
|
+
const { turn, completed } = turnData(event.data)
|
|
38
|
+
if (!completed) return
|
|
39
|
+
if (session?.header?.parentSession !== undefined && session?.header?.parentSession !== null) return
|
|
40
|
+
const sessionId = String(session.id)
|
|
41
|
+
options.log?.('task-completed-observed', { sessionId, turn })
|
|
42
|
+
const previous = pending.get(sessionId)
|
|
43
|
+
if (previous !== undefined) clearTimeout(previous)
|
|
44
|
+
pending.set(sessionId, setTimeout(() => {
|
|
45
|
+
pending.delete(sessionId)
|
|
46
|
+
options.log?.('task-completed-announced', { sessionId, turn })
|
|
47
|
+
options.onTaskCompleted(Object.freeze({ sessionId, turn, kind: 'done' }))
|
|
48
|
+
}, debounceMs))
|
|
49
|
+
})
|
|
50
|
+
return () => {
|
|
51
|
+
disposeListener()
|
|
52
|
+
for (const timer of pending.values()) clearTimeout(timer)
|
|
53
|
+
pending.clear()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One subscription feeding every sink; normally a single pocket server. */
|
|
58
|
+
export class TaskEventHub {
|
|
59
|
+
constructor() {
|
|
60
|
+
this.sinks = new Set()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
add(sink) {
|
|
64
|
+
this.sinks.add(sink)
|
|
65
|
+
return () => { this.sinks.delete(sink) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
broadcast(event) {
|
|
69
|
+
for (const sink of [...this.sinks]) {
|
|
70
|
+
try { sink.broadcastTaskEvent(event) } catch { /* A failing sink must not break siblings. */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get size() {
|
|
75
|
+
return this.sinks.size
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapted from saya-ch/dsh-mobile (Apache-2.0) task-notify fallbacks.
|
|
3
|
+
* Host-side completion inference when `session/event` turn/end is missing:
|
|
4
|
+
* 90s quiet after activity, or busy→idle with 10s grace.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const TASK_NOTIFY_QUIET_MS = 90_000
|
|
8
|
+
export const TASK_NOTIFY_GRACE_MS = 10_000
|
|
9
|
+
export const TASK_NOTIFY_MIN_BUSY_MS = 20_000
|
|
10
|
+
export const TASK_NOTIFY_STALE_MS = 30 * 60_000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-session fallback tracker driven by api-session status/activity.
|
|
14
|
+
* Emits the same minimal {sessionId, turn, kind:'done'} shape as turn/end.
|
|
15
|
+
*/
|
|
16
|
+
export class TaskFallbackTracker {
|
|
17
|
+
/**
|
|
18
|
+
* @param {{
|
|
19
|
+
* quietMs?: number,
|
|
20
|
+
* graceMs?: number,
|
|
21
|
+
* minBusyMs?: number,
|
|
22
|
+
* now?: () => number,
|
|
23
|
+
* onTaskCompleted: (event: {sessionId: string, turn: number, kind: 'done'}) => void,
|
|
24
|
+
* isRootSession?: (sessionId: string) => boolean,
|
|
25
|
+
* hasPendingQuestion?: (sessionId: string) => boolean,
|
|
26
|
+
* }} options
|
|
27
|
+
*/
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.quietMs = options.quietMs ?? TASK_NOTIFY_QUIET_MS
|
|
30
|
+
this.graceMs = options.graceMs ?? TASK_NOTIFY_GRACE_MS
|
|
31
|
+
this.minBusyMs = options.minBusyMs ?? TASK_NOTIFY_MIN_BUSY_MS
|
|
32
|
+
this.now = options.now ?? Date.now
|
|
33
|
+
this.onTaskCompleted = options.onTaskCompleted
|
|
34
|
+
this.isRootSession = options.isRootSession ?? (() => true)
|
|
35
|
+
this.hasPendingQuestion = options.hasPendingQuestion ?? (() => false)
|
|
36
|
+
/** @type {Map<string, {
|
|
37
|
+
* lastActivityAt?: number,
|
|
38
|
+
* notifiedDoneAt?: number,
|
|
39
|
+
* wasBusy: boolean,
|
|
40
|
+
* busySince?: number,
|
|
41
|
+
* idleAnchoredAt?: number,
|
|
42
|
+
* timer?: ReturnType<typeof setTimeout>,
|
|
43
|
+
* }>} */
|
|
44
|
+
this.states = new Map()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
state(sessionId) {
|
|
48
|
+
let s = this.states.get(sessionId)
|
|
49
|
+
if (!s) {
|
|
50
|
+
s = { wasBusy: false }
|
|
51
|
+
this.states.set(sessionId, s)
|
|
52
|
+
}
|
|
53
|
+
return s
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
markActivity(sessionId, at = this.now()) {
|
|
57
|
+
if (!this.isRootSession(sessionId)) return
|
|
58
|
+
const s = this.state(sessionId)
|
|
59
|
+
s.lastActivityAt = at
|
|
60
|
+
if (s.notifiedDoneAt !== undefined && s.notifiedDoneAt <= at) s.notifiedDoneAt = undefined
|
|
61
|
+
this.schedule(sessionId)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Suppress fallback announce after an exact turn/end completion for the same session. */
|
|
65
|
+
markAnnounced(sessionId, at = this.now()) {
|
|
66
|
+
const s = this.state(sessionId)
|
|
67
|
+
s.notifiedDoneAt = at
|
|
68
|
+
s.idleAnchoredAt = undefined
|
|
69
|
+
if (s.timer !== undefined) {
|
|
70
|
+
clearTimeout(s.timer)
|
|
71
|
+
s.timer = undefined
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onStatus(sessionId, running) {
|
|
76
|
+
if (!this.isRootSession(sessionId)) return
|
|
77
|
+
const s = this.state(sessionId)
|
|
78
|
+
const now = this.now()
|
|
79
|
+
if (!s.wasBusy && running) s.busySince = now
|
|
80
|
+
if (s.wasBusy && !running) {
|
|
81
|
+
const busyFor = s.busySince === undefined ? 0 : now - s.busySince
|
|
82
|
+
s.busySince = undefined
|
|
83
|
+
if (!this.hasPendingQuestion(sessionId) && busyFor >= this.minBusyMs) {
|
|
84
|
+
s.idleAnchoredAt = undefined
|
|
85
|
+
this.announce(sessionId, s, now)
|
|
86
|
+
s.wasBusy = false
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
s.idleAnchoredAt = now
|
|
90
|
+
}
|
|
91
|
+
s.wasBusy = running
|
|
92
|
+
if (running) s.idleAnchoredAt = undefined
|
|
93
|
+
this.schedule(sessionId)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Drop tracking when a session is removed. */
|
|
97
|
+
forget(sessionId) {
|
|
98
|
+
const s = this.states.get(sessionId)
|
|
99
|
+
if (!s) return
|
|
100
|
+
if (s.timer !== undefined) clearTimeout(s.timer)
|
|
101
|
+
this.states.delete(sessionId)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
dispose() {
|
|
105
|
+
for (const s of this.states.values()) {
|
|
106
|
+
if (s.timer !== undefined) clearTimeout(s.timer)
|
|
107
|
+
}
|
|
108
|
+
this.states.clear()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
schedule(sessionId) {
|
|
112
|
+
const s = this.state(sessionId)
|
|
113
|
+
if (s.timer !== undefined) {
|
|
114
|
+
clearTimeout(s.timer)
|
|
115
|
+
s.timer = undefined
|
|
116
|
+
}
|
|
117
|
+
const delay = this.nextDelayMs(sessionId)
|
|
118
|
+
if (delay === undefined) return
|
|
119
|
+
s.timer = setTimeout(() => {
|
|
120
|
+
s.timer = undefined
|
|
121
|
+
this.evaluate(sessionId)
|
|
122
|
+
}, delay)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
nextDelayMs(sessionId) {
|
|
126
|
+
const s = this.states.get(sessionId)
|
|
127
|
+
if (!s || s.wasBusy || this.hasPendingQuestion(sessionId)) return undefined
|
|
128
|
+
const now = this.now()
|
|
129
|
+
const delays = []
|
|
130
|
+
if (s.idleAnchoredAt !== undefined) {
|
|
131
|
+
delays.push(Math.max(0, this.graceMs - (now - s.idleAnchoredAt)))
|
|
132
|
+
}
|
|
133
|
+
if (s.lastActivityAt !== undefined) {
|
|
134
|
+
const since = now - s.lastActivityAt
|
|
135
|
+
if (since > TASK_NOTIFY_STALE_MS) return undefined
|
|
136
|
+
if (s.notifiedDoneAt !== undefined && s.notifiedDoneAt >= s.lastActivityAt) {
|
|
137
|
+
/* already announced for this activity window */
|
|
138
|
+
} else {
|
|
139
|
+
delays.push(Math.max(0, this.quietMs - since))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (delays.length === 0) return undefined
|
|
143
|
+
return Math.min(...delays)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
evaluate(sessionId) {
|
|
147
|
+
if (!this.isRootSession(sessionId)) return
|
|
148
|
+
const s = this.state(sessionId)
|
|
149
|
+
const now = this.now()
|
|
150
|
+
if (s.wasBusy || this.hasPendingQuestion(sessionId)) return
|
|
151
|
+
if (s.idleAnchoredAt !== undefined) {
|
|
152
|
+
if (now - s.idleAnchoredAt < this.graceMs) {
|
|
153
|
+
this.schedule(sessionId)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
s.idleAnchoredAt = undefined
|
|
157
|
+
this.announce(sessionId, s, now)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (s.lastActivityAt === undefined) return
|
|
161
|
+
if (now - s.lastActivityAt < this.quietMs) {
|
|
162
|
+
this.schedule(sessionId)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (now - s.lastActivityAt > TASK_NOTIFY_STALE_MS) {
|
|
166
|
+
s.lastActivityAt = undefined
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (s.notifiedDoneAt !== undefined && s.notifiedDoneAt >= s.lastActivityAt) return
|
|
170
|
+
this.announce(sessionId, s, now)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
announce(sessionId, s, now) {
|
|
174
|
+
s.notifiedDoneAt = now
|
|
175
|
+
this.onTaskCompleted(Object.freeze({ sessionId, turn: 0, kind: 'done' }))
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/token.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/** Load the persistent bearer token, creating a 32-byte one on first run (0600). */
|
|
6
|
+
export function loadOrCreateToken(dataDir, fixed) {
|
|
7
|
+
const path = join(dataDir, 'token')
|
|
8
|
+
if (typeof fixed === 'string' && fixed.length > 0) return { token: fixed, created: false, path: '(config)' }
|
|
9
|
+
if (existsSync(path)) {
|
|
10
|
+
const token = readFileSync(path, 'utf8').trim()
|
|
11
|
+
if (token.length >= 16) return { token, created: false, path }
|
|
12
|
+
}
|
|
13
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 })
|
|
14
|
+
const token = randomBytes(32).toString('base64url')
|
|
15
|
+
writeFileSync(path, token + '\n', { mode: 0o600 })
|
|
16
|
+
chmodSync(path, 0o600)
|
|
17
|
+
return { token, created: true, path }
|
|
18
|
+
}
|