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.
- package/README.md +15 -0
- package/package.json +33 -0
- package/src/agent-transcript.js +80 -0
- package/src/agent.js +170 -0
- package/src/agents.js +213 -0
- package/src/attachments.js +141 -0
- package/src/boot.js +28 -0
- package/src/catalog-snapshot.json +1 -0
- package/src/catalog.js +86 -0
- package/src/codex-models.js +56 -0
- package/src/commands.js +61 -0
- package/src/compaction.js +82 -0
- package/src/completion.js +21 -0
- package/src/config.js +32 -0
- package/src/context.js +82 -0
- package/src/controller.js +1263 -0
- package/src/conversation-search.js +101 -0
- package/src/deliberation-history.js +65 -0
- package/src/deliberation.js +61 -0
- package/src/derive.js +307 -0
- package/src/events.js +54 -0
- package/src/export.js +16 -0
- package/src/files.js +39 -0
- package/src/format.js +6 -0
- package/src/fuzzy.js +41 -0
- package/src/git.js +156 -0
- package/src/history.js +51 -0
- package/src/init.js +25 -0
- package/src/keys.js +26 -0
- package/src/mcp.js +280 -0
- package/src/memory.js +120 -0
- package/src/models.js +14 -0
- package/src/openai-auth.js +204 -0
- package/src/paths.js +67 -0
- package/src/reversible-edit.js +79 -0
- package/src/rewind.js +84 -0
- package/src/session-index.js +264 -0
- package/src/session-lock.js +27 -0
- package/src/session.js +160 -0
- package/src/shells.js +166 -0
- package/src/skills.js +164 -0
- package/src/steer.js +129 -0
- package/src/system-prompt.js +49 -0
- package/src/terminal-theme.js +49 -0
- package/src/tools/bash.js +184 -0
- package/src/tools/diff.js +18 -0
- package/src/tools/edit.js +95 -0
- package/src/tools/glob.js +43 -0
- package/src/tools/grep.js +59 -0
- package/src/tools/index.js +296 -0
- package/src/tools/read.js +49 -0
- package/src/tools/recorder.js +74 -0
- package/src/tools/web.js +84 -0
- package/src/tools/write.js +48 -0
- package/src/update.js +82 -0
- package/src/user-tools.js +59 -0
- package/src/wakeups.js +40 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { streamEvents } from './events.js'
|
|
5
|
+
import { withIndexLock, withSessionLock } from './session-lock.js'
|
|
6
|
+
|
|
7
|
+
const INDEX_VERSION = 4
|
|
8
|
+
const INDEX_FILE = 'index.json'
|
|
9
|
+
const DIRTY_SUFFIX = '.dirty'
|
|
10
|
+
const DELETED_SUFFIX = '.jsonl.deleted'
|
|
11
|
+
const FLUSH_DELAY_MS = 1000
|
|
12
|
+
|
|
13
|
+
const dirtyFiles = new Set()
|
|
14
|
+
const pendingEvents = new Map()
|
|
15
|
+
let flushTimer = null
|
|
16
|
+
let indexWrites = Promise.resolve()
|
|
17
|
+
|
|
18
|
+
function messageText(content) {
|
|
19
|
+
if (typeof content === 'string') return content
|
|
20
|
+
if (!Array.isArray(content)) return ''
|
|
21
|
+
return content.filter((part) => part.type === 'text').map((part) => part.text).join('')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function applyEvent(meta, event) {
|
|
25
|
+
const next = { ...meta }
|
|
26
|
+
if (event.type === 'message' && ['user', 'assistant'].includes(event.data?.message?.role)) {
|
|
27
|
+
const { role, content } = event.data.message
|
|
28
|
+
const text = messageText(content).trim()
|
|
29
|
+
if (text) {
|
|
30
|
+
next.previewMessageCount++
|
|
31
|
+
next.preview = [...next.preview, { role, text }]
|
|
32
|
+
const userIndexes = next.preview.flatMap((message, index) => message.role === 'user' ? [index] : [])
|
|
33
|
+
if (userIndexes.length > 2) next.preview = next.preview.slice(userIndexes.at(-2))
|
|
34
|
+
}
|
|
35
|
+
if (role !== 'user') return next
|
|
36
|
+
next.turns++
|
|
37
|
+
if (!next.automaticTitle) {
|
|
38
|
+
next.automaticTitle = text.slice(0, 200) || null
|
|
39
|
+
if (!next.customTitle) next.title = next.automaticTitle || next.title
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (event.type === 'title') {
|
|
43
|
+
next.customTitle = event.data.text
|
|
44
|
+
next.title = event.data.text || next.automaticTitle || 'Untitled session'
|
|
45
|
+
}
|
|
46
|
+
if (event.type === 'color') next.color = event.data.value
|
|
47
|
+
return next
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function metadataFromFile(file) {
|
|
51
|
+
let meta = null
|
|
52
|
+
for await (const event of streamEvents(file)) {
|
|
53
|
+
if (!meta) {
|
|
54
|
+
if (event.type !== 'session') throw new Error('invalid session')
|
|
55
|
+
meta = {
|
|
56
|
+
file,
|
|
57
|
+
header: event,
|
|
58
|
+
title: 'Untitled session',
|
|
59
|
+
automaticTitle: null,
|
|
60
|
+
customTitle: undefined,
|
|
61
|
+
color: null,
|
|
62
|
+
turns: 0,
|
|
63
|
+
preview: [],
|
|
64
|
+
previewMessageCount: 0,
|
|
65
|
+
at: 0,
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
meta = applyEvent(meta, event)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!meta) throw new Error('invalid session')
|
|
72
|
+
meta.at = (await stat(file)).mtimeMs
|
|
73
|
+
return meta
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function indexPath(dir) {
|
|
77
|
+
return join(dir, INDEX_FILE)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readIndex(dir) {
|
|
81
|
+
const parsed = JSON.parse(await readFile(indexPath(dir), 'utf-8'))
|
|
82
|
+
if (parsed.version !== INDEX_VERSION || !Array.isArray(parsed.sessions)) throw new Error('invalid session index')
|
|
83
|
+
return parsed.sessions
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeIndex(dir, sessions) {
|
|
87
|
+
await mkdir(dir, { recursive: true })
|
|
88
|
+
const file = indexPath(dir)
|
|
89
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`
|
|
90
|
+
try {
|
|
91
|
+
await writeFile(temporary, JSON.stringify({ version: INDEX_VERSION, sessions }) + '\n')
|
|
92
|
+
await rename(temporary, file)
|
|
93
|
+
} finally {
|
|
94
|
+
await rm(temporary, { force: true })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function dirtyPath(file) {
|
|
99
|
+
return `${file}${DIRTY_SUFFIX}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fileFromDirtyName(dir, name) {
|
|
103
|
+
return join(dir, name.slice(0, -DIRTY_SUFFIX.length))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function enqueueIndexWrite(task) {
|
|
107
|
+
const result = indexWrites.then(task)
|
|
108
|
+
indexWrites = result.catch(() => {})
|
|
109
|
+
return result
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function rebuildIndex(dir, names) {
|
|
113
|
+
if (!names) {
|
|
114
|
+
try {
|
|
115
|
+
names = await readdir(dir)
|
|
116
|
+
} catch {
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const files = names.filter((name) => name.endsWith('.jsonl')).map((name) => join(dir, name))
|
|
121
|
+
const sessions = (await Promise.all(files.map((file) => metadataFromFile(file).catch(() => null)))).filter(Boolean)
|
|
122
|
+
await writeIndex(dir, sessions)
|
|
123
|
+
return sessions
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function refreshFiles(dir, sessions, files, eventsByFile = new Map()) {
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
try {
|
|
129
|
+
const at = sessions.findIndex((session) => session.file === file)
|
|
130
|
+
const events = eventsByFile.get(file)
|
|
131
|
+
let row
|
|
132
|
+
if (at >= 0 && events?.length) {
|
|
133
|
+
const { mtimeMs } = await stat(file)
|
|
134
|
+
row = events.reduce(applyEvent, { ...sessions[at], at: mtimeMs })
|
|
135
|
+
} else {
|
|
136
|
+
row = await withSessionLock(file, () => metadataFromFile(file))
|
|
137
|
+
}
|
|
138
|
+
if (at < 0) sessions.push(row)
|
|
139
|
+
else sessions[at] = row
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err.code === 'ENOENT') sessions = sessions.filter((session) => session.file !== file)
|
|
142
|
+
else throw err
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await writeIndex(dir, sessions)
|
|
146
|
+
await Promise.all(files.map((file) => rm(dirtyPath(file), { force: true })))
|
|
147
|
+
return sessions
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function currentSessions(dir) {
|
|
151
|
+
const names = await readdir(dir)
|
|
152
|
+
let sessions
|
|
153
|
+
try {
|
|
154
|
+
sessions = await readIndex(dir)
|
|
155
|
+
} catch {
|
|
156
|
+
return rebuildIndex(dir, names)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const dirty = names.filter((name) => name.endsWith(`.jsonl${DIRTY_SUFFIX}`)).map((name) => fileFromDirtyName(dir, name))
|
|
160
|
+
if (dirty.length > 0) {
|
|
161
|
+
const files = [...new Set(dirty)]
|
|
162
|
+
sessions = await refreshFiles(dir, sessions, files)
|
|
163
|
+
files.forEach((file) => pendingEvents.delete(file))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const deleted = new Set(names.filter((name) => name.endsWith(DELETED_SUFFIX)).map((name) => join(dir, name.slice(0, -'.deleted'.length))))
|
|
167
|
+
if (deleted.size > 0) {
|
|
168
|
+
const filtered = sessions.filter((session) => !deleted.has(session.file))
|
|
169
|
+
if (filtered.length !== sessions.length) await writeIndex(dir, filtered)
|
|
170
|
+
sessions = filtered
|
|
171
|
+
}
|
|
172
|
+
return sessions
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function indexedSessions(dir) {
|
|
176
|
+
return enqueueIndexWrite(() => withIndexLock(dir, () => currentSessions(dir))).catch(async (err) => {
|
|
177
|
+
if (err.code === 'ENOENT') return []
|
|
178
|
+
throw err
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function markSessionIndexDirty(file, onError = () => {}) {
|
|
183
|
+
dirtyFiles.add(file)
|
|
184
|
+
try {
|
|
185
|
+
writeFileSync(dirtyPath(file), '')
|
|
186
|
+
} catch (err) {
|
|
187
|
+
onError(err)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function scheduleFlush() {
|
|
192
|
+
if (flushTimer) return
|
|
193
|
+
flushTimer = setTimeout(() => {
|
|
194
|
+
flushTimer = null
|
|
195
|
+
flushSessionIndex().catch(() => {})
|
|
196
|
+
}, FLUSH_DELAY_MS)
|
|
197
|
+
flushTimer.unref?.()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function scheduleSessionIndexUpdate(file, event) {
|
|
201
|
+
dirtyFiles.add(file)
|
|
202
|
+
if (event) {
|
|
203
|
+
const events = pendingEvents.get(file) || []
|
|
204
|
+
events.push(event)
|
|
205
|
+
pendingEvents.set(file, events)
|
|
206
|
+
}
|
|
207
|
+
scheduleFlush()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function flushSessionIndex() {
|
|
211
|
+
if (dirtyFiles.size === 0) return indexWrites
|
|
212
|
+
const files = [...dirtyFiles]
|
|
213
|
+
const eventsByFile = new Map(files.map((file) => [file, pendingEvents.get(file)]))
|
|
214
|
+
dirtyFiles.clear()
|
|
215
|
+
files.forEach((file) => pendingEvents.delete(file))
|
|
216
|
+
clearTimeout(flushTimer)
|
|
217
|
+
flushTimer = null
|
|
218
|
+
const byDir = Map.groupBy(files, dirname)
|
|
219
|
+
return enqueueIndexWrite(async () => {
|
|
220
|
+
const failed = []
|
|
221
|
+
await Promise.all([...byDir].map(async ([dir, projectFiles]) => {
|
|
222
|
+
try {
|
|
223
|
+
await withIndexLock(dir, async () => {
|
|
224
|
+
let sessions
|
|
225
|
+
let rebuilt = false
|
|
226
|
+
try {
|
|
227
|
+
sessions = await readIndex(dir)
|
|
228
|
+
} catch {
|
|
229
|
+
sessions = await rebuildIndex(dir)
|
|
230
|
+
rebuilt = true
|
|
231
|
+
}
|
|
232
|
+
await refreshFiles(dir, sessions, projectFiles, rebuilt ? new Map() : eventsByFile)
|
|
233
|
+
})
|
|
234
|
+
} catch (err) {
|
|
235
|
+
failed.push(...projectFiles)
|
|
236
|
+
}
|
|
237
|
+
}))
|
|
238
|
+
if (failed.length > 0) {
|
|
239
|
+
failed.forEach((file) => {
|
|
240
|
+
dirtyFiles.add(file)
|
|
241
|
+
const events = eventsByFile.get(file)
|
|
242
|
+
if (events?.length) pendingEvents.set(file, [...events, ...(pendingEvents.get(file) || [])])
|
|
243
|
+
})
|
|
244
|
+
scheduleFlush()
|
|
245
|
+
throw new Error('failed to update session index')
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function removeFromSessionIndex(file) {
|
|
251
|
+
dirtyFiles.delete(file)
|
|
252
|
+
pendingEvents.delete(file)
|
|
253
|
+
const dir = dirname(file)
|
|
254
|
+
return enqueueIndexWrite(() => withIndexLock(dir, async () => {
|
|
255
|
+
await rm(dirtyPath(file), { force: true })
|
|
256
|
+
let sessions
|
|
257
|
+
try {
|
|
258
|
+
sessions = await readIndex(dir)
|
|
259
|
+
} catch {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
await writeIndex(dir, sessions.filter((session) => session.file !== file))
|
|
263
|
+
}))
|
|
264
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import lockfile from 'proper-lockfile'
|
|
4
|
+
|
|
5
|
+
const lockOptions = {
|
|
6
|
+
realpath: false,
|
|
7
|
+
retries: { retries: 100, minTimeout: 10, maxTimeout: 100 },
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function withLock(target, lockfilePath, task) {
|
|
11
|
+
const release = await lockfile.lock(target, { ...lockOptions, lockfilePath })
|
|
12
|
+
try {
|
|
13
|
+
return await task()
|
|
14
|
+
} finally {
|
|
15
|
+
await release()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function withSessionLock(file, task) {
|
|
20
|
+
await mkdir(dirname(file), { recursive: true })
|
|
21
|
+
return withLock(file, `${file}.lock`, task)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function withIndexLock(dir, task) {
|
|
25
|
+
await mkdir(dir, { recursive: true })
|
|
26
|
+
return withLock(dir, join(dir, '.index.lock'), task)
|
|
27
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { access, appendFile, open, readdir, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { picoHome, sessionsDir, ensureDir, projectDir } from './paths.js'
|
|
4
|
+
import { withSessionLock } from './session-lock.js'
|
|
5
|
+
import { flushSessionIndex, indexedSessions, markSessionIndexDirty, removeFromSessionIndex, scheduleSessionIndexUpdate } from './session-index.js'
|
|
6
|
+
import { makeEvent, makeHeader, readEvents, serializeLine } from './events.js'
|
|
7
|
+
|
|
8
|
+
const appendQueues = new Map()
|
|
9
|
+
const writeFailures = new Map()
|
|
10
|
+
const creationFailures = new Map()
|
|
11
|
+
const deletedSessions = new Set()
|
|
12
|
+
|
|
13
|
+
let onWriteError = () => {}
|
|
14
|
+
|
|
15
|
+
// appends are chained so they cannot interleave. a rejected link would poison
|
|
16
|
+
// every later append on that file, so failures are reported and absorbed here
|
|
17
|
+
// instead of propagating: one bad write must not silently end the log
|
|
18
|
+
export function onSessionWriteError(handler) {
|
|
19
|
+
onWriteError = handler || (() => {})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function reportWriteError(file, err) {
|
|
23
|
+
try {
|
|
24
|
+
onWriteError(err, file)
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deletedPath(file) {
|
|
29
|
+
return `${file}.deleted`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function repairIncompleteLine(file) {
|
|
33
|
+
let handle
|
|
34
|
+
try {
|
|
35
|
+
handle = await open(file, 'r+')
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err.code === 'ENOENT') return
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const { size } = await handle.stat()
|
|
42
|
+
if (size === 0) return
|
|
43
|
+
const last = Buffer.alloc(1)
|
|
44
|
+
await handle.read(last, 0, 1, size - 1)
|
|
45
|
+
if (last[0] !== 0x0a) await handle.write('\n', size)
|
|
46
|
+
} finally {
|
|
47
|
+
await handle.close()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function appendSessionEvent(file, event) {
|
|
52
|
+
if (deletedSessions.has(file)) return Promise.reject(new Error(`session has been deleted: ${file}`))
|
|
53
|
+
markSessionIndexDirty(file, (err) => reportWriteError(file, err))
|
|
54
|
+
const queued = (appendQueues.get(file) || Promise.resolve()).then(async () => {
|
|
55
|
+
try {
|
|
56
|
+
await withSessionLock(file, async () => {
|
|
57
|
+
if (deletedSessions.has(file)) throw new Error(`session has been deleted: ${file}`)
|
|
58
|
+
try {
|
|
59
|
+
await access(deletedPath(file))
|
|
60
|
+
throw new Error(`session has been deleted: ${file}`)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err.code !== 'ENOENT') throw err
|
|
63
|
+
}
|
|
64
|
+
if (event.type === 'session') {
|
|
65
|
+
await writeFile(file, serializeLine(event), { flag: 'wx' })
|
|
66
|
+
} else {
|
|
67
|
+
await repairIncompleteLine(file)
|
|
68
|
+
await appendFile(file, serializeLine(event))
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
} catch (err) {
|
|
72
|
+
writeFailures.set(file, err)
|
|
73
|
+
if (event.type === 'session') creationFailures.set(file, err)
|
|
74
|
+
reportWriteError(file, err)
|
|
75
|
+
scheduleSessionIndexUpdate(file)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
writeFailures.delete(file)
|
|
79
|
+
scheduleSessionIndexUpdate(file, event)
|
|
80
|
+
})
|
|
81
|
+
appendQueues.set(file, queued)
|
|
82
|
+
return queued
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createSession({ cwd, root, forkedFrom }) {
|
|
86
|
+
const header = makeHeader({ cwd, root, forkedFrom })
|
|
87
|
+
const file = join(ensureDir(sessionsDir(root)), `${header.id}.jsonl`)
|
|
88
|
+
const session = openSession({ file, header })
|
|
89
|
+
session.append(header)
|
|
90
|
+
return session
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function forkSession({ source, cwd, root, events, label }) {
|
|
94
|
+
await source?.flush()
|
|
95
|
+
const session = createSession({ cwd, root, forkedFrom: source?.id })
|
|
96
|
+
for (const event of events) session.append(event)
|
|
97
|
+
const title = makeEvent('title', { text: label })
|
|
98
|
+
session.append(title)
|
|
99
|
+
await session.flush()
|
|
100
|
+
return { session, events: [...events, title] }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function openSession({ file, header }) {
|
|
104
|
+
return {
|
|
105
|
+
id: header.id,
|
|
106
|
+
file,
|
|
107
|
+
header,
|
|
108
|
+
append(event) {
|
|
109
|
+
return appendSessionEvent(file, event)
|
|
110
|
+
},
|
|
111
|
+
async flush() {
|
|
112
|
+
await (appendQueues.get(file) || Promise.resolve())
|
|
113
|
+
const failure = creationFailures.get(file) || writeFailures.get(file)
|
|
114
|
+
if (failure) throw failure
|
|
115
|
+
return flushSessionIndex()
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function loadSession(file) {
|
|
121
|
+
const events = await readEvents(file)
|
|
122
|
+
const header = events[0]?.type === 'session' ? events.shift() : null
|
|
123
|
+
if (!header) throw new Error(`not a pico session file: ${file}`)
|
|
124
|
+
return { header, events }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function deleteSession(file) {
|
|
128
|
+
const sessionId = basename(file, '.jsonl')
|
|
129
|
+
const project = dirname(dirname(file))
|
|
130
|
+
deletedSessions.add(file)
|
|
131
|
+
await (appendQueues.get(file) || Promise.resolve())
|
|
132
|
+
appendQueues.delete(file)
|
|
133
|
+
writeFailures.delete(file)
|
|
134
|
+
creationFailures.delete(file)
|
|
135
|
+
await withSessionLock(file, async () => {
|
|
136
|
+
await writeFile(deletedPath(file), '')
|
|
137
|
+
await rm(file, { force: true })
|
|
138
|
+
})
|
|
139
|
+
await removeFromSessionIndex(file)
|
|
140
|
+
await rm(join(project, 'scratchpads', sessionId), { recursive: true, force: true })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function deleteProjectData(root) {
|
|
144
|
+
return rm(projectDir(root), { recursive: true, force: true })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function listSessions({ scope, root }) {
|
|
148
|
+
let sessions
|
|
149
|
+
if (scope === 'everywhere') {
|
|
150
|
+
const projectsDir = join(picoHome(), 'projects')
|
|
151
|
+
let projects = []
|
|
152
|
+
try {
|
|
153
|
+
projects = await readdir(projectsDir)
|
|
154
|
+
} catch {}
|
|
155
|
+
sessions = (await Promise.all(projects.map((project) => indexedSessions(join(projectsDir, project, 'sessions'))))).flat()
|
|
156
|
+
} else {
|
|
157
|
+
sessions = await indexedSessions(sessionsDir(root))
|
|
158
|
+
}
|
|
159
|
+
return sessions.filter((session) => session.title !== 'Untitled session').sort((a, b) => b.at - a.at)
|
|
160
|
+
}
|
package/src/shells.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const MAX_LINES = 2000
|
|
4
|
+
|
|
5
|
+
function killTree(child, signal) {
|
|
6
|
+
if (!child.pid) return false
|
|
7
|
+
if (process.platform !== 'win32') {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(-child.pid, signal)
|
|
10
|
+
return true
|
|
11
|
+
} catch {}
|
|
12
|
+
}
|
|
13
|
+
return child.kill(signal)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createShellManager({ onChange = () => {}, onExit = () => {} } = {}) {
|
|
17
|
+
const shells = new Map()
|
|
18
|
+
let nextId = 1
|
|
19
|
+
|
|
20
|
+
function publicView(shell) {
|
|
21
|
+
return {
|
|
22
|
+
id: shell.id,
|
|
23
|
+
command: shell.command,
|
|
24
|
+
description: shell.description,
|
|
25
|
+
sessionId: shell.sessionId,
|
|
26
|
+
sessionFile: shell.sessionFile,
|
|
27
|
+
cwd: shell.cwd,
|
|
28
|
+
status: shell.status,
|
|
29
|
+
exitCode: shell.exitCode,
|
|
30
|
+
startedAt: shell.startedAt,
|
|
31
|
+
endedAt: shell.endedAt,
|
|
32
|
+
killedBy: shell.killedBy,
|
|
33
|
+
lastLine: shell.lines.at(-1) || '',
|
|
34
|
+
lineCount: shell.lines.length,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function track(child, command, { cwd, description, sessionId, sessionFile, hidden = false } = {}) {
|
|
39
|
+
const id = String(nextId++)
|
|
40
|
+
const shell = {
|
|
41
|
+
id,
|
|
42
|
+
command,
|
|
43
|
+
description: description || null,
|
|
44
|
+
sessionId: sessionId || null,
|
|
45
|
+
sessionFile: sessionFile || null,
|
|
46
|
+
cwd: cwd || process.cwd(),
|
|
47
|
+
child,
|
|
48
|
+
status: 'running',
|
|
49
|
+
exitCode: null,
|
|
50
|
+
startedAt: Date.now(),
|
|
51
|
+
endedAt: null,
|
|
52
|
+
killedBy: null,
|
|
53
|
+
lines: [],
|
|
54
|
+
partial: '',
|
|
55
|
+
hidden,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const push = (chunk) => {
|
|
59
|
+
const text = shell.partial + chunk.toString()
|
|
60
|
+
const lines = text.split('\n')
|
|
61
|
+
shell.partial = lines.pop() || ''
|
|
62
|
+
shell.lines.push(...lines)
|
|
63
|
+
if (shell.lines.length > MAX_LINES) shell.lines.splice(0, shell.lines.length - MAX_LINES)
|
|
64
|
+
if (!shell.hidden) onChange()
|
|
65
|
+
}
|
|
66
|
+
child.stdout.on('data', push)
|
|
67
|
+
child.stderr.on('data', push)
|
|
68
|
+
child.on('error', (err) => {
|
|
69
|
+
push(`[failed to start: ${err.message}]\n`)
|
|
70
|
+
})
|
|
71
|
+
child.on('exit', (code, signal) => {
|
|
72
|
+
if (shell.partial) {
|
|
73
|
+
shell.lines.push(shell.partial)
|
|
74
|
+
shell.partial = ''
|
|
75
|
+
}
|
|
76
|
+
shell.status = 'exited'
|
|
77
|
+
shell.exitCode = code ?? (signal ? 1 : 0)
|
|
78
|
+
shell.endedAt = Date.now()
|
|
79
|
+
if (!shell.hidden) onChange()
|
|
80
|
+
if (!shell.hidden) onExit(publicView(shell))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
shells.set(id, shell)
|
|
84
|
+
if (!shell.hidden) onChange()
|
|
85
|
+
return { id }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function start(command, options = {}) {
|
|
89
|
+
const child = spawn(command, {
|
|
90
|
+
shell: true,
|
|
91
|
+
cwd: options.cwd || process.cwd(),
|
|
92
|
+
env: { ...process.env, ...options.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
93
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
94
|
+
detached: process.platform !== 'win32',
|
|
95
|
+
})
|
|
96
|
+
return track(child, command, options)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function get(id) {
|
|
100
|
+
const shell = shells.get(String(id))
|
|
101
|
+
if (!shell) throw new Error(`no shell with id ${id}; it is gone and cannot be inspected (exited shells disappear when dismissed)`)
|
|
102
|
+
return shell
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
start,
|
|
107
|
+
track,
|
|
108
|
+
reveal(id) {
|
|
109
|
+
const shell = get(id)
|
|
110
|
+
if (shell.hidden) {
|
|
111
|
+
shell.hidden = false
|
|
112
|
+
onChange()
|
|
113
|
+
if (shell.status === 'exited') onExit(publicView(shell))
|
|
114
|
+
}
|
|
115
|
+
return publicView(shell)
|
|
116
|
+
},
|
|
117
|
+
discardHidden(id) {
|
|
118
|
+
const shell = shells.get(String(id))
|
|
119
|
+
if (shell?.hidden) shells.delete(String(id))
|
|
120
|
+
},
|
|
121
|
+
output(id, { tail = 100 } = {}) {
|
|
122
|
+
const shell = get(id)
|
|
123
|
+
return {
|
|
124
|
+
id: shell.id,
|
|
125
|
+
command: shell.command,
|
|
126
|
+
status: shell.status,
|
|
127
|
+
exitCode: shell.exitCode,
|
|
128
|
+
output: shell.lines.slice(-tail).join('\n'),
|
|
129
|
+
totalLines: shell.lines.length,
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
kill(id, by = 'model') {
|
|
133
|
+
const shell = get(id)
|
|
134
|
+
if (shell.status !== 'running') return { id: shell.id, status: shell.status, exitCode: shell.exitCode }
|
|
135
|
+
shell.killedBy = by
|
|
136
|
+
killTree(shell.child, 'SIGTERM')
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
if (shell.status === 'running') killTree(shell.child, 'SIGKILL')
|
|
139
|
+
}, 3000).unref()
|
|
140
|
+
return { id: shell.id, status: 'killing' }
|
|
141
|
+
},
|
|
142
|
+
dismiss(id) {
|
|
143
|
+
const shell = shells.get(String(id))
|
|
144
|
+
if (shell && shell.status === 'exited') {
|
|
145
|
+
shells.delete(String(id))
|
|
146
|
+
if (!shell.hidden) onChange()
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
list() {
|
|
150
|
+
return [...shells.values()].filter((shell) => !shell.hidden).map(publicView)
|
|
151
|
+
},
|
|
152
|
+
running() {
|
|
153
|
+
return [...shells.values()].filter((s) => s.status === 'running').length
|
|
154
|
+
},
|
|
155
|
+
killAll() {
|
|
156
|
+
for (const shell of shells.values()) {
|
|
157
|
+
if (shell.status === 'running') {
|
|
158
|
+
shell.killedBy = 'user'
|
|
159
|
+
try {
|
|
160
|
+
killTree(shell.child, 'SIGKILL')
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|