@gotcos/glasses-server 6.43.4 → 6.44.1

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,22 @@
1
+ ## 6.44.1
2
+
3
+ Board and lens titles are readable.
4
+
5
+ - `projectRow` and `projectTaskRun` sliced the raw `tasks.md` line at 44 characters, so a title kept its markdown and was cut mid-word: 119 of the 201 rows served today began with an unbalanced `**`, and one row read `**Provide data migration process docs by ind`. Both now go through `taskTitle`, which strips code spans, links and emphasis first — buying back the characters the asterisks were spending — then cuts on a word boundary and marks the cut. Verified over all 312 live task lines: none over the cap, none with a stray asterisk, none empty.
6
+ - Underscore emphasis is anchored, so `cos_python` and `hermit_crabs` survive; only `_emphasis_` is stripped.
7
+ - `TASK_TITLE_MAX` is exported so tests pin the real cap instead of a copy of it.
8
+
9
+ ## 6.44.0
10
+
11
+ Task store: one COS `tasks.md` contract with a durable lock, columns, and a run ledger.
12
+
13
+ - 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.
14
+ - `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.
15
+ - 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.
16
+ - Health advertises `capabilities.tasks.gate` so a new client can tell an old server from a new one.
17
+ - `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`.
18
+ - `GET /api/tasks` returns `{ tasks, workBadge, gate }`. A client that still expects a domain map must adapt.
19
+
1
20
  ## 6.43.4
2
21
 
3
22
  Every message now says who started it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.43.4",
3
+ "version": "6.44.1",
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": {
@@ -50,7 +50,9 @@
50
50
  "url": "git+https://github.com/ukaoma/cos-glasses-server.git"
51
51
  },
52
52
  "homepage": "https://www.gotcos.com",
53
- "bugs": { "url": "https://github.com/ukaoma/cos-glasses-server/issues" },
53
+ "bugs": {
54
+ "url": "https://github.com/ukaoma/cos-glasses-server/issues"
55
+ },
54
56
  "publishConfig": {
55
57
  "access": "public"
56
58
  },
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
@@ -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 })
@@ -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)
@@ -70,6 +70,10 @@ export interface MorningBriefSchedulerDeps {
70
70
  now?: () => number
71
71
  tickMs?: number
72
72
  log?: (line: string) => void
73
+ dispatchDueTasks?: () => Promise<{ fired: number; reason?: string }>
74
+ reconcileDispatch?: () => Promise<{ fired: number; reason?: string }>
75
+ onDispatch?: (result: { fired: number; reason?: string }) => void
76
+ taskDigest?: (day: string) => string | Promise<string>
73
77
  }
74
78
 
75
79
  export class MorningBriefRunError extends Error {
@@ -134,6 +138,10 @@ export class MorningBriefScheduler {
134
138
  /** One chain for tick() AND runNow(): the in-progress read in runNow and the
135
139
  * ledger write in fire() must never interleave with a scheduled fire. */
136
140
  private serial: Promise<unknown> = Promise.resolve()
141
+ private serialTasks: Promise<unknown> = Promise.resolve()
142
+ private serialTasksDepth = 0
143
+ private dispatchInFlight: Promise<unknown> | null = null
144
+ private reconcileInFlight: Promise<unknown> | null = null
137
145
  private readonly now: () => number
138
146
  private readonly log: (line: string) => void
139
147
  readonly quarantinedConfig?: string
@@ -190,6 +198,23 @@ export class MorningBriefScheduler {
190
198
 
191
199
  /** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
192
200
  tick(): Promise<TickResult> {
201
+ if (this.deps.dispatchDueTasks && !this.dispatchInFlight) {
202
+ const mine = this.serializeTaskWork(() => this.deps.dispatchDueTasks!())
203
+ this.dispatchInFlight = mine
204
+ void mine.then(
205
+ result => this.deps.onDispatch?.(result),
206
+ () => undefined,
207
+ ).finally(() => {
208
+ if (this.dispatchInFlight === mine) this.dispatchInFlight = null
209
+ })
210
+ }
211
+ if (this.deps.reconcileDispatch && !this.reconcileInFlight) {
212
+ const mine = this.serializeTaskWork(() => this.deps.reconcileDispatch!())
213
+ this.reconcileInFlight = mine
214
+ void mine.finally(() => {
215
+ if (this.reconcileInFlight === mine) this.reconcileInFlight = null
216
+ })
217
+ }
193
218
  if (this.tickInFlight) return this.tickInFlight
194
219
  this.tickInFlight = this.serialize(() => this.runTick()).finally(() => { this.tickInFlight = null })
195
220
  return this.tickInFlight
@@ -201,6 +226,22 @@ export class MorningBriefScheduler {
201
226
  return next
202
227
  }
203
228
 
229
+ private serializeTaskWork<T>(fn: () => Promise<T> | T): Promise<T> {
230
+ if (this.serialTasksDepth > 0) {
231
+ return Promise.reject(new Error('nested serializeTaskWork'))
232
+ }
233
+ const run = this.serialTasks.then(async () => {
234
+ this.serialTasksDepth += 1
235
+ try {
236
+ return await fn()
237
+ } finally {
238
+ this.serialTasksDepth -= 1
239
+ }
240
+ })
241
+ this.serialTasks = run.then(() => undefined, () => undefined)
242
+ return run
243
+ }
244
+
204
245
  private async runTick(): Promise<TickResult> {
205
246
  if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
206
247
  if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
@@ -309,7 +350,14 @@ export class MorningBriefScheduler {
309
350
  // Ledger first. A crash after this line is a resume, not a second brief.
310
351
  this.replaceRun(run)
311
352
 
312
- const prompt = composeMorningBriefPrompt({ config: this.config, day, ownerName: this.deps.ownerName(), trigger })
353
+ const digest = this.deps.taskDigest ? await this.deps.taskDigest(day) : undefined
354
+ const prompt = composeMorningBriefPrompt({
355
+ config: this.config,
356
+ day,
357
+ ownerName: this.deps.ownerName(),
358
+ trigger,
359
+ ...(digest ? { taskDigest: digest } : {}),
360
+ })
313
361
  try {
314
362
  const admission = await this.deps.submit({
315
363
  clientJobId,
@@ -78,9 +78,9 @@ if (pythonAvailable) {
78
78
  * pipeline is configured; otherwise resolves to an empty/no-op result so the
79
79
  * context builder degrades gracefully on a standalone install.
80
80
  */
81
- export function callPython(args: string[], timeoutMs = 30_000): Promise<unknown> {
81
+ export function callPython(args: string[], timeoutMs = 30_000, input?: string): Promise<unknown> {
82
82
  if (pythonAvailable) {
83
- return callPythonDirect(args, timeoutMs)
83
+ return callPythonDirect(args, timeoutMs, input)
84
84
  }
85
85
  return Promise.resolve(standaloneNoop(args))
86
86
  }
@@ -186,6 +186,13 @@ function standaloneNoop(args: string[]): unknown {
186
186
  return { error: 'cos_pipeline_not_configured' }
187
187
  }
188
188
  case 'badges': return {}
189
+ case 'task-rows':
190
+ case 'task-capture':
191
+ case 'task-set-run-at':
192
+ case 'task-set-marker':
193
+ case 'task-move':
194
+ case 'task-check':
195
+ return { error: { code: 'cos_pipeline_not_configured' } }
189
196
  default: return {}
190
197
  }
191
198
  }
@@ -199,15 +206,18 @@ function argLimit(args: string[], fallback: number): number {
199
206
  }
200
207
 
201
208
  /** Full Python bridge — requires the user's venv + cos_api_bridge.py. */
202
- function callPythonDirect(args: string[], timeoutMs: number): Promise<unknown> {
209
+ function callPythonDirect(args: string[], timeoutMs: number, input?: string): Promise<unknown> {
203
210
  return new Promise((resolvePromise, reject) => {
204
- execFile(
211
+ const child = execFile(
205
212
  PYTHON_BIN!,
206
213
  [BRIDGE_SCRIPT!, ...args],
207
214
  { cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
208
215
  (err, stdout, stderr) => {
209
216
  if (err) {
210
217
  const msg = stderr?.trim() || err.message
218
+ if (typeof msg === 'string' && msg.includes('unknown command')) {
219
+ return resolvePromise({ error: { code: 'cos_pipeline_not_configured', message: msg } })
220
+ }
211
221
  return reject(new Error(`python-bridge: ${msg}`))
212
222
  }
213
223
  try {
@@ -217,5 +227,9 @@ function callPythonDirect(args: string[], timeoutMs: number): Promise<unknown> {
217
227
  }
218
228
  }
219
229
  )
230
+ if (input != null) {
231
+ child.stdin?.write(input)
232
+ child.stdin?.end()
233
+ }
220
234
  })
221
235
  }
@@ -3,6 +3,7 @@ import {
3
3
  QueryJobStore,
4
4
  QueryJobStoreError,
5
5
  type QueryJobAdmissionResult,
6
+ type QueryJobMutationResult,
6
7
  type QueryJobSubscription,
7
8
  } from './query-job-store.js'
8
9
  import {
@@ -502,7 +503,7 @@ export class QueryJobCoordinator {
502
503
  }
503
504
  }
504
505
 
505
- async cancel(jobId: string, generation: number): Promise<QueryJobSnapshot> {
506
+ async cancel(jobId: string, generation: number): Promise<QueryJobMutationResult> {
506
507
  const result = await this.store.cancel(jobId, generation)
507
508
  if (result.applied) {
508
509
  const active = this.active.get(jobId)
@@ -514,7 +515,12 @@ export class QueryJobCoordinator {
514
515
  this.finishActive(active)
515
516
  }
516
517
  }
517
- return result.job
518
+ return result
519
+ }
520
+
521
+ finishIfActive(jobId: string): void {
522
+ const active = this.active.get(jobId)
523
+ if (active) this.finishActive(active)
518
524
  }
519
525
 
520
526
  getSnapshot(jobId: string, generation?: number): Promise<QueryJobSnapshot> {
@@ -92,8 +92,14 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
92
92
  }
93
93
  try {
94
94
  const resolved = await resolveQueryAttachments(input)
95
+ const { dispatch: _strippedDispatch, origin: rawOrigin, ...rest } = input
96
+ const origin = (rawOrigin && typeof rawOrigin === 'object' && (rawOrigin as { kind?: string }).kind === 'task')
97
+ ? undefined
98
+ : rawOrigin
99
+ if (_strippedDispatch !== undefined || origin !== rawOrigin) queryJobStore.noteOriginStripped()
95
100
  return {
96
- ...input,
101
+ ...rest,
102
+ ...(origin !== undefined ? { origin } : {}),
97
103
  messageEra: activeEra,
98
104
  attachmentIds: resolved.ids,
99
105
  attachmentRefs: resolved.refs,
@@ -124,7 +130,7 @@ function originStamp(request: QueryJobRequest): { origin?: NonNullable<QueryJobR
124
130
  * cache. Journaled request/response text always wins over bridge-written
125
131
  * partial rows; validated media refs may be merged because output media can
126
132
  * finish immediately before a crash. Exact provenance collapses duplicates. */
127
- async function projectPublicConversationTerminal(
133
+ export async function projectPublicConversationTerminal(
128
134
  job: QueryJobSnapshot,
129
135
  request: QueryJobRequest,
130
136
  ): Promise<void> {
@@ -334,6 +340,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
334
340
  requestAttachments: resolvedAttachments.refs,
335
341
  attachmentPromptBlock: resolvedAttachments.promptBlock,
336
342
  sessionLockHeld: true,
343
+ ...(request.dispatch ? { dispatch: request.dispatch } : {}),
337
344
  },
338
345
  )
339
346
  }
@@ -319,6 +319,7 @@ export class QueryJobStore {
319
319
  interruptedOnBoot: 0,
320
320
  evictedHydratedJobs: 0,
321
321
  originDropped: 0,
322
+ originStripped: 0,
322
323
  fingerprintMismatches: 0,
323
324
  lastErrorCode: null,
324
325
  lastSuccessfulWriteAt: null,
@@ -1162,6 +1163,10 @@ export class QueryJobStore {
1162
1163
  this.health.counts = counts
1163
1164
  }
1164
1165
 
1166
+ noteOriginStripped(): void {
1167
+ this.health.originStripped++
1168
+ }
1169
+
1165
1170
  getHealth(): QueryJobStoreHealth {
1166
1171
  this.refreshHealth()
1167
1172
  return clone(this.health)