@gotcos/glasses-server 6.98.1 → 6.99.16

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 (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -1,3 +1,6 @@
1
+ import { resolveProviderWorkDir } from './launch-dir.js'
2
+ import { COS_SCRIPTS_DIR } from './python-bridge.js'
3
+ import { consolidationModelRunner } from './meeting-consolidation-run.js'
1
4
  import { routineAuditCaller } from './routines-config.js'
2
5
  import { jobAuditCaller } from './job-audit-caller.js'
3
6
  import { carriesBoundTo } from './agent-session-binding-store.js'
@@ -8,7 +11,9 @@ import { emitDisplay } from './display-bus.js'
8
11
  import { resolveQueryAttachments } from './query-attachments.js'
9
12
  import { getMediaStore } from './media-store.js'
10
13
  import { dataPath } from './data-dir.js'
11
- import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
14
+ import { currentMessageEra, exchangeBelongsToEra, LEGACY_MESSAGE_ERA } from './message-era.js'
15
+ import { isWebAsk, prepareWebAsk } from './web-ask-admission.js'
16
+ import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
12
17
  import { durableQueryJobsEnabled, messagesSteerEnabled, messagesTrailEnabled } from './query-job-feature.js'
13
18
  import { claudeSupportsSteer } from './claude-cli-support.js'
14
19
  import { steerProviderFor } from './claude-steer.js'
@@ -25,6 +30,9 @@ import {
25
30
  import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
26
31
  import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/media-attachment.js'
27
32
  import {
33
+ createSession,
34
+ getActiveSessions,
35
+ getSessionRaw,
28
36
  findExchangesByJobIdentity,
29
37
  flushConversationToDisk,
30
38
  getSessionBinding,
@@ -44,7 +52,7 @@ import {
44
52
  } from './query-job-types.js'
45
53
  import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
46
54
  import type { JobTrailDraft } from './job-trail.js'
47
- import { registerMessageReservationSource } from './message-reservations.js'
55
+ import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
48
56
  import { bootResumeGate } from './boot-resume.js'
49
57
  import { keyAnswerCompletionFields, keyAnswerDoneFields } from './key-answer-fields.js'
50
58
 
@@ -69,9 +77,36 @@ export class QueryJobAdmissionPreparationError extends Error {
69
77
  * the strict QueryJobRequest parser before persistence. Once a reset era
70
78
  * exists, reject pre-era clients before they stamp five-digit numbers into
71
79
  * the fresh namespace. */
80
+ /** 6.99.10: the next-number ceiling for a web Ask, the same arithmetic /api/message-counter serves. */
81
+ function webAskCurrentMax(): number {
82
+ const era = currentMessageEra()
83
+ let liveMax = 0
84
+ for (const session of getActiveSessions()) {
85
+ for (const exchange of (session as { exchanges?: Array<{ globalMsgNum?: unknown; messageEra?: unknown }> }).exchanges ?? []) {
86
+ if (!exchangeBelongsToEra(exchange, era)) continue
87
+ if (typeof exchange?.globalMsgNum === 'number' && exchange.globalMsgNum > liveMax) liveMax = exchange.globalMsgNum
88
+ }
89
+ }
90
+ return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era), maxReservedGlobalMsgNum(era))
91
+ }
92
+
72
93
  export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<unknown> {
73
94
  if (!raw || typeof raw !== 'object') return raw
74
- const input = raw as Record<string, unknown>
95
+ let input = raw as Record<string, unknown>
96
+ // 6.99.10: Ask from COS Control Web (`source: 'web'`): the server picks the conversation and mints the message
97
+ // number (lib/web-ask-admission.ts). Every other caller is untouched.
98
+ if (isWebAsk(input) && !carriesBoundTo(input)) {
99
+ input = await prepareWebAsk(input, {
100
+ currentMax: webAskCurrentMax,
101
+ activeEra: currentMessageEra,
102
+ sessionExists: id => !!getSessionRaw(id),
103
+ createSession,
104
+ priorJob: async (clientJobId, generation) => {
105
+ const job = await queryJobCoordinator.getByClientGeneration(clientJobId, generation)
106
+ return job ? { sessionId: job.sessionId, globalMsgNum: job.globalMsgNum, messageEra: job.messageEra } : undefined
107
+ },
108
+ })
109
+ }
75
110
 
76
111
  // Plan 4.2: an attached turn must NEVER silently degrade into an ordinary COS
77
112
  // turn. The binding lives in the route path rather than the body, so a
@@ -255,7 +290,13 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
255
290
  ? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
256
291
  : { provider: providerFor(model), resolvedModel: model }
257
292
 
258
- await callModelStreaming(
293
+ const runModel = request.maintenanceWorkflow === 'meeting-consolidation-v1'
294
+ ? consolidationModelRunner(callModelStreaming, {
295
+ root: dataPath('meeting-consolidation'),
296
+ workspace: resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }),
297
+ runId: `${request.clientJobId}-g${request.generation}`,
298
+ }) : callModelStreaming
299
+ await runModel(
259
300
  request.query,
260
301
  request.sessionId,
261
302
  {
@@ -134,6 +134,8 @@ export interface QueryJobRequest {
134
134
  * it names the session, it does not change what is asked.
135
135
  */
136
136
  sessionName?: string
137
+ /** Explicit user-started, checkpointed maintenance. Changes execution semantics. */
138
+ maintenanceWorkflow?: 'meeting-consolidation-v1'
137
139
  /**
138
140
  * Key lanes (QA U9 / C10): run THIS turn on the key profile it names without binding the
139
141
  * session to it (the plan-limit "run this one on your key" offer). Kept only with a
@@ -599,6 +601,12 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
599
601
  const handoffCode = optionalString(input.handoffCode, 'handoff_code', 128)
600
602
  const clientQueueItemId = optionalString(input.clientQueueItemId, 'client_queue_item_id', 120)
601
603
  const sessionName = sanitizeSessionName(input.sessionName)
604
+ const maintenanceWorkflow = input.maintenanceWorkflow
605
+ if (maintenanceWorkflow != null && (maintenanceWorkflow !== 'meeting-consolidation-v1'
606
+ || !['opus', 'codex-frontier'].includes(model ?? '') || input.dispatch != null || input.steerable === true
607
+ || (Array.isArray(input.attachmentIds) && input.attachmentIds.length > 0))) {
608
+ throw new QueryJobValidationError('invalid_maintenance_workflow')
609
+ }
602
610
  const globalMsgNum = input.globalMsgNum == null ? undefined : Number(input.globalMsgNum)
603
611
  if (globalMsgNum != null && (!Number.isSafeInteger(globalMsgNum) || globalMsgNum < 1)) {
604
612
  throw new QueryJobValidationError('invalid_global_msg_num')
@@ -670,6 +678,7 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
670
678
  ...(input.handoffLatest === true ? { handoffLatest: true } : {}),
671
679
  ...(clientQueueItemId ? { clientQueueItemId } : {}),
672
680
  ...(sessionName ? { sessionName } : {}),
681
+ ...(maintenanceWorkflow === 'meeting-consolidation-v1' ? { maintenanceWorkflow } as const : {}),
673
682
  ...(input.keyTurnOnly === true && isProfilePreference(model) ? { keyTurnOnly: true as const } : {}),
674
683
  ...(input.steerable === true ? { steerable: true as const } : {}),
675
684
  attachmentIds,
@@ -735,7 +744,7 @@ export const FINGERPRINT_KEYS = [
735
744
  'clientJobId', 'generation', 'query', 'sessionId', 'model', 'effort',
736
745
  'cursorExecutionMode', 'messageEra', 'globalMsgNum', 'reference', 'handoffCode',
737
746
  'handoffLatest', 'clientQueueItemId', 'attachmentIds', 'attachmentRefs',
738
- 'activityToolMode', 'keyTurnOnly',
747
+ 'activityToolMode', 'keyTurnOnly', 'maintenanceWorkflow',
739
748
  ] as const satisfies readonly (keyof QueryJobRequest)[]
740
749
 
741
750
  /** The request keys deliberately OUTSIDE the identity. Every key of
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Drop it P2 (plan PLAN_drop_it_2026-11-04 section 7): firing the reminders he set.
3
+ *
4
+ * - The time lives on the drop: `remind: { at, tz, firedAt, ackAt, snoozes }`.
5
+ * - A 30-second tick (the morning-brief scheduler pattern). After sleep or a server restart the first tick fires
6
+ * everything overdue, once each, and says how late it is when that is over 10 minutes.
7
+ * - `firedAt` is written under the drop store's writer lock BEFORE delivery, so a crash after the write can lose a
8
+ * banner but never repeat one, and two ticks can never both fire it.
9
+ * - The banner uses the notification id `reminder-<drop id>`. Control mirrors each pending reminder as a local
10
+ * calendar-trigger notification with the same id, so it fires with the server down and a later server fire
11
+ * replaces it instead of doubling it.
12
+ * - Actions: Done, Snooze 1 hour, Tomorrow 9. A snooze sets a new `at`, which is a new exactly-once key.
13
+ *
14
+ * A reminder he set fires at any hour (the one quiet-hours exception, per CLAUDE.md). No model, no script.
15
+ */
16
+ import type { DropRecord } from './drop-store.js'
17
+ import { parseWhen, snoozeTargets, type WhenResult } from './drop-when.js'
18
+ import { clip, PING_TEXT, type PingCandidate } from './ping-sources.js'
19
+
20
+ export const REMINDER_TICK_MS = 30_000
21
+ /** Over this late, the reminder says how late it is (plan section 7). */
22
+ export const REMINDER_LATE_MS = 10 * 60_000
23
+ export const REMINDER_ACTIONS = [
24
+ { id: 'reminder_done', label: 'Done' },
25
+ { id: 'snooze_1h', label: 'Snooze 1 hour' }, // the id stays; the length is Settings, Reminders, snooze
26
+ { id: 'snooze_tomorrow', label: 'Tomorrow 9' },
27
+ ] as const
28
+ export type ReminderAction = typeof REMINDER_ACTIONS[number]['id']
29
+
30
+ /** The two store calls the scheduler needs; DropStore has both. */
31
+ export interface ReminderStore {
32
+ select(match: (row: Readonly<DropRecord>) => boolean): DropRecord[]
33
+ update(id: string, fn: (current: DropRecord) => DropRecord): DropRecord
34
+ }
35
+
36
+ /** The notification id Control's local backup uses for the same reminder. */
37
+ export const reminderNotifyId = (dropId: string) => `reminder-${dropId}`
38
+
39
+ /** What the drop asked to be reminded about: its words without "remind me" and the time. */
40
+ export function reminderAbout(drop: Pick<DropRecord, 'text' | 'createdAt' | 'remind'>): string {
41
+ const tz = drop.remind?.tz || 'UTC'
42
+ const about = parseWhen(drop.text, Date.parse(drop.createdAt) || 0, tz).about
43
+ return clip(about || drop.text, PING_TEXT.title) || 'your reminder'
44
+ }
45
+
46
+ /** The ping's buttons, with the snooze named by its length ("Snooze 30 minutes"). */
47
+ export function reminderActions(snoozeMinutes = 60): Array<{ id: ReminderAction; label: string }> {
48
+ const len = snoozeMinutes % 60 === 0 ? `${snoozeMinutes / 60} hour${snoozeMinutes === 60 ? '' : 's'}` : `${snoozeMinutes} minutes`
49
+ return REMINDER_ACTIONS.map(a => ({ id: a.id, label: a.id === 'snooze_1h' ? `Snooze ${len}` : a.label }))
50
+ }
51
+
52
+ /** "2 hours", "35 minutes", "1 day": how late a fire is. */
53
+ export function lateWords(ms: number): string {
54
+ const min = Math.round(ms / 60_000)
55
+ if (min < 60) return `${min} minute${min === 1 ? '' : 's'}`
56
+ const h = Math.round(min / 60)
57
+ if (h < 48) return `${h} hour${h === 1 ? '' : 's'}`
58
+ const d = Math.round(h / 24)
59
+ return `${d} day${d === 1 ? '' : 's'}`
60
+ }
61
+
62
+ const pending = (r: Readonly<DropRecord>) => r.state !== 'undone' && r.dest === 'reminder' && !!r.remind?.at && !r.remind.firedAt && !r.remind.ackAt
63
+
64
+ /** Every reminder still to fire, soonest first: what Control mirrors as local notifications. */
65
+ export function pendingReminders(store: Pick<ReminderStore, 'select'>): Array<{ id: string; at: string; title: string; notifyId: string }> {
66
+ return store.select(pending)
67
+ .map(r => ({ id: r.id, at: r.remind!.at!, title: `Reminder: ${reminderAbout(r)}`, notifyId: reminderNotifyId(r.id) }))
68
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
69
+ }
70
+
71
+ export interface FireResult { fired: number; late: number }
72
+
73
+ /**
74
+ * Fire every reminder whose time has come. `deliver` receives the candidates only after each `firedAt` is on disk.
75
+ * A store write that fails (store closed, disk full) skips that reminder this tick; it is tried again next tick.
76
+ */
77
+ export function fireDueReminders(store: ReminderStore, nowMs: number, deliver: (c: PingCandidate[]) => void, options?: { snoozeMinutes?: number }): FireResult {
78
+ const due = store.select(r => pending(r) && Date.parse(r.remind!.at!) <= nowMs)
79
+ const out: PingCandidate[] = []
80
+ let late = 0
81
+ for (const drop of due) {
82
+ const at = drop.remind!.at!
83
+ let written: DropRecord
84
+ try {
85
+ written = store.update(drop.id, cur => {
86
+ // Re-checked under the writer lock: another tick, an ack or an undo may have landed since the select.
87
+ if (!pending(cur) || cur.remind!.at !== at) throw new Error('reminder_moved')
88
+ return { ...cur, remind: { ...cur.remind!, firedAt: new Date(nowMs).toISOString() } }
89
+ })
90
+ } catch { continue }
91
+ const lateMs = nowMs - Date.parse(at)
92
+ const about = reminderAbout(written)
93
+ const isLate = lateMs > REMINDER_LATE_MS
94
+ if (isLate) late++
95
+ out.push({
96
+ source: 'reminder', key: `reminder:${drop.id}:${at}`, title: 'Reminder',
97
+ line: isLate ? `Reminder (${lateWords(lateMs)} late): ${about}` : `Reminder: ${about}`,
98
+ links: [{ kind: 'reminder', id: drop.id, label: about }],
99
+ actions: reminderActions(options?.snoozeMinutes),
100
+ createdAt: nowMs, notifyId: reminderNotifyId(drop.id),
101
+ })
102
+ }
103
+ if (out.length) deliver(out)
104
+ return { fired: out.length, late }
105
+ }
106
+
107
+ /**
108
+ * Done, Snooze 1 hour or Tomorrow 9, on a reminder that has fired (or a pending one: Done also cancels it).
109
+ * Returns the updated drop, or null when it is not a live reminder.
110
+ */
111
+ export function applyReminderAction(store: ReminderStore, dropId: string, action: ReminderAction, nowMs: number,
112
+ options?: { snoozeMs?: number; dayMinutes?: number }): DropRecord | null {
113
+ const [row] = store.select(r => r.id === dropId)
114
+ if (!row || row.state === 'undone' || row.dest !== 'reminder' || !row.remind || row.remind.ackAt) return null
115
+ const iso = (ms: number) => new Date(ms).toISOString()
116
+ const targets = snoozeTargets(nowMs, row.remind.tz || 'UTC', options)
117
+ try {
118
+ return store.update(dropId, cur => {
119
+ if (!cur.remind || cur.remind.ackAt) throw new Error('reminder_gone')
120
+ if (action === 'reminder_done') return { ...cur, remind: { ...cur.remind, ackAt: iso(nowMs) } }
121
+ const at = action === 'snooze_1h' ? targets.hour : targets.tomorrow9
122
+ return { ...cur, remind: { ...cur.remind, at: iso(at), firedAt: null, snoozes: cur.remind.snoozes + 1 } }
123
+ })
124
+ } catch { return null }
125
+ }
126
+
127
+ /** The reminder time read from a drop's words at filing, or why it could not be read (COS then asks once). */
128
+ export function reminderTimeFor(text: string, nowMs: number, timezone: string): WhenResult {
129
+ return parseWhen(text, nowMs, timezone)
130
+ }
131
+
132
+ /** The 30-second tick. `start` is idempotent; the timer never holds the process open. */
133
+ export function createReminderScheduler(deps: { store: ReminderStore; deliver: (c: PingCandidate[]) => void; now?: () => number; intervalMs?: number; snoozeMinutes?: () => number }) {
134
+ const now = deps.now ?? (() => Date.now())
135
+ let timer: ReturnType<typeof setInterval> | null = null
136
+ let running = false
137
+ const tick = (): FireResult => {
138
+ if (running) return { fired: 0, late: 0 }
139
+ running = true
140
+ try {
141
+ let snoozeMinutes: number | undefined
142
+ try { snoozeMinutes = deps.snoozeMinutes?.() } catch { snoozeMinutes = undefined }
143
+ const r = fireDueReminders(deps.store, now(), deps.deliver, { snoozeMinutes })
144
+ if (r.fired) console.log(`[reminders] fired=${r.fired} late=${r.late}`)
145
+ return r
146
+ } catch { return { fired: 0, late: 0 } } finally { running = false }
147
+ }
148
+ return {
149
+ tick,
150
+ start() { if (timer) return; tick(); timer = setInterval(tick, deps.intervalMs ?? REMINDER_TICK_MS); timer.unref?.() },
151
+ stop() { if (timer) clearInterval(timer); timer = null },
152
+ }
153
+ }
@@ -0,0 +1,103 @@
1
+ // This Mac's own Tailscale MagicDNS name, for COS Control Web's origin checks.
2
+ //
3
+ // WHY NOT THE TAILSCALE CLI. Under the server's LaunchAgent the `tailscale status --json` call
4
+ // the nudger makes does not answer (server.log: "trigger=boot dropped: Tailscale did not answer
5
+ // for 120s"), so the nudger never learns Self.DNSName and a phone at
6
+ // http://<mac>.<tailnet>.ts.net:3141/control was refused by CORS on every write (6.99.1).
7
+ // MagicDNS itself answers a reverse lookup of this Mac's own 100.x address at 100.100.100.100
8
+ // in about a millisecond, with no CLI involved. The nudger's answer still wins when it has one.
9
+ //
10
+ // EXACT NAME ONLY. The answer is normalised by normalizeTailscaleDnsName (lowercase, one
11
+ // trailing dot dropped, must end in .ts.net) and compared for equality; never a suffix match.
12
+ // No answer means no name: the tailnet NAME is refused and the tailnet IP URL keeps working.
13
+
14
+ import { Resolver } from 'node:dns/promises'
15
+ import { networkInterfaces } from 'node:os'
16
+ import { normalizeTailscaleDnsName, reachableIpv4Addresses } from './network-policy.js'
17
+
18
+ export const TAILNET_NAME_HIT_TTL_MS = 10 * 60_000
19
+ export const TAILNET_NAME_MISS_TTL_MS = 60_000
20
+ export const TAILNET_REVERSE_TIMEOUT_MS = 1_500
21
+ const MAGIC_DNS_RESOLVER = '100.100.100.100'
22
+
23
+ export interface TailnetSelfNameDeps {
24
+ /** The nudger's own reading, preferred whenever it has one. */
25
+ nudgerName?: () => string | null
26
+ /** This Mac's Tailscale IPv4, or null when Tailscale is not up. */
27
+ tailscaleIpv4?: () => string | null
28
+ /** PTR lookup of an address; names may carry a trailing dot. */
29
+ reverse?: (ip: string) => Promise<string[]>
30
+ now?: () => number
31
+ }
32
+
33
+ export interface TailnetSelfName {
34
+ /** The name known right now, without waiting. */
35
+ currentSelfDnsName(): string | null
36
+ /** Look the name up (single-flight, cached); resolves to the name or null. Never throws. */
37
+ refresh(): Promise<string | null>
38
+ /** `SelfDnsSource` for isAllowedCorsOrigin: same as refresh(). */
39
+ queryStatus(): Promise<string | null>
40
+ }
41
+
42
+ function defaultTailscaleIpv4(): string | null {
43
+ return reachableIpv4Addresses(networkInterfaces()).find(a => a.kind === 'tailscale')?.address ?? null
44
+ }
45
+
46
+ function defaultReverse(ip: string): Promise<string[]> {
47
+ const resolver = new Resolver({ timeout: TAILNET_REVERSE_TIMEOUT_MS, tries: 1 })
48
+ resolver.setServers([MAGIC_DNS_RESOLVER])
49
+ return resolver.reverse(ip)
50
+ }
51
+
52
+ export function createTailnetSelfName(deps: TailnetSelfNameDeps = {}): TailnetSelfName {
53
+ const now = deps.now ?? (() => Date.now())
54
+ const ipv4 = deps.tailscaleIpv4 ?? defaultTailscaleIpv4
55
+ const reverse = deps.reverse ?? defaultReverse
56
+ let cached: { name: string | null; at: number } | null = null
57
+ let inflight: Promise<string | null> | null = null
58
+
59
+ const fromNudger = (): string | null => {
60
+ try { return normalizeTailscaleDnsName(deps.nudgerName?.() ?? null) } catch { return null }
61
+ }
62
+
63
+ const currentSelfDnsName = (): string | null => fromNudger() ?? cached?.name ?? null
64
+
65
+ const refresh = (): Promise<string | null> => {
66
+ const known = fromNudger()
67
+ if (known) return Promise.resolve(known)
68
+ const at = now()
69
+ if (cached && at - cached.at < (cached.name ? TAILNET_NAME_HIT_TTL_MS : TAILNET_NAME_MISS_TTL_MS)) {
70
+ return Promise.resolve(cached.name)
71
+ }
72
+ if (inflight) return inflight
73
+ inflight = (async () => {
74
+ let name: string | null = null
75
+ try {
76
+ const ip = ipv4()
77
+ if (ip) {
78
+ const names = await reverse(ip)
79
+ name = names.map(n => normalizeTailscaleDnsName(n)).find((n): n is string => n !== null) ?? null
80
+ }
81
+ } catch {
82
+ name = null
83
+ }
84
+ // A miss never erases a name we already had: a blip in MagicDNS must not lock the phone out.
85
+ cached = { name: name ?? cached?.name ?? null, at: now() }
86
+ return cached.name
87
+ })().finally(() => { inflight = null })
88
+ return inflight
89
+ }
90
+
91
+ return { currentSelfDnsName, refresh, queryStatus: refresh }
92
+ }
93
+
94
+ let singleton: TailnetSelfName | null = null
95
+ /** index.ts installs the server's instance, wired to its nudger. */
96
+ export function installTailnetSelfName(deps: TailnetSelfNameDeps): TailnetSelfName {
97
+ singleton = createTailnetSelfName(deps)
98
+ return singleton
99
+ }
100
+ export function getTailnetSelfName(): TailnetSelfName {
101
+ if (!singleton) singleton = createTailnetSelfName()
102
+ return singleton
103
+ }
@@ -39,7 +39,7 @@ import { execFile } from 'node:child_process'
39
39
  import { accessSync, constants as fsConstants } from 'node:fs'
40
40
  import { networkInterfaces } from 'node:os'
41
41
  import { readSwitch } from './env-switch.js'
42
- import { isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
42
+ import { isTailscaleIpv4, normalizeRemoteIp, normalizeTailscaleDnsName } from './network-policy.js'
43
43
 
44
44
  export const TAILSCALE_NUDGE_LIMITS = Object.freeze({
45
45
  tickMs: 10_000,
@@ -135,6 +135,8 @@ export interface PeerStatus {
135
135
  export interface TailscaleStatus {
136
136
  backendState: string
137
137
  selfIps: string[]
138
+ selfDnsName?: string | null
139
+ certDomains?: string[]
138
140
  peers: PeerStatus[]
139
141
  at: number
140
142
  }
@@ -145,6 +147,10 @@ export function parseTailscaleStatus(text: string, at: number): TailscaleStatus
145
147
  try { raw = JSON.parse(text) } catch { return null }
146
148
  if (!raw || typeof raw !== 'object' || typeof raw.BackendState !== 'string') return null
147
149
  const v4 = (ips: unknown): string[] => Array.isArray(ips) ? ips.filter((ip): ip is string => typeof ip === 'string' && isTailscaleIpv4(ip)) : []
150
+ const selfDnsName = normalizeTailscaleDnsName(raw.Self?.DNSName)
151
+ const certDomains = Array.isArray(raw.CertDomains)
152
+ ? (raw.CertDomains as unknown[]).filter((d): d is string => typeof d === 'string' && d.trim().length > 0).map((d: string) => d.trim().toLowerCase().replace(/\.$/, ''))
153
+ : []
148
154
  const peers: PeerStatus[] = []
149
155
  const peerMap = raw.Peer && typeof raw.Peer === 'object' ? raw.Peer : {}
150
156
  for (const p of Object.values(peerMap) as any[]) {
@@ -160,7 +166,7 @@ export function parseTailscaleStatus(text: string, at: number): TailscaleStatus
160
166
  lastHandshakeMs: Number.isFinite(hs) && hs > 0 ? hs : 0,
161
167
  })
162
168
  }
163
- return { backendState: raw.BackendState, selfIps: v4(raw.Self?.TailscaleIPs).sort(), peers, at }
169
+ return { backendState: raw.BackendState, selfIps: v4(raw.Self?.TailscaleIPs).sort(), selfDnsName, certDomains, peers, at }
164
170
  }
165
171
 
166
172
  /** Disco ping output: "pong from <name> (<ip>) via <DERP(x) | ip:port | peer-relay(...)> in 91ms". */
@@ -419,6 +425,38 @@ export class TailscaleNudger {
419
425
  return read
420
426
  }
421
427
 
428
+ /**
429
+ * Cached or last-known Tailscale status, plus a single-flight on-demand refresh.
430
+ * Preserves the last known-good `selfDnsName` across transient read failures so CORS
431
+ * never tears or fails open under concurrent requests.
432
+ */
433
+ private statusRefreshInFlight: Promise<TailscaleStatus | null> | null = null
434
+ private lastKnownSelfDnsName: string | null = null
435
+
436
+ currentSelfDnsName(): string | null {
437
+ if (this.status?.selfDnsName) {
438
+ this.lastKnownSelfDnsName = this.status.selfDnsName
439
+ }
440
+ return this.lastKnownSelfDnsName
441
+ }
442
+
443
+ async queryStatus(forceRefresh = false): Promise<TailscaleStatus | null> {
444
+ const at = this.now()
445
+ if (!forceRefresh && this.status && at - this.status.at <= TAILSCALE_NUDGE_LIMITS.statusFreshMs) {
446
+ if (this.status.selfDnsName) this.lastKnownSelfDnsName = this.status.selfDnsName
447
+ return this.status
448
+ }
449
+ if (this.statusRefreshInFlight) return this.statusRefreshInFlight
450
+ if (!this.ensureBin(at)) return this.status
451
+ this.statusRefreshInFlight = this.readStatus(at).then(parsed => {
452
+ if (parsed?.selfDnsName) this.lastKnownSelfDnsName = parsed.selfDnsName
453
+ return parsed ?? this.status
454
+ }).finally(() => {
455
+ this.statusRefreshInFlight = null
456
+ })
457
+ return this.statusRefreshInFlight
458
+ }
459
+
422
460
  private async runEvent(pending: PendingEvent, at: number): Promise<void> {
423
461
  const L = TAILSCALE_NUDGE_LIMITS
424
462
  const status = await this.freshStatus(at)
@@ -853,3 +853,35 @@ export function liveLedgerFor(taskId: string, day: string, paths = taskStorePath
853
853
  run.taskId === taskId && run.day === day && (run.status === 'dispatching' || run.status === 'running'),
854
854
  )
855
855
  }
856
+
857
+ /**
858
+ * Drop it (plan section 3): a new Inbox card from a drop, in one locked bridge write that answers the card's id
859
+ * (`task-capture-drop`, stdin `{ text, dropId }`). `captureTask` answers no id, and Undo needs one. Repeating the
860
+ * same dropId answers the same card (`created: false`). A bridge without the verb answers
861
+ * `cos_pipeline_not_configured`, which the caller turns into a plain note.
862
+ */
863
+ export interface CapturedDropCard { id: string; workIdentity: string; created: boolean }
864
+ export async function captureDropCard(domain: string, text: string, dropId: string): Promise<CapturedDropCard> {
865
+ if (!isSafeDomainName(domain) || !taskDomains().includes(domain)) throw new TaskRunError(400, 'invalid_domain', 'Unknown task domain')
866
+ const clean = text.replace(/\s+/g, ' ').trim()
867
+ if (!clean || clean.length > 2000) throw new TaskRunError(422, 'invalid_task_text', 'Card text must be 1 to 2,000 characters')
868
+ rejectReservedWorkText(clean)
869
+ const out = await withLockRetry(() => bridge(['task-capture-drop', domain], JSON.stringify({ text: clean, dropId }))) as Partial<CapturedDropCard> & { ok?: unknown }
870
+ if (out?.ok !== true || typeof out.id !== 'string' || !/^[a-f0-9]{12}$/.test(out.id) || typeof out.created !== 'boolean'
871
+ || (out.workIdentity !== undefined && (typeof out.workIdentity !== 'string' || !/^[a-f0-9]{12}$/.test(out.workIdentity)))) {
872
+ throw new TaskRunError(502, 'capture_unverified', 'The card could not be confirmed. Refresh Work before trying again.')
873
+ }
874
+ return { id: out.id, workIdentity: out.workIdentity ?? out.id, created: out.created }
875
+ }
876
+
877
+ /**
878
+ * Undo for captureDropCard: archive (`[~]`) the card only while it is untouched (`task-archive-drop`, stdin
879
+ * `{ dropId, expectedText }`). `touched` means it was edited, checked or moved since: Undo refuses with that reason.
880
+ * `gone` means the card is no longer there, which Undo treats as done.
881
+ */
882
+ export async function archiveDropCard(domain: string, id: string, dropId: string, expectedText: string): Promise<'archived' | 'touched' | 'gone'> {
883
+ if (!isSafeDomainName(domain) || !/^[a-f0-9]{12}$/.test(id)) throw new TaskRunError(422, 'invalid_task_id', 'Use the exact card id')
884
+ const out = await withLockRetry(() => bridge(['task-archive-drop', domain, id], JSON.stringify({ dropId, expectedText: expectedText.replace(/\s+/g, ' ').trim() }))) as { ok?: unknown; result?: unknown }
885
+ if (out?.ok === true && (out.result === 'archived' || out.result === 'touched' || out.result === 'gone')) return out.result
886
+ throw new TaskRunError(502, 'archive_unverified', 'The card could not be confirmed. Refresh Work before trying again.')
887
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 6.99.10 (Drop it plan section 12): Ask from COS Control Web goes through POST /api/query-jobs, the route the glasses
3
+ * use, with `source: 'web'`. The browser holds no message counter and no conversation, so the server fills in what the
4
+ * phone would send:
5
+ *
6
+ * - `sessionId`: kept when it names a live conversation (a follow-up), else a new conversation is made, as the morning
7
+ * brief does. The answer projects into that conversation, so it reads in Messages (Recent on the Mac and the web)
8
+ * and in the lens message list like any other turn.
9
+ * - `globalMsgNum`: the next message number, minted from the same ceiling /api/message-counter serves (live sessions,
10
+ * the archive, and every number a not-yet-projected job holds), and never below a number this process minted
11
+ * already, so two web asks at once take two numbers. One web Ask moves the counter by exactly one.
12
+ * - `messageEra`: the active era.
13
+ *
14
+ * A retry of the same clientJobId and generation (a lost 202) reuses the first admission's session and number, so it
15
+ * replays instead of conflicting. `source` itself is consumed here; the job journal does not store it.
16
+ *
17
+ * Any other source value, or none, is left exactly as it was: the glasses and Control paths are unchanged.
18
+ */
19
+ export interface WebAskAdmissionDeps {
20
+ /** Highest message number in use in the active era (live, archive, reservations). */
21
+ currentMax: () => number
22
+ activeEra: () => string
23
+ sessionExists: (sessionId: string) => boolean
24
+ createSession: () => string
25
+ /** The admitted job for this client identity, if any (lost-202 replay). */
26
+ priorJob: (clientJobId: string, generation: number) => Promise<{ sessionId?: string; globalMsgNum?: number; messageEra?: string } | undefined>
27
+ }
28
+
29
+ export const WEB_ASK_SOURCE = 'web'
30
+ let lastMinted = 0
31
+
32
+ /** Test-only: forget the in-process floor. */
33
+ export function resetWebAskMintForTests(): void { lastMinted = 0 }
34
+
35
+ export function isWebAsk(input: Record<string, unknown>): boolean {
36
+ return input.source === WEB_ASK_SOURCE
37
+ }
38
+
39
+ /** Returns the request with `source` removed and the web fields filled. Synchronous after the replay lookup, so the
40
+ * mint and the floor update happen together. */
41
+ export async function prepareWebAsk(input: Record<string, unknown>, deps: WebAskAdmissionDeps): Promise<Record<string, unknown>> {
42
+ const { source: _source, ...rest } = input
43
+ const clientJobId = typeof rest.clientJobId === 'string' ? rest.clientJobId.toLowerCase() : ''
44
+ const generation = Number(rest.generation)
45
+ const prior = clientJobId && Number.isSafeInteger(generation) && generation >= 1
46
+ ? await deps.priorJob(clientJobId, generation).catch(() => undefined)
47
+ : undefined
48
+ if (prior?.sessionId && prior.globalMsgNum) {
49
+ return { ...rest, sessionId: prior.sessionId, globalMsgNum: prior.globalMsgNum, messageEra: prior.messageEra ?? deps.activeEra() }
50
+ }
51
+ const asked = typeof rest.sessionId === 'string' ? rest.sessionId : ''
52
+ const sessionId = asked && deps.sessionExists(asked) ? asked : deps.createSession()
53
+ const globalMsgNum = Math.max(deps.currentMax(), lastMinted) + 1
54
+ lastMinted = globalMsgNum
55
+ return { ...rest, sessionId, globalMsgNum, messageEra: deps.activeEra() }
56
+ }