@gotcos/glasses-server 6.81.0 → 6.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/docs/work-orchestrator.md +184 -0
  3. package/package.json +6 -1
  4. package/server/index.ts +19 -0
  5. package/server/lib/claude-bridge.ts +10 -1
  6. package/server/lib/codex-bridge.ts +1 -1
  7. package/server/lib/cursor-bridge.ts +1 -1
  8. package/server/lib/direct-bridge.ts +1 -1
  9. package/server/lib/morning-brief-schedule.ts +13 -7
  10. package/server/lib/query-job-coordinator.ts +5 -1
  11. package/server/lib/query-job-runtime.ts +3 -0
  12. package/server/lib/routines-config.ts +421 -0
  13. package/server/lib/routines-registry.ts +151 -0
  14. package/server/lib/routines-runtime.ts +64 -0
  15. package/server/lib/routines-scheduler.ts +509 -0
  16. package/server/lib/work-chat.ts +76 -9
  17. package/server/lib/work-lens-rounds.ts +445 -0
  18. package/server/lib/work-orchestrator/discovery.ts +99 -0
  19. package/server/lib/work-orchestrator/input.ts +109 -0
  20. package/server/lib/work-orchestrator/pickup.ts +85 -0
  21. package/server/lib/work-orchestrator/plan-log.ts +38 -0
  22. package/server/lib/work-orchestrator/redact.ts +41 -0
  23. package/server/lib/work-orchestrator/runtime.ts +74 -0
  24. package/server/lib/work-orchestrator/session-log.ts +114 -0
  25. package/server/lib/work-orchestrator/snapshot.ts +87 -0
  26. package/server/lib/work-orchestrator/tag-index.ts +272 -0
  27. package/server/lib/work-orchestrator/tags.ts +52 -0
  28. package/server/lib/work-orchestrator/usage-events.ts +30 -0
  29. package/server/lib/work-orchestrator/usage.ts +341 -0
  30. package/server/routes/routines.ts +101 -0
  31. package/server/routes/work-chat.ts +54 -11
  32. package/server/routes/work-lens-rounds.ts +179 -0
  33. package/server/routes/work-orchestrator.ts +71 -0
  34. package/server/scripts/work-lens-rounds-mutation-gate.mjs +151 -0
@@ -0,0 +1,341 @@
1
+ /**
2
+ * 6.83.0 (M3 planner input, canary C1): how much PLAN usage each provider has left (Miles, Q2: plan usage only; a key
3
+ * lane is never a silent fallback). Read without spending any of it.
4
+ *
5
+ * Claude `claude -p` stream-json control request `get_usage` with `skip_behaviors` (no turn, no transcript).
6
+ * Codex `codex app-server` JSON-RPC `account/rateLimits/read` with `excludeResetCreditDetails`. Never sends
7
+ * `supportsLunaReserve` (it records experiment exposure); never calls ConsumeAccountRateLimitResetCredit.
8
+ * Cursor unknown. Its only usage reader is the interactive TUI (which auto-updates the CLI) or a Keychain token;
9
+ * Miles has approved neither, so the server reads neither.
10
+ *
11
+ * Read ON DEMAND only (6.83.0 release decision, Miles has not approved a background poll for every user): a plan
12
+ * input (`POST /api/work/orchestrator/input`, the moment a fork or a new session is chosen) re-reads a provider whose
13
+ * reading is older than 2 minutes; `GET /api/work/orchestrator/usage` re-reads one older than 10 minutes (2 with
14
+ * `refresh=1`). Nothing reads usage on a timer unless the owner opts in with `COS_WORK_USAGE_POLL=1` (every 10 minutes).
15
+ * Passive refresh needs no probe: the `rate_limit_event` frames the server's own plan-lane Claude jobs already receive.
16
+ * A `provider_limit` failure marks the provider exhausted until the reset it states (or one hour), and that beats any
17
+ * cached percentage.
18
+ *
19
+ * How it stops: no timer by default; three failed probes in a row pause that provider's probe for an hour. Each probe
20
+ * runs in a fresh empty directory with hooks and MCP off, and is killed by its exact PID after 15 seconds.
21
+ */
22
+ import { spawn } from 'node:child_process'
23
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { dirname, join } from 'node:path'
26
+ import { atomicWriteFileSync } from '../atomic-fs.js'
27
+ import { resolveProviderBinary } from '../provider-binary.js'
28
+
29
+ export type UsageProvider = 'claude' | 'codex' | 'cursor'
30
+ export const USAGE_PROVIDERS: UsageProvider[] = ['claude', 'codex', 'cursor']
31
+ /** The opt-in poll's period (`COS_WORK_USAGE_POLL=1` only). */
32
+ export const USAGE_POLL_MS = 10 * 60_000
33
+ /** Before a plan (it may fork or start a session): a reading older than this is re-read. */
34
+ export const USAGE_FRESH_MS = 2 * 60_000
35
+ /** An on-demand usage read (GET /usage) answers from a reading younger than this. */
36
+ export const USAGE_CACHE_MS = 10 * 60_000
37
+ export const USAGE_STALE_MS = 25 * 60_000
38
+ export const USAGE_PROBE_TIMEOUT_MS = 15_000
39
+ export const USAGE_BREAKER_FAILURES = 3
40
+ export const USAGE_BREAKER_MS = 60 * 60_000
41
+ export const USAGE_LIMIT_DEFAULT_MS = 60 * 60_000
42
+ export const USAGE_LOW_HEADROOM = 15
43
+
44
+ export interface UsageWindow { kind: string; usedPct: number; resetsAt: string | null; durationMins?: number }
45
+ export interface UsageReading {
46
+ plan: string | null
47
+ windows: UsageWindow[]
48
+ /** Codex `ordinaryUsageAllowed`; Claude false only when a window reports a lock. Null: not stated. */
49
+ allowed: boolean | null
50
+ /** Claude `rate_limits_available: false` (an API key or a third-party login): no plan limits to read. */
51
+ available: boolean
52
+ }
53
+ export interface UsageEntry {
54
+ provider: UsageProvider
55
+ reading: UsageReading | null
56
+ observedAt: number | null
57
+ source: 'probe' | 'passive' | 'none'
58
+ lastError: string | null
59
+ failures: number
60
+ pausedUntil: number | null
61
+ /** Exhausted until this time, from a provider_limit failure or a rejected rate-limit frame. */
62
+ limitUntil: number | null
63
+ limitSource: string | null
64
+ warning: boolean
65
+ }
66
+ export type UsageState = 'ok' | 'low' | 'exhausted' | 'unknown'
67
+ /** What the planner sees: one line per provider. */
68
+ export interface UsageView { provider: UsageProvider; state: UsageState; headroomPct: number | null; resetsAt: string | null; ageSec: number | null; source: string; plan: string | null; stale: boolean; note: string }
69
+
70
+ /** The windows that bind: Claude's 5-hour session and weekly all-models; every Codex window; Cursor's included. */
71
+ const BINDING: Record<UsageProvider, (kind: string) => boolean> = {
72
+ claude: kind => kind === 'session' || kind === 'weekly_all',
73
+ codex: () => true,
74
+ cursor: kind => kind === 'included',
75
+ }
76
+
77
+ const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
78
+ const iso = (s: number) => new Date(s * 1000).toISOString()
79
+
80
+ /** Claude `get_usage` response (`response.response`). Classify on `kind`, never on a label. Null when the shape is not one we know. */
81
+ export function parseClaudeUsage(r: unknown): UsageReading | null {
82
+ if (!r || typeof r !== 'object') return null
83
+ const v = r as Record<string, any>
84
+ if (v.rate_limits_available !== true) return typeof v.rate_limits_available === 'boolean' ? { plan: typeof v.subscription_type === 'string' ? v.subscription_type : null, windows: [], allowed: null, available: false } : null
85
+ const limits = v.rate_limits?.limits
86
+ if (!Array.isArray(limits)) return null
87
+ const windows: UsageWindow[] = []
88
+ let locked = false
89
+ for (const l of limits) {
90
+ if (!l || typeof l.kind !== 'string' || !num(l.percent) || l.percent < 0 || l.percent > 1000) return null
91
+ windows.push({ kind: l.kind, usedPct: l.percent, resetsAt: typeof l.resets_at === 'string' && !Number.isNaN(Date.parse(l.resets_at)) ? new Date(Date.parse(l.resets_at)).toISOString() : null })
92
+ if (l.severity === 'locked' || l.severity === 'blocked') locked = true
93
+ }
94
+ return { plan: typeof v.subscription_type === 'string' ? v.subscription_type : null, windows, allowed: locked ? false : null, available: true }
95
+ }
96
+
97
+ /** Codex `account/rateLimits/read` result. `ordinaryUsageAllowed` is authoritative over percentages (its schema says so). */
98
+ export function parseCodexUsage(r: unknown): UsageReading | null {
99
+ if (!r || typeof r !== 'object') return null
100
+ const v = r as Record<string, any>
101
+ const limits = v.rateLimitsByLimitId?.codex ?? v.rateLimits
102
+ if (!limits || typeof limits !== 'object') return null
103
+ const windows: UsageWindow[] = []
104
+ for (const [kind, w] of [['primary', limits.primary], ['secondary', limits.secondary]] as const) {
105
+ if (w == null) continue
106
+ if (typeof w !== 'object' || !num(w.usedPercent)) return null
107
+ windows.push({ kind, usedPct: w.usedPercent, resetsAt: num(w.resetsAt) ? iso(w.resetsAt) : null, ...(num(w.windowDurationMins) ? { durationMins: w.windowDurationMins } : {}) })
108
+ }
109
+ const reached = limits.rateLimitReachedType != null || limits.spendControlReached === true
110
+ const allowed = typeof v.ordinaryUsageAllowed === 'boolean' ? v.ordinaryUsageAllowed && !reached : reached ? false : null
111
+ return { plan: typeof limits.planType === 'string' ? limits.planType : null, windows, allowed, available: true }
112
+ }
113
+
114
+ /** A reset time named in a provider's own limit text ("resets 2026-10-21T05:50Z", "resets in 3h", "try again in 20 minutes"). */
115
+ export function parseLimitReset(message: string, now: number): number | null {
116
+ const isoMatch = /\b(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2}))/.exec(message)
117
+ if (isoMatch && !Number.isNaN(Date.parse(isoMatch[1]!))) return Date.parse(isoMatch[1]!)
118
+ const rel = /\b(?:resets?|try again|retry)\s+in\s+(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)\b/i.exec(message)
119
+ if (rel) return now + Number(rel[1]) * (/^h/i.test(rel[2]!) ? 3_600_000 : 60_000)
120
+ const epoch = /\bresets?(?:At)?["':\s]+(\d{10})\b/.exec(message)
121
+ if (epoch) return Number(epoch[1]) * 1000
122
+ return null
123
+ }
124
+
125
+ export function headroomOf(provider: UsageProvider, reading: UsageReading | null): number | null {
126
+ if (!reading || !reading.available) return null
127
+ const bind = reading.windows.filter(w => BINDING[provider](w.kind))
128
+ if (!bind.length) return null
129
+ return Math.max(0, Math.round(100 - Math.max(...bind.map(w => w.usedPct))))
130
+ }
131
+
132
+ export function viewOf(e: UsageEntry, now: number): UsageView {
133
+ const ageSec = e.observedAt ? Math.round((now - e.observedAt) / 1000) : null
134
+ const stale = !e.observedAt || now - e.observedAt > USAGE_STALE_MS
135
+ const headroom = headroomOf(e.provider, e.reading)
136
+ const bind = (e.reading?.windows ?? []).filter(w => BINDING[e.provider](w.kind))
137
+ const tightest = bind.length ? bind.reduce((a, b) => (b.usedPct > a.usedPct ? b : a)) : null
138
+ const base = { provider: e.provider, ageSec, source: e.source, plan: e.reading?.plan ?? null, stale }
139
+ // A stated limit beats any percentage (canary C1 rule), until the time it names.
140
+ if (e.limitUntil && e.limitUntil > now) {
141
+ return { ...base, state: 'exhausted', headroomPct: 0, resetsAt: new Date(e.limitUntil).toISOString(), source: e.limitSource ?? 'provider_limit', note: 'plan limit reached' }
142
+ }
143
+ if (e.reading?.allowed === false) return { ...base, state: 'exhausted', headroomPct: 0, resetsAt: tightest?.resetsAt ?? null, note: 'the provider says ordinary usage is not allowed now' }
144
+ if (e.provider === 'cursor' && !e.reading) return { ...base, state: 'unknown', headroomPct: null, resetsAt: null, note: 'not read: Cursor usage needs its app or a Keychain read, neither approved' }
145
+ if (!e.reading || headroom === null) return { ...base, state: 'unknown', headroomPct: null, resetsAt: null, note: e.reading && !e.reading.available ? 'no plan limits on this login' : e.lastError ? `not read: ${e.lastError}` : 'not read yet' }
146
+ const state: UsageState = headroom <= 0 ? 'exhausted' : headroom < USAGE_LOW_HEADROOM || e.warning ? 'low' : 'ok'
147
+ return { ...base, state, headroomPct: headroom, resetsAt: tightest?.resetsAt ?? null, note: stale ? 'reading is old' : '' }
148
+ }
149
+
150
+ /** The background poll runs only when the owner opted in: `COS_WORK_USAGE_POLL=1`, nothing else. */
151
+ export function usagePollOptedIn(env: NodeJS.ProcessEnv = process.env): boolean { return env.COS_WORK_USAGE_POLL === '1' }
152
+
153
+ export interface UsageProbes {
154
+ claude: () => Promise<UsageReading>
155
+ codex: () => Promise<UsageReading>
156
+ }
157
+
158
+ export interface UsageMonitorOptions { path?: string; probes?: Partial<UsageProbes>; now?: () => number; pollEnabled?: boolean }
159
+
160
+ export class UsageMonitor {
161
+ private entries: Record<UsageProvider, UsageEntry>
162
+ private inflight: Partial<Record<UsageProvider, Promise<void>>> = {}
163
+ private timer: NodeJS.Timeout | null = null
164
+ private readonly now: () => number
165
+ private readonly probes: UsageProbes
166
+ constructor(private readonly options: UsageMonitorOptions = {}) {
167
+ this.now = options.now ?? Date.now
168
+ this.probes = { claude: options.probes?.claude ?? probeClaudeUsage, codex: options.probes?.codex ?? probeCodexUsage }
169
+ const blank = (provider: UsageProvider): UsageEntry => ({ provider, reading: null, observedAt: null, source: 'none', lastError: null, failures: 0, pausedUntil: null, limitUntil: null, limitSource: null, warning: false })
170
+ this.entries = { claude: blank('claude'), codex: blank('codex'), cursor: blank('cursor') }
171
+ if (options.path) {
172
+ try {
173
+ const saved = JSON.parse(readFileSync(options.path, 'utf8'))
174
+ for (const p of USAGE_PROVIDERS) if (saved?.[p]?.provider === p) this.entries[p] = { ...this.entries[p], ...saved[p], failures: 0, pausedUntil: null }
175
+ } catch { /* none saved yet */ }
176
+ }
177
+ }
178
+ private save(): void {
179
+ if (!this.options.path) return
180
+ try { mkdirSync(dirname(this.options.path), { recursive: true, mode: 0o700 }); atomicWriteFileSync(this.options.path, JSON.stringify(this.entries)) } catch { /* cache only */ }
181
+ }
182
+ entry(p: UsageProvider): UsageEntry { return this.entries[p] }
183
+ views(): UsageView[] { const now = this.now(); return USAGE_PROVIDERS.map(p => viewOf(this.entries[p], now)) }
184
+ /** Probe one provider now (shares a probe already running). Failures count toward the breaker; never throws. */
185
+ refresh(p: 'claude' | 'codex'): Promise<void> {
186
+ if (this.inflight[p]) return this.inflight[p]!
187
+ const e = this.entries[p]
188
+ if (e.pausedUntil && e.pausedUntil > this.now()) return Promise.resolve()
189
+ const run = (async () => {
190
+ try {
191
+ const reading = await this.probes[p]()
192
+ e.reading = reading; e.observedAt = this.now(); e.source = 'probe'; e.lastError = null; e.failures = 0; e.pausedUntil = null; e.warning = false
193
+ // A fresh reading that says usage is allowed again clears an older limit only when its own reset passed.
194
+ if (e.limitUntil && e.limitUntil <= this.now()) { e.limitUntil = null; e.limitSource = null }
195
+ } catch (error) {
196
+ e.lastError = String((error as Error)?.message ?? error).slice(0, 120)
197
+ e.failures++
198
+ if (e.failures >= USAGE_BREAKER_FAILURES) {
199
+ e.pausedUntil = this.now() + USAGE_BREAKER_MS
200
+ console.warn(`[work-orchestrator] usage probe ${p} paused for 1 h after ${e.failures} failures: ${e.lastError}`)
201
+ }
202
+ } finally { delete this.inflight[p]; this.save() }
203
+ })()
204
+ this.inflight[p] = run
205
+ return run
206
+ }
207
+ /** Re-read any provider whose reading is older than `maxAgeMs` (default 2 minutes), waiting at most `waitMs`. */
208
+ async ensureFresh(maxAgeMs = USAGE_FRESH_MS, waitMs = 16_000): Promise<void> {
209
+ const now = this.now()
210
+ const due = (['claude', 'codex'] as const).filter(p => !this.entries[p].observedAt || now - this.entries[p].observedAt! > maxAgeMs)
211
+ if (!due.length) return
212
+ await Promise.race([Promise.all(due.map(p => this.refresh(p))), new Promise(r => setTimeout(r, waitMs).unref?.())])
213
+ }
214
+ /** Before a plan (POST /input), which may fork or start a new session: re-read what is older than 2 minutes. */
215
+ async forPlan(): Promise<UsageView[]> { await this.ensureFresh(USAGE_FRESH_MS); return this.views() }
216
+ /** On demand (GET /usage): answer from a reading younger than 10 minutes; `refresh` tightens that to 2. */
217
+ async onDemand(refresh = false): Promise<UsageView[]> { await this.ensureFresh(refresh ? USAGE_FRESH_MS : USAGE_CACHE_MS); return this.views() }
218
+ /** A provider refused a plan-lane turn with its limit text. Never called for a key-lane turn. */
219
+ noteLimit(p: UsageProvider, message: string, source = 'provider_limit'): void {
220
+ const now = this.now()
221
+ const until = parseLimitReset(message, now) ?? now + USAGE_LIMIT_DEFAULT_MS
222
+ const e = this.entries[p]
223
+ e.limitUntil = Math.max(e.limitUntil ?? 0, until > now ? until : now + USAGE_LIMIT_DEFAULT_MS); e.limitSource = source
224
+ console.log(`[work-orchestrator] usage ${p} marked exhausted until ${new Date(e.limitUntil).toISOString()} (${source})`)
225
+ this.save()
226
+ }
227
+ /** A `rate_limit_event` frame from one of this server's own plan-lane Claude jobs (C1: free passive refresh). */
228
+ noteClaudeRateLimitEvent(frame: unknown): void {
229
+ const info = (frame as any)?.rate_limit_info
230
+ if (!info || typeof info !== 'object' || typeof info.status !== 'string') return
231
+ const e = this.entries.claude
232
+ if (info.status === 'rejected') {
233
+ const resetMs = num(info.resetsAt) ? info.resetsAt * 1000 : this.now() + USAGE_LIMIT_DEFAULT_MS
234
+ e.limitUntil = Math.max(e.limitUntil ?? 0, resetMs); e.limitSource = 'rate_limit_event'
235
+ } else if (info.status === 'allowed_warning') e.warning = true
236
+ else if (info.status === 'allowed') { e.warning = false; if (e.limitSource === 'rate_limit_event' && (e.limitUntil ?? 0) <= this.now()) e.limitUntil = null }
237
+ if (e.source === 'none') e.source = 'passive'
238
+ this.save()
239
+ }
240
+ /** The opt-in background poll. Off unless `pollEnabled` is true or the env says `COS_WORK_USAGE_POLL=1`. */
241
+ start(env: NodeJS.ProcessEnv = process.env): void {
242
+ if (this.timer || !(this.options.pollEnabled ?? usagePollOptedIn(env))) return
243
+ const tick = () => { void this.refresh('claude'); void this.refresh('codex') }
244
+ this.timer = setInterval(tick, USAGE_POLL_MS)
245
+ this.timer.unref?.()
246
+ setTimeout(tick, 30_000).unref?.()
247
+ }
248
+ stop(): void { if (this.timer) clearInterval(this.timer); this.timer = null }
249
+ }
250
+
251
+ // ─── The real probes ───
252
+
253
+ interface LineChild { pid?: number; write(line: string): void; end(): void; kill(): void; onLine(fn: (o: Record<string, any>) => void): void; onExit(fn: (code: number | null) => void): void }
254
+
255
+ function spawnLines(bin: string, args: string[], cwd: string): LineChild {
256
+ const env = { ...process.env }
257
+ delete env.CLAUDECODE
258
+ const child = spawn(bin, args, { cwd, env, stdio: ['pipe', 'pipe', 'ignore'] })
259
+ let buffer = ''
260
+ const lineFns: Array<(o: Record<string, any>) => void> = []
261
+ child.stdout.on('data', (chunk: Buffer) => {
262
+ buffer += chunk.toString('utf8')
263
+ let nl
264
+ while ((nl = buffer.indexOf('\n')) >= 0) {
265
+ const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1)
266
+ try { const o = JSON.parse(line); if (o && typeof o === 'object') for (const fn of lineFns) fn(o) } catch { /* not JSON */ }
267
+ }
268
+ })
269
+ child.stdin.on('error', () => {})
270
+ return {
271
+ pid: child.pid,
272
+ write: line => { try { child.stdin.write(line + '\n') } catch { /* closed */ } },
273
+ end: () => { try { child.stdin.end() } catch { /* closed */ } },
274
+ // Exact PID: this child, never a pattern.
275
+ kill: () => { try { if (child.exitCode === null) child.kill('SIGTERM') } catch { /* gone */ } },
276
+ onLine: fn => { lineFns.push(fn) },
277
+ onExit: fn => { child.on('exit', fn); child.on('error', () => fn(null)) },
278
+ }
279
+ }
280
+
281
+ export type SpawnLines = (bin: string, args: string[], cwd: string) => LineChild
282
+
283
+ /** The stdin lines each probe sends, in order. Pinned by tests: no luna reserve, no reset-credit call. */
284
+ export const CLAUDE_USAGE_LINES = [
285
+ { type: 'control_request', request_id: 'cos_init', request: { subtype: 'initialize' } },
286
+ { type: 'control_request', request_id: 'cos_usage', request: { subtype: 'get_usage', skip_behaviors: true } },
287
+ ]
288
+ export const CODEX_INIT = { id: 1, method: 'initialize', params: { clientInfo: { name: 'cos', title: null, version: '6.83.0' }, capabilities: null } }
289
+ export const CODEX_READ = { id: 2, method: 'account/rateLimits/read', params: { excludeResetCreditDetails: true } }
290
+
291
+ function withScratch<T>(fn: (dir: string) => Promise<T>): Promise<T> {
292
+ const dir = mkdtempSync(join(tmpdir(), 'cos-usage-'))
293
+ return fn(dir).finally(() => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* best effort */ } })
294
+ }
295
+
296
+ export function probeClaudeUsage(spawnFn: SpawnLines = spawnLines, timeoutMs = USAGE_PROBE_TIMEOUT_MS): Promise<UsageReading> {
297
+ const bin = resolveProviderBinary('claude')
298
+ return withScratch(dir => new Promise((resolve, reject) => {
299
+ const child = spawnFn(bin.ok ? bin.path : 'claude', ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
300
+ '--strict-mcp-config', '--settings', JSON.stringify({ disableAllHooks: true })], dir)
301
+ let done = false
302
+ const finish = (err: Error | null, value?: UsageReading) => {
303
+ if (done) return
304
+ done = true; clearTimeout(timer); child.end(); setTimeout(() => child.kill(), 2000).unref?.()
305
+ if (err) reject(err); else resolve(value!)
306
+ }
307
+ const timer = setTimeout(() => { child.kill(); finish(new Error('timed out')) }, timeoutMs)
308
+ child.onLine(o => {
309
+ if (o.type !== 'control_response' || o.response?.request_id !== 'cos_usage') return
310
+ if (o.response?.subtype !== 'success') return finish(new Error('get_usage refused'))
311
+ const reading = parseClaudeUsage(o.response.response)
312
+ if (reading) finish(null, reading); else finish(new Error('unknown get_usage shape'))
313
+ })
314
+ child.onExit(code => finish(new Error(`exited ${code ?? 'on error'} before answering`)))
315
+ for (const line of CLAUDE_USAGE_LINES) child.write(JSON.stringify(line))
316
+ }))
317
+ }
318
+
319
+ export function probeCodexUsage(spawnFn: SpawnLines = spawnLines, timeoutMs = USAGE_PROBE_TIMEOUT_MS): Promise<UsageReading> {
320
+ const bin = resolveProviderBinary('codex')
321
+ if (!bin.ok) return Promise.reject(new Error('codex not found'))
322
+ return withScratch(dir => new Promise((resolve, reject) => {
323
+ const child = spawnFn(bin.path, ['app-server'], dir)
324
+ let done = false
325
+ const finish = (err: Error | null, value?: UsageReading) => {
326
+ if (done) return
327
+ done = true; clearTimeout(timer); child.end(); setTimeout(() => child.kill(), 2000).unref?.()
328
+ if (err) reject(err); else resolve(value!)
329
+ }
330
+ const timer = setTimeout(() => { child.kill(); finish(new Error('timed out')) }, timeoutMs)
331
+ child.onLine(o => {
332
+ if (o.id === 1 && !done) { child.write(JSON.stringify({ method: 'initialized' })); child.write(JSON.stringify(CODEX_READ)); return }
333
+ if (o.id !== 2) return
334
+ if (o.error) return finish(new Error('rateLimits/read refused'))
335
+ const reading = parseCodexUsage(o.result)
336
+ if (reading) finish(null, reading); else finish(new Error('unknown rateLimits shape'))
337
+ })
338
+ child.onExit(code => finish(new Error(`exited ${code ?? 'on error'} before answering`)))
339
+ child.write(JSON.stringify(CODEX_INIT))
340
+ }))
341
+ }
@@ -0,0 +1,101 @@
1
+ // 6.82.0 Routines: every routine on this server, the morning brief first.
2
+ //
3
+ // GET /api/routines list (the brief, then skill routines) + master switch + limits
4
+ // GET /api/routines/skills the workspace skills a routine may run (labels only, never paths)
5
+ // GET /api/routines/runs recent runs of every skill routine
6
+ // POST /api/routines create a skill routine (201; 400 bad field; 409 at the limit)
7
+ // GET /api/routines/:id one routine
8
+ // PATCH /api/routines/:id edit (PUT is the same); `resetBreaker: true` closes an open breaker
9
+ // POST /api/routines/:id/pause turn it off
10
+ // POST /api/routines/:id/resume turn it on
11
+ // DELETE /api/routines/:id delete a skill routine (the brief can only be turned off)
12
+ // POST /api/routines/:id/run Run now (202; 409 running or off; 423 breaker open; 429 capped)
13
+ // GET /api/routines/:id/runs that routine's run history
14
+ //
15
+ // Authenticated by the global /api middleware like every settings route. No route returns a run's
16
+ // full answer: it lands in the conversation store like any reply.
17
+
18
+ import { Router, type Response } from 'express'
19
+ import { MORNING_BRIEF_ROUTINE_ID } from '../lib/morning-brief-scheduler.js'
20
+ import { RoutineRegistryError, type RoutineRegistry } from '../lib/routines-registry.js'
21
+ import type { WorkspaceSkill } from '../lib/workspace-skills.js'
22
+
23
+ const ID_RE = /^(morning-brief|routine-[0-9a-f]{10})$/
24
+
25
+ export function createRoutinesRouter(registry: () => RoutineRegistry, skills: () => WorkspaceSkill[]): Router {
26
+ const router = Router()
27
+
28
+ const fail = (res: Response, error: unknown, fallback: string) => {
29
+ if (error instanceof RoutineRegistryError) {
30
+ return res.status(error.status).json({ error: { code: error.code, message: error.message, ...error.detail } })
31
+ }
32
+ console.error(`[routines] ${fallback}:`, error)
33
+ return res.status(500).json({ error: { code: fallback, message: 'The routine could not be read or saved.' } })
34
+ }
35
+ const idOf = (raw: string, res: Response): string | null => {
36
+ if (ID_RE.test(raw)) return raw
37
+ res.status(404).json({ error: { code: 'routine_not_found', message: 'That routine does not exist.' } })
38
+ return null
39
+ }
40
+
41
+ router.get('/routines', async (_req, res) => {
42
+ try { res.json(await registry().list()) } catch (error) { fail(res, error, 'routines_unavailable') }
43
+ })
44
+
45
+ router.get('/routines/skills', (_req, res) => {
46
+ res.set('Cache-Control', 'private, no-store')
47
+ try {
48
+ res.json({ skills: skills().map(s => ({ name: s.name, slash: s.slash, description: s.description, group: s.group })) })
49
+ } catch (error) { fail(res, error, 'skills_unavailable') }
50
+ })
51
+
52
+ router.get('/routines/runs', async (req, res) => {
53
+ const limit = Number.parseInt(String(req.query.limit ?? '20'), 10)
54
+ try { res.json({ runs: await registry().runs(undefined, Number.isFinite(limit) ? limit : 20) }) } catch (error) { fail(res, error, 'routines_unavailable') }
55
+ })
56
+
57
+ router.post('/routines', async (req, res) => {
58
+ try { res.status(201).json({ routine: await registry().create(req.body) }) } catch (error) { fail(res, error, 'routine_save_failed') }
59
+ })
60
+
61
+ router.get('/routines/:id', async (req, res) => {
62
+ const id = idOf(req.params.id, res); if (!id) return
63
+ try { res.json({ routine: await registry().get(id) }) } catch (error) { fail(res, error, 'routines_unavailable') }
64
+ })
65
+
66
+ const edit = async (req: { params: { id: string }; body: unknown }, res: Response) => {
67
+ const id = idOf(req.params.id, res); if (!id) return
68
+ try { res.json({ routine: await registry().update(id, req.body) }) } catch (error) { fail(res, error, 'routine_save_failed') }
69
+ }
70
+ router.patch('/routines/:id', edit)
71
+ router.put('/routines/:id', edit)
72
+
73
+ router.post('/routines/:id/pause', async (req, res) => {
74
+ const id = idOf(req.params.id, res); if (!id) return
75
+ try { res.json({ routine: await registry().setEnabled(id, false) }) } catch (error) { fail(res, error, 'routine_save_failed') }
76
+ })
77
+ router.post('/routines/:id/resume', async (req, res) => {
78
+ const id = idOf(req.params.id, res); if (!id) return
79
+ try { res.json({ routine: await registry().setEnabled(id, true) }) } catch (error) { fail(res, error, 'routine_save_failed') }
80
+ })
81
+
82
+ router.delete('/routines/:id', async (req, res) => {
83
+ const id = idOf(req.params.id, res); if (!id) return
84
+ try { await registry().remove(id); res.status(204).end() } catch (error) { fail(res, error, 'routine_save_failed') }
85
+ })
86
+
87
+ router.post('/routines/:id/run', async (req, res) => {
88
+ const id = idOf(req.params.id, res); if (!id) return
89
+ try { res.status(202).json({ run: await registry().runNow(id) }) } catch (error) { fail(res, error, 'routine_run_failed') }
90
+ })
91
+
92
+ router.get('/routines/:id/runs', async (req, res) => {
93
+ const id = idOf(req.params.id, res); if (!id) return
94
+ const limit = Number.parseInt(String(req.query.limit ?? '14'), 10)
95
+ try { res.json({ runs: await registry().runs(id, Number.isFinite(limit) ? limit : 14) }) } catch (error) { fail(res, error, 'routines_unavailable') }
96
+ })
97
+
98
+ return router
99
+ }
100
+
101
+ export { MORNING_BRIEF_ROUTINE_ID }
@@ -1,33 +1,76 @@
1
1
  import { Router } from 'express'
2
- import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
3
- WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES } from '../lib/work-chat.js'
2
+ import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_ORCHESTRATOR_RULES, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
3
+ WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES,
4
+ WORK_CHAT_BODY_BYTES, WORK_CHAT_ORCHESTRATOR_BODY_BYTES, type WorkChatInput, type WorkChatAnswer } from '../lib/work-chat.js'
4
5
  import { runMeetingAskModel } from '../lib/meeting-ask-runner.js'
5
6
  import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
6
- export function createWorkChatRouter(deps = {run:runMeetingAskModel, admissions:maintenanceAdmissionsOpen, lease:()=>acquireMaintenanceWork('work_chat')}) {
7
+ import { appendPlanLog, type PlanLogRow } from '../lib/work-orchestrator/plan-log.js'
8
+ import { PLAN_LOG_PATH } from '../lib/work-orchestrator/runtime.js'
9
+ import { defaultLensRoundStore } from '../lib/work-lens-rounds.js'
10
+
11
+ /** 6.83.0: one plan's log row. Ids and enums only (plan-log.ts). */
12
+ export function planLogRow(input: WorkChatInput, outcome: PlanLogRow['outcome'], answer: WorkChatAnswer | null, ms: number, now = new Date()): PlanLogRow {
13
+ return {
14
+ at: now.toISOString(), workID: input.workID, mode: input.mode, planner: input.planner ?? 'sonnet', effort: input.effort ?? WORK_CHAT_DEFAULT_EFFORT,
15
+ orchestrator: input.orchestrator === true,
16
+ sessions: input.sessions.map(s => ({ id: s.id, automatic: s.automatic, ...(s.available !== undefined ? { available: s.available } : {}),
17
+ ...(s.reason ? { reason: s.reason } : {}), ...(s.discoveredBy ? { discoveredBy: s.discoveredBy } : {}) })),
18
+ leftOut: (input.leftOut ?? []).map(l => ({ id: l.id, reason: l.reason })),
19
+ usageAgeSec: Object.fromEntries((input.usage ?? []).map(u => [u.provider, u.ageSec])),
20
+ usageState: Object.fromEntries((input.usage ?? []).map(u => [u.provider, u.state])),
21
+ assignments: (answer?.assignments ?? []).map(a => ({ mode: a.mode ?? 'continue', target: a.mode === 'newSession' ? `new:${a.provider}` : a.sessionID ?? '' })),
22
+ outcome, ms,
23
+ }
24
+ }
25
+
26
+ const WORK_CHAT_DEPS = {run:runMeetingAskModel, admissions:maintenanceAdmissionsOpen, lease:()=>acquireMaintenanceWork('work_chat'),
27
+ planLog:(row: PlanLogRow)=>appendPlanLog(PLAN_LOG_PATH(), row), now:()=>Date.now(),
28
+ lensPlansControl:()=>defaultLensRoundStore().controlStatus()}
29
+ export function createWorkChatRouter(given: Partial<typeof WORK_CHAT_DEPS> = {}) {
30
+ // 6.83.0: a caller that passes only some deps (the tests pass run, admissions and lease) gets the rest by default.
31
+ const deps={...WORK_CHAT_DEPS,...given}
7
32
  const router=Router(); let running=false
8
33
  router.use('/work/chat', (_req,res,next)=>{res.set('Cache-Control','private, no-store');next()})
9
34
  // 6.73.15: queueRounds means sessions may carry openWork, and the planner routes around sessions with earlier work open.
10
35
  // references means the input may carry read-only reference sessions (never targets).
11
- router.get('/work/chat',(_req,res)=>res.json({protocolVersion:1,planner:'sonnet',planners:WORK_CHAT_PLANNERS,effort:WORK_CHAT_DEFAULT_EFFORT,efforts:WORK_CHAT_EFFORTS,automaticProviders:['claude','codex'],maxAssignments:WORK_CHAT_MAX_ASSIGNMENTS,contextItems:true,maxContextItems:WORK_CHAT_MAX_ITEMS,queueRounds:true,references:true,maxReferences:WORK_CHAT_MAX_REFERENCES}))
36
+ // 6.83.0: orchestrator 1 means a client may send the orchestrator input (POST /api/work/orchestrator/input) and get
37
+ // fork and new-session assignments back (docs/work-orchestrator.md).
38
+ // 6.83.0: lensPlans 1 means the lens may record a plan into the card's Work chat history and ask COS Control to send
39
+ // it (routes/work-lens-rounds.ts); lensPlansControl says whether a COS Control polled the lens inbox in the last 60 s.
40
+ router.get('/work/chat',(_req,res)=>res.json({protocolVersion:1,planner:'sonnet',planners:WORK_CHAT_PLANNERS,effort:WORK_CHAT_DEFAULT_EFFORT,efforts:WORK_CHAT_EFFORTS,automaticProviders:['claude','codex'],maxAssignments:WORK_CHAT_MAX_ASSIGNMENTS,contextItems:true,maxContextItems:WORK_CHAT_MAX_ITEMS,queueRounds:true,references:true,maxReferences:WORK_CHAT_MAX_REFERENCES,orchestrator:1,
41
+ lensPlans:1,lensPlansControl:deps.lensPlansControl()}))
12
42
  router.post('/work/chat',async(req,res)=>{
13
- let input
14
- try { if (Buffer.byteLength(JSON.stringify(req.body))>100000) throw new Error(); input=parseWorkChatInput(req.body) }
43
+ let input: WorkChatInput
44
+ try {
45
+ const limit=req.body?.orchestrator===true ? WORK_CHAT_ORCHESTRATOR_BODY_BYTES : WORK_CHAT_BODY_BYTES
46
+ if (Buffer.byteLength(JSON.stringify(req.body))>limit) throw new Error(); input=parseWorkChatInput(req.body)
47
+ }
15
48
  catch {return res.status(400).json({error:'invalid_work_chat'})}
16
49
  if (running || !deps.admissions()) return res.status(409).json({error:'work_chat_busy'})
17
50
  running=true
51
+ const started=deps.now()
18
52
  let lease: ReturnType<typeof acquireMaintenanceWork> | undefined
53
+ let outcome: PlanLogRow['outcome']='unavailable', answer: WorkChatAnswer | null=null
19
54
  const controller=new AbortController(); const abort=()=>controller.abort()
20
55
  res.on('close',abort)
21
56
  try {
22
57
  lease=deps.lease()
23
58
  // Planner and effort select how the model runs; they are not evidence, so they never reach the prompt.
24
59
  const {planner='sonnet',effort=WORK_CHAT_DEFAULT_EFFORT,...prompt}=input
25
- const out=await deps.run({engine:'claude',model:planner},JSON.stringify(prompt),WORK_CHAT_SYSTEM,controller.signal,undefined,{caller:'work_chat_'+input.mode},
26
- {effort,maxOutputTokens:WORK_CHAT_OUTPUT_TOKENS,maxThinkingTokens:WORK_CHAT_THINKING_TOKENS[effort],deadlineMs:WORK_CHAT_DEADLINE_MS})
27
- const answer=parseWorkChatAnswer(out.text,input)
60
+ const orchestrator=input.orchestrator===true
61
+ const system=orchestrator ? WORK_CHAT_SYSTEM+'\n\n'+WORK_CHAT_ORCHESTRATOR_RULES : WORK_CHAT_SYSTEM
62
+ const out=await deps.run({engine:'claude',model:planner},JSON.stringify(prompt),system,controller.signal,undefined,{caller:'work_chat_'+input.mode},
63
+ {effort,maxOutputTokens:orchestrator ? WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS : WORK_CHAT_OUTPUT_TOKENS,maxThinkingTokens:WORK_CHAT_THINKING_TOKENS[effort],deadlineMs:WORK_CHAT_DEADLINE_MS})
64
+ try { answer=parseWorkChatAnswer(out.text,input) } catch (e) { outcome='invalid_answer'; throw e }
65
+ outcome='planned'
28
66
  if (!res.destroyed) return res.json({protocolVersion:1,...answer,model:out.model??`claude:${planner} (requested)`,effort})
29
- } catch {if(!res.destroyed) return res.status(503).json({error:'work_chat_unavailable',message:'No assignment was dispatched. Your saved request can be retried.'})}
30
- finally {res.off('close',abort);lease?.release();running=false}
67
+ outcome='aborted'
68
+ } catch {if (controller.signal.aborted && outcome==='unavailable') outcome='aborted'; if(!res.destroyed) return res.status(503).json({error:'work_chat_unavailable',message:'No assignment was dispatched. Your saved request can be retried.'})}
69
+ finally {
70
+ res.off('close',abort);lease?.release();running=false
71
+ // 6.83.0: every plan is logged, whatever its outcome (plan-log.ts); a log failure never changes the answer.
72
+ try { deps.planLog(planLogRow(input,outcome,answer,deps.now()-started)) } catch { /* observation only */ }
73
+ }
31
74
  })
32
75
  return router
33
76
  }