@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.
Files changed (44) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +237 -2
  3. package/bin/pocket.js +31 -0
  4. package/cordis.patch.yml +6 -0
  5. package/index.js +360 -0
  6. package/lib/client.js +2453 -0
  7. package/package.json +91 -4
  8. package/src/advertise-host.js +69 -0
  9. package/src/approval-hook.js +50 -0
  10. package/src/client/ConfirmDialog.tsx +80 -0
  11. package/src/client/HostUiLauncher.tsx +101 -0
  12. package/src/client/ManagePanel.tsx +218 -0
  13. package/src/client/PhoneIcon.tsx +48 -0
  14. package/src/client/PocketSettings.tsx +13 -0
  15. package/src/client/api.ts +97 -0
  16. package/src/client/completion-sync.js +289 -0
  17. package/src/client/host-ui-styles.ts +62 -0
  18. package/src/client/index.ts +47 -0
  19. package/src/client/locales.ts +75 -0
  20. package/src/client/nav-icon.ts +31 -0
  21. package/src/client/qr.ts +15 -0
  22. package/src/client/styles.ts +76 -0
  23. package/src/completion-route.js +87 -0
  24. package/src/completion-tracker.js +95 -0
  25. package/src/devices.js +60 -0
  26. package/src/extra-ops.js +604 -0
  27. package/src/host-ui.js +466 -0
  28. package/src/instance-id.js +33 -0
  29. package/src/iroh.js +260 -0
  30. package/src/manage.js +122 -0
  31. package/src/mdns-advertise.js +75 -0
  32. package/src/office-preview.js +129 -0
  33. package/src/pairing-uri.js +42 -0
  34. package/src/pairing.js +34 -0
  35. package/src/parity-ops.js +250 -0
  36. package/src/server.js +761 -0
  37. package/src/settings-ops.js +387 -0
  38. package/src/task-events.js +77 -0
  39. package/src/task-fallback.js +177 -0
  40. package/src/token.js +18 -0
  41. package/src/transport-pref.js +47 -0
  42. package/src/trust.js +25 -0
  43. package/src/user-question-hook.js +93 -0
  44. 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
+ }