picocode-core 0.9.119

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 (57) hide show
  1. package/README.md +15 -0
  2. package/package.json +33 -0
  3. package/src/agent-transcript.js +80 -0
  4. package/src/agent.js +170 -0
  5. package/src/agents.js +213 -0
  6. package/src/attachments.js +141 -0
  7. package/src/boot.js +28 -0
  8. package/src/catalog-snapshot.json +1 -0
  9. package/src/catalog.js +86 -0
  10. package/src/codex-models.js +56 -0
  11. package/src/commands.js +61 -0
  12. package/src/compaction.js +82 -0
  13. package/src/completion.js +21 -0
  14. package/src/config.js +32 -0
  15. package/src/context.js +82 -0
  16. package/src/controller.js +1263 -0
  17. package/src/conversation-search.js +101 -0
  18. package/src/deliberation-history.js +65 -0
  19. package/src/deliberation.js +61 -0
  20. package/src/derive.js +307 -0
  21. package/src/events.js +54 -0
  22. package/src/export.js +16 -0
  23. package/src/files.js +39 -0
  24. package/src/format.js +6 -0
  25. package/src/fuzzy.js +41 -0
  26. package/src/git.js +156 -0
  27. package/src/history.js +51 -0
  28. package/src/init.js +25 -0
  29. package/src/keys.js +26 -0
  30. package/src/mcp.js +280 -0
  31. package/src/memory.js +120 -0
  32. package/src/models.js +14 -0
  33. package/src/openai-auth.js +204 -0
  34. package/src/paths.js +67 -0
  35. package/src/reversible-edit.js +79 -0
  36. package/src/rewind.js +84 -0
  37. package/src/session-index.js +264 -0
  38. package/src/session-lock.js +27 -0
  39. package/src/session.js +160 -0
  40. package/src/shells.js +166 -0
  41. package/src/skills.js +164 -0
  42. package/src/steer.js +129 -0
  43. package/src/system-prompt.js +49 -0
  44. package/src/terminal-theme.js +49 -0
  45. package/src/tools/bash.js +184 -0
  46. package/src/tools/diff.js +18 -0
  47. package/src/tools/edit.js +95 -0
  48. package/src/tools/glob.js +43 -0
  49. package/src/tools/grep.js +59 -0
  50. package/src/tools/index.js +296 -0
  51. package/src/tools/read.js +49 -0
  52. package/src/tools/recorder.js +74 -0
  53. package/src/tools/web.js +84 -0
  54. package/src/tools/write.js +48 -0
  55. package/src/update.js +82 -0
  56. package/src/user-tools.js +59 -0
  57. package/src/wakeups.js +40 -0
@@ -0,0 +1,1263 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { makeEvent } from './events.js'
5
+ import { createSession, forkSession, openSession, loadSession, listSessions, deleteSession, appendSessionEvent, onSessionWriteError } from './session.js'
6
+ import { createContextTracker } from './context.js'
7
+ import { deriveState, userEntries, rewindStats } from './derive.js'
8
+ import { appendPrompt } from './history.js'
9
+ import { runTurn, summarizeText, compactHistory, compactProgress } from './agent.js'
10
+ import { createAgentManager } from './agents.js'
11
+ import { runDeliberation, validateDeliberation } from './deliberation.js'
12
+ import { deliberationsFromEvents } from './deliberation-history.js'
13
+ import { compactionPrompt, formatCompactSummary, summarySections, compactionKeepFrom } from './compaction.js'
14
+ import { createToolset } from './tools/index.js'
15
+ import { defaultTitle } from './tools/recorder.js'
16
+ import { scanUserTools } from './user-tools.js'
17
+ import { createSkillIndex } from './skills.js'
18
+ import { createCommandIndex } from './commands.js'
19
+ import { initPrompt } from './init.js'
20
+ import { revertEdits, reapplyEdits } from './rewind.js'
21
+ import { buildSystemPrompt } from './system-prompt.js'
22
+ import { memoryIndex } from './memory.js'
23
+ import { transcriptToMarkdown } from './export.js'
24
+ import { findModel, estimateCost } from './models.js'
25
+ import { adhocModel } from './catalog.js'
26
+ import { writeConfig } from './config.js'
27
+ import { connectOpenAI, openaiCredentials, disconnectOpenAI } from './openai-auth.js'
28
+ import { agentScratchDir, ensureDir } from './paths.js'
29
+ import { loadCodexModels } from './codex-models.js'
30
+ import { fuzzyScore } from './fuzzy.js'
31
+ import { finalizeUserContent, inputTextFromContent, mediaTypeFor } from './attachments.js'
32
+
33
+ export const EFFORT_LEVELS = [
34
+ { key: null, desc: 'let the provider decide how much to think' },
35
+ { key: 'low', desc: 'quick answers, minimal thinking' },
36
+ { key: 'medium', desc: 'moderate thinking budget' },
37
+ { key: 'high', desc: 'generous thinking budget' },
38
+ { key: 'max', desc: 'maximum thinking budget' },
39
+ ]
40
+
41
+ export const SESSION_COLORS = {
42
+ red: '#f87171',
43
+ orange: '#fb923c',
44
+ yellow: '#facc15',
45
+ green: '#6BE795',
46
+ teal: '#2dd4bf',
47
+ cyan: '#22d3ee',
48
+ blue: '#60a5fa',
49
+ purple: '#a78bfa',
50
+ pink: '#f472b6',
51
+ gray: '#9ca3af',
52
+ }
53
+
54
+ const WORKER_TOOLS = ['read', 'write', 'edit', 'bash', 'glob', 'grep', 'shell_output', 'shell_kill', 'web_search', 'web_fetch']
55
+ const AGENT_TOOLS = ['agent_plan', 'agent_start', 'agent_list', 'agent_collect', 'agent_cancel']
56
+ const CONTEXT_COLORS = ['#67b7ff', '#c792ea', '#f7c66a', '#f78c6c', '#6be795']
57
+
58
+ function createEmitter() {
59
+ const listeners = new Map()
60
+ return {
61
+ on(type, fn) {
62
+ if (!listeners.has(type)) listeners.set(type, new Set())
63
+ listeners.get(type).add(fn)
64
+ return () => listeners.get(type)?.delete(fn)
65
+ },
66
+ emit(type, payload) {
67
+ for (const fn of [...(listeners.get(type) || [])]) fn(payload)
68
+ },
69
+ }
70
+ }
71
+
72
+ function parallelPrompt(task, agentLimit) {
73
+ return `Use parallel agents for the following task: ${task}\n\nFirst call agent_plan. Interpret any agent-count instruction in the user's task semantically and declare that count; if the user gave no count, declare the configured default budget of ${agentLimit}. Then use agent_start within that enforced budget to delegate distinct, focused parts of the task to the configured worker model. Collect workers with agent_collect, critically evaluate their results, and synthesize the final response. When useful and the budget permits, use independent workers to check important disputed or weak conclusions. Do not delegate final synthesis. Do not emit progress updates while agents run; Pico displays agent activity automatically.`
74
+ }
75
+
76
+ function deliberatePrompt(decision) {
77
+ return `Deliberate on the following decision: ${decision}\n\nImmediately call deliberate with a self-contained brief. Do not research first, start ordinary agents, or approximate the deliberation yourself. The deliberation participants own all supporting research.`
78
+ }
79
+
80
+ function workerSystemPrompt(scratchpad) {
81
+ return `You are an isolated worker operating in the user's real project. Complete only the assigned task and do not ask the user questions. You may read, modify, and test project files. Put disposable scripts, generated data, experiments, and temporary package installs in your session-persistent scratchpad at ${scratchpad}, also available as $PICO_SCRATCHPAD. Use primary sources where possible, distinguish evidence from inference, and return a concise self-contained result.`
82
+ }
83
+
84
+ function participantSystemPrompt(scratchpad) {
85
+ return `You are one participant in an isolated, bounded deliberation. Research actively with project and web tools, prefer primary sources, and distinguish evidence from inference. Do not ask the user questions or modify project files. Your persistent scratchpad is ${scratchpad}.`
86
+ }
87
+
88
+ function shellRuntime(shell) {
89
+ const secs = Math.max(0, Math.round(((shell.endedAt || Date.now()) - shell.startedAt) / 1000))
90
+ return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
91
+ }
92
+
93
+ function errorText(err, limit) {
94
+ return String(err?.message || err).slice(0, limit)
95
+ }
96
+
97
+ export function createController({ boot }) {
98
+ const { on, emit } = createEmitter()
99
+
100
+ const state = {
101
+ session: null,
102
+ events: [],
103
+ persisted: 0,
104
+ derived: deriveState([]),
105
+ model: boot.initialModel,
106
+ defaultModel: boot.initialModel,
107
+ effort: boot.initialEffort,
108
+ defaultEffort: boot.initialEffort,
109
+ busy: false,
110
+ compacting: false,
111
+ compactStatus: null,
112
+ turnPhase: 'idle',
113
+ startedAt: 0,
114
+ overlay: [],
115
+ streaming: null,
116
+ queued: [],
117
+ expedited: [],
118
+ sent: [],
119
+ question: null,
120
+ rewindUndo: null,
121
+ attachments: new Map(),
122
+ imageCount: 0,
123
+ activityVersion: 0,
124
+ held: false,
125
+ }
126
+
127
+ let abort = null
128
+ let sendAfterToolTriggered = false
129
+ let nextResearchAgentLimit = null
130
+ let pendingSystemNotes = []
131
+ const warnedTools = new Set()
132
+
133
+ const changed = () => emit('change', state)
134
+ const flash = (message) => emit('flash', message)
135
+
136
+ function set(patch) {
137
+ Object.assign(state, patch)
138
+ changed()
139
+ }
140
+
141
+ function bumpActivity() {
142
+ set({ activityVersion: state.activityVersion + 1 })
143
+ }
144
+
145
+ function persist(event) {
146
+ state.events.push(event)
147
+ if (state.session) {
148
+ state.session.append(event)
149
+ state.persisted = state.events.length
150
+ }
151
+ }
152
+
153
+ function ensureSession() {
154
+ if (!state.session) state.session = createSession({ cwd: boot.cwd, root: boot.root })
155
+ while (state.persisted < state.events.length) {
156
+ state.session.append(state.events[state.persisted])
157
+ state.persisted++
158
+ }
159
+ }
160
+
161
+ function reDerive() {
162
+ state.derived = deriveState(state.events)
163
+ emit('derived', state.derived)
164
+ changed()
165
+ }
166
+
167
+ async function codexAuth() {
168
+ if (state.model.provider !== 'codex') return { auth: null, ok: true }
169
+ const auth = await openaiCredentials().catch(() => null)
170
+ if (!auth) flash('codex models need a ChatGPT sign-in: run /connect')
171
+ return { auth, ok: !!auth }
172
+ }
173
+
174
+ const agents = createAgentManager({
175
+ concurrency: 8,
176
+ defaults: () => ({ model: boot.researchModel }),
177
+ onChange: bumpActivity,
178
+ onCreate: (agent) => persist(makeEvent('agent_start', { agentId: agent.id, description: agent.description, prompt: agent.prompt, model: agent.model, role: agent.role, sessionId: agent.sessionId, sessionFile: agent.sessionFile, tools: agent.tools })),
179
+ onEvent: (agent, event) => {
180
+ if (['tool_executing', 'tool_complete', 'tool_error', 'usage'].includes(event.type)) persist(makeEvent('agent_event', { agentId: agent.id, event }))
181
+ },
182
+ onFinish: (agent) => {
183
+ persist(makeEvent('agent_result', { agentId: agent.id, result: agent.result, usage: agent.usage, interrupted: agent.status === 'cancelled', error: agent.error }))
184
+ noteSystem(`Agent ${agent.id} (${agent.description}) finished with status ${agent.status}. Its result is ready. Call agent_collect with id ${agent.id} before finishing your response.`, { wake: true, agentId: agent.id, sessionId: agent.sessionId, sessionFile: agent.sessionFile })
185
+ },
186
+ // this closure outlives any single boot: /connect and project switches
187
+ // both replace boot fields, so it reads the live boot every run
188
+ run: async (agent, signal, onStream) => {
189
+ const worker = boot.models.find((m) => m.name === agent.model)
190
+ if (!worker || worker.available === false) throw new Error(`research model unavailable: ${agent.model}`)
191
+ const auth = worker.provider === 'codex' ? await openaiCredentials() : null
192
+ const sessionId = state.session?.id
193
+ if (!sessionId) throw new Error('worker requires an active session')
194
+ const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, agent.id))
195
+ const requestedTools = agent.tools?.length ? agent.tools.filter((name) => WORKER_TOOLS.includes(name)) : WORKER_TOOLS
196
+ const { tools, recorder } = createToolset({
197
+ cwd: boot.cwd,
198
+ env: { PICO_SCRATCHPAD: scratchpad },
199
+ // a private tracker seeded from the main one: a worker may read an
200
+ // AGENTS.md the main agent has not seen, and consuming it from the
201
+ // shared set would mean the main agent never receives it
202
+ tracker: createContextTracker({ stopDir: boot.startupContext.stopDir, loaded: new Set(boot.tracker.loaded) }),
203
+ shells: boot.shells,
204
+ sessionId,
205
+ sessionFile: state.session?.file,
206
+ dredge: boot.dredge,
207
+ signal,
208
+ maxToolCalls: 30,
209
+ allowNames: requestedTools,
210
+ })
211
+ return runTurn({
212
+ history: [{ role: 'user', content: agent.prompt }],
213
+ tools,
214
+ recorder,
215
+ modelName: worker.name,
216
+ effort: worker.effort ? 'low' : null,
217
+ auth,
218
+ system: workerSystemPrompt(scratchpad),
219
+ signal,
220
+ onStream,
221
+ })
222
+ },
223
+ })
224
+
225
+ const deliberations = {
226
+ run: async ({ brief, rounds, signal }) => {
227
+ const options = validateDeliberation({ brief, rounds })
228
+ brief = options.brief
229
+ rounds = options.rounds
230
+ const existingIds = deliberationsFromEvents(state.events).map((item) => Number(item.deliberationId)).filter(Number.isFinite)
231
+ const id = String(Math.max(0, ...existingIds) + 1)
232
+ const modelName = boot.deliberationModel
233
+ const worker = boot.models.find((m) => m.name === modelName)
234
+ if (!worker || worker.available === false) throw new Error(`deliberation model unavailable: ${modelName}`)
235
+ const auth = worker.provider === 'codex' ? await openaiCredentials() : null
236
+ const sessionId = state.session?.id
237
+ if (!sessionId) throw new Error('deliberation requires an active session')
238
+ persist(makeEvent('deliberation_start', { deliberationId: id, brief, rounds, model: modelName }))
239
+ bumpActivity()
240
+
241
+ const persistDeliberation = (event) => {
242
+ persist(event)
243
+ bumpActivity()
244
+ }
245
+
246
+ const runWorker = async ({ history, role, tools: enabled = true, onStream }) => {
247
+ const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, `deliberation-${id}-${role}`))
248
+ const toolset = createToolset({
249
+ cwd: boot.cwd,
250
+ env: { PICO_SCRATCHPAD: scratchpad },
251
+ tracker: createContextTracker({ stopDir: boot.startupContext.stopDir, loaded: new Set(boot.tracker.loaded) }),
252
+ shells: boot.shells,
253
+ sessionId,
254
+ sessionFile: state.session?.file,
255
+ dredge: boot.dredge,
256
+ signal,
257
+ maxToolCalls: 30,
258
+ allowNames: enabled ? WORKER_TOOLS : [],
259
+ })
260
+ return runTurn({
261
+ history,
262
+ tools: toolset.tools,
263
+ recorder: toolset.recorder,
264
+ modelName,
265
+ effort: worker.effort ? 'low' : null,
266
+ auth,
267
+ system: participantSystemPrompt(scratchpad),
268
+ signal,
269
+ onStream,
270
+ })
271
+ }
272
+
273
+ const result = await runDeliberation({
274
+ brief,
275
+ rounds,
276
+ signal,
277
+ runParticipant: ({ history, role, round }) => runWorker({
278
+ history,
279
+ role,
280
+ onStream: (event) => {
281
+ if (['tool_executing', 'tool_complete', 'tool_error'].includes(event.type)) {
282
+ persistDeliberation(makeEvent('deliberation_event', { deliberationId: id, role, round, event }))
283
+ }
284
+ },
285
+ }),
286
+ runSynthesis: ({ history }) => runWorker({ history, role: 'synthesizer', tools: false }),
287
+ onEvent: (event) => persistDeliberation(makeEvent('deliberation_turn', { deliberationId: id, ...event })),
288
+ })
289
+ persistDeliberation(makeEvent('deliberation_result', { deliberationId: id, result: result.result, usage: result.usage, interrupted: result.interrupted, error: result.error }))
290
+ if (result.error) throw new Error(result.error)
291
+ return {
292
+ deliberationId: id,
293
+ synthesis: result.result,
294
+ interrupted: result.interrupted,
295
+ review: 'The full deliberation transcript and tool activity are available in the session activity panel.',
296
+ }
297
+ },
298
+ }
299
+
300
+ onSessionWriteError((err) => flash(`session not saved: ${errorText(err, 80)}`))
301
+ boot.setMcpNotify(() => emit('mcp', boot.mcp.list()))
302
+ boot.setShellsNotify(() => emit('shells'))
303
+ boot.setWakeupsNotify(() => emit('shells'))
304
+ boot.setGitNotify(() => emit('git'))
305
+ boot.setWakeupsFire((wakeup) => {
306
+ flash(`wake-up ${wakeup.id} fired`)
307
+ noteSystem(`[system notification] scheduled wake-up ${wakeup.id} fired. Note to self: ${wakeup.note}`, { wake: true })
308
+ })
309
+ boot.setShellsExit((shell) => {
310
+ if (shell.killedBy === 'model') {
311
+ flash(`shell ${shell.id} killed`)
312
+ return
313
+ }
314
+ if (shell.killedBy === 'user') {
315
+ flash(`shell ${shell.id} killed`)
316
+ noteSystem(`[system notification] the user manually killed background shell ${shell.id} (${shell.description || shell.command}) from the shells panel (SIGTERM). This was deliberate; do not restart it unless asked.`, { wake: false, sessionId: shell.sessionId, sessionFile: shell.sessionFile })
317
+ return
318
+ }
319
+ flash(`shell ${shell.id} exited · code ${shell.exitCode}`)
320
+ const tail = boot.shells.output(shell.id, { tail: 30 }).output
321
+ noteSystem(
322
+ `[system notification] background shell ${shell.id} (${shell.description || shell.command}) exited with code ${shell.exitCode} after ${shellRuntime(shell)}.` +
323
+ (tail ? `\nRecent output:\n${tail}` : ''),
324
+ { wake: true, sessionId: shell.sessionId, sessionFile: shell.sessionFile },
325
+ )
326
+ })
327
+
328
+ function noteSystem(text, { wake, agentId, sessionId = state.session?.id, sessionFile = state.session?.file } = {}) {
329
+ pendingSystemNotes.push({ text, wake, agentId, sessionId, sessionFile })
330
+ flushSystemNotes()
331
+ }
332
+
333
+ function discardCollectedAgentNotes(agentIds) {
334
+ const collected = new Set(agentIds.map(String))
335
+ pendingSystemNotes = pendingSystemNotes.filter((note) => !collected.has(String(note.agentId)))
336
+ }
337
+
338
+ function flushSystemNotes() {
339
+ if (!pendingSystemNotes.length || state.busy || state.held || !state.session) return
340
+ pendingSystemNotes = pendingSystemNotes.filter((note) => !note.agentId || !agents.get(note.agentId)?.collectedAt)
341
+ const currentSessionId = state.session.id
342
+ const current = pendingSystemNotes.filter((note) => !note.sessionId || note.sessionId === currentSessionId)
343
+ const elsewhere = pendingSystemNotes.filter((note) => note.sessionId && note.sessionId !== currentSessionId)
344
+ pendingSystemNotes = []
345
+ for (const notes of Map.groupBy(elsewhere, (note) => note.sessionId).values()) {
346
+ const sessionFile = notes.find((note) => note.sessionFile)?.sessionFile
347
+ if (sessionFile) appendSessionEvent(sessionFile, makeEvent('system_note', { text: notes.map((n) => n.text).join('\n\n') }))
348
+ }
349
+ if (!current.length) return
350
+ persist(makeEvent('system_note', { text: current.map((n) => n.text).join('\n\n') }))
351
+ reDerive()
352
+ if (current.some((n) => n.wake)) runAgentTurn()
353
+ }
354
+
355
+ function hold(held) {
356
+ set({ held })
357
+ if (!held) flushSystemNotes()
358
+ }
359
+
360
+ function flushStream(items) {
361
+ if (state.streaming) items.push({ kind: 'assistant', text: state.streaming })
362
+ state.streaming = null
363
+ }
364
+
365
+ async function executeTurn(text) {
366
+ const { content } = finalizeUserContent(text, state.attachments)
367
+ persist(makeEvent('message', { message: { role: 'user', content } }))
368
+ ensureSession()
369
+ reDerive()
370
+ await runAgentTurn()
371
+ }
372
+
373
+ function takePending() {
374
+ const next = [...state.expedited, ...state.queued]
375
+ set({ expedited: [], queued: [] })
376
+ return next
377
+ }
378
+
379
+ async function compact(instructions = '') {
380
+ if (state.busy || state.compacting) return flash('finish or interrupt the current turn first')
381
+ const current = state.derived
382
+ if (current.providerHistory.length < 4) return flash('nothing to compact yet')
383
+
384
+ const controller = new AbortController()
385
+ abort = controller
386
+ set({ busy: true, compacting: true, compactStatus: null, startedAt: Date.now() })
387
+
388
+ const { auth, ok } = await codexAuth()
389
+ if (!ok) {
390
+ abort = null
391
+ set({ compacting: false, busy: false })
392
+ return
393
+ }
394
+
395
+ const keepFrom = compactionKeepFrom(current, state.model.context)
396
+ let streamed = ''
397
+ try {
398
+ const raw = await compactHistory({
399
+ history: current.providerHistory,
400
+ modelName: state.model.name,
401
+ auth,
402
+ prompt: compactionPrompt(instructions),
403
+ signal: controller.signal,
404
+ onStream: (event) => {
405
+ if (event.type !== 'content') return
406
+ streamed += event.content
407
+ set({ compactStatus: compactProgress(streamed) })
408
+ },
409
+ })
410
+ const summary = formatCompactSummary(raw)
411
+ if (!summary) throw new Error('empty summary')
412
+ if (summarySections(summary) < 5) throw new Error('malformed summary, conversation left untouched')
413
+ persist(makeEvent('compact', { summary, keepFrom, sessionFile: state.session?.file || null }))
414
+ reDerive()
415
+ flash('compacted · recent messages kept verbatim')
416
+ } catch (err) {
417
+ if (controller.signal.aborted) flash('compaction cancelled')
418
+ else flash(`compact failed: ${errorText(err, 100)}`)
419
+ } finally {
420
+ abort = null
421
+ set({ compacting: false, compactStatus: null, busy: false })
422
+ }
423
+
424
+ if (state.expedited.length > 0 || state.queued.length > 0) {
425
+ const next = takePending()
426
+ if (controller.signal.aborted) emit('input', next.join('\n'))
427
+ else {
428
+ executeTurn(next.join('\n'))
429
+ return
430
+ }
431
+ }
432
+ flushSystemNotes()
433
+ }
434
+
435
+ function maybeAutoCompact() {
436
+ if (boot.autoCompact === false || state.busy || state.compacting) return
437
+ const limit = state.model.context
438
+ const used = state.derived.lastPromptTokens
439
+ if (!limit || !used || state.derived.lastPromptModel !== state.model.name) return
440
+ const ratio = used / limit
441
+ if (ratio >= 0.85) {
442
+ flash(`context ${Math.round(ratio * 100)}% full · auto-compacting`)
443
+ compact()
444
+ }
445
+ }
446
+
447
+ async function refreshProjectIndexes() {
448
+ boot.skills = await createSkillIndex(boot.root).catch(() => boot.skills) ?? boot.skills
449
+ boot.commands = await createCommandIndex(boot.root).catch(() => boot.commands) ?? boot.commands
450
+ const scan = await scanUserTools({ cwd: boot.cwd, root: boot.root }).catch(() => ({ tools: [], errors: [] }))
451
+ for (const failure of scan.errors) {
452
+ const key = `${failure.file}:${failure.error}`
453
+ if (warnedTools.has(key)) continue
454
+ warnedTools.add(key)
455
+ flash(`tool skipped: ${failure.file.split('/').pop()} · ${failure.error}`)
456
+ }
457
+ return scan
458
+ }
459
+
460
+ function askUser(questions) {
461
+ return new Promise((resolve) => {
462
+ set({ question: { questions, resolve } })
463
+ emit('question', state.question)
464
+ })
465
+ }
466
+
467
+ function updateOverlay(fn) {
468
+ state.overlay = fn(state.overlay)
469
+ changed()
470
+ }
471
+
472
+ function onToolUpdate(pending) {
473
+ if (pending.name !== 'bash') return
474
+ updateOverlay((items) => items.map((item) =>
475
+ item.kind === 'tool' && item.callId === pending.callId
476
+ ? { ...item, fullOutput: pending.fullOutput, outputLineStart: pending.outputLineStart, outputLineCount: pending.outputLineCount }
477
+ : item,
478
+ ))
479
+ }
480
+
481
+ function streamHandler(recorder, controller) {
482
+ return (event) => {
483
+ if (event.type === 'thinking') {
484
+ updateOverlay((items) => {
485
+ const next = [...items]
486
+ flushStream(next)
487
+ const last = next.at(-1)
488
+ if (last?.kind === 'thoughts') last.text += event.content
489
+ else next.push({ kind: 'thoughts', text: event.content })
490
+ return next
491
+ })
492
+ set({ turnPhase: 'thinking' })
493
+ } else if (event.type === 'content') {
494
+ set({ turnPhase: 'responding', streaming: (state.streaming || '') + event.content })
495
+ } else if (event.type === 'tool_calls_ready') {
496
+ set({ turnPhase: 'tools' })
497
+ updateOverlay((items) => {
498
+ const next = [...items]
499
+ flushStream(next)
500
+ return next
501
+ })
502
+ } else if (event.type === 'tool_executing') {
503
+ updateOverlay((items) => {
504
+ const next = [...items]
505
+ flushStream(next)
506
+ let args = {}
507
+ try {
508
+ args = JSON.parse(event.call.function.arguments)
509
+ } catch {}
510
+ next.push({
511
+ kind: 'tool',
512
+ callId: event.call.id,
513
+ name: event.call.function.name,
514
+ description: args.description,
515
+ title: defaultTitle(event.call.function.name, args),
516
+ titleLang: event.call.function.name === 'bash' ? 'bash' : null,
517
+ status: 'running',
518
+ startedAt: Date.now(),
519
+ })
520
+ return next
521
+ })
522
+ } else if (event.type === 'tool_complete' || event.type === 'tool_error') {
523
+ boot.git.refresh()
524
+ const entry = recorder.entries.at(-1)
525
+ updateOverlay((items) =>
526
+ items.map((item) =>
527
+ item.kind === 'tool' && item.callId === event.call.id
528
+ ? { ...item, ...(entry?.callId === event.call.id ? entry : {}), kind: 'tool', status: entry?.status || 'done' }
529
+ : item,
530
+ ),
531
+ )
532
+ if (state.expedited.length > 0) {
533
+ sendAfterToolTriggered = true
534
+ controller.abort()
535
+ }
536
+ }
537
+ }
538
+ }
539
+
540
+ async function runAgentTurn() {
541
+ const researchAgentLimit = nextResearchAgentLimit || null
542
+ nextResearchAgentLimit = null
543
+ emit('turn', 'start')
544
+ set({ busy: true, turnPhase: 'responding', startedAt: Date.now() })
545
+
546
+ const { auth, ok } = await codexAuth()
547
+ if (!ok) {
548
+ set({ turnPhase: 'idle', busy: false })
549
+ flushSystemNotes()
550
+ return
551
+ }
552
+
553
+ const controller = new AbortController()
554
+ abort = controller
555
+ const { tracker } = boot
556
+ const loadedBefore = new Set(tracker.loaded)
557
+ const userToolScan = await refreshProjectIndexes()
558
+ const freshSkills = boot.skills
559
+ const { tools, recorder } = createToolset({
560
+ cwd: boot.cwd,
561
+ tracker,
562
+ skills: freshSkills,
563
+ shells: boot.shells,
564
+ sessionId: state.session?.id,
565
+ sessionFile: state.session?.file,
566
+ wakeups: boot.wakeups,
567
+ memory: boot.memory,
568
+ agents: boot.researchModel ? agents : null,
569
+ deliberations: boot.deliberationModel ? deliberations : null,
570
+ onAgentsCollected: discardCollectedAgentNotes,
571
+ askUser,
572
+ dredge: boot.dredge,
573
+ mcpTools: boot.mcp.tools(),
574
+ userTools: userToolScan.tools,
575
+ signal: controller.signal,
576
+ maxAgentStarts: researchAgentLimit ? 100 : undefined,
577
+ requireAgentPlan: !!researchAgentLimit,
578
+ allowNames: researchAgentLimit ? AGENT_TOOLS : undefined,
579
+ onToolUpdate,
580
+ })
581
+
582
+ sendAfterToolTriggered = false
583
+ let result
584
+ try {
585
+ result = await runTurn({
586
+ history: state.derived.providerHistory,
587
+ tools,
588
+ recorder,
589
+ modelName: state.model.name,
590
+ effort: effortApplies() ? state.effort ?? 'auto' : null,
591
+ auth,
592
+ system: buildSystemPrompt({
593
+ cwd: boot.cwd,
594
+ contextFiles: boot.startupContext.files,
595
+ skills: freshSkills.list(),
596
+ memoryIndexText: memoryIndex(await boot.memory.list().catch(() => []), boot.root),
597
+ }),
598
+ signal: controller.signal,
599
+ onStream: streamHandler(recorder, controller),
600
+ })
601
+ } catch (err) {
602
+ abort = null
603
+ set({ overlay: [], streaming: null, turnPhase: 'idle', busy: false })
604
+ flash(`error: ${errorText(err, 120)}`)
605
+ return
606
+ }
607
+
608
+ const turnTranscript = [...state.overlay]
609
+ flushStream(turnTranscript)
610
+ for (const message of result.messages) persist(makeEvent('message', { message, hideFromTranscript: true }))
611
+ persist(makeEvent('turn_transcript', { items: turnTranscript }))
612
+ for (const entry of recorder.entries) persist(makeEvent('tool_meta', entry))
613
+ for (const path of tracker.loaded) {
614
+ if (!loadedBefore.has(path)) persist(makeEvent('context_file', { path }))
615
+ }
616
+ if (result.usage) persist(makeEvent('usage', { model: state.model.name, usage: result.usage, lastPrompt: result.lastPromptTokens }))
617
+ if (result.interrupted) persist(makeEvent('interrupt', {}))
618
+
619
+ abort = null
620
+ state.overlay = []
621
+ state.streaming = null
622
+ state.turnPhase = 'idle'
623
+ state.busy = false
624
+ reDerive()
625
+ boot.git.refresh()
626
+ if (result.stalled) {
627
+ flash('model stalled · turn interrupted')
628
+ noteSystem(
629
+ '[system notification] the previous turn was cut off automatically: the model produced no output for 5 minutes (provider stall). Work may have stopped mid-task; pick up where it left off.',
630
+ { wake: false },
631
+ )
632
+ } else if (result.error) {
633
+ flash(`error: ${result.error.slice(0, 120)}`)
634
+ noteSystem(
635
+ `[system notification] the previous turn ended with a provider error: ${result.error.slice(0, 300)}. Work may have stopped mid-task; pick up where it left off.`,
636
+ { wake: false },
637
+ )
638
+ }
639
+
640
+ const expeditedMessages = state.expedited
641
+ const pendingMessages = state.queued
642
+ if (expeditedMessages.length > 0 || pendingMessages.length > 0) {
643
+ set({ expedited: [], queued: [] })
644
+ if (result.interrupted && !sendAfterToolTriggered) {
645
+ emit('input', [...expeditedMessages, ...pendingMessages].join('\n'))
646
+ } else {
647
+ const next = sendAfterToolTriggered ? expeditedMessages : [...expeditedMessages, ...pendingMessages]
648
+ if (sendAfterToolTriggered && pendingMessages.length > 0) set({ queued: pendingMessages })
649
+ executeTurn(next.join('\n'))
650
+ return
651
+ }
652
+ }
653
+ flushSystemNotes()
654
+ maybeAutoCompact()
655
+ }
656
+
657
+ function send(text) {
658
+ const value = text.trim()
659
+ if (!value) return
660
+ state.sent = [...state.sent, { text: value, at: Date.now() }]
661
+ appendPrompt(boot.root, value).catch(() => {})
662
+ if (state.busy) {
663
+ set({ queued: [...state.queued, value] })
664
+ return
665
+ }
666
+ changed()
667
+ executeTurn(value)
668
+ }
669
+
670
+ function interrupt() {
671
+ if (!state.busy) return
672
+ sendAfterToolTriggered = false
673
+ cancelQuestion()
674
+ abort?.abort()
675
+ }
676
+
677
+ function answerQuestion(answers) {
678
+ const request = state.question
679
+ if (!request) return
680
+ set({ question: null })
681
+ request.resolve({ answers })
682
+ }
683
+
684
+ function cancelQuestion() {
685
+ const request = state.question
686
+ if (!request) return
687
+ set({ question: null })
688
+ request.resolve({ cancelled: true })
689
+ }
690
+
691
+ function recallPending() {
692
+ const next = takePending()
693
+ return next.join('\n')
694
+ }
695
+
696
+ function expediteQueued() {
697
+ set({ expedited: [...state.expedited, ...state.queued], queued: [] })
698
+ }
699
+
700
+ function resetConversation({ model, effort } = {}) {
701
+ state.session = null
702
+ state.events = []
703
+ state.persisted = 0
704
+ state.rewindUndo = null
705
+ state.queued = []
706
+ state.expedited = []
707
+ state.sent = []
708
+ state.model = model ?? state.defaultModel
709
+ state.effort = effort === undefined ? state.defaultEffort : effort
710
+ }
711
+
712
+ function newSession() {
713
+ if (state.busy) return flash('finish or interrupt the current turn first')
714
+ resetConversation()
715
+ agents.clear()
716
+ reDerive()
717
+ emit('session', state.session)
718
+ flash('new session')
719
+ }
720
+
721
+ async function deleteCurrentSession() {
722
+ const { session } = state
723
+ if (!session) return false
724
+ if (state.busy) {
725
+ flash('finish or interrupt the current turn first')
726
+ return false
727
+ }
728
+ try {
729
+ await session.flush()
730
+ await deleteSession(session.file)
731
+ } catch (err) {
732
+ flash(`delete failed: ${errorText(err, 80)}`)
733
+ return false
734
+ }
735
+ resetConversation()
736
+ reDerive()
737
+ emit('session', state.session)
738
+ flash('session deleted')
739
+ return true
740
+ }
741
+
742
+ async function fork(label) {
743
+ if (!label) return flash('usage: /fork <label>')
744
+ if (state.busy) return flash('finish or interrupt the current turn first')
745
+ ensureSession()
746
+ const forked = await forkSession({ source: state.session, cwd: boot.cwd, root: boot.root, events: state.events, label })
747
+ state.session = forked.session
748
+ state.events = forked.events
749
+ state.persisted = forked.events.length
750
+ state.rewindUndo = null
751
+ state.queued = []
752
+ state.expedited = []
753
+ state.sent = []
754
+ agents.restore(forked.events)
755
+ reDerive()
756
+ emit('session', state.session)
757
+ flash(`forked session as "${label}"`)
758
+ }
759
+
760
+ function rename(text) {
761
+ const automaticTitle = userEntries(state.derived)[0]?.text.trim().slice(0, 200)
762
+ persist(makeEvent('title', { text: text || null }))
763
+ ensureSession()
764
+ reDerive()
765
+ if (text) flash(`session renamed to "${text}"`)
766
+ else if (automaticTitle) flash(`session name reset to "${automaticTitle}"`)
767
+ else flash('session name reset')
768
+ }
769
+
770
+ function setColor(input = '') {
771
+ const names = Object.keys(SESSION_COLORS)
772
+ const values = Object.values(SESSION_COLORS)
773
+ let value
774
+ if (!input) {
775
+ value = values[(values.indexOf(state.derived.color) + 1) % values.length]
776
+ } else {
777
+ value = SESSION_COLORS[input.toLowerCase()] || (/^#[0-9a-fA-F]{6}$/.test(input) ? input : null)
778
+ if (!value) return flash(`usage: /color to cycle, or /color <${names.join('|')}|#hex>`)
779
+ }
780
+ persist(makeEvent('color', { value }))
781
+ ensureSession()
782
+ reDerive()
783
+ flash(`session color: ${names[values.indexOf(value)] || value}`)
784
+ }
785
+
786
+ function clear() {
787
+ if (state.busy) return flash('finish or interrupt the current turn first')
788
+ persist(makeEvent('clear', {}))
789
+ reDerive()
790
+ flash('conversation cleared')
791
+ }
792
+
793
+ function recallText(entry) {
794
+ return inputTextFromContent(entry.content ?? entry.text, {
795
+ attachments: state.attachments,
796
+ nextId: () => ++state.imageCount,
797
+ })
798
+ }
799
+
800
+ function attachImage(path) {
801
+ const mediaType = mediaTypeFor(path)
802
+ if (!mediaType) return null
803
+ const placeholder = `[Image #${++state.imageCount}]`
804
+ state.attachments.set(placeholder, { path, mediaType })
805
+ return placeholder
806
+ }
807
+
808
+ function attachProjectFile(file) {
809
+ const full = join(boot.cwd, file)
810
+ if (!mediaTypeFor(file) || !existsSync(full)) return null
811
+ return attachImage(full)
812
+ }
813
+
814
+ function detachImage(placeholder) {
815
+ state.attachments.delete(placeholder)
816
+ }
817
+
818
+ function restoreModelFromSession() {
819
+ const name = state.derived.model
820
+ const catalogMatch = boot.models.find((m) => m.name === name && m.available !== false)
821
+ const restored = name && (catalogMatch || adhocModel(name, boot.providers))
822
+ if (restored) {
823
+ state.model = restored
824
+ return
825
+ }
826
+ state.model = state.defaultModel
827
+ if (name) flash(`model ${name} unavailable, using ${state.defaultModel.name}`)
828
+ }
829
+
830
+ async function resume(meta) {
831
+ if (state.busy) return flash('finish or interrupt the current turn before switching sessions')
832
+ if (meta.header.root !== boot.root) return switchProject(meta)
833
+ try {
834
+ const { header, events } = await loadSession(meta.file)
835
+ state.session = openSession({ file: meta.file, header })
836
+ state.events = [...events]
837
+ state.persisted = events.length
838
+ state.rewindUndo = null
839
+ agents.restore(events)
840
+ reDerive()
841
+ restoreModelFromSession()
842
+ state.effort = state.derived.effort === undefined ? state.defaultEffort : state.derived.effort
843
+ state.sent = userEntries(state.derived).map((e) => ({ text: recallText(e), at: header.createdAt }))
844
+ changed()
845
+ emit('session', state.session)
846
+ emit('resumed', meta)
847
+ } catch (err) {
848
+ flash(`resume failed: ${errorText(err, 80)}`)
849
+ }
850
+ }
851
+
852
+ async function listProjects() {
853
+ const metas = await listSessions({ scope: 'everywhere', root: boot.root })
854
+ const byRoot = new Map()
855
+ for (const m of metas) {
856
+ const entry = byRoot.get(m.header.root)
857
+ if (entry) {
858
+ entry.sessions.push(m)
859
+ entry.count++
860
+ continue
861
+ }
862
+ byRoot.set(m.header.root, { root: m.header.root, latest: m, sessions: [m], count: 1, current: m.header.root === boot.root })
863
+ }
864
+ return [...byRoot.values()]
865
+ }
866
+
867
+ async function switchProject(meta) {
868
+ if (state.busy) return flash('finish or interrupt the current turn first')
869
+ try {
870
+ const previousMcp = boot.mcp
871
+ const next = await boot.rebuild(meta.header.root)
872
+ previousMcp.closeAll().catch(() => {})
873
+ process.chdir(next.cwd)
874
+ Object.assign(boot, next)
875
+ boot.git.retarget(next.root)
876
+ next.mcp.connectAll()
877
+ emit('mcp', next.mcp.list())
878
+ set({ queued: [], expedited: [] })
879
+ emit('project', boot)
880
+ await resume(meta)
881
+ flash(`switched to ${next.displayCwd}`)
882
+ } catch (err) {
883
+ flash(`switch failed: ${errorText(err, 80)}`)
884
+ }
885
+ }
886
+
887
+ function resolveModel(name) {
888
+ const exact = boot.models.find((m) => m.name === name)
889
+ if (exact) return exact
890
+ if (name.includes('/')) return adhocModel(name, boot.providers)
891
+ const scored = boot.models
892
+ .map((m) => [fuzzyScore(name, m.name), m])
893
+ .filter(([score]) => score >= 0)
894
+ .sort((a, b) => b[0] - a[0])
895
+ return scored[0]?.[1] || null
896
+ }
897
+
898
+ function switchModel(pick, { asDefault = false, note = '' } = {}) {
899
+ if (!pick) return false
900
+ if (pick.available === false) {
901
+ flash(`set ${pick.keyHint} in your environment to use ${pick.name}`)
902
+ return false
903
+ }
904
+ persist(makeEvent('model_switch', { from: state.model.name, to: pick.name }))
905
+ state.model = pick
906
+ if (asDefault) {
907
+ state.defaultModel = pick
908
+ writeConfig({ defaultModel: pick.name }).catch(() => {})
909
+ }
910
+ changed()
911
+ flash(`model set to ${pick.name}${note}${asDefault ? ' · saved as default' : ''}`)
912
+ return true
913
+ }
914
+
915
+ function switchModelByName(name) {
916
+ const pick = resolveModel(name)
917
+ if (!pick) return flash(`no available model matches "${name}"`)
918
+ const adhoc = !boot.models.includes(pick)
919
+ switchModel(pick, { note: adhoc ? ' (not in catalog, pricing unknown)' : '' })
920
+ }
921
+
922
+ const effortApplies = () => !!state.model.effort
923
+
924
+ function setEffort(next, { asDefault = false } = {}) {
925
+ persist(makeEvent('effort', { to: next }))
926
+ state.effort = next
927
+ if (asDefault) {
928
+ state.defaultEffort = next
929
+ writeConfig({ defaultEffort: next }).catch(() => {})
930
+ }
931
+ changed()
932
+ flash(`effort: ${next ?? 'default'}${asDefault ? ' · saved as default' : ''}`)
933
+ }
934
+
935
+ function setEffortByName(level) {
936
+ if (!effortApplies()) return flash(`${state.model.name} does not support effort control`)
937
+ if (level === 'default') return setEffort(null)
938
+ if (!EFFORT_LEVELS.some((l) => l.key === level)) return flash('usage: /effort <default|low|medium|high|max>')
939
+ setEffort(level)
940
+ }
941
+
942
+ function modelAvailable(name) {
943
+ return !!name && boot.models.some((m) => m.name === name && m.available !== false)
944
+ }
945
+
946
+ function sendParallel(task) {
947
+ if (!task) {
948
+ flash('usage: /parallel <task>')
949
+ return true
950
+ }
951
+ if (!modelAvailable(boot.researchModel)) return false
952
+ nextResearchAgentLimit = boot.researchAgentLimit
953
+ send(parallelPrompt(task, boot.researchAgentLimit))
954
+ return true
955
+ }
956
+
957
+ function sendDeliberate(decision) {
958
+ if (!decision) {
959
+ flash('usage: /deliberate <decision>')
960
+ return true
961
+ }
962
+ if (!modelAvailable(boot.deliberationModel)) return false
963
+ send(deliberatePrompt(decision))
964
+ return true
965
+ }
966
+
967
+ async function sendSkill(name) {
968
+ const body = await boot.skills.load(name)
969
+ if (!body) return flash(`could not load skill ${name}`)
970
+ persist(makeEvent('skill', { name, source: 'user' }))
971
+ send(`Follow these skill instructions now.\n\n${body}`)
972
+ }
973
+
974
+ async function sendCommand(name, args) {
975
+ const text = await boot.commands.load(name, args)
976
+ if (!text) return flash(`could not load command ${name}`)
977
+ send(text)
978
+ }
979
+
980
+ function sendInit(args) {
981
+ send(initPrompt(args))
982
+ }
983
+
984
+ function previewSteer(changes) {
985
+ if (!changes?.length) return state.derived
986
+ return deriveState([...state.events, { id: '__steer_preview__', at: Date.now(), type: 'steer', data: { changes } }])
987
+ }
988
+
989
+ function applySteer(changes) {
990
+ if (!changes?.length) return
991
+ persist(makeEvent('steer', { changes }))
992
+ reDerive()
993
+ }
994
+
995
+ async function rewind(target, mode) {
996
+ const current = state.derived
997
+ const { edits } = rewindStats(current, target.index)
998
+ const editsLabel = `${edits.length} ${edits.length === 1 ? 'edit' : 'edits'}`
999
+ let reverted = []
1000
+ let skipped = []
1001
+
1002
+ if (mode === 'both' || mode === 'code') {
1003
+ const result = await revertEdits(edits)
1004
+ reverted = result.reverted
1005
+ skipped = result.skipped
1006
+ }
1007
+
1008
+ let summaryText = null
1009
+ if (mode === 'summary') {
1010
+ const tail = current.transcript.slice(target.index)
1011
+ const text = tail.filter((m) => m.text).map((m) => `${m.kind}: ${m.text}`).join('\n')
1012
+ flash('summarizing...')
1013
+ const { auth } = await codexAuth()
1014
+ summaryText = await summarizeText({ text, modelName: state.model.name, auth }).catch(() => {
1015
+ flash('summary model call failed · kept a crude digest of the rewound turns')
1016
+ return tail.filter((m) => m.text).slice(0, 3).map((m) => m.text.split(/\s+/).slice(0, 6).join(' ')).join(' · ')
1017
+ })
1018
+ }
1019
+
1020
+ const event = makeEvent('rewind', { target: target.eventId, mode, summaryText, reverted, skipped })
1021
+ persist(event)
1022
+ state.rewindUndo = { rewindId: event.id, edits: edits.filter((e) => reverted.includes(e.callId)) }
1023
+ reDerive()
1024
+ if (mode !== 'code') emit('input', recallText(target))
1025
+
1026
+ const skippedNote = skipped.length ? ` · skipped ${skipped.length} drifted` : ''
1027
+ if (mode === 'code') flash(`reverted ${editsLabel}, conversation kept${skippedNote} · ctrl+z to undo`)
1028
+ else if (mode === 'summary') flash(`rewound and summarized${skippedNote} · ctrl+z to undo`)
1029
+ else if (mode === 'both') flash(`rewound, ${editsLabel} reverted${skippedNote} · ctrl+z to undo`)
1030
+ else flash(`rewound, file changes kept · ctrl+z to undo`)
1031
+ }
1032
+
1033
+ async function undoRewind() {
1034
+ const undo = state.rewindUndo
1035
+ if (!undo) return
1036
+ const { skipped } = await reapplyEdits(undo.edits)
1037
+ persist(makeEvent('rewind_undo', { rewindId: undo.rewindId }))
1038
+ state.rewindUndo = null
1039
+ reDerive()
1040
+ flash(skipped.length ? `rewind undone · ${skipped.length} file(s) drifted` : 'rewind undone')
1041
+ }
1042
+
1043
+ function costSummary() {
1044
+ const current = state.derived
1045
+ const entries = Object.entries(current.usageByModel)
1046
+ if (entries.length === 0) return null
1047
+ const costOf = (byModel) =>
1048
+ Object.entries(byModel).reduce((sum, [name, usage]) => sum + estimateCost(findModel(boot.models, name), usage), 0)
1049
+ return {
1050
+ spent: costOf(current.usageByModel),
1051
+ active: costOf(current.usageActiveByModel),
1052
+ promptTokens: current.usage.promptTokens,
1053
+ completionTokens: current.usage.completionTokens,
1054
+ }
1055
+ }
1056
+
1057
+ async function exportMarkdown(file = join(boot.cwd, `pico-export-${Date.now()}.md`)) {
1058
+ await writeFile(file, transcriptToMarkdown(state.derived.transcript, { title: `pico session · ${boot.cwd}` }))
1059
+ return file
1060
+ }
1061
+
1062
+ function baseToolset(extra = {}) {
1063
+ return createToolset({
1064
+ cwd: boot.cwd,
1065
+ tracker: boot.tracker,
1066
+ skills: boot.skills,
1067
+ shells: boot.shells,
1068
+ wakeups: boot.wakeups,
1069
+ memory: boot.memory,
1070
+ dredge: boot.dredge,
1071
+ ...extra,
1072
+ })
1073
+ }
1074
+
1075
+ async function describeTools() {
1076
+ const scan = await scanUserTools({ cwd: boot.cwd, root: boot.root }).catch(() => ({ tools: [], errors: [] }))
1077
+ const { tools: builtins } = baseToolset()
1078
+ return {
1079
+ mcpCount: boot.mcp.tools().length,
1080
+ rows: [
1081
+ ...builtins.map((t) => ({ name: t.name, desc: t.description.split('\n')[0], note: 'builtin' })),
1082
+ ...scan.tools.map((t) => ({ name: t.name, desc: t.description, note: `${t.source} · ${t._file.split('/').pop()}` })),
1083
+ ...scan.errors.map((e) => ({ name: e.file.split('/').pop(), desc: e.error, note: 'broken' })),
1084
+ ],
1085
+ }
1086
+ }
1087
+
1088
+ async function contextBreakdown() {
1089
+ const est = (text) => Math.round(String(text).length / 4)
1090
+ const current = state.derived
1091
+ const memoryIndexText = memoryIndex(await boot.memory.list().catch(() => []), boot.root)
1092
+ const skillList = boot.skills.list()
1093
+ const files = boot.startupContext.files
1094
+ const systemFull = buildSystemPrompt({ cwd: boot.cwd, contextFiles: files, skills: skillList, memoryIndexText })
1095
+ const systemBase = buildSystemPrompt({ cwd: boot.cwd, contextFiles: [], skills: [], memoryIndexText: '' })
1096
+ const userToolScan = await scanUserTools({ cwd: boot.cwd, root: boot.root }).catch(() => ({ tools: [], errors: [] }))
1097
+ const { tools } = baseToolset({ mcpTools: boot.mcp.tools(), userTools: userToolScan.tools })
1098
+ const toolTokens = est(JSON.stringify(tools))
1099
+
1100
+ const history = current.providerHistory
1101
+ const compacted = history[0]?.role === 'user'
1102
+ && current.historyEventIds[0] === null
1103
+ && String(history[0].content).startsWith('[system notification] The earlier portion')
1104
+ const summaryTokens = compacted ? est(history[0].content) : 0
1105
+ const messages = compacted ? history.slice(1) : history
1106
+ const messageTokens = est(JSON.stringify(messages))
1107
+
1108
+ const rows = [
1109
+ { name: 'system prompt', desc: 'identity, environment, tool guidance', tokens: est(systemBase) },
1110
+ { name: `tool schemas (${tools.length})`, desc: tools.map((t) => t.name).join(' · '), tokens: toolTokens },
1111
+ ]
1112
+ if (files.length) {
1113
+ rows.push({
1114
+ name: `project instructions (${files.length})`,
1115
+ desc: files.map((f) => f.path.replace(`${boot.root}/`, '')).join(', '),
1116
+ tokens: files.reduce((sum, f) => sum + est(f.content), 0),
1117
+ })
1118
+ }
1119
+ if (skillList.length) {
1120
+ rows.push({
1121
+ name: `skills index (${skillList.length})`,
1122
+ desc: skillList.map((s) => s.name).join(', '),
1123
+ tokens: est(skillList.map((s) => `- ${s.name}: ${s.description}`).join('\n')),
1124
+ })
1125
+ }
1126
+ if (memoryIndexText) rows.push({ name: 'memory index', desc: 'one line per saved memory', tokens: est(memoryIndexText) })
1127
+ if (compacted) rows.push({ name: 'compaction summary', desc: 'stands in for everything before the last compact', tokens: summaryTokens })
1128
+ rows.push({
1129
+ name: `conversation (${messages.length} messages)`,
1130
+ desc: compacted ? 'kept verbatim since the last compact' : 'every message this session',
1131
+ tokens: messageTokens,
1132
+ })
1133
+
1134
+ const measured = current.lastPromptTokens && current.lastPromptModel === state.model.name ? current.lastPromptTokens : null
1135
+
1136
+ const segments = [
1137
+ { label: 'system', tokens: est(systemBase) },
1138
+ { label: 'tools', tokens: toolTokens },
1139
+ { label: 'project', tokens: Math.max(0, est(systemFull) - est(systemBase)) },
1140
+ ...(summaryTokens ? [{ label: 'summary', tokens: summaryTokens }] : []),
1141
+ { label: 'conversation', tokens: messageTokens },
1142
+ ].filter((segment) => segment.tokens > 0)
1143
+ .map((segment, i) => ({ ...segment, color: CONTEXT_COLORS[i] }))
1144
+
1145
+ return { model: state.model.name, rows, measured, segments }
1146
+ }
1147
+
1148
+ async function connectProvider() {
1149
+ const { email } = await connectOpenAI()
1150
+ boot.providers = [...new Set([...boot.providers, 'codex'])]
1151
+ const creds = await openaiCredentials().catch(() => null)
1152
+ const codex = (await loadCodexModels(creds)).map((m) => ({ ...m, available: true, keyHint: '/connect' }))
1153
+ boot.models = [...boot.models.filter((m) => m.provider !== 'codex'), ...codex]
1154
+ reDerive()
1155
+ return { email, count: codex.length }
1156
+ }
1157
+
1158
+ async function disconnectProvider() {
1159
+ await disconnectOpenAI().catch(() => {})
1160
+ boot.providers = boot.providers.filter((p) => p !== 'codex')
1161
+ boot.models = boot.models.map((m) => (m.provider === 'codex' ? { ...m, available: false } : m))
1162
+ if (state.model.provider === 'codex') state.model = state.defaultModel
1163
+ changed()
1164
+ }
1165
+
1166
+ function activity() {
1167
+ const rows = [...agents.list(), ...deliberationsFromEvents(state.events)]
1168
+ return rows.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
1169
+ }
1170
+
1171
+ function cancelAgent(id) {
1172
+ agents.cancel(id)
1173
+ }
1174
+
1175
+ function dismissAgent(id) {
1176
+ if (!agents.dismiss(id)) return false
1177
+ persist(makeEvent('agent_dismiss', { agentId: id }))
1178
+ return true
1179
+ }
1180
+
1181
+ function dismissDeliberation(deliberationId) {
1182
+ persist(makeEvent('deliberation_dismiss', { deliberationId }))
1183
+ bumpActivity()
1184
+ }
1185
+
1186
+ function shellRows() {
1187
+ return boot.shells.list()
1188
+ .filter((shell) => shell.sessionId === state.session?.id)
1189
+ .sort((a, b) => Number(b.id) - Number(a.id))
1190
+ }
1191
+
1192
+ function cancelWakeup(wakeup) {
1193
+ boot.wakeups.cancel(wakeup.id)
1194
+ flash(`cancelled wake-up ${wakeup.id}`)
1195
+ noteSystem(
1196
+ `[system notification] the user cancelled scheduled wake-up ${wakeup.id} (note was: ${wakeup.note.replace(/\n/g, ' ')}). This was deliberate; do not reschedule it unless asked.`,
1197
+ { wake: false },
1198
+ )
1199
+ }
1200
+
1201
+ async function shutdown() {
1202
+ await state.session?.flush()
1203
+ boot.shells.killAll()
1204
+ boot.mcp.terminateAll()
1205
+ }
1206
+
1207
+ return {
1208
+ state,
1209
+ boot,
1210
+ on,
1211
+ agents,
1212
+ send,
1213
+ interrupt,
1214
+ compact,
1215
+ answerQuestion,
1216
+ cancelQuestion,
1217
+ recallPending,
1218
+ expediteQueued,
1219
+ hold,
1220
+ noteSystem,
1221
+ newSession,
1222
+ deleteCurrentSession,
1223
+ fork,
1224
+ rename,
1225
+ setColor,
1226
+ clear,
1227
+ resume,
1228
+ listProjects,
1229
+ switchProject,
1230
+ resolveModel,
1231
+ switchModel,
1232
+ switchModelByName,
1233
+ effortApplies,
1234
+ setEffort,
1235
+ setEffortByName,
1236
+ sendParallel,
1237
+ sendDeliberate,
1238
+ sendSkill,
1239
+ sendCommand,
1240
+ sendInit,
1241
+ previewSteer,
1242
+ applySteer,
1243
+ rewind,
1244
+ undoRewind,
1245
+ recallText,
1246
+ attachImage,
1247
+ attachProjectFile,
1248
+ detachImage,
1249
+ costSummary,
1250
+ exportMarkdown,
1251
+ describeTools,
1252
+ contextBreakdown,
1253
+ connectProvider,
1254
+ disconnectProvider,
1255
+ activity,
1256
+ cancelAgent,
1257
+ dismissAgent,
1258
+ dismissDeliberation,
1259
+ shellRows,
1260
+ cancelWakeup,
1261
+ shutdown,
1262
+ }
1263
+ }