@threadcrumbs/common 0.1.2 → 0.1.4

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.
@@ -2,35 +2,49 @@ import fsp from 'node:fs/promises'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { exists, jsonLines, listDirs, listFiles } from '../lib/fsutil.mjs'
5
- import { jsonlFollower } from '../lib/jsonl-follower.mjs'
5
+ import { contextBreakdown, emptyFixed, emptySizes, setFixed, sizeCall, sizeCompaction, sizePrompt, sizeReply, sizeRequest, sizeResult } from '../lib/context-breakdown.mjs'
6
+ import { jsonlFollower, recentReaders } from '../lib/jsonl-follower.mjs'
6
7
  import { columnsOf, tablesOf, withReadOnly } from '../lib/sqlite.mjs'
7
8
  import {
8
9
  DETAIL_LIMITS,
9
10
  RECENT_MS,
10
11
  activityDetail,
11
12
  addFileChange,
13
+ askOf,
14
+ askQuestions,
12
15
  clip,
13
16
  clipBlock,
14
17
  diffHunk,
15
18
  emptyActivity,
19
+ emptySample,
16
20
  endCall,
17
21
  fileList,
22
+ inputKind,
23
+ joinedText,
18
24
  keepNewest,
25
+ keepText,
19
26
  nextPlan,
20
27
  number,
21
28
  planItems,
22
29
  planSummary,
23
30
  relativePath,
31
+ sampleList,
32
+ samplePoint,
24
33
  settleCalls,
25
34
  startCall,
26
35
  text,
36
+ textList,
27
37
  titleFromPrompt,
28
38
  toolInput,
39
+ toolNow,
29
40
  toolTarget,
30
41
  } from '../lib/telemetry-fields.mjs'
31
42
 
32
43
  const ACTIVE_WINDOW_MS = 30 * 60 * 1000
33
44
  const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
45
+ const OPENED_KEPT = 3
46
+ const COMPACT_SHARE = 0.9
47
+ const REPORTED_SHARE = 0.95
34
48
  const ROLLOUT_FILE = /^rollout-.{19}-([0-9a-f-]{36})(?:_[0-9a-f-]{36})?\.jsonl$/i
35
49
  const STATE_DB = /^state_(\d+)\.sqlite$/
36
50
  const CALLS = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'tool_search_call'])
@@ -105,9 +119,9 @@ function callInput(item, args, patch) {
105
119
  function latestCall(calls) {
106
120
  let latest = null
107
121
  for (const call of calls.values()) {
108
- if (!latest || call.startedAt >= latest.startedAt) latest = { name: call.name, startedAt: call.startedAt }
122
+ if (!latest || call.startedAt >= latest.startedAt) latest = call
109
123
  }
110
- return latest
124
+ return toolNow(latest)
111
125
  }
112
126
 
113
127
  function emptyRollout() {
@@ -128,12 +142,15 @@ function emptyRollout() {
128
142
  fallbackPrompt: '',
129
143
  fallbackPrompts: 0,
130
144
  open: false,
145
+ turnStartedAt: 0,
146
+ usageLimits: null,
131
147
  failed: false,
132
148
  calls: new Map(),
133
149
  patches: new Map(),
134
150
  lines: { added: 0, removed: 0 },
135
151
  tokens: null,
136
152
  context: null,
153
+ contextSample: emptySample(),
137
154
  plan: null,
138
155
  history: [],
139
156
  contextParts: null,
@@ -147,6 +164,87 @@ function emptyRollout() {
147
164
  }
148
165
  }
149
166
 
167
+ const AGENTS_FILE = /^# AGENTS\.md instructions for ([^\n]+)/
168
+
169
+ const emptyOpened = () => ({
170
+ lastAt: 0,
171
+ thoughts: [],
172
+ replies: [],
173
+ sizes: emptySizes(),
174
+ sent: { base: 0, developer: [], other: 0, memory: new Map(), tools: new Map() },
175
+ })
176
+
177
+ function callName(item) {
178
+ if (item.type === 'local_shell_call') return 'shell'
179
+ if (item.type === 'tool_search_call') return 'tool_search'
180
+ return `${text(item.namespace)}${text(item.name)}`
181
+ }
182
+
183
+ function codexFixed(sent) {
184
+ const fixed = emptyFixed()
185
+ const developer = sent.developer.reduce((n, chars) => n + chars, 0)
186
+ fixed.system = developer + (sent.developer.includes(sent.base) ? 0 : sent.base) + sent.other
187
+ fixed.mcp = new Map(sent.tools)
188
+ fixed.memory = new Map(sent.memory)
189
+ return fixed
190
+ }
191
+
192
+ function applyOpened(state, record) {
193
+ const at = Date.parse(record.timestamp) || state.lastAt
194
+ state.lastAt = at
195
+ const item = record.payload
196
+ if (!item || typeof item !== 'object') return
197
+ const { sizes, sent } = state
198
+ const before = sizes.first == null
199
+ if (record.type === 'session_meta') {
200
+ sent.base = text(item.base_instructions?.text).length
201
+ } else if (record.type === 'compacted') {
202
+ sizeCompaction(sizes)
203
+ return
204
+ } else if (record.type === 'event_msg') {
205
+ if (item.type === 'token_count' && item.info?.last_token_usage) sizeRequest(sizes, number(item.info.last_token_usage.input_tokens), at)
206
+ return
207
+ } else if (record.type !== 'response_item') {
208
+ return
209
+ } else if (item.type === 'reasoning') {
210
+ const said = joinedText(item.summary, 'summary_text', 'text')
211
+ keepText(state.thoughts, 'thoughts', { id: text(item.id) || null, at, text: said })
212
+ sizeReply(sizes, said.length)
213
+ return
214
+ } else if (item.type === 'message' && item.role === 'assistant') {
215
+ const said = joinedText(item.content, 'output_text', 'text')
216
+ keepText(state.replies, 'replies', { id: text(item.id) || null, at, text: said })
217
+ sizeReply(sizes, said.length)
218
+ return
219
+ } else if (CALLS.has(item.type)) {
220
+ sizeCall(sizes, text(item.call_id), callName(item), (text(item.arguments) || text(item.input) || JSON.stringify(item.action ?? '')).length)
221
+ return
222
+ } else if (OUTPUTS.has(item.type)) {
223
+ sizeResult(sizes, text(item.call_id), outputText(item.output).length)
224
+ return
225
+ } else if (item.type === 'additional_tools') {
226
+ for (const group of Array.isArray(item.tools) ? item.tools : []) {
227
+ const tools = Array.isArray(group?.tools) ? group.tools.length : 0
228
+ sent.tools.set(text(group?.name) || 'tools', { tools: tools || null, chars: JSON.stringify(group ?? null).length })
229
+ }
230
+ } else if (item.type === 'message') {
231
+ const raw = contentText(item.content)
232
+ const agents = item.role === 'user' ? AGENTS_FILE.exec(raw) : null
233
+ if (agents) sent.memory.set(`${agents[1].trim().replace(/[\/]+$/, '')}/AGENTS.md`, raw.length)
234
+ else if (item.role === 'user' && promptText(raw)) {
235
+ sizePrompt(sizes, raw.length)
236
+ return
237
+ } else if (!before) {
238
+ sizePrompt(sizes, raw.length)
239
+ return
240
+ } else if (item.role === 'developer' || item.role === 'system') sent.developer.push(raw.length)
241
+ else sent.other += raw.length
242
+ } else {
243
+ return
244
+ }
245
+ setFixed(sizes, codexFixed(sent))
246
+ }
247
+
150
248
  function applyRecord(state, record) {
151
249
  const at = Date.parse(record.timestamp)
152
250
  if (at) {
@@ -199,23 +297,19 @@ function applyItem(state, item, at) {
199
297
  const id = text(item.call_id)
200
298
  if (!id) return
201
299
  if (CALLS.has(item.type)) {
202
- let name = `${text(item.namespace)}${text(item.name)}`
203
- if (item.type === 'local_shell_call') name = 'shell'
204
- if (item.type === 'tool_search_call') name = 'tool_search'
205
- state.calls.set(id, { name, startedAt: at })
300
+ const name = callName(item)
206
301
  const args = item.type === 'function_call' ? parseArgs(item.arguments) : null
207
302
  if (item.type === 'function_call' && !item.namespace && item.name === 'update_plan') state.plan = nextPlan(state.plan, 'update_plan', args)
208
303
  const patch = item.name === 'apply_patch' ? (item.type === 'custom_tool_call' ? text(item.input) : text(args?.input)) : ''
209
304
  if (patch) state.patches.set(id, patch)
210
305
  const file = patch ? patchFile(patch) : ''
211
- startCall(state.activity, {
212
- id,
213
- name,
214
- target: callTarget(item, args, patch, state.cwd),
215
- input: callInput(item, args, patch),
216
- path: file ? relativePath(file, state.cwd) : null,
217
- startedAt: at,
218
- })
306
+ const target = callTarget(item, args, patch, state.cwd)
307
+ const input = callInput(item, args, patch)
308
+ const entry = { name, startedAt: at, target, kind: inputKind(name, input).kind }
309
+ const questions = QUESTION_TOOLS.has(name) ? askQuestions(args) : null
310
+ if (questions) entry.questions = questions
311
+ state.calls.set(id, entry)
312
+ startCall(state.activity, { id, name, target, input, path: file ? relativePath(file, state.cwd) : null, startedAt: at })
219
313
  return
220
314
  }
221
315
  if (!OUTPUTS.has(item.type)) return
@@ -274,6 +368,7 @@ function applyEvent(state, event, at) {
274
368
  switch (event.type) {
275
369
  case 'task_started':
276
370
  case 'turn_started':
371
+ if (!state.open) state.turnStartedAt = at
277
372
  state.open = true
278
373
  state.failed = false
279
374
  state.calls.clear()
@@ -298,6 +393,7 @@ function applyEvent(state, event, at) {
298
393
  break
299
394
  case 'token_count':
300
395
  applyTokens(state, event.info, at)
396
+ applyLimits(state, event.rate_limits, at)
301
397
  break
302
398
  case 'user_message':
303
399
  state.typed = true
@@ -316,6 +412,21 @@ function applyEvent(state, event, at) {
316
412
  }
317
413
  }
318
414
 
415
+ const LIMIT_WINDOWS = ['primary', 'secondary']
416
+
417
+ function applyLimits(state, limits, at) {
418
+ if (!limits || typeof limits !== 'object') return
419
+ const windows = []
420
+ for (const id of LIMIT_WINDOWS) {
421
+ const w = limits[id]
422
+ if (!w || typeof w !== 'object' || w.used_percent == null) continue
423
+ const resetsAt = w.resets_at != null ? number(w.resets_at) * 1000 : w.resets_in_seconds != null ? at + number(w.resets_in_seconds) * 1000 : null
424
+ windows.push({ id, usedPercent: number(w.used_percent), windowMinutes: w.window_minutes == null ? null : number(w.window_minutes), resetsAt: resetsAt || null })
425
+ }
426
+ if (!windows.length) return
427
+ state.usageLimits = { at, subscription: text(limits.plan_type) || null, windows }
428
+ }
429
+
319
430
  function applyTokens(state, info, at) {
320
431
  if (!info) return
321
432
  const total = info.total_token_usage
@@ -344,6 +455,7 @@ function applyTokens(state, info, at) {
344
455
  const sum = number(total?.total_tokens)
345
456
  if (sum === state.lastTotal) return
346
457
  state.lastTotal = sum
458
+ samplePoint(state.contextSample, last.total_tokens)
347
459
  keepNewest(state.history, { at, context: number(last.total_tokens), output: number(last.output_tokens) }, DETAIL_LIMITS.history)
348
460
  }
349
461
 
@@ -356,6 +468,7 @@ export function codex({
356
468
  const locksDir = path.join(home, 'thread-writer-locks')
357
469
  const indexFile = path.join(home, 'session_index.jsonl')
358
470
  const rollouts = jsonlFollower({ empty: emptyRollout, apply: applyRecord })
471
+ const openedFor = recentReaders(() => jsonlFollower({ empty: emptyOpened, apply: applyOpened }), OPENED_KEPT)
359
472
  let index = { mtime: -1, names: new Map() }
360
473
  let known = new Map()
361
474
  let childrenOf = new Map()
@@ -440,8 +553,9 @@ export function codex({
440
553
  for (const { id, stat, state } of read) {
441
554
  if (!state.subagent || !state.parentId || !state.open) continue
442
555
  if (!locks.has(id) && at - stat.mtimeMs > SUBAGENT_WINDOW_MS) continue
443
- running.set(state.parentId, (running.get(state.parentId) || 0) + 1)
556
+ running.set(state.parentId, [...(running.get(state.parentId) || []), { id, name: childName(state), startedAt: state.startedAt }])
444
557
  }
558
+ const workingOf = (id) => (running.get(id) || []).sort((a, b) => a.startedAt - b.startedAt).map(({ id: childId, name }) => ({ id: childId, name }))
445
559
 
446
560
  return read
447
561
  .filter(({ id, state }) => !state.subagent && !state.internal && !archived.has(id))
@@ -449,7 +563,8 @@ export function codex({
449
563
  const lastActivityAt = Math.max(state.lastAt, Math.floor(stat.mtimeMs))
450
564
  const fresh = at - lastActivityAt < ACTIVE_WINDOW_MS
451
565
  const live = locking ? locks.has(id) : state.open && fresh
452
- return toSession({ id, state, names, live, turnLive: state.open && (locking ? live : fresh), fresh, locking, unread: unread.get(id), subagents: running.get(id) || 0, lastActivityAt })
566
+ const workingSubagents = workingOf(id)
567
+ return toSession({ id, state, names, live, turnLive: state.open && (locking ? live : fresh), fresh, locking, unread: unread.get(id), subagents: workingSubagents.length, workingSubagents, lastActivityAt })
453
568
  })
454
569
  }
455
570
 
@@ -465,7 +580,9 @@ export function codex({
465
580
  const id = String(key).replace(/^codex:/, '').toLowerCase()
466
581
  const found = await readKnown(id)
467
582
  if (!found || found.state.subagent || found.state.internal) return null
468
- const { file, state } = found
583
+ const { file, stat, state } = found
584
+ const said = await openedFor(file).read(file, stat).catch(() => null)
585
+ const limit = state.context?.limit || null
469
586
  const subagents = []
470
587
  for (const childId of childrenOf.get(id) || []) {
471
588
  const child = await readKnown(childId)
@@ -475,7 +592,7 @@ export function codex({
475
592
  const status = c.open && now() - lastActivityAt < ACTIVE_WINDOW_MS ? 'working' : c.failed ? 'error' : 'done'
476
593
  subagents.push({
477
594
  id: childId,
478
- name: titleFromPrompt(c.typed ? c.firstPrompt : c.fallbackPrompt) || 'Subagent',
595
+ name: childName(c),
479
596
  type: '',
480
597
  status,
481
598
  model: c.model,
@@ -498,6 +615,13 @@ export function codex({
498
615
  subagents: subagents.sort((a, b) => a.startedAt - b.startedAt),
499
616
  files: fileList(state.files, state.cwd),
500
617
  prompts: (state.typed ? state.promptList : state.fallbackPromptList).map((p) => ({ ...p })),
618
+ thoughts: textList(said?.thoughts),
619
+ replies: textList(said?.replies),
620
+ contextBreakdown: contextBreakdown(said?.sizes, {
621
+ total: state.context?.used,
622
+ limit,
623
+ reserved: limit ? Math.round(limit * (1 - COMPACT_SHARE / REPORTED_SHARE)) : null,
624
+ }),
501
625
  errors: state.errors.map((e) => ({ ...e })),
502
626
  }
503
627
  }
@@ -512,17 +636,21 @@ export function codex({
512
636
  }
513
637
  }
514
638
 
515
- function toSession({ id, state, names, live, turnLive, fresh, locking, unread, subagents, lastActivityAt }) {
516
- const calls = [...state.calls.values()]
639
+ const childName = (state) => titleFromPrompt(state.typed ? state.firstPrompt : state.fallbackPrompt) || 'Subagent'
640
+
641
+ function toSession({ id, state, names, live, turnLive, fresh, locking, unread, subagents, workingSubagents, lastActivityAt }) {
642
+ const calls = [...state.calls.values()].sort((a, b) => a.startedAt - b.startedAt)
643
+ const asked = calls.filter((c) => QUESTION_TOOLS.has(c.name))
517
644
  let status
645
+ let statusReason
518
646
  if (turnLive) {
519
- if (calls.some((c) => QUESTION_TOOLS.has(c.name))) status = 'asking'
520
- else if (calls.length ? calls.every((c) => AGENT_TOOLS.has(c.name)) : subagents > 0) status = 'delegating'
521
- else status = 'working'
522
- } else if (subagents) status = 'delegating'
523
- else if (state.failed) status = 'error'
524
- else if (unread !== undefined) status = unread ? 'done' : 'idle'
525
- else status = (locking ? live : fresh) ? 'done' : 'idle'
647
+ if (asked.length) [status, statusReason] = ['asking', asked.some((c) => c.name === 'request_user_input') ? 'question' : 'approval']
648
+ else if (calls.length ? calls.every((c) => AGENT_TOOLS.has(c.name)) : subagents > 0) [status, statusReason] = ['delegating', 'subagents']
649
+ else [status, statusReason] = ['working', 'turn-open']
650
+ } else if (subagents) [status, statusReason] = ['delegating', 'subagents']
651
+ else if (state.failed) [status, statusReason] = ['error', 'turn-failed']
652
+ else if (unread !== undefined) [status, statusReason] = unread ? ['done', 'unread'] : ['idle', 'read']
653
+ else [status, statusReason] = (locking ? live : fresh) ? ['done', 'recent'] : ['idle', 'quiet']
526
654
 
527
655
  const tool = turnLive && (status === 'working' || status === 'delegating') ? latestCall(state.calls) : null
528
656
  const prompt = state.typed ? state.firstPrompt : state.fallbackPrompt
@@ -545,11 +673,17 @@ function toSession({ id, state, names, live, turnLive, fresh, locking, unread, s
545
673
  tool,
546
674
  tokens: state.tokens ? { ...state.tokens } : null,
547
675
  context: state.context && state.context.used ? { ...state.context } : null,
676
+ contextHistory: sampleList(state.contextSample, state.context?.used),
548
677
  costUsd: null,
549
678
  lines: { ...state.lines },
550
679
  plan: planSummary(state.plan),
551
680
  subagents,
681
+ workingSubagents,
552
682
  prompts: state.typed ? state.prompts : state.fallbackPrompts,
683
+ turnStartedAt: turnLive ? state.turnStartedAt || null : null,
684
+ statusReason,
685
+ ask: askOf(status, { calls: asked, errorText: state.errors.at(-1)?.message }),
686
+ usageLimits: state.usageLimits ? { ...state.usageLimits, windows: state.usageLimits.windows.map((w) => ({ ...w })) } : null,
553
687
  }
554
688
  }
555
689
 
@@ -3,24 +3,30 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { decodeProjectDir } from '../lib/cursor-folder.mjs'
5
5
  import { exists, listDirs, listFiles } from '../lib/fsutil.mjs'
6
- import { jsonlFollower } from '../lib/jsonl-follower.mjs'
6
+ import { jsonlFollower, recentReaders } from '../lib/jsonl-follower.mjs'
7
7
  import {
8
8
  DETAIL_LIMITS,
9
9
  RECENT_MS,
10
10
  activityDetail,
11
+ askOf,
11
12
  clip,
12
13
  clipBlock,
13
14
  emptyActivity,
15
+ joinedText,
14
16
  keepNewest,
17
+ keepText,
15
18
  nextPlan,
19
+ openTool,
16
20
  planItems,
17
21
  planSummary,
18
22
  relativePath,
19
23
  startCall,
20
24
  text,
25
+ textList,
21
26
  titleFromPrompt,
22
27
  toolFile,
23
28
  toolInput,
29
+ toolNow,
24
30
  toolTarget,
25
31
  } from '../lib/telemetry-fields.mjs'
26
32
 
@@ -57,6 +63,7 @@ function emptyTranscript() {
57
63
  closed: false,
58
64
  failed: false,
59
65
  modelsTurn: false,
66
+ turnStartedAt: 0,
60
67
  tool: null,
61
68
  plan: null,
62
69
  activity: emptyActivity(),
@@ -80,6 +87,7 @@ function applyRecord(state, record) {
80
87
  state.closed = false
81
88
  if (record.role === 'user') {
82
89
  const prompt = userText(record)
90
+ if (!state.modelsTurn) state.turnStartedAt = stamp(record)
83
91
  if (prompt) {
84
92
  state.prompts++
85
93
  state.firstPrompt ||= prompt
@@ -98,7 +106,16 @@ function applyRecord(state, record) {
98
106
  startCall(state.activity, { name: text(call.name), target: toolTarget(call.input, '', Infinity), input: toolInput(call.input), ...toolFile(call.input), startedAt: 0, running: false })
99
107
  }
100
108
  state.modelsTurn = calls.length > 0
101
- state.tool = calls.length ? { name: text(calls.at(-1).name), startedAt: 0 } : null
109
+ state.tool = calls.length ? openTool(text(calls.at(-1).name), 0, calls.at(-1).input) : null
110
+ }
111
+
112
+ const WORDS_KEPT = 3
113
+
114
+ const emptyWords = () => ({ turnAt: 0, replies: [] })
115
+
116
+ function applyWords(state, record) {
117
+ if (record.role === 'user') state.turnAt = stamp(record) || state.turnAt
118
+ else if (record.role === 'assistant') keepText(state.replies, 'replies', { at: state.turnAt, text: joinedText(record.message?.content, 'text') })
102
119
  }
103
120
 
104
121
  export function cursor({
@@ -107,6 +124,7 @@ export function cursor({
107
124
  } = {}) {
108
125
  const transcripts = jsonlFollower({ empty: emptyTranscript, apply: applyRecord })
109
126
  const subagentFiles = jsonlFollower({ empty: emptyTranscript, apply: applyRecord })
127
+ const wordsFor = recentReaders(() => jsonlFollower({ empty: emptyWords, apply: applyWords }), WORDS_KEPT)
110
128
  let subagentsSweptAt = 0
111
129
  let known = new Map()
112
130
 
@@ -142,15 +160,17 @@ export function cursor({
142
160
  }
143
161
 
144
162
  async function runningSubagents(sessionDir) {
145
- if (!sessionDir) return 0
146
- let count = 0
163
+ if (!sessionDir) return []
164
+ const working = []
147
165
  for (const file of await listFiles(path.join(sessionDir, 'subagents'), (n) => n.endsWith('.jsonl'))) {
148
166
  const stat = await fsp.stat(file).catch(() => null)
149
167
  if (!stat || now() - stat.mtimeMs > SUBAGENT_WINDOW_MS) continue
150
168
  const state = await follow(file, stat, subagentFiles).catch(() => null)
151
- if (state && isOpen(state, true)) count++
169
+ if (!state || !isOpen(state, true)) continue
170
+ const id = path.basename(file, '.jsonl')
171
+ working.push({ id, name: titleFromPrompt(state.firstPrompt) || id, startedAt: state.startedAt || Math.floor(stat.birthtimeMs) || Math.floor(stat.mtimeMs) })
152
172
  }
153
- return count
173
+ return working.sort((a, b) => a.startedAt - b.startedAt).map(({ id, name }) => ({ id, name }))
154
174
  }
155
175
 
156
176
  async function subagentList(sessionDir) {
@@ -170,7 +190,7 @@ export function cursor({
170
190
  status: open ? 'working' : state.failed ? 'error' : 'done',
171
191
  model: '',
172
192
  tokens: null,
173
- tool: open && state.tool ? { ...state.tool } : null,
193
+ tool: open && state.tool ? toolNow(state.tool) : null,
174
194
  startedAt: state.startedAt || Math.floor(stat.birthtimeMs) || Math.floor(stat.mtimeMs),
175
195
  lastActivityAt: Math.floor(stat.mtimeMs),
176
196
  })
@@ -184,6 +204,7 @@ export function cursor({
184
204
  const stat = entry ? await fsp.stat(entry.file).catch(() => null) : null
185
205
  if (!stat) return null
186
206
  const state = await follow(entry.file, stat)
207
+ const said = await wordsFor(entry.file).read(entry.file, stat).catch(() => null)
187
208
  const open = isOpen(state, now() - stat.mtimeMs < ACTIVE_WINDOW_MS)
188
209
  const { activity, tools } = activityDetail(state.activity)
189
210
  for (const call of activity) {
@@ -205,6 +226,9 @@ export function cursor({
205
226
  subagents: await subagentList(entry.sessionDir),
206
227
  files: null,
207
228
  prompts: state.promptList.map((p) => ({ ...p })),
229
+ thoughts: null,
230
+ replies: textList(said?.replies),
231
+ contextBreakdown: null,
208
232
  errors: state.errors.map((e) => ({ ...e })),
209
233
  }
210
234
  }
@@ -221,14 +245,16 @@ export function cursor({
221
245
  }
222
246
  const fresh = now() - entry.stat.mtimeMs < ACTIVE_WINDOW_MS
223
247
  const open = isOpen(state, fresh)
224
- const subagents = await runningSubagents(entry.sessionDir)
248
+ const workingSubagents = await runningSubagents(entry.sessionDir)
249
+ const subagents = workingSubagents.length
225
250
  const projectPath = await decodeProjectDir(path.basename(entry.projectDir))
226
251
  found.set(entry.id, { file: entry.file, sessionDir: entry.sessionDir, projectPath })
227
252
  let status
228
- if (subagents) status = 'delegating'
229
- else if (open) status = 'working'
230
- else if (state.failed) status = 'error'
231
- else status = fresh ? 'done' : 'idle'
253
+ let statusReason
254
+ if (subagents) [status, statusReason] = ['delegating', 'subagents']
255
+ else if (open) [status, statusReason] = ['working', 'turn-open']
256
+ else if (state.failed) [status, statusReason] = ['error', 'turn-failed']
257
+ else [status, statusReason] = fresh ? ['done', 'recent'] : ['idle', 'quiet']
232
258
  sessions.push({
233
259
  id: `cursor:${entry.id}`,
234
260
  harness: 'cursor',
@@ -244,14 +270,20 @@ export function cursor({
244
270
  live: open,
245
271
  startedAt: state.startedAt || Math.floor(entry.stat.birthtimeMs) || Math.floor(entry.stat.mtimeMs),
246
272
  lastActivityAt: Math.floor(entry.stat.mtimeMs),
247
- tool: open && state.tool ? { ...state.tool } : null,
273
+ tool: open && state.tool ? { ...toolNow(state.tool), target: clip(relativePath(state.tool.target, projectPath)) } : null,
248
274
  tokens: null,
249
275
  context: null,
276
+ contextHistory: null,
250
277
  costUsd: null,
251
278
  lines: null,
252
279
  plan: planSummary(state.plan),
253
280
  subagents,
281
+ workingSubagents,
254
282
  prompts: state.prompts,
283
+ turnStartedAt: open ? state.turnStartedAt || null : null,
284
+ statusReason,
285
+ ask: askOf(status, { errorText: state.errors.at(-1)?.message }),
286
+ usageLimits: null,
255
287
  })
256
288
  }
257
289
  transcripts.sweep()