@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,352 @@
1
+ // Exercise native transcript ingestion, evidence linkage, bounds, and the model process lifecycle.
2
+ const assert = require('node:assert/strict')
3
+ const fs = require('node:fs/promises')
4
+ const os = require('node:os')
5
+ const path = require('node:path')
6
+ const { build } = require('esbuild')
7
+ const { once } = require('node:events')
8
+ const { createReadStream } = require('node:fs')
9
+ const { createInterface } = require('node:readline')
10
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
11
+
12
+ async function until(check, timeout = 7000) {
13
+ const end = Date.now() + timeout
14
+ while (Date.now() < end) { if (check()) return; await pause(40) }
15
+ assert.fail('Timed out waiting for work map state')
16
+ }
17
+
18
+ async function main() {
19
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ctlsurf-map-'))
20
+ const repo = path.resolve(__dirname, '..')
21
+ let map
22
+ let worker
23
+ let tailer
24
+ try {
25
+ await build({ stdin: { contents: `export { WorkMapTree, MAP_NODE_LIMIT } from './src/main/workMap/tree';
26
+ export { normalizeMapEvents, mapThinkingStatus } from './src/main/workMap/normalize';
27
+ export { ClefWorker, validateDecisions } from './src/main/workMap/clef';
28
+ export { WorkMap } from './src/main/workMap/workMap';
29
+ export { TranscriptTailer } from './src/main/transcripts';
30
+ export { readMapSource } from './src/main/workMap/source';
31
+ export { thinkingHighlights } from './src/renderer/lib/workMapHighlights';`, resolveDir: repo },
32
+ bundle: true, platform: 'node', format: 'cjs', outfile: path.join(root, 'map.cjs') })
33
+ const { WorkMapTree, MAP_NODE_LIMIT, normalizeMapEvents, mapThinkingStatus, ClefWorker, validateDecisions, WorkMap, TranscriptTailer, readMapSource, thinkingHighlights } = require(path.join(root, 'map.cjs'))
34
+ const unavailableMap = new WorkMap(root, () => {})
35
+ unavailableMap.configure({ enabled: true, engine: 'clef-flash' })
36
+ unavailableMap.attach('headless-tab', 'codex', 'Codex', '/headless')
37
+ assert.equal(unavailableMap.snapshot('headless-tab').settings.enabled, false)
38
+ assert.equal(unavailableMap.snapshot('headless-tab').capability.state, 'unavailable')
39
+ assert.equal(unavailableMap.tabs.get('headless-tab').tailer, null, 'saved settings cannot activate collection in terminal mode')
40
+ assert.equal(unavailableMap.clef.child, null)
41
+ unavailableMap.shutdown()
42
+ const timestamp = new Date().toISOString()
43
+ const normalize = raw => normalizeMapEvents({ timestamp, ...raw }, 'session.jsonl', 'claude')
44
+ const tree = new WorkMapTree()
45
+ for (const e of normalize({ type: 'user', uuid: 'u', message: { content: 'Fix terminal scrolling' } })) tree.ingest(e)
46
+ for (const e of normalize({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'test-1', name: 'Bash', input: { command: 'npm test' } }] } })) tree.ingest(e)
47
+ for (const e of normalize({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'test-1', is_error: true, content: 'All tests passed (misleading output)' }] } })) tree.ingest(e)
48
+ const step = tree.nodes.find(n => n.kind === 'step')
49
+ assert.equal(step.status, 'failed')
50
+ assert.equal(step.evidence.length, 2)
51
+ const job = tree.request([step.id])
52
+ const previousBranch = step.parentId
53
+ const answers = Object.fromEntries(Object.entries(job.request.questions).map(([key, q]) => {
54
+ const choice = key.startsWith('branch') ? 'new_topic' : 'editing'
55
+ return [key, { choice, probabilities: Object.fromEntries(Object.keys(q.criteria).map(k => [k, +(k === choice)])) }]
56
+ }))
57
+ validateDecisions({ answers }, job.request)
58
+ tree.apply(job, answers)
59
+ assert.equal(step.status, 'failed', 'model suggestions cannot overwrite runtime failures')
60
+ assert.equal(step.placement, 'clef-flash')
61
+ assert.notEqual(step.parentId, previousBranch, 'a new-topic decision must create an actual fork')
62
+ assert.throws(() => validateDecisions({ answers: { ...answers, invented: {} } }, job.request))
63
+ assert.throws(() => validateDecisions({ answers: { ...answers, branch_0: { choice: 'invented', probabilities: {} } } }, job.request))
64
+ assert.deepEqual(normalizeMapEvents({ type: 'response_item', payload: { type: 'reasoning', encrypted_content: 'private' } }, 'x', 'codex'), [])
65
+ assert.deepEqual(normalizeMapEvents({ type: 'response_item', payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'injected' }] } }, 'x', 'codex'), [])
66
+ const modern = normalizeMapEvents({ timestamp, type: 'event_msg', payload: { type: 'item_completed', item: {
67
+ type: 'CommandExecution', id: 'command-1', command: ['npm', 'test'], status: 'completed', exit_code: 0, aggregated_output: 'The word FAILED is just fixture text',
68
+ } } }, 'codex.jsonl', 'codex')
69
+ assert.equal(modern.length, 2)
70
+ assert.equal(modern[1].status, 'succeeded')
71
+ const unknown = normalizeMapEvents({ timestamp, type: 'response_item', payload: { type: 'function_call_output', call_id: 'x', output: 'Looks successful' } }, 'codex.jsonl', 'codex')
72
+ assert.equal(unknown[0].status, 'unknown')
73
+ const cli = normalizeMapEvents({ timestamp, type: 'response_item', payload: { type: 'function_call_output', call_id: 'x', output: 'Process exited with code 2' } }, 'codex.jsonl', 'codex')
74
+ assert.equal(cli[0].status, 'failed')
75
+
76
+ const thinkingText = 'The likely cause is a stale viewport flag. I will preserve the reader position instead of forcing the viewport down. Next I will check the scroll regression.'
77
+ const claudeThought = { timestamp, type: 'assistant', uuid: 'thought-message', message: { content: [{ type: 'thinking', thinking: thinkingText, signature: 'SECRET_SIGNATURE' }] } }
78
+ const codexThought = { timestamp, type: 'response_item', payload: { type: 'reasoning', id: 'thought-1', summary: [{ type: 'summary_text', text: thinkingText }], encrypted_content: 'SECRET_ENCRYPTED' } }
79
+ const completedThought = { timestamp, type: 'event_msg', payload: { type: 'item_completed', item: { type: 'Reasoning', id: 'thought-1', summary_text: [thinkingText], raw_content: ['SECRET_RAW'] } } }
80
+ const jsonRpcThought = { timestamp, method: 'item/completed', params: { item: { type: 'reasoning', id: 'thought-1', summary: [thinkingText], content: ['SECRET_RAW'] } } }
81
+ const legacyThought = { timestamp, type: 'event_msg', payload: { type: 'agent_reasoning', text: thinkingText } }
82
+ const intentTrees = []
83
+ for (const [agent, record] of [['claude', claudeThought], ['codex', codexThought], ['codex', completedThought], ['codex', jsonRpcThought], ['codex', legacyThought]]) {
84
+ const events = normalizeMapEvents(record, 'thinking.jsonl', agent)
85
+ assert.equal(events.length, 1)
86
+ assert.equal(events[0].kind, 'thinking')
87
+ assert.equal(events[0].text, thinkingText)
88
+ assert(!JSON.stringify(events).includes('SECRET_'), 'signatures, raw content, and encrypted reasoning never reach the map')
89
+ assert.equal(mapThinkingStatus(record, agent), 'available')
90
+ const intentTree = new WorkMapTree()
91
+ const thoughtId = intentTree.ingest(events[0])
92
+ const thoughtJob = intentTree.request([thoughtId])
93
+ const statement = thoughtJob.statements[thoughtId].findIndex(s => s.includes('preserve'))
94
+ assert(statement >= 0)
95
+ const selections = Object.fromEntries(Object.entries(thoughtJob.request.questions).map(([key, q]) => {
96
+ const choice = key.startsWith('statement') ? `s${statement}` : key.startsWith('intent') ? 'hypothesis' : Object.keys(q.criteria)[0]
97
+ return [key, { choice, probabilities: Object.fromEntries(Object.keys(q.criteria).map(k => [k, +(k === choice)])) }]
98
+ }))
99
+ validateDecisions({ answers: selections }, thoughtJob.request)
100
+ intentTree.apply(thoughtJob, selections)
101
+ const thoughtNode = intentTree.nodes.find(n => n.id === thoughtId)
102
+ assert.equal(thoughtNode.activity, 'thinking')
103
+ assert.equal(thoughtNode.intent, 'decision', 'a model label cannot contradict explicit wording in the selected excerpt')
104
+ assert.match(thoughtNode.title, /preserve/)
105
+ assert.equal(thoughtNode.focus.text, thoughtJob.statements[thoughtId][statement])
106
+ assert.equal(thoughtNode.status, 'unknown', 'thinking must not count as a verified outcome')
107
+ const actionId = intentTree.ingest({ id: 'following-action', kind: 'tool', source: 'thinking.jsonl', ts: timestamp, tool: 'Read', text: 'src/terminal.ts', status: 'running' })
108
+ assert.equal(intentTree.nodes.find(n => n.id === actionId).contextId, thoughtId)
109
+ const actionState = JSON.parse(intentTree.request([actionId]).request.state)
110
+ assert.equal(actionState.events[0].preceding_intent, thinkingText)
111
+ intentTrees.push(intentTree)
112
+ }
113
+ const duplicateTree = new WorkMapTree()
114
+ for (const record of [codexThought, completedThought, jsonRpcThought, legacyThought]) {
115
+ for (const e of normalizeMapEvents(record, 'thinking.jsonl', 'codex')) duplicateTree.ingest(e)
116
+ }
117
+ assert.equal(duplicateTree.nodes.filter(n => n.activity === 'thinking').length, 1)
118
+ const redacted = { type: 'assistant', message: { content: [{ type: 'redacted_thinking', data: 'SECRET_REDACTED' }] } }
119
+ assert.deepEqual(normalizeMapEvents(redacted, 'x', 'claude'), [])
120
+ assert.equal(mapThinkingStatus(redacted, 'claude'), 'unavailable')
121
+ const omitted = { type: 'response_item', payload: { type: 'reasoning', summary: [], encrypted_content: 'SECRET_ENCRYPTED' } }
122
+ assert.deepEqual(normalizeMapEvents(omitted, 'x', 'codex'), [])
123
+ assert.equal(mapThinkingStatus(omitted, 'codex'), 'unavailable')
124
+ assert.deepEqual(normalizeMapEvents({ type: 'event_msg', payload: { type: 'agent_reasoning_raw_content', text: 'SECRET_RAW' } }, 'x', 'codex'), [])
125
+ console.log('PASS Claude thinking, Codex summary formats, decision excerpts, action context, deduplication and omitted thinking')
126
+
127
+ const longThinking = 'Background detail. '.repeat(150) + 'I will choose curses instead of installing pygame. Because it avoids third-party dependencies. ' + 'More routine detail. '.repeat(150)
128
+ const fullBlock = normalizeMapEvents({ ...claudeThought, message: { content: [{ type: 'thinking', thinking: longThinking }] } }, 'middle.jsonl', 'claude')[0]
129
+ assert(!fullBlock.text.includes('choose curses'))
130
+ assert(fullBlock.segments.some(s => s.includes('choose curses')), 'important middle segments survive display truncation')
131
+ const snakeText = 'Building it as a single snake.py with only the standard library, so nothing gets installed into the virtual environment.'
132
+ const snakeUpdate = normalizeMapEvents({ timestamp, type: 'assistant', uuid: 'snake-choice', message: { content: [{ type: 'text', text: snakeText }] } }, 'snake.jsonl', 'claude')[0]
133
+ const snakeTree = new WorkMapTree()
134
+ const snakeId = snakeTree.ingest(snakeUpdate)
135
+ const snakeJob = snakeTree.request([snakeId])
136
+ const snakeStatement = snakeJob.statements[snakeId].findIndex(s => s.includes('Building'))
137
+ const snakeReason = snakeJob.reasons[snakeId].findIndex(s => s.startsWith('so nothing'))
138
+ assert(snakeStatement >= 0 && snakeReason >= 0)
139
+ const snakeAnswers = Object.fromEntries(Object.entries(snakeJob.request.questions).map(([key, q]) => {
140
+ const choice = key.startsWith('statement') ? `s${snakeStatement}` : key.startsWith('reason') ? `r${snakeReason}` : key.startsWith('intent') ? 'decision' : Object.keys(q.criteria)[0]
141
+ return [key, { choice, probabilities: Object.fromEntries(Object.keys(q.criteria).map(k => [k, +(k === choice)])) }]
142
+ }))
143
+ validateDecisions({ answers: snakeAnswers }, snakeJob.request)
144
+ snakeTree.apply(snakeJob, snakeAnswers)
145
+ const snakeNode = snakeTree.nodes.find(n => n.id === snakeId)
146
+ assert.equal(snakeNode.intent, 'decision')
147
+ assert.equal(snakeNode.evidence[0].kind, 'message', 'a stated update is not mislabeled as private thinking')
148
+ assert.match(snakeNode.focus.text, /single snake.py/)
149
+ assert.match(snakeNode.focus.reason, /nothing gets installed/)
150
+ const snakeAction = snakeTree.ingest({ id: 'write-snake', kind: 'tool', tool: 'Write', text: 'snake.py', source: 'snake.jsonl', ts: timestamp })
151
+ assert.equal(snakeTree.nodes.find(n => n.id === snakeAction).contextId, snakeId)
152
+ const sourceText = 'Before the choice.\n**I will use `curses`** for rendering,\n because it avoids dependencies.\nAfter the choice.'
153
+ const ranges = thinkingHighlights(sourceText, { text: 'I will use curses', reason: 'because it avoids dependencies.' })
154
+ assert.equal(ranges.length, 2)
155
+ assert.equal(sourceText.slice(ranges[0].start, ranges[0].end), '**I will use `curses`**')
156
+ assert.equal(sourceText.slice(ranges[1].start, ranges[1].end), 'because it avoids dependencies.')
157
+ assert.deepEqual(thinkingHighlights(sourceText, { text: 'An invented choice' }), [])
158
+ const sourceFile = path.join(root, 'full-thinking.jsonl')
159
+ const sourceRecord = { ...claudeThought, message: { content: [{ type: 'thinking', thinking: longThinking, signature: 'SECRET_SIGNATURE' }] } }
160
+ await fs.writeFile(sourceFile, JSON.stringify(sourceRecord) + '\n')
161
+ const sourceEvent = normalizeMapEvents(sourceRecord, sourceFile, 'claude')[0]
162
+ assert.equal(sourceEvent.fullText, undefined, 'full thinking stays out of live snapshots')
163
+ const fullSource = await readMapSource(sourceFile, 'claude', sourceEvent.id)
164
+ assert.equal(fullSource.text, longThinking.trim())
165
+ assert(!fullSource.text.includes('SECRET_SIGNATURE'))
166
+ assert.equal(await readMapSource(sourceFile, 'claude', 'missing-event'), null)
167
+ console.log('PASS full-block segment selection and snake-game choice with its stated reason')
168
+
169
+ for (const agent of ['claude', 'codex']) {
170
+ const index = process.argv.indexOf(`--replay-${agent}`)
171
+ if (index < 0) continue
172
+ const file = process.argv[index + 1]
173
+ assert(file && !file.startsWith('--'), 'Replay requires a transcript file path')
174
+ const replayTree = new WorkMapTree()
175
+ let records = 0, readableThoughts = 0, omittedThoughts = 0, decisions = 0
176
+ for await (const line of createInterface({ input: createReadStream(file), crlfDelay: Infinity })) {
177
+ let raw
178
+ try { raw = JSON.parse(line) } catch { continue }
179
+ records++
180
+ if (mapThinkingStatus(raw, agent) === 'unavailable') omittedThoughts++
181
+ for (const e of normalizeMapEvents(raw, file, agent)) {
182
+ const id = replayTree.ingest(e)
183
+ if (id && e.kind === 'thinking') readableThoughts++
184
+ if (id && replayTree.nodes.find(n => n.id === id)?.intent !== undefined && replayTree.nodes.find(n => n.id === id)?.intent !== 'context') decisions++
185
+ }
186
+ }
187
+ console.log('LOCAL REPLAY', JSON.stringify({ agent, records, readableThoughts, omittedThoughts, statedChoicesOrHypotheses: decisions }))
188
+ }
189
+ for (let i = 0; i < 1000; i++) {
190
+ tree.ingest({ id: `prompt-${i}`, kind: 'prompt', ts: timestamp, text: `Goal ${i}`, source: 'x' })
191
+ tree.ingest({ id: `tool-${i}`, kind: 'tool', ts: timestamp, text: `src/work-${i}.ts`, tool: 'Read', toolId: `tool-${i}`, source: 'x', status: 'running' })
192
+ }
193
+ assert(tree.nodes.length <= MAP_NODE_LIMIT)
194
+ const ids = new Set(tree.nodes.map(n => n.id))
195
+ assert(tree.nodes.every(n => !n.parentId || ids.has(n.parentId)), 'pruning leaves no orphan branches')
196
+ assert(tree.dropped > 0)
197
+ tree.end()
198
+ assert(!tree.nodes.some(n => n.status === 'running'))
199
+ console.log('PASS event normalization, linked outcomes, validated decisions, bounded trees')
200
+
201
+ const sessions = path.join(root, 'sessions')
202
+ const d = new Date()
203
+ const dateDir = path.join(sessions, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0'))
204
+ await fs.mkdir(dateDir, { recursive: true })
205
+ const lines = events => events.map(e => JSON.stringify({ timestamp: new Date().toISOString(), ...e })).join('\n') + '\n'
206
+ const native = path.join(dateDir, 'rollout-main.jsonl')
207
+ const nativeEvents = [
208
+ { type: 'session_meta', payload: { cwd: '/project', thread_source: 'user', timestamp } },
209
+ { type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Fix terminal scrolling' }] } },
210
+ { ...codexThought, timestamp: undefined },
211
+ { ...completedThought, timestamp: undefined },
212
+ { type: 'response_item', payload: { type: 'function_call', call_id: 'read-1', name: 'read_file', arguments: JSON.stringify({ file_path: 'src/terminal.ts' }) } },
213
+ { type: 'response_item', payload: { type: 'function_call_output', call_id: 'read-1', output: 'Process exited with code 0' } },
214
+ ]
215
+ await fs.writeFile(native, lines(nativeEvents))
216
+ await fs.writeFile(path.join(dateDir, 'rollout-foreign.jsonl'), lines([
217
+ { type: 'session_meta', payload: { cwd: '/other' } },
218
+ { type: 'response_item', payload: { type: 'function_call', call_id: 'foreign', name: 'read_file', arguments: '{}' } },
219
+ ]))
220
+ const raw = []
221
+ tailer = new TranscriptTailer({ agentId: 'codex', cwd: '/project', codexSessionsDir: sessions, sink() {}, rawSink: e => raw.push(e) })
222
+ tailer.start()
223
+ await until(() => raw.length >= nativeEvents.length)
224
+ assert(!raw.some(e => e.payload?.call_id === 'foreign' || e.payload?.cwd === '/other'))
225
+ // A very long line without a newline cannot grow the tailer's buffer indefinitely.
226
+ await fs.appendFile(native, 'x'.repeat(2 * 1024 * 1024) + '\n' + lines([{ type: 'event_msg', payload: { type: 'agent_message', message: 'After the oversized line' } }]))
227
+ await until(() => raw.some(e => e.payload?.message === 'After the oversized line'), 9000)
228
+ assert([...tailer.files.values()].every(f => f.remainder.length <= 1024 * 1024))
229
+ tailer.stop(); tailer = null
230
+ console.log('PASS project isolation and bounded transcript reading')
231
+
232
+ if (process.platform !== 'darwin' || process.arch !== 'arm64') {
233
+ console.log('SKIP local MLX process checks: Apple Silicon required')
234
+ return
235
+ }
236
+
237
+ // A tiny protocol fixture exercises cancellation and process cleanup without loading weights.
238
+ await fs.mkdir(path.join(root, 'scripts'))
239
+ const fake = path.join(root, 'scripts/work-map-clef.py')
240
+ await fs.writeFile(fake, `import json,sys,time\nprint(json.dumps({'ready':True}),flush=True)\nfor line in sys.stdin:\n r=json.loads(line); answers={}\n for k,q in r['request']['questions'].items():\n choice=next(iter(q['criteria'])); answers[k]={'choice':choice,'probabilities':{o:int(o==choice) for o in q['criteria']}}\n time.sleep(.1)\n print(json.dumps({'id':r['id'],'answers':answers}),flush=True)\n`)
241
+ await fs.mkdir(path.join(root, 'model'))
242
+ await fs.writeFile(path.join(root, 'model/clef_mlx.py'), '')
243
+ const settings = { enabled: true, engine: 'clef-flash', pythonPath: '/usr/bin/python3', modelPath: path.join(root, 'model') }
244
+ worker = new ClefWorker(fake, () => {})
245
+ const req = { model: 'clef-flash', state: 'Example', questions: { topic: { type: 'choice', instructions: 'Pick', criteria: { a: 'A', b: 'B' } } } }
246
+ await worker.predict(settings, req)
247
+ const child = worker.child
248
+ const closed = once(child, 'close')
249
+ worker.stop()
250
+ await closed
251
+ assert.equal(worker.state, 'idle')
252
+ assert.equal(worker.child, null)
253
+ await assert.rejects(worker.predict({ ...settings, pythonPath: '/missing/python' }, req))
254
+ assert.equal(worker.state, 'error')
255
+ assert.match(worker.error, /runtime not found/)
256
+ worker.stop()
257
+ const cancelled = worker.predict(settings, req)
258
+ worker.stop()
259
+ await assert.rejects(cancelled)
260
+ assert.equal(worker.state, 'idle', 'off must not become an error when an old request settles')
261
+ const inFlight = worker.predict(settings, req)
262
+ await until(() => worker.state === 'thinking')
263
+ const runningChild = worker.child
264
+ const runningClosed = once(runningChild, 'close')
265
+ worker.stop()
266
+ await assert.rejects(inFlight)
267
+ await runningClosed
268
+ assert.equal(worker.state, 'idle', 'in-flight cancellation must release the process without a stale error')
269
+ worker.stop(); worker = null
270
+
271
+ await fs.writeFile(native, lines(nativeEvents))
272
+ await fs.writeFile(path.join(dateDir, 'rollout-foreign.jsonl'), lines([
273
+ { type: 'session_meta', payload: { cwd: '/other' } },
274
+ { type: 'response_item', payload: { type: 'function_call', call_id: 'foreign', name: 'read_file', arguments: '{}' } },
275
+ ]))
276
+ await fs.writeFile(path.join(dateDir, 'rollout-guardian.jsonl'), lines([
277
+ { type: 'session_meta', payload: { cwd: '/project', thread_source: 'guardian' } },
278
+ { type: 'response_item', payload: { type: 'function_call', call_id: 'guardian', name: 'read_file', arguments: '{}' } },
279
+ ]))
280
+ await fs.writeFile(path.join(dateDir, 'rollout-omitted.jsonl'), lines([
281
+ { type: 'session_meta', payload: { cwd: '/omitted-project' } }, omitted,
282
+ ]))
283
+ const claudeProjectsDir = path.join(root, 'claude')
284
+ await fs.mkdir(path.join(claudeProjectsDir, '-claude-project'), { recursive: true })
285
+ await fs.writeFile(path.join(claudeProjectsDir, '-claude-project/session.jsonl'), lines([
286
+ { ...claudeThought, timestamp: new Date().toISOString(), cwd: '/claude-project' },
287
+ ]))
288
+ map = new WorkMap(root, () => {}, { codexSessionsDir: sessions, claudeProjectsDir }, { state: 'available', reason: null, gpuName: 'Test GPU' })
289
+ map.configure(settings)
290
+ map.attach('tab-a', 'codex', 'Codex', '/project')
291
+ map.attach('tab-b', 'codex', 'Codex', '/other')
292
+ map.attach('tab-c', 'claude', 'Claude Code', '/claude-project')
293
+ map.attach('tab-omitted', 'codex', 'Codex', '/omitted-project')
294
+ await until(() => map.snapshot('tab-a').nodes.some(n => n.placement === 'clef-flash'), 12000)
295
+ assert(!map.snapshot('tab-a').nodes.some(n => n.evidence.some(e => e.toolId === 'foreign')))
296
+ assert(!map.snapshot('tab-a').nodes.some(n => n.evidence.some(e => e.toolId === 'guardian')))
297
+ assert(map.snapshot('tab-b').nodes.some(n => n.evidence.some(e => e.toolId === 'foreign')))
298
+ assert(map.snapshot('tab-a').nodes.some(n => n.status === 'succeeded'))
299
+ await until(() => map.snapshot('tab-c').nodes.some(n => n.placement === 'clef-flash'), 12000)
300
+ assert.equal(map.snapshot('tab-a').thinkingStatus, 'available')
301
+ assert.equal(map.snapshot('tab-c').thinkingStatus, 'available')
302
+ assert.equal(map.snapshot('tab-omitted').thinkingStatus, 'unavailable')
303
+ assert.equal(map.snapshot('tab-omitted').nodes.length, 0)
304
+ const mappedThought = map.snapshot('tab-c').nodes.find(n => n.evidence[0]?.kind === 'thinking').evidence[0]
305
+ assert.equal((await map.getSource('tab-c', mappedThought.id)).text, thinkingText)
306
+ assert.equal(await map.getSource('tab-b', mappedThought.id), null, 'a different tab cannot fetch this source record')
307
+ map.configure({ enabled: false })
308
+ const count = map.snapshot('tab-a').nodes.length
309
+ await fs.appendFile(native, lines([{ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'This arrives while off' }] } }]))
310
+ await pause(1200)
311
+ assert.equal(map.snapshot('tab-a').nodes.length, count)
312
+ assert.equal(map.snapshot('tab-a').runtime, 'off')
313
+ assert.equal(map.clef.child, null)
314
+ map.clear('tab-a')
315
+ assert.equal(map.snapshot('tab-a').nodes.length, 0)
316
+ map.attach('tab-a', 'shell', 'Shell', '/project')
317
+ assert.equal(map.snapshot('tab-a').supported, false)
318
+ map.shutdown(); map = null
319
+ console.log('PASS Clef protocol, cancellation, error recovery, tab isolation, off and clear')
320
+ if (process.argv.includes('--real-clef')) {
321
+ const cache = path.join(repo, 'experiments/live-map/.cache')
322
+ worker = new ClefWorker(path.join(repo, 'scripts/work-map-clef.py'), () => {})
323
+ const intentTree = intentTrees[0]
324
+ const intentNode = intentTree.nodes.find(n => n.activity === 'thinking')
325
+ const intentJob = intentTree.request([intentNode.id])
326
+ const answer = await worker.predict({ enabled: true, engine: 'clef-flash',
327
+ pythonPath: path.join(cache, 'envs/clef-flash/bin/python'), modelPath: path.join(cache, 'models/clef-flash'),
328
+ }, intentJob.request)
329
+ intentTree.apply(intentJob, answer)
330
+ const realSnakeJob = snakeTree.request([snakeId])
331
+ const realSnakeAnswer = await worker.predict({ enabled: true, engine: 'clef-flash',
332
+ pythonPath: path.join(cache, 'envs/clef-flash/bin/python'), modelPath: path.join(cache, 'models/clef-flash'),
333
+ }, realSnakeJob.request)
334
+ snakeTree.apply(realSnakeJob, realSnakeAnswer)
335
+ assert(snakeUpdate.segments.includes(snakeNode.focus.text))
336
+ if (snakeNode.focus.reason) assert(snakeUpdate.segments.includes(snakeNode.focus.reason))
337
+ const process = worker.child
338
+ const closed = once(process, 'close')
339
+ worker.stop()
340
+ await closed
341
+ worker = null
342
+ assert(answer.branch_0.choice)
343
+ assert(intentJob.statements[intentNode.id].some(s => s.startsWith(intentNode.title.replace(/…$/, ''))))
344
+ console.log('PASS real offline Clef-Flash choice/reason extraction and unload:', JSON.stringify({ selectedExcerpt: answer.statement_0.choice, displayedIntent: intentNode.intent, snakeIntent: snakeNode.intent, snakeHasReason: !!snakeNode.focus.reason }))
345
+ }
346
+ } finally {
347
+ tailer?.stop(); map?.shutdown(); worker?.stop()
348
+ await fs.rm(root, { recursive: true, force: true })
349
+ }
350
+ }
351
+
352
+ main().catch(err => { console.error(err); process.exitCode = 1 })
@@ -0,0 +1,81 @@
1
+ """Offline JSONL worker for the live work map. One request at a time; no HTTP listener."""
2
+ import contextlib
3
+ import copy
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+ import time
9
+ import resource
10
+ import threading
11
+
12
+
13
+ def emit(value):
14
+ print(json.dumps(value), flush=True)
15
+
16
+
17
+ def guard_memory():
18
+ # Inspect our own RSS; no ps subprocess or OS process-inspection permission needed.
19
+ while True:
20
+ peak_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
21
+ rss_bytes = peak_rss if sys.platform == 'darwin' else peak_rss * 1024
22
+ if rss_bytes > 16 * 1024 ** 3:
23
+ emit({'error': 'Clef exceeded the 16 GiB process memory budget and was unloaded.'})
24
+ os._exit(1)
25
+ time.sleep(1)
26
+
27
+
28
+ def budget_request(request, tokenizer, encode_record):
29
+ """Keep the typed schema and at least 256 state tokens within the fixed context budget."""
30
+ for length in (320, 240, 160, 100, 64, 40):
31
+ bounded = copy.deepcopy(request)
32
+ for question in bounded['questions'].values():
33
+ question['criteria'] = {key: text[:length] for key, text in question['criteria'].items()}
34
+ try:
35
+ encode_record(tokenizer, bounded, max_length=1792, truncate=True)
36
+ return bounded
37
+ except ValueError as exc:
38
+ if 'schema requires' not in str(exc):
39
+ raise
40
+ raise ValueError('Work-map schema cannot fit in its token budget')
41
+
42
+
43
+ def main():
44
+ model_path = Path(sys.argv[1]).resolve(strict=True)
45
+ threading.Thread(target=guard_memory, daemon=True).start()
46
+ # Set before importing any Hub/tokenizer code. Never download during inference.
47
+ os.environ.update(HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1", HF_HUB_DISABLE_TELEMETRY="1",
48
+ TOKENIZERS_PARALLELISM="false", DO_NOT_TRACK="1",
49
+ HF_HOME=str(model_path.parent / '.runtime-cache'),
50
+ XDG_CACHE_HOME=str(model_path.parent / '.runtime-cache'))
51
+ with contextlib.redirect_stdout(sys.stderr):
52
+ import mlx.core as mx
53
+ mx.set_memory_limit(14 * 1024 ** 3)
54
+ mx.set_cache_limit(256 * 1024 ** 2)
55
+ sys.path.insert(0, str(model_path))
56
+ import clef_mlx
57
+ model = clef_mlx.load(model_path)
58
+ emit({"ready": True})
59
+ for line in sys.stdin:
60
+ req = {}
61
+ try:
62
+ if len(line) > 32_000:
63
+ raise ValueError("Work-map request exceeded its input budget")
64
+ req = json.loads(line)
65
+ start = time.monotonic()
66
+ with contextlib.redirect_stdout(sys.stderr):
67
+ request = budget_request(req['request'], model.tokenizer, clef_mlx.encode_record)
68
+ response = model.systemone(request, max_length=2048, truncate=True)
69
+ mx.clear_cache()
70
+ emit({"id": req["id"], "answers": response["answers"],
71
+ "latency_ms": round((time.monotonic() - start) * 1000)})
72
+ except Exception as exc:
73
+ emit({"id": req.get("id"), "error": f"{type(exc).__name__}: {exc}"[:600]})
74
+
75
+
76
+ if __name__ == "__main__":
77
+ try:
78
+ main()
79
+ except Exception as exc:
80
+ emit({"error": f"{type(exc).__name__}: {exc}"[:600]})
81
+ sys.exit(1)
package/src/main/index.ts CHANGED
@@ -25,6 +25,8 @@ function log(...args: unknown[]): void {
25
25
  import { AgentConfig, getDefaultAgent, getAvailableAgents } from './agents'
26
26
  import { getSettingsDir } from './settingsDir'
27
27
  import { Orchestrator } from './orchestrator'
28
+ import type { MapSettings } from '../shared/workMap'
29
+ import { detectCapability, usableElectronGpu } from '../../scripts/check-work-map-gpu.cjs'
28
30
  import { AttentionController } from './agentState'
29
31
  import { listSshHosts, getRecentHosts, addRecentHost, probeRemote, buildRemoteAgent, type RemoteAction } from './ssh'
30
32
  import { ResourceMonitor } from './monitor'
@@ -230,6 +232,8 @@ const orchestrator = new Orchestrator(
230
232
  onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
231
233
  onObserverUpdate: (item) => mainWindow?.webContents.send('observer:update', item),
232
234
  onObserverActivity: (reviewing) => mainWindow?.webContents.send('observer:activity', reviewing),
235
+ onWorkMapUpdate: (snapshot) => mainWindow?.webContents.send('workmap:update', snapshot),
236
+ onWorkMapCapability: (capability) => mainWindow?.webContents.send('workmap:capability', capability),
233
237
  onAgentStateChange: (tabId, state, anyAwaiting) => {
234
238
  log(`[agent-state] tab=${tabId} state=${state} anyAwaiting=${anyAwaiting}`)
235
239
  mainWindow?.webContents.send('agent:state', tabId, state, anyAwaiting)
@@ -242,7 +246,8 @@ const orchestrator = new Orchestrator(
242
246
  // the local launch cwd for unbound/local tabs.
243
247
  updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd)
244
248
  },
245
- }
249
+ },
250
+ { appRoot: app.isPackaged ? process.resourcesPath : app.getAppPath(), desktop: true },
246
251
  )
247
252
 
248
253
  // ─── Window ───────────────────────────────────────
@@ -728,6 +733,33 @@ ipcMain.handle('speak:set', (_event, enabled: boolean) => {
728
733
  return { enabled: orchestrator.speakRepliesEnabled }
729
734
  })
730
735
 
736
+ // ─── Work map IPC ─────────────────────────────────
737
+
738
+ ipcMain.handle('workmap:get', (_event, tabId: string) => orchestrator.workMap.snapshot(String(tabId)))
739
+ ipcMain.handle('workmap:capability', () => orchestrator.workMap.getCapability())
740
+ ipcMain.handle('workmap:source', (_event, tabId: string, eventId: string) => orchestrator.workMap.getSource(String(tabId), String(eventId)))
741
+ ipcMain.handle('workmap:set', (_event, tabId: string, data: unknown) => {
742
+ if (!data || typeof data !== 'object') throw new Error('Invalid work map settings')
743
+ const input = data as Record<string, unknown>
744
+ const clean: MapSettings = {
745
+ ...orchestrator.workMap.snapshot(String(tabId)).settings,
746
+ }
747
+ if (typeof input.enabled === 'boolean') clean.enabled = input.enabled
748
+ if (input.engine === 'rules' || input.engine === 'clef-flash') clean.engine = input.engine
749
+ for (const key of ['pythonPath', 'modelPath'] as const) {
750
+ if (typeof input[key] === 'string') {
751
+ if (input[key].length > 4096 || !path.isAbsolute(input[key])) throw new Error('Model paths must be absolute local paths')
752
+ clean[key] = input[key]
753
+ }
754
+ }
755
+ orchestrator.configureWorkMap(clean)
756
+ return orchestrator.workMap.snapshot(String(tabId))
757
+ })
758
+ ipcMain.handle('workmap:clear', (_event, tabId: string) => {
759
+ orchestrator.workMap.clear(String(tabId))
760
+ return orchestrator.workMap.snapshot(String(tabId))
761
+ })
762
+
731
763
  // ─── Observer IPC ─────────────────────────────────
732
764
 
733
765
  ipcMain.handle('observer:get', () => orchestrator.getObserverSettingsSummary())
@@ -824,6 +856,8 @@ app.whenReady().then(() => {
824
856
 
825
857
  orchestrator.loadSettings()
826
858
  createWindow()
859
+ void detectCapability({ desktop: true, verifyGpu: async () => usableElectronGpu(await app.getGPUInfo('complete')) })
860
+ .then(capability => { if (!shutdownStarted) orchestrator.setWorkMapCapability(capability) })
827
861
 
828
862
  // Kick off an initial npm update check shortly after launch, then poll periodically.
829
863
  setTimeout(() => { void checkForUpdate() }, 3000)
@@ -26,6 +26,8 @@ import { createModelCaller } from './anthropic'
26
26
 
27
27
  /**
28
28
  * Observer — supervision layer over the session ledger (spec v0.1, Phase 1).
29
+ * ON HOLD: retained engine; the app's orchestrator disables runtime and the
30
+ * renderer does not expose the feature. Standalone test harnesses still work.
29
31
  *
30
32
  * Consumes the agent's native transcript (via its own TranscriptTailer) and
31
33
  * pty-derived activity signals, normalizes them into a local append-only