@phenx-inc/ctlsurf 0.17.2 → 0.17.3

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 (51) hide show
  1. package/README.md +25 -2
  2. package/out/headless/index.mjs +1033 -128
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +929 -17
  5. package/out/preload/index.js +15 -0
  6. package/out/renderer/assets/{cssMode-BtQxI2Eb.js → cssMode-BJfoToLX.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-DRW5dmY5.js → freemarker2-T0eMtP1Z.js} +1 -1
  8. package/out/renderer/assets/{handlebars-CFLF3cP7.js → handlebars-Dbbib8fi.js} +1 -1
  9. package/out/renderer/assets/{html-DtnBOkwp.js → html-BRHT_oxc.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-DMVysDCg.js → htmlMode-BRlZTxME.js} +3 -3
  11. package/out/renderer/assets/{index-CEjwKjtd.css → index-CBWAeUYW.css} +91 -0
  12. package/out/renderer/assets/{index-Dm70hk5s.js → index-RCAeuJTr.js} +476 -411
  13. package/out/renderer/assets/{javascript-ChlyA1sf.js → javascript-BbOZOxq9.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-D1_oHzeN.js → jsonMode-CUvAeYcT.js} +3 -3
  15. package/out/renderer/assets/{liquid-D4ftdy1G.js → liquid-DRIACo_s.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-Ckqu5ubG.js → lspLanguageFeatures-Ds-3nH9p.js} +1 -1
  17. package/out/renderer/assets/{mdx-B9wsVYSC.js → mdx-D76jlpLO.js} +1 -1
  18. package/out/renderer/assets/{python-e23tD6IS.js → python-CMuRPKJi.js} +1 -1
  19. package/out/renderer/assets/{razor-CrDyXj3d.js → razor-DRoH5mal.js} +1 -1
  20. package/out/renderer/assets/{tsMode-BwGC5o4O.js → tsMode-dPJzgFD7.js} +1 -1
  21. package/out/renderer/assets/{typescript-Do-98d_O.js → typescript-D5USKnLj.js} +1 -1
  22. package/out/renderer/assets/{xml-Dpdu5R3m.js → xml-qYg1wt2P.js} +1 -1
  23. package/out/renderer/assets/{yaml-B0Zt7LCJ.js → yaml-Uc-CvnvL.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +4 -2
  26. package/scripts/check-work-map-gpu.cjs +62 -0
  27. package/scripts/check-work-map-gpu.d.cts +13 -0
  28. package/scripts/postinstall.cjs +15 -0
  29. package/scripts/prepare-work-map.py +37 -0
  30. package/scripts/test-work-map-capability.cjs +45 -0
  31. package/scripts/test-work-map-ui.cjs +130 -0
  32. package/scripts/test-work-map.cjs +352 -0
  33. package/scripts/work-map-clef.py +81 -0
  34. package/src/main/index.ts +35 -1
  35. package/src/main/observer/observer.ts +2 -0
  36. package/src/main/orchestrator.ts +39 -4
  37. package/src/main/transcripts.ts +35 -16
  38. package/src/main/workMap/clef.ts +156 -0
  39. package/src/main/workMap/normalize.ts +172 -0
  40. package/src/main/workMap/source.ts +28 -0
  41. package/src/main/workMap/tree.ts +199 -0
  42. package/src/main/workMap/workMap.ts +244 -0
  43. package/src/preload/index.ts +17 -0
  44. package/src/renderer/App.tsx +15 -16
  45. package/src/renderer/components/ObserverPanel.tsx +3 -0
  46. package/src/renderer/components/SettingsDialog.tsx +0 -104
  47. package/src/renderer/components/WorkMapPanel.tsx +235 -0
  48. package/src/renderer/components/WorkMapToggle.tsx +50 -0
  49. package/src/renderer/lib/workMapHighlights.ts +41 -0
  50. package/src/renderer/styles.css +91 -0
  51. package/src/shared/workMap.d.ts +70 -0
@@ -0,0 +1,199 @@
1
+ import type { MapActivity, MapEvidence, MapIntent, MapNode } from '../../shared/workMap'
2
+ import type { DecisionRequest, Decision } from './clef'
3
+ import { activityFor, decisionCandidates, intentFor, shortLabel, topicFor } from './normalize'
4
+
5
+ const ACTIVITIES: Record<Exclude<MapActivity, 'thinking'>, string> = {
6
+ investigating: 'Reading files, searching, inspecting or diagnosing',
7
+ editing: 'Changing files, code, dependencies or configuration',
8
+ testing: 'Running tests, builds or validation',
9
+ planning: 'Discussing the approach or deciding what to do next',
10
+ reporting: 'Explaining findings or summarizing completed work',
11
+ }
12
+ const INTENTS: Record<MapIntent, string> = {
13
+ decision: 'A stated choice of approach or rejection of another approach',
14
+ hypothesis: 'An unverified explanation or suspicion to investigate',
15
+ alternative: 'Weighing different possible approaches without choosing yet',
16
+ next_step: 'A stated next action or check to perform',
17
+ context: 'General narration with no clear choice, hypothesis or next step',
18
+ }
19
+ export interface MapDecisionJob {
20
+ request: DecisionRequest
21
+ ids: string[]
22
+ goalId: string
23
+ statements: Record<string, string[]>
24
+ reasons: Record<string, string[]>
25
+ }
26
+ export const MAP_NODE_LIMIT = 240
27
+
28
+ /** Bounded evidence-backed tree. Model suggestions never change execution status. */
29
+ export class WorkMapTree {
30
+ nodes: MapNode[] = []
31
+ dropped = 0
32
+ private goal: string | null = null
33
+ private seq = 0
34
+ private seen = new Set<string>()
35
+ private tools = new Map<string, string>()
36
+ private lastText: { key: string; time: number } | null = null
37
+ private contextId: string | undefined
38
+
39
+ ingest(e: MapEvidence): string | null {
40
+ if (this.seen.has(e.id)) return null
41
+ this.seen.add(e.id)
42
+ if (this.seen.size > 1200) this.seen.delete(this.seen.values().next().value!)
43
+ if (e.kind === 'result') {
44
+ const id = this.tools.get(`${e.source}:${e.toolId}`)
45
+ const node = this.nodes.find(n => n.id === id)
46
+ if (node) { node.status = e.status || 'unknown'; node.evidence = [node.evidence[0], e] }
47
+ return null
48
+ }
49
+ if (e.kind === 'prompt' || e.kind === 'message' || e.kind === 'thinking') {
50
+ const key = `${e.source}:${e.kind}:${e.text}`
51
+ const time = Date.parse(e.ts)
52
+ if (this.lastText?.key === key && Math.abs(time - this.lastText.time) < 3000) return null
53
+ this.lastText = { key, time }
54
+ }
55
+ if (e.kind === 'prompt') {
56
+ this.contextId = undefined
57
+ this.goal = this.add({ parentId: null, kind: 'goal', title: shortLabel(e.text, 110), activity: 'planning', status: 'unknown', ts: e.ts, evidence: [e] }).id
58
+ this.trim()
59
+ return null
60
+ }
61
+ if (!this.goal) this.goal = this.add({ parentId: null, kind: 'goal', title: 'Session work', activity: 'planning', status: 'unknown', ts: e.ts, evidence: [] }).id
62
+ const topic = topicFor(e)
63
+ const branch = this.branch(this.goal, topic, e.ts)
64
+ const activity = activityFor(e)
65
+ const statement = e.kind === 'thinking' || e.kind === 'message' ? e.segments?.[0] || decisionCandidates(e.text)[0] || e.text : e.text
66
+ const title = e.kind === 'tool' ? `${e.tool || 'Tool'} · ${shortLabel(e.text, 65)}` : shortLabel(statement, 110)
67
+ const intent = e.kind === 'thinking' || e.kind === 'message' ? intentFor(statement) : undefined
68
+ const node = this.add({ parentId: branch.id, kind: 'step', title, activity, status: e.status || 'unknown', ts: e.ts, evidence: [e], placement: 'rules',
69
+ intent, ...(intent && intent !== 'context' ? { focus: { text: statement } } : {}),
70
+ ...(e.kind === 'tool' && this.contextId ? { contextId: this.contextId } : {}),
71
+ })
72
+ if (e.kind === 'thinking' || e.kind === 'message') this.contextId = node.id
73
+ if (e.toolId) this.tools.set(`${e.source}:${e.toolId}`, node.id)
74
+ this.trim()
75
+ return node.id
76
+ }
77
+
78
+ end(): void {
79
+ for (const n of this.nodes) if (n.status === 'running') n.status = 'interrupted'
80
+ }
81
+
82
+ request(ids: string[]): MapDecisionJob | null {
83
+ const nodes = ids.map(id => this.nodes.find(n => n.id === id && n.kind === 'step')).filter((n): n is MapNode => !!n)
84
+ if (!nodes.length) return null
85
+ const parent = this.nodes.find(n => n.id === nodes[0].parentId)
86
+ if (!parent?.parentId) return null
87
+ const goalId = parent.parentId
88
+ const sameGoal = nodes.filter(n => this.nodes.find(b => b.id === n.parentId)?.parentId === goalId)
89
+ // Richer thought questions get a whole bounded request; tool batches remain at most two.
90
+ const isIntent = (n: MapNode | undefined) => n?.evidence[0]?.kind === 'thinking' || n?.evidence[0]?.kind === 'message'
91
+ const selected = sameGoal.slice(0, isIntent(sameGoal[0]) || isIntent(sameGoal[1]) ? 1 : 2)
92
+ const goal = this.nodes.find(n => n.id === goalId)!
93
+ const branches = this.nodes.filter(n => n.kind === 'branch' && n.parentId === goalId).slice(-4)
94
+ const criteria: Record<string, string> = Object.fromEntries(branches.map(n => [n.id, n.title.slice(0, 48)]))
95
+ criteria.new_topic = 'A different work topic; create a new branch'
96
+ const questions: DecisionRequest['questions'] = {}
97
+ const statements: Record<string, string[]> = {}
98
+ const reasons: Record<string, string[]> = {}
99
+ selected.forEach((node, i) => {
100
+ questions[`branch_${i}`] = { type: 'choice', instructions: `Which work topic does event_${i} belong to? Use the goal and stated reasoning, not incidental file paths.`, criteria }
101
+ if (isIntent(node)) {
102
+ const candidates = node.evidence[0].segments || decisionCandidates(node.evidence[0].text)
103
+ statements[node.id] = candidates.length ? candidates : [node.evidence[0].text.slice(0, 220)]
104
+ questions[`statement_${i}`] = { type: 'choice', instructions: `Which excerpt best captures the agent's choice, hypothesis or next check in event_${i}?`,
105
+ criteria: Object.fromEntries(statements[node.id].map((s, j) => [`s${j}`, s])),
106
+ }
107
+ questions[`intent_${i}`] = { type: 'choice', instructions: `What does the selected excerpt express? A tentative explanation is not an established decision.`, criteria: INTENTS }
108
+ reasons[node.id] = candidates.filter(s => /\b(?:because|since|so|avoid|tradeoff|due to|constraint|rather|instead)\b/i.test(s)).slice(0, 3)
109
+ if (reasons[node.id].length) questions[`reason_${i}`] = { type: 'choice', instructions: 'Which excerpt states why the agent chose its approach? Choose none if no supporting reason is stated.',
110
+ criteria: { ...Object.fromEntries(reasons[node.id].map((s, j) => [`r${j}`, s])), none: 'No supporting reason stated' },
111
+ }
112
+ } else {
113
+ questions[`activity_${i}`] = { type: 'choice', instructions: `What activity does event_${i} show?`, criteria: ACTIVITIES }
114
+ }
115
+ })
116
+ return {
117
+ ids: selected.map(n => n.id), goalId, statements, reasons,
118
+ request: {
119
+ model: 'clef-flash',
120
+ state: JSON.stringify({ goal: goal.title, events: selected.map((n, i) => ({ id: `event_${i}`, kind: n.evidence[0]?.kind, tool: n.evidence[0]?.tool,
121
+ text: n.evidence[0]?.text.slice(0, 450),
122
+ preceding_intent: this.nodes.find(c => c.id === n.contextId)?.evidence[0]?.text.slice(-400),
123
+ current_topic: this.nodes.find(b => b.id === n.parentId)?.title,
124
+ })) }),
125
+ questions,
126
+ },
127
+ }
128
+ }
129
+
130
+ apply(job: Pick<MapDecisionJob, 'ids' | 'goalId' | 'statements' | 'reasons'>, answers: Record<string, Decision>): void {
131
+ job.ids.forEach((id, i) => {
132
+ const node = this.nodes.find(n => n.id === id)
133
+ if (!node || !this.nodes.some(n => n.id === job.goalId)) return
134
+ const decision = answers[`branch_${i}`]
135
+ const textSource = node.evidence[0]?.kind === 'thinking' || node.evidence[0]?.kind === 'message'
136
+ if (textSource) {
137
+ const choice = answers[`statement_${i}`]?.choice
138
+ const statement = job.statements[node.id]?.[Number(choice?.slice(1))]
139
+ if (statement) node.title = shortLabel(statement, 110)
140
+ // Joint decision heads can choose one excerpt but label another part
141
+ // of the window. Explicit wording in the chosen excerpt takes priority.
142
+ const statedIntent = intentFor(statement || '')
143
+ node.intent = statedIntent !== 'context' ? statedIntent : answers[`intent_${i}`]?.choice as MapIntent
144
+ const reasonChoice = answers[`reason_${i}`]?.choice
145
+ const reason = reasonChoice && reasonChoice !== 'none' ? job.reasons[node.id]?.[Number(reasonChoice.slice(1))] : undefined
146
+ node.focus = statement ? { text: statement, ...(reason && reason !== statement ? { reason } : {}) } : undefined
147
+ }
148
+ const topic = topicFor(node.evidence[0])
149
+ const newTitle = shortLabel(node.evidence[0].kind === 'thinking' ? node.title : node.evidence[0].text, 48)
150
+ const target = decision.choice === 'new_topic'
151
+ ? this.branch(job.goalId, newTitle === topic ? `${node.activity} · ${topic}` : newTitle, node.ts)
152
+ : this.nodes.find(n => n.id === decision.choice && n.kind === 'branch' && n.parentId === job.goalId)
153
+ if (!target) return
154
+ node.parentId = target.id
155
+ node.activity = node.evidence[0]?.kind === 'thinking' ? 'thinking' : textSource ? node.intent === 'context' ? 'reporting' : 'planning' : answers[`activity_${i}`].choice as MapActivity
156
+ node.placement = 'clef-flash'
157
+ node.confidence = decision.probabilities[decision.choice]
158
+ })
159
+ this.removeEmptyBranches()
160
+ this.trim()
161
+ }
162
+
163
+ private add(node: Omit<MapNode, 'id'>): MapNode {
164
+ const result = { ...node, id: `map_${++this.seq}` }
165
+ this.nodes.push(result)
166
+ return result
167
+ }
168
+
169
+ private branch(goal: string, title: string, ts: string): MapNode {
170
+ return this.nodes.find(n => n.kind === 'branch' && n.parentId === goal && n.title === title)
171
+ || this.add({ parentId: goal, kind: 'branch', title, activity: 'planning', status: 'unknown', ts, evidence: [] })
172
+ }
173
+
174
+ private removeEmptyBranches(): void {
175
+ const parents = new Set(this.nodes.map(n => n.parentId))
176
+ this.nodes = this.nodes.filter(n => n.kind !== 'branch' || parents.has(n.id))
177
+ }
178
+
179
+ private trim(): void {
180
+ while (this.nodes.length > MAP_NODE_LIMIT) {
181
+ const oldGoal = this.nodes.find(n => n.kind === 'goal' && n.id !== this.goal)
182
+ if (oldGoal) {
183
+ const removed = new Set([oldGoal.id, ...this.nodes.filter(n => n.parentId === oldGoal.id).map(n => n.id)])
184
+ const before = this.nodes.length
185
+ this.nodes = this.nodes.filter(n => !removed.has(n.id) && !removed.has(n.parentId || ''))
186
+ this.dropped += before - this.nodes.length
187
+ } else {
188
+ const oldest = this.nodes.findIndex(n => n.kind === 'step')
189
+ if (oldest < 0) break
190
+ this.nodes.splice(oldest, 1)
191
+ this.dropped++
192
+ this.removeEmptyBranches()
193
+ }
194
+ }
195
+ const retained = new Set(this.nodes.map(n => n.id))
196
+ if (this.contextId && !retained.has(this.contextId)) this.contextId = undefined
197
+ for (const [key, id] of this.tools) if (!retained.has(id)) this.tools.delete(key)
198
+ }
199
+ }
@@ -0,0 +1,244 @@
1
+ import path from 'path'
2
+ import fs from 'fs'
3
+ import os from 'os'
4
+ import type { MapCapability, MapSettings, MapSnapshot, MapSource } from '../../shared/workMap'
5
+ import { initialCapability } from '../../../scripts/check-work-map-gpu.cjs'
6
+ import { TranscriptTailer, supportsTranscriptLogging, type TranscriptTailerOptions } from '../transcripts'
7
+ import { ClefWorker } from './clef'
8
+ import { mapThinkingStatus, normalizeMapEvents } from './normalize'
9
+ import { WorkMapTree } from './tree'
10
+ import { readMapSource } from './source'
11
+
12
+ interface MapTab {
13
+ agentId: string
14
+ agentName: string
15
+ cwd: string
16
+ tree: WorkMapTree
17
+ tailer: TranscriptTailer | null
18
+ ended: boolean
19
+ state: string
20
+ source: string | null
21
+ ignored: Set<string>
22
+ pending: Set<string>
23
+ thinkingStatus: MapSnapshot['thinkingStatus']
24
+ sources: Map<string, string>
25
+ }
26
+
27
+ export class WorkMap {
28
+ private tabs = new Map<string, MapTab>()
29
+ private owners = new Map<string, string>()
30
+ private settings: MapSettings
31
+ private clef: ClefWorker
32
+ private revision = 0
33
+ private epoch = 0
34
+ private timer: ReturnType<typeof setTimeout> | null = null
35
+ private publishing: ReturnType<typeof setTimeout> | null = null
36
+ private working = false
37
+ private round = 0
38
+
39
+ constructor(appRoot: string, private onUpdate: (snapshot: MapSnapshot) => void,
40
+ private roots: Pick<TranscriptTailerOptions, 'claudeProjectsDir' | 'codexSessionsDir'> = {},
41
+ private capability: MapCapability = initialCapability()) {
42
+ const cache = path.join(appRoot, 'experiments/live-map/.cache')
43
+ const prepared = path.join(os.homedir(), '.ctlsurf/clef-flash')
44
+ const pilotPython = path.join(cache, 'envs/clef-flash/bin/python')
45
+ this.settings = {
46
+ enabled: false, engine: 'clef-flash',
47
+ pythonPath: process.env.CTLSURF_CLEF_PYTHON || (fs.existsSync(pilotPython) ? pilotPython : path.join(prepared, 'env/bin/python')),
48
+ modelPath: process.env.CTLSURF_CLEF_MODEL || (fs.existsSync(pilotPython) ? path.join(cache, 'models/clef-flash') : path.join(prepared, 'model')),
49
+ }
50
+ this.clef = new ClefWorker(path.join(appRoot, 'scripts/work-map-clef.py'), () => this.publish())
51
+ }
52
+
53
+ configure(value: Partial<MapSettings>): MapSettings {
54
+ ++this.epoch
55
+ const previous = this.settings
56
+ this.settings = {
57
+ enabled: this.capability.state === 'available' && (typeof value.enabled === 'boolean' ? value.enabled : previous.enabled),
58
+ engine: value.engine === 'rules' || value.engine === 'clef-flash' ? value.engine : previous.engine,
59
+ pythonPath: typeof value.pythonPath === 'string' && value.pythonPath.trim() ? value.pythonPath.trim() : previous.pythonPath,
60
+ modelPath: typeof value.modelPath === 'string' && value.modelPath.trim() ? value.modelPath.trim() : previous.modelPath,
61
+ }
62
+ this.clef.stop()
63
+ if (this.timer) clearTimeout(this.timer)
64
+ this.timer = null
65
+ for (const tab of this.tabs.values()) {
66
+ tab.pending.clear()
67
+ if (this.settings.enabled && !tab.ended) this.start(tab)
68
+ else { tab.tailer?.stop(); tab.tailer = null; tab.tree.end() }
69
+ if (this.settings.enabled && this.settings.engine === 'clef-flash') {
70
+ tab.tree.nodes.filter(n => n.kind === 'step' && n.placement !== 'clef-flash').slice(-24).forEach(n => tab.pending.add(n.id))
71
+ }
72
+ }
73
+ this.publish()
74
+ this.schedule()
75
+ return { ...this.settings }
76
+ }
77
+
78
+ attach(tabId: string, agentId: string, agentName: string, cwd: string): void {
79
+ this.remove(tabId)
80
+ const tab: MapTab = { agentId, agentName, cwd, tree: new WorkMapTree(), tailer: null, ended: false, state: 'idle', source: null, ignored: new Set(), pending: new Set(), thinkingStatus: 'waiting', sources: new Map() }
81
+ this.tabs.set(tabId, tab)
82
+ if (this.settings.enabled) this.start(tab)
83
+ this.publish()
84
+ }
85
+
86
+ getCapability(): MapCapability {
87
+ return { ...this.capability }
88
+ }
89
+
90
+ setCapability(capability: MapCapability): void {
91
+ this.capability = { ...capability }
92
+ if (capability.state !== 'available') this.configure({ enabled: false })
93
+ this.publish()
94
+ }
95
+
96
+ end(tabId: string): void {
97
+ const tab = this.tabs.get(tabId)
98
+ if (!tab) return
99
+ tab.ended = true
100
+ tab.tailer?.stop()
101
+ tab.tailer = null
102
+ tab.pending.clear()
103
+ tab.tree.end()
104
+ for (const [source, owner] of this.owners) if (owner === tabId) this.owners.delete(source)
105
+ if (![...this.tabs.values()].some(t => !t.ended)) this.clef.stop()
106
+ this.publish()
107
+ }
108
+
109
+ remove(tabId: string): void {
110
+ this.end(tabId)
111
+ this.tabs.delete(tabId)
112
+ for (const [source, owner] of this.owners) if (owner === tabId) this.owners.delete(source)
113
+ }
114
+
115
+ clear(tabId: string): void {
116
+ const tab = this.tabs.get(tabId)
117
+ if (tab) { tab.tree = new WorkMapTree(); tab.pending.clear() }
118
+ this.publish()
119
+ }
120
+
121
+ noteState(tabId: string, state: string): void {
122
+ const tab = this.tabs.get(tabId)
123
+ if (tab && tab.state !== state) { tab.state = state; this.publish() }
124
+ }
125
+
126
+ snapshot(tabId: string): MapSnapshot {
127
+ const tab = this.tabs.get(tabId)
128
+ return {
129
+ revision: ++this.revision, tabId, agentName: tab?.agentName || null,
130
+ supported: !!tab && supportsTranscriptLogging(tab.agentId), ended: !!tab?.ended,
131
+ state: tab?.state || 'idle', settings: { ...this.settings },
132
+ runtime: !this.settings.enabled ? 'off' : this.settings.engine === 'rules' ? 'ready' : this.clef.state,
133
+ error: this.settings.engine === 'clef-flash' ? this.clef.error : null,
134
+ nodes: tab?.tree.nodes || [], dropped: tab?.tree.dropped || 0,
135
+ thinkingStatus: tab?.thinkingStatus || 'waiting',
136
+ capability: this.getCapability(),
137
+ }
138
+ }
139
+
140
+ async getSource(tabId: string, eventId: string): Promise<MapSource | null> {
141
+ const tab = this.tabs.get(tabId)
142
+ const event = tab?.tree.nodes.flatMap(n => n.evidence).find(e => e.id === eventId && (e.kind === 'thinking' || e.kind === 'message'))
143
+ const file = event && tab?.sources.get(event.source)
144
+ if (!event || !file || !tab) return null
145
+ return readMapSource(file, tab.agentId, event.id)
146
+ }
147
+
148
+ private start(tab: MapTab): void {
149
+ if (tab.tailer || !supportsTranscriptLogging(tab.agentId)) return
150
+ tab.tailer = new TranscriptTailer({
151
+ ...this.roots,
152
+ agentId: tab.agentId, cwd: tab.cwd, sink: () => {},
153
+ rawSink: (raw: any, source) => {
154
+ if (!source || !this.settings.enabled || tab.ended) return
155
+ const file = source.filePath
156
+ if (tab.ignored.has(file)) return
157
+ if (raw.type === 'session_meta') {
158
+ const meta = raw.payload || {}
159
+ if ((meta.thread_source && meta.thread_source !== 'user') || (meta.source && typeof meta.source === 'object') || (meta.agent_path && meta.agent_path !== '/root')) {
160
+ tab.ignored.add(file)
161
+ return
162
+ }
163
+ }
164
+ const tabId = [...this.tabs].find(([, candidate]) => candidate === tab)?.[0]
165
+ if (!tabId) return
166
+ if (this.owners.has(file) && this.owners.get(file) !== tabId) return
167
+ // Pin a source; a newly created session (/new or /clear) may replace it.
168
+ if (tab.source && tab.source !== file) {
169
+ if (raw.type !== 'session_meta' || Date.parse(raw.payload?.timestamp || raw.timestamp) < source.sinceMs) return
170
+ this.owners.delete(tab.source)
171
+ }
172
+ tab.source = file
173
+ this.owners.set(file, tabId)
174
+ const thinking = mapThinkingStatus(raw, tab.agentId)
175
+ const statusChanged = thinking !== null && thinking !== tab.thinkingStatus && (thinking === 'available' || tab.thinkingStatus === 'waiting')
176
+ if (statusChanged) tab.thinkingStatus = thinking
177
+ const events = normalizeMapEvents(raw, file, tab.agentId)
178
+ for (const e of events) {
179
+ tab.sources.set(e.source, file)
180
+ const id = tab.tree.ingest(e)
181
+ if (id && this.settings.engine === 'clef-flash') {
182
+ tab.pending.add(id)
183
+ // A burst cannot create an unbounded inference backlog.
184
+ if (tab.pending.size > 24) tab.pending.delete(tab.pending.values().next().value!)
185
+ }
186
+ }
187
+ const retainedSources = new Set(tab.tree.nodes.flatMap(n => n.evidence.map(e => e.source)))
188
+ for (const name of tab.sources.keys()) if (!retainedSources.has(name)) tab.sources.delete(name)
189
+ if (events.length || statusChanged) { this.publish(); this.schedule() }
190
+ },
191
+ })
192
+ tab.tailer.start()
193
+ }
194
+
195
+ private schedule(): void {
196
+ if (this.timer || this.working || !this.settings.enabled || this.settings.engine !== 'clef-flash' || this.clef.state === 'error') return
197
+ if (![...this.tabs.values()].some(t => t.pending.size)) return
198
+ this.timer = setTimeout(() => { this.timer = null; void this.infer() }, 1000)
199
+ }
200
+
201
+ private async infer(): Promise<void> {
202
+ const candidates = [...this.tabs].filter(([, t]) => t.pending.size && !t.ended)
203
+ const candidate = candidates[this.round++ % candidates.length]
204
+ if (!candidate || !this.settings.enabled || this.settings.engine !== 'clef-flash') return
205
+ const [tabId, tab] = candidate
206
+ const tree = tab.tree
207
+ const ids = [...tab.pending].slice(0, 2)
208
+ const job = tree.request(ids)
209
+ const consumed = job?.ids || ids
210
+ consumed.forEach(id => tab.pending.delete(id))
211
+ if (!job) { this.schedule(); return }
212
+ const epoch = this.epoch
213
+ this.working = true
214
+ try {
215
+ const answers = await this.clef.predict(this.settings, job.request)
216
+ if (epoch === this.epoch && this.tabs.get(tabId) === tab && !tab.ended && tab.tree === tree) tree.apply(job, answers)
217
+ } catch { /* The worker exposes errors in snapshots; never affect the working agent. */ }
218
+ finally {
219
+ this.working = false
220
+ this.publish()
221
+ this.schedule()
222
+ }
223
+ }
224
+
225
+ private publish(): void {
226
+ if (this.publishing) return
227
+ this.publishing = setTimeout(() => {
228
+ this.publishing = null
229
+ for (const tabId of this.tabs.keys()) this.onUpdate(this.snapshot(tabId))
230
+ }, 120)
231
+ }
232
+
233
+ shutdown(): void {
234
+ this.settings.enabled = false
235
+ ++this.epoch
236
+ for (const tab of this.tabs.values()) tab.tailer?.stop()
237
+ this.clef.stop()
238
+ if (this.timer) clearTimeout(this.timer)
239
+ if (this.publishing) clearTimeout(this.publishing)
240
+ this.timer = this.publishing = null
241
+ this.tabs.clear()
242
+ this.owners.clear()
243
+ }
244
+ }
@@ -1,4 +1,5 @@
1
1
  import { contextBridge, ipcRenderer } from 'electron'
2
+ import type { MapCapability, MapSettings, MapSnapshot, MapSource } from '../shared/workMap'
2
3
 
3
4
  export interface AgentConfig {
4
5
  id: string
@@ -161,6 +162,22 @@ const api = {
161
162
  setSpeakReplies: (enabled: boolean): Promise<{ enabled: boolean }> =>
162
163
  ipcRenderer.invoke('speak:set', enabled),
163
164
 
165
+ getWorkMap: (tabId: string): Promise<MapSnapshot> => ipcRenderer.invoke('workmap:get', tabId),
166
+ getWorkMapCapability: (): Promise<MapCapability> => ipcRenderer.invoke('workmap:capability'),
167
+ onWorkMapCapability: (callback: (capability: MapCapability) => void) => {
168
+ const listener = (_event: Electron.IpcRendererEvent, capability: MapCapability) => callback(capability)
169
+ ipcRenderer.on('workmap:capability', listener)
170
+ return () => ipcRenderer.removeListener('workmap:capability', listener)
171
+ },
172
+ getWorkMapSource: (tabId: string, eventId: string): Promise<MapSource | null> => ipcRenderer.invoke('workmap:source', tabId, eventId),
173
+ setWorkMap: (tabId: string, settings: Partial<MapSettings>): Promise<MapSnapshot> => ipcRenderer.invoke('workmap:set', tabId, settings),
174
+ clearWorkMap: (tabId: string): Promise<MapSnapshot> => ipcRenderer.invoke('workmap:clear', tabId),
175
+ onWorkMapUpdate: (callback: (snapshot: MapSnapshot) => void) => {
176
+ const listener = (_event: Electron.IpcRendererEvent, snapshot: MapSnapshot) => callback(snapshot)
177
+ ipcRenderer.on('workmap:update', listener)
178
+ return () => ipcRenderer.removeListener('workmap:update', listener)
179
+ },
180
+
164
181
  // Observer supervision layer (global). The API key is write-only over IPC:
165
182
  // the getter reports only whether one is configured and from where.
166
183
  getObserverSettings: (): Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }> =>
@@ -17,7 +17,9 @@ import { StatusBar } from './components/StatusBar'
17
17
  import type { ResourceSnapshot } from './components/Resources'
18
18
  import { SettingsDialog } from './components/SettingsDialog'
19
19
  import { TicketPane } from './components/TicketPanel'
20
- import { ObserverPane } from './components/ObserverPanel'
20
+ import { WorkMapPanel } from './components/WorkMapPanel'
21
+ import { WorkMapToggle, canOpenWorkMap } from './components/WorkMapToggle'
22
+ import type { MapCapability, MapSettings, MapSnapshot, MapSource } from '../shared/workMap'
21
23
 
22
24
  interface AgentConfig {
23
25
  id: string
@@ -71,6 +73,13 @@ declare global {
71
73
  getLogChat: () => Promise<{ enabled: boolean }>
72
74
  setLogChat: (enabled: boolean) => Promise<{ enabled: boolean }>
73
75
  getSpeakReplies: () => Promise<{ enabled: boolean }>
76
+ getWorkMap: (tabId: string) => Promise<MapSnapshot>
77
+ getWorkMapCapability: () => Promise<MapCapability>
78
+ onWorkMapCapability: (callback: (capability: MapCapability) => void) => () => void
79
+ getWorkMapSource: (tabId: string, eventId: string) => Promise<MapSource | null>
80
+ setWorkMap: (tabId: string, settings: Partial<MapSettings>) => Promise<MapSnapshot>
81
+ clearWorkMap: (tabId: string) => Promise<MapSnapshot>
82
+ onWorkMapUpdate: (callback: (snapshot: MapSnapshot) => void) => () => void
74
83
  setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
75
84
  getObserverSettings: () => Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }>
76
85
  setObserverSettings: (data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number; sensitivity?: 'severe' | 'balanced' | 'all' }) => Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }>
@@ -520,6 +529,7 @@ export default function App() {
520
529
 
521
530
  // Toggle pane: hide removes from layout tree, show re-inserts
522
531
  const togglePane = useCallback((id: string) => {
532
+ if (id === 'workmap' && !visiblePaneIds.includes(id) && !canOpenWorkMap()) return
523
533
  if (visiblePaneIds.includes(id)) {
524
534
  // Hide: remove from tree
525
535
  const newLayout = removePane(layout, id)
@@ -575,6 +585,7 @@ export default function App() {
575
585
  case 'T': e.preventDefault(); togglePane('terminal'); break
576
586
  case 'P': e.preventDefault(); togglePane('ctlsurf'); break
577
587
  case 'K': e.preventDefault(); togglePane('tickets'); break
588
+ case 'M': e.preventDefault(); togglePane('workmap'); break
578
589
  }
579
590
  }
580
591
  }
@@ -670,7 +681,7 @@ export default function App() {
670
681
  },
671
682
  { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
672
683
  { id: 'tickets', label: 'Tickets', content: <TicketPane onSendToTerminal={handleSendToTerminal} /> },
673
- { id: 'observer', label: 'Observer', content: <ObserverPane /> },
684
+ { id: 'workmap', label: 'Work map', content: <WorkMapPanel key={activeTabId} tabId={activeTabId} /> },
674
685
  ]
675
686
 
676
687
  return (
@@ -707,20 +718,6 @@ export default function App() {
707
718
  </svg>
708
719
  <span>Tickets</span>
709
720
  </button>
710
- <button
711
- className={`titlebar-btn titlebar-icon-btn ${visiblePaneIds.includes('observer') ? 'active' : ''}`}
712
- onClick={() => togglePane('observer')}
713
- title="Toggle Observer pane — live session review"
714
- aria-label="Observer"
715
- >
716
- <svg viewBox="0 0 24 24" width="13" height="13"
717
- fill="none" stroke="currentColor" strokeWidth="2"
718
- strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
719
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
720
- <circle cx="12" cy="12" r="3" />
721
- </svg>
722
- <span>Observer</span>
723
- </button>
724
721
  <button
725
722
  className={`titlebar-btn titlebar-icon-btn ${showFloatingMic ? 'active' : ''}`}
726
723
  onClick={() => setFloatingMicVisible(!showFloatingMic)}
@@ -735,6 +732,8 @@ export default function App() {
735
732
  <line x1="8" y1="23" x2="16" y2="23" />
736
733
  </svg>
737
734
  </button>
735
+ <WorkMapToggle visible={visiblePaneIds.includes('workmap')} onToggle={() => togglePane('workmap')}
736
+ onUnavailable={() => setLayout(previous => removePane(previous, 'workmap') || { type: 'leaf', paneId: 'terminal' })} />
738
737
  <SpeakControls remote={activeAgentIsRemote} />
739
738
  <span className="titlebar-separator" />
740
739
  <button
@@ -1,6 +1,9 @@
1
1
  import { useState, useEffect, useCallback } from 'react'
2
2
 
3
3
  /**
4
+ * ON HOLD: retained for future work; not mounted in the app or exposed in Settings.
5
+ * Runtime is disabled by OBSERVER_ON_HOLD in the orchestrator.
6
+ *
4
7
  * Observer side pane — live supervision feed for the current session.
5
8
  *
6
9
  * Shares the drawer chrome with the Tickets pane but renders its own card