@gotcos/glasses-server 6.43.3 → 6.44.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/CHANGELOG.md CHANGED
@@ -1,3 +1,53 @@
1
+ ## 6.44.0
2
+
3
+ Task store: one COS `tasks.md` contract with a durable lock, columns, and a run ledger.
4
+
5
+ - Bridge subcommands `task-rows`, `task-capture`, `task-set-run-at`, `task-set-marker`, `task-move`, and `task-check` are first-class. Standalone installs without a COS pipeline return `cos_pipeline_not_configured` instead of an empty object.
6
+ - `GET /api/tasks` and `/api/tasks/runs` project the board and the ledger. Capture, check, move, and schedule go through the same writer lock the Python side uses.
7
+ - Morning-brief config gains `taskCatchUpMinutes` (default 180). Task due/missed math uses `taskInstant`, which steps a spring-forward gap by 60 minutes instead of pretending `zonedInstant` already did that.
8
+ - Health advertises `capabilities.tasks.gate` so a new client can tell an old server from a new one.
9
+ - `POST /api/tasks/:id/run` is Run now: a restricted Claude job (`Read` / `Grep` / `Glob`) on the same durable path as the morning brief. The same task already live → 409 `task_running`. A second different task while the slot is reserved → 503 `dispatch_slots_busy`.
10
+ - `GET /api/tasks` returns `{ tasks, workBadge, gate }`. A client that still expects a domain map must adapt.
11
+
12
+ ## 6.43.4
13
+
14
+ Every message now says who started it.
15
+
16
+ - **Server-started jobs carry an origin label.** A durable job request may
17
+ carry `origin: { kind: 'routine' | 'task', id }`; the scheduled morning
18
+ brief sends `{ kind: 'routine', id: 'morning-brief' }`. The label lands on
19
+ both halves of the projected exchange (`origin`, `originId`), on the display
20
+ bus (`start`, `done`, `error`, flattened to the same two keys), and on the
21
+ today/all-messages and day-archive views the phone and COS Control read.
22
+ A prompt typed on the phone carries nothing; the phone's own `G2` stamp
23
+ never reaches the server. Nothing is inferred from the label's absence.
24
+ - **The request fingerprint is pinned to the sixteen identity keys it always
25
+ had.** `origin` (and any future provenance or enforcement key) is outside
26
+ it, so a build that does not know the key still hydrates a journal written
27
+ by one that does, and a rollback from this version to 6.43.3 keeps every
28
+ retained job. Stored fingerprints are byte-identical before and after.
29
+ - **Admission is strict only about a known kind with a bad id (400
30
+ `invalid_origin`).** An unknown kind, and the phone's bare-string stamp, are
31
+ dropped and counted on both paths, so a newer client's label degrades to
32
+ unlabeled and never refuses the job; hydration is lenient throughout. Two new
33
+ store counters on `/api/health`: `originDropped` (once per job created, once
34
+ per journal record hydrated) and `fingerprintMismatches` — the second is a
35
+ job that did NOT hydrate, and the first mismatch logs at error level. No
36
+ client reads either yet; `/api/health` is where a human sees them.
37
+ - **The label is validated at every boundary it crosses.** The conversation
38
+ store drops an unknown kind (and an orphan id) when it loads from disk, and
39
+ the bus stamp is spread after provider metadata so no provider key can
40
+ overwrite it. A real 6.43.3-written journal is committed as a fixture and
41
+ hydrated by the suite, in addition to the in-process hash oracle.
42
+ - **The scheduler adopts an admitted brief on every scheduled fire**, not only
43
+ on the crash-resume branch, and replaces the last of the day's scheduled
44
+ ledger rows instead of pushing another; "Run now" and the scheduled tick
45
+ are serialised, so two overlapping admissions can no longer interleave. A
46
+ manual and a scheduled brief on the same day remain two briefs, by design.
47
+ - Briefs that ran on 6.43.0–6.43.3 stay unlabeled; badges start with runs
48
+ from this version. A routine's reply is unlabeled while it is still running
49
+ and gains the label when it lands.
50
+
1
51
  ## 6.43.3
2
52
 
3
53
  Managed-server updates no longer hinge on one vendor's meter.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.43.3",
3
+ "version": "6.44.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -59,6 +59,7 @@ import { promptEditRouter } from './routes/prompt-edit.js'
59
59
  import { bookmarksRouter } from './routes/bookmarks.js'
60
60
  import { welcomeContextRouter } from './routes/welcome-context.js'
61
61
  import { liveCuesRouter } from './routes/live-cues.js'
62
+ import { tasksRouter } from './routes/tasks.js'
62
63
  import { memoryRouter } from './routes/memory.js'
63
64
  import { threadsRouter } from './routes/threads.js'
64
65
  import { shutdownLiveCues } from './lib/live-cues-engine.js'
@@ -689,6 +690,7 @@ app.use('/api', promptEditRouter)
689
690
  app.use('/api', bookmarksRouter)
690
691
  app.use('/api', welcomeContextRouter)
691
692
  app.use('/api', liveCuesRouter)
693
+ app.use('/api', tasksRouter)
692
694
 
693
695
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
694
696
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -0,0 +1,2 @@
1
+ {"schemaVersion":1,"recordId":"02b09947-ed7d-47ef-99c5-4985af3ec924","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"43b397bd-dd67-4312-9a5c-4c5d4b246f1d","clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"turnId":"a456fd1d-1e82-4b2d-8a20-5d34681201fd","requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"query":"written by 6.43.3","sessionId":"session-6-43-3","model":"opus","effort":"high","cursorExecutionMode":"ask","messageEra":"era1","globalMsgNum":78,"reference":{"query":"earlier q","response":"earlier a"},"handoffCode":"ABCD","handoffLatest":true,"clientQueueItemId":"q1","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95"}}
2
+ {"schemaVersion":1,"recordId":"a1145a40-2ced-4da3-9cf7-76d84cffc946","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"64c5b502-13f2-4871-b6c4-007b91be2414","clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"turnId":"843e633f-0f92-4fe3-b2a1-9dc683715791","requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"query":"minimal by 6.43.3","sessionId":"session-6-43-3-min","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638"}}
@@ -472,7 +472,8 @@ export function getArchiveChats(date: string): ArchiveChatSummary[] {
472
472
  }
473
473
 
474
474
  /** Get paired Q&A messages for a specific chat within a day. Request refs on
475
- * the user turn and model-output refs on the assistant turn surface together. */
475
+ * the user turn and model-output refs on the assistant turn surface together.
476
+ * Deliberately no origin label here (6.43.4): the day view carries it. */
476
477
  export function getArchiveChatMessages(
477
478
  date: string,
478
479
  chatIndex: number,
@@ -507,11 +508,11 @@ export function getArchiveChatMessages(
507
508
  * (sessionId, timestamp) instead of bare timestamp (collision-prone). */
508
509
  export function getArchiveDayMessages(
509
510
  date: string,
510
- ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> {
511
+ ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; origin?: 'routine' | 'task'; originId?: string; attachments?: MediaAttachmentRef[] }> {
511
512
  const archive = loadArchive(date)
512
513
  if (!archive) return []
513
514
 
514
- const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> = []
515
+ const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; origin?: 'routine' | 'task'; originId?: string; attachments?: MediaAttachmentRef[] }> = []
515
516
  for (const chat of archive.chats) {
516
517
  for (let i = 0; i < chat.exchanges.length; i++) {
517
518
  const ex = chat.exchanges[i]
@@ -522,6 +523,8 @@ export function getArchiveDayMessages(
522
523
  const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
523
524
  const messageEra = ex.messageEra ?? next.messageEra
524
525
  const modelPreference = resolveExchangePairModel(ex, next, null)
526
+ // Same one-half rule as the live view (routes/sessions.ts).
527
+ const stamped = ex.origin ? ex : next
525
528
  messages.push({
526
529
  query: ex.content,
527
530
  text: next.content,
@@ -531,6 +534,7 @@ export function getArchiveDayMessages(
531
534
  ...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
532
535
  ...(messageEra ? { messageEra } : {}),
533
536
  ...(modelPreference ? { modelPreference } : {}),
537
+ ...(stamped.origin ? { origin: stamped.origin, ...(stamped.originId ? { originId: stamped.originId } : {}) } : {}),
534
538
  ...(attachments.length > 0 ? { attachments } : {}),
535
539
  })
536
540
  i++
@@ -54,6 +54,7 @@ import {
54
54
  buildClaudeToolList,
55
55
  claudeMcpConfigArgs,
56
56
  claudeToolCapabilityPrompt,
57
+ readOnlyCapabilityPrompt,
57
58
  } from './claude-tool-access.js'
58
59
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
59
60
  import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
@@ -388,6 +389,8 @@ export interface CallOptions {
388
389
  cursorExecutionMode?: import('../../shared/model-preference.js').CursorExecutionMode
389
390
  /** Durable coordinator already owns the per-session provider lease. */
390
391
  sessionLockHeld?: boolean
392
+ /** Read-only scheduled-task dispatch. Same shape as QueryJobRequest.dispatch. */
393
+ dispatch?: { restricted: true; tools: readonly string[] }
391
394
  }
392
395
 
393
396
  export async function callClaudeStreaming(
@@ -418,7 +421,7 @@ export async function callClaudeStreaming(
418
421
  // Notify client immediately — model is known before any async work
419
422
  // Pass existing CLI session ID if resuming (new sessions get it after first result)
420
423
  const resolvedCliKey = cliSessionKey(sid, resolvedModel)
421
- let existingCliSession = cliSessionMap.get(resolvedCliKey)
424
+ let existingCliSession = options?.dispatch ? undefined : cliSessionMap.get(resolvedCliKey)
422
425
  callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
423
426
  clientJobId: options?.clientJobId,
424
427
  generation: options?.generation,
@@ -461,14 +464,18 @@ export async function callClaudeStreaming(
461
464
  const allowedToolList = buildClaudeToolList({
462
465
  publisherTool: outputImagePublisher?.claudeAllowedTool,
463
466
  })
464
- let mcpConfigArgs: string[]
465
- try {
466
- mcpConfigArgs = claudeMcpConfigArgs()
467
- } catch (err) {
468
- outputImagePublisher?.cleanup()
469
- throw err
467
+ let mcpConfigArgs: string[] = []
468
+ if (!options?.dispatch) {
469
+ try {
470
+ mcpConfigArgs = claudeMcpConfigArgs()
471
+ } catch (err) {
472
+ outputImagePublisher?.cleanup()
473
+ throw err
474
+ }
470
475
  }
471
- systemPrompt = `${systemPrompt}\n\n${claudeToolCapabilityPrompt(allowedToolList)}`
476
+ systemPrompt = options?.dispatch
477
+ ? `${systemPrompt}\n\n${readOnlyCapabilityPrompt('scheduled task dispatch', 'Read, Grep and Glob only; no writes, no shell, no web', 'the task owner on the Mac')}`
478
+ : `${systemPrompt}\n\n${claudeToolCapabilityPrompt(allowedToolList)}`
472
479
  if (outputImagePublisher) systemPrompt = `${systemPrompt}\n\n${outputImagePublisher.promptInstructions}`
473
480
 
474
481
  // Phase: thinking (waiting for Claude to start)
@@ -519,7 +526,8 @@ export async function callClaudeStreaming(
519
526
 
520
527
  // Check if we have a prior CLI session for this COS session.
521
528
  // If not, use the pre-warmed session (eliminates 2-15s cold start on first query).
522
- if (!existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
529
+ // Restricted dispatch never steals the pre-warmed unrestricted session.
530
+ if (!options?.dispatch && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
523
531
  // Only the default model consumes the pre-warmed session.
524
532
  // Hey Even (Haiku) cold-starts its own session to avoid model contamination.
525
533
  existingCliSession = preWarmedCliSessionId
@@ -540,11 +548,21 @@ export async function callClaudeStreaming(
540
548
  '--output-format', 'stream-json',
541
549
  '--verbose', // Required: stream-json requires --verbose
542
550
  '--system-prompt', systemPrompt,
543
- ...mcpConfigArgs,
551
+ ...(options?.dispatch ? [] : mcpConfigArgs),
544
552
  ]
545
553
 
546
554
  // Full and lightweight paths share the same explicit MCP selector contract.
547
- if (options?.lightweight) {
555
+ if (options?.dispatch) {
556
+ args.push(
557
+ '--restricted',
558
+ '--tools', options.dispatch.tools.join(','),
559
+ '--permission-mode', 'dontAsk',
560
+ '--strict-mcp-config',
561
+ '--mcp-config', '{"mcpServers":{}}',
562
+ '--no-session-persistence',
563
+ '--include-partial-messages',
564
+ )
565
+ } else if (options?.lightweight) {
548
566
  args.push(...claudePermissionArgs(getClaudeTrustMode(), tools))
549
567
  } else {
550
568
  args.push(...claudePermissionArgs(getClaudeTrustMode(), tools), '--include-partial-messages')
@@ -929,7 +947,7 @@ export async function callClaudeStreaming(
929
947
  continue
930
948
  }
931
949
  // Capture CLI session ID for future --resume (avoids cold start on next query)
932
- if (event.session_id) {
950
+ if (event.session_id && !options?.dispatch) {
933
951
  cliSessionMap.set(resolvedCliKey, event.session_id)
934
952
  scheduleCliSessionSave()
935
953
  updateClaudeRun(run.runId, { cliSessionId: event.session_id })
@@ -37,6 +37,20 @@ export interface Exchange {
37
37
  * photos on the user exchange, model-published images on the assistant
38
38
  * exchange. Bytes, filesystem paths, and capabilities never persist here. */
39
39
  attachments?: MediaAttachmentRef[]
40
+ /** Who started the job that produced this pair, when it was not the person
41
+ * on the phone or the glasses: `routine` (the scheduler) or `task` (a
42
+ * dispatched task). Absent on every human prompt. The phone's own `g2` stamp
43
+ * never reaches the server; this is the server's half of the label. */
44
+ origin?: 'routine' | 'task'
45
+ /** `morning-brief`, or a task's 12-hex id. */
46
+ originId?: string
47
+ }
48
+
49
+ /** Structural twin of `QueryJobOrigin` so the conversation store takes no
50
+ * import from the job module. */
51
+ export interface ExchangeOriginStamp {
52
+ kind: 'routine' | 'task'
53
+ id: string
40
54
  }
41
55
 
42
56
  export interface ExchangeJobProvenance {
@@ -159,6 +173,14 @@ function loadFromDisk(): void {
159
173
  if (refs.length > 0) exchange.attachments = refs
160
174
  else delete exchange.attachments
161
175
  }
176
+ // Same boundary for the origin label (6.43.4): only the two kinds this
177
+ // build renders survive a load, and an id never survives without its kind.
178
+ if (exchange.origin !== 'routine' && exchange.origin !== 'task') {
179
+ delete exchange.origin
180
+ delete exchange.originId
181
+ } else if (typeof exchange.originId !== 'string' || !exchange.originId) {
182
+ delete exchange.originId
183
+ }
162
184
  }
163
185
  sessions.set(id, session)
164
186
  loaded++
@@ -528,6 +550,7 @@ export function reconcileExchangeByJobIdentity(
528
550
  attachments?: MediaAttachmentRef[],
529
551
  messageEra?: string,
530
552
  modelPreference?: ModelPreference | null,
553
+ origin?: ExchangeOriginStamp,
531
554
  ): ReconciledExchange {
532
555
  if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
533
556
  throw new Error('conversation: invalid durable job identity')
@@ -572,6 +595,7 @@ export function reconcileExchangeByJobIdentity(
572
595
  generation: identity.generation,
573
596
  ...(stampedModel ? { modelPreference: stampedModel } : {}),
574
597
  ...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
598
+ ...(origin ? { origin: origin.kind, originId: origin.id } : {}),
575
599
  }
576
600
  session.exchanges.push(exchange)
577
601
  } else {
@@ -580,6 +604,9 @@ export function reconcileExchangeByJobIdentity(
580
604
  exchange.globalMsgNum = globalMsgNum
581
605
  exchange.messageEra = projectedMessageEra
582
606
  if (stampedModel) exchange.modelPreference = stampedModel
607
+ // Guarded like the model: the cursor and ollama bridges reconcile the user
608
+ // half mid-run WITHOUT an origin, and must not erase the runtime's stamp.
609
+ if (origin) { exchange.origin = origin.kind; exchange.originId = origin.id }
583
610
  if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
584
611
  else delete exchange.attachments
585
612
 
@@ -54,6 +54,12 @@ export type MaintenanceWorkKind =
54
54
  // budget must stay under COS Control's 90s drain timeout (main.swift:1853)
55
55
  // or every drain that catches a cue in flight hard-fails to Repair.
56
56
  | 'live_cue_pipeline'
57
+ // Scheduled-task dispatch and reconcile (6.44.0). Held for
58
+ // TASK_LEASE_CEILING_MS so an Update Server drain cannot unload the
59
+ // server while a restricted Claude submit or marker write is live.
60
+ // Two `task_dispatch` leases (dispatch + reconcile) are expected —
61
+ // acquire() is a concurrent Map, not a per-kind mutex.
62
+ | 'task_dispatch'
57
63
  // Miles-triggered recovery of a quarantined unsaved capture (6.19.0):
58
64
  // batch-transcribes retained WAVs into a durable scribe. Held for the whole
59
65
  // background run so an Update Server drain waits for it like any batch.
@@ -105,6 +105,10 @@ export async function callModelStreaming(
105
105
  }
106
106
 
107
107
  try {
108
+ if (options?.dispatch && !isClaudeModel(resolvedModel)) {
109
+ await lockedCallbacks.onError('dispatch_requires_claude')
110
+ return sid
111
+ }
108
112
  // Cursor slots fail closed — never fall through to Claude/Codex.
109
113
  if (isCursorModel(resolvedModel)) {
110
114
  await getCursorModelCatalog()
@@ -29,6 +29,12 @@ import {
29
29
  export const MORNING_BRIEF_PROTOCOL_VERSION = 1 as const
30
30
  export const MORNING_BRIEF_CONFIG_VERSION = 1 as const
31
31
 
32
+ export const TASK_CATCH_UP_LIMITS = Object.freeze({
33
+ defaultMinutes: 180,
34
+ minMinutes: 30,
35
+ maxMinutes: 720,
36
+ })
37
+
32
38
  export const MORNING_BRIEF_LIMITS = Object.freeze({
33
39
  /** How late after the slot a missed fire may still happen (Mac was asleep). */
34
40
  maxCatchUpMinutes: 12 * 60,
@@ -91,6 +97,8 @@ export interface MorningBriefConfig {
91
97
  /** 0 = Sunday … 6 = Saturday. */
92
98
  days: number[]
93
99
  catchUpMinutes: number
100
+ /** How late a task runAt may still count as due (not missed). */
101
+ taskCatchUpMinutes: number
94
102
  model?: ModelPreference
95
103
  effort?: EffortPreference
96
104
  /** Ordered: section order in the brief. */
@@ -271,6 +279,7 @@ export function defaultMorningBriefConfig(now = new Date()): MorningBriefConfig
271
279
  timezone: serverTimezone(),
272
280
  days: [1, 2, 3, 4, 5],
273
281
  catchUpMinutes: MORNING_BRIEF_LIMITS.defaultCatchUpMinutes,
282
+ taskCatchUpMinutes: TASK_CATCH_UP_LIMITS.defaultMinutes,
274
283
  sources: defaultSources(),
275
284
  closingInstruction: '',
276
285
  updatedAt: now.toISOString(),
@@ -389,6 +398,13 @@ export function applyMorningBriefPatch(current: MorningBriefConfig, raw: unknown
389
398
  }
390
399
  next.catchUpMinutes = minutes
391
400
  }
401
+ if ('taskCatchUpMinutes' in patch) {
402
+ const minutes = Number(patch.taskCatchUpMinutes)
403
+ if (!Number.isSafeInteger(minutes) || minutes < TASK_CATCH_UP_LIMITS.minMinutes || minutes > TASK_CATCH_UP_LIMITS.maxMinutes) {
404
+ throw new MorningBriefConfigError('invalid_task_catch_up', `taskCatchUpMinutes must be ${TASK_CATCH_UP_LIMITS.minMinutes} to ${TASK_CATCH_UP_LIMITS.maxMinutes}.`)
405
+ }
406
+ next.taskCatchUpMinutes = minutes
407
+ }
392
408
  if ('model' in patch) {
393
409
  if (patch.model == null || patch.model === '') {
394
410
  delete next.model
@@ -429,6 +445,7 @@ export function coerceMorningBriefConfig(raw: unknown, now = new Date()): Mornin
429
445
  const input = raw as Record<string, unknown>
430
446
  const time = typeof input.time === 'string' && TIME_RE.test(input.time.trim()) ? input.time.trim() : base.time
431
447
  const catchUp = Number(input.catchUpMinutes)
448
+ const taskCatchUp = Number(input.taskCatchUpMinutes)
432
449
  const model = normalizeModelPreference(input.model)
433
450
  const effort = normalizeEffortPreference(input.effort)
434
451
  let sources: MorningBriefSource[]
@@ -441,6 +458,10 @@ export function coerceMorningBriefConfig(raw: unknown, now = new Date()): Mornin
441
458
  days: normalizeDays(input.days, base.days),
442
459
  catchUpMinutes: Number.isSafeInteger(catchUp) && catchUp >= 0 && catchUp <= MORNING_BRIEF_LIMITS.maxCatchUpMinutes
443
460
  ? catchUp : base.catchUpMinutes,
461
+ taskCatchUpMinutes: Number.isSafeInteger(taskCatchUp)
462
+ && taskCatchUp >= TASK_CATCH_UP_LIMITS.minMinutes
463
+ && taskCatchUp <= TASK_CATCH_UP_LIMITS.maxMinutes
464
+ ? taskCatchUp : base.taskCatchUpMinutes,
444
465
  ...(model ? { model } : {}),
445
466
  ...(effort ? { effort } : {}),
446
467
  sources,
@@ -463,7 +484,7 @@ export function morningBriefPaths(root?: string): MorningBriefStorePaths {
463
484
  return { config: `${base}/config.json`, runs: `${base}/runs.json` }
464
485
  }
465
486
 
466
- function ensurePrivateDir(file: string): void {
487
+ export function ensurePrivateDir(file: string): void {
467
488
  const dir = dirname(file)
468
489
  mkdirSync(dir, { recursive: true, mode: 0o700 })
469
490
  try { chmodSync(dir, 0o700) } catch { /* best effort */ }
@@ -30,6 +30,8 @@ export interface MorningBriefPromptInput {
30
30
  day: string
31
31
  ownerName: string
32
32
  trigger: 'scheduled' | 'manual'
33
+ /** Optional TASKS digest. Kept only when the full prompt still fits. */
34
+ taskDigest?: string
33
35
  }
34
36
 
35
37
  const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
@@ -200,7 +202,7 @@ const KNOWN_IDS = new Set<MorningBriefSourceId>(MORNING_BRIEF_SOURCES.map(spec =
200
202
 
201
203
  /** Compose the brief prompt. Enabled sources become numbered sections in the
202
204
  * user's order; a section whose source has nothing to say is skipped. */
203
- export function composeMorningBriefPrompt(input: MorningBriefPromptInput): string {
205
+ function assembleMorningBriefPrompt(input: MorningBriefPromptInput, includeDigest: boolean): string {
204
206
  const { config, day, ownerName, trigger } = input
205
207
  const slotMinutes = parseTime(config.time)
206
208
  const slot = `${String(Math.floor(slotMinutes / 60)).padStart(2, '0')}:${String(slotMinutes % 60).padStart(2, '0')}`
@@ -244,6 +246,11 @@ export function composeMorningBriefPrompt(input: MorningBriefPromptInput): strin
244
246
  lines.push('')
245
247
  }
246
248
 
249
+ if (includeDigest && input.taskDigest) {
250
+ lines.push(input.taskDigest)
251
+ lines.push('')
252
+ }
253
+
247
254
  lines.push(
248
255
  'Format for the glasses: plain text only. No markdown headings, tables, or bullet symbols; a section is its label on one line followed by short lines. ' +
249
256
  'Keep every line under 60 characters where you can, because the lens is 576 pixels wide and wraps silently. Keep the whole brief under about 60 lines; ' +
@@ -256,7 +263,14 @@ export function composeMorningBriefPrompt(input: MorningBriefPromptInput): strin
256
263
  )
257
264
  lines.push('Do not say "here is" or "I found". Do not add a preamble or a sign-off.')
258
265
 
259
- const prompt = lines.join('\n')
266
+ return lines.join('\n')
267
+ }
268
+
269
+ export function composeMorningBriefPrompt(input: MorningBriefPromptInput): string {
270
+ const withDigest = assembleMorningBriefPrompt(input, true)
271
+ const prompt = input.taskDigest && withDigest.length <= MORNING_BRIEF_PROMPT_MAX_CHARS
272
+ ? withDigest
273
+ : assembleMorningBriefPrompt(input, false)
260
274
  return prompt.length > MORNING_BRIEF_PROMPT_MAX_CHARS
261
275
  ? `${prompt.slice(0, MORNING_BRIEF_PROMPT_MAX_CHARS - 1)}…`
262
276
  : prompt
@@ -13,9 +13,16 @@ import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
13
13
  import { dataPath } from './data-dir.js'
14
14
  import { getOwnerName } from './profile.js'
15
15
  import { durableQueryJobsEnabled } from './query-job-feature.js'
16
- import { queryJobCoordinator } from './query-job-runtime.js'
16
+ import { projectPublicConversationTerminal, queryJobCoordinator } from './query-job-runtime.js'
17
17
  import { maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
18
18
  import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
19
+ import {
20
+ bindTaskDispatcher,
21
+ dispatchDueTasks,
22
+ reconcileDispatch,
23
+ reservationsForEra,
24
+ } from './task-dispatcher.js'
25
+ import { composeTaskDigest, listBoard } from './task-store.js'
19
26
  import { morningBriefPaths } from './morning-brief-config.js'
20
27
  import { MorningBriefScheduler } from './morning-brief-scheduler.js'
21
28
  import {
@@ -162,9 +169,25 @@ export function probeSkill(rawName: string, workDir = resolveProviderWorkDir({ s
162
169
 
163
170
  let scheduler: MorningBriefScheduler | null = null
164
171
  let unregisterReservations: (() => void) | null = null
172
+ let unregisterTaskReservations: (() => void) | null = null
165
173
 
166
174
  export function getMorningBriefScheduler(): MorningBriefScheduler {
167
175
  if (!scheduler) {
176
+ bindTaskDispatcher({
177
+ submit: raw => queryJobCoordinator.submit(raw),
178
+ findByClientGeneration: (clientJobId, generation) => queryJobCoordinator.getByClientGeneration(clientJobId, generation),
179
+ getSnapshot: jobId => queryJobCoordinator.getSnapshot(jobId),
180
+ getExecution: jobId => queryJobCoordinator.store.getExecution(jobId),
181
+ cancel: (jobId, generation) => queryJobCoordinator.cancel(jobId, generation),
182
+ complete: (jobId, input) => queryJobCoordinator.store.complete(jobId, input),
183
+ createSession,
184
+ currentMessageEra,
185
+ currentMessageMax,
186
+ durableJobsEnabled: durableQueryJobsEnabled,
187
+ admissionsOpen: maintenanceAdmissionsOpen,
188
+ projectTerminal: projectPublicConversationTerminal,
189
+ finishIfActive: jobId => queryJobCoordinator.finishIfActive(jobId),
190
+ })
168
191
  const instance = new MorningBriefScheduler({
169
192
  paths: morningBriefPaths(),
170
193
  submit: raw => queryJobCoordinator.submit(raw),
@@ -184,9 +207,15 @@ export function getMorningBriefScheduler(): MorningBriefScheduler {
184
207
  reflection: probeReflection,
185
208
  skill: name => probeSkill(name),
186
209
  }),
210
+ dispatchDueTasks,
211
+ reconcileDispatch,
212
+ taskDigest: async () => {
213
+ try { return composeTaskDigest(await listBoard()) } catch { return '' }
214
+ },
187
215
  })
188
216
  // The ledger row exists before the job does; its number must count.
189
217
  unregisterReservations = registerMessageReservationSource(() => instance.liveReservations(currentMessageEra()))
218
+ unregisterTaskReservations = registerMessageReservationSource(() => reservationsForEra(currentMessageEra()))
190
219
  scheduler = instance
191
220
  }
192
221
  return scheduler
@@ -200,4 +229,6 @@ export function stopMorningBriefScheduler(): void {
200
229
  scheduler?.stop()
201
230
  unregisterReservations?.()
202
231
  unregisterReservations = null
232
+ unregisterTaskReservations?.()
233
+ unregisterTaskReservations = null
203
234
  }
@@ -74,8 +74,10 @@ export function shiftDay(day: string, delta: number): string {
74
74
  * The instant at which `day` reaches `minutes` past midnight in `timezone`.
75
75
  * Two-pass offset correction: read the zone's wall clock at a UTC guess, apply
76
76
  * the difference, and re-check once so a DST transition between the guess and
77
- * the target lands on the right side. On a non-existent local time (spring
78
- * forward) this returns the first instant after the gap.
77
+ * the target lands on the right side. A non-existent local time local-clocks
78
+ * ~60 min early on the old offset (02:30 CDT gap → 01:30; 02:00 → 01:00);
79
+ * 03:00 lands on 03:00 CDT. `taskInstant` adds 60 min when earlier:
80
+ * 02:00→03:00, 02:30→03:30.
79
81
  */
80
82
  export function zonedInstant(day: string, minutes: number, timezone: string): number {
81
83
  const [y, m, d] = day.split('-').map(Number)
@@ -90,6 +92,21 @@ export function zonedInstant(day: string, minutes: number, timezone: string): nu
90
92
  return guess
91
93
  }
92
94
 
95
+ /** Instant for a task runAt. If the rendered local (day, minutes) is earlier
96
+ * than requested (spring-forward gap), add 60 minutes up to three times.
97
+ * Fall back to the first zonedInstant. */
98
+ export function taskInstant(day: string, minutes: number, timezone: string): number {
99
+ const first = zonedInstant(day, minutes, timezone)
100
+ let instant = first
101
+ for (let pass = 0; pass < 3; pass++) {
102
+ const clock = localClock(instant, timezone)
103
+ const earlier = clock.day < day || (clock.day === day && clock.minutes < minutes)
104
+ if (!earlier) return instant
105
+ instant += 60 * 60_000
106
+ }
107
+ return first
108
+ }
109
+
93
110
  function daysBetween(fromDay: string, toDay: string): number {
94
111
  const [fy, fm, fd] = fromDay.split('-').map(Number)
95
112
  const [ty, tm, td] = toDay.split('-').map(Number)