picocode-core 0.9.199 → 0.9.200
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/package.json +1 -1
- package/src/daemon-patch.js +35 -0
- package/src/daemon-runtime.js +34 -14
- package/src/daemon-wire.js +35 -2
- package/src/remote-controller.js +35 -6
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
2
|
+
|
|
3
|
+
export function diffSnapshot(previous, next, path = [], patches = []) {
|
|
4
|
+
if (isDeepStrictEqual(previous, next)) return patches
|
|
5
|
+
if (typeof previous === 'string' && typeof next === 'string' && next.startsWith(previous)) {
|
|
6
|
+
patches.push({ op: 'appendText', path, value: next.slice(previous.length) })
|
|
7
|
+
} else if (Array.isArray(previous) && Array.isArray(next)) {
|
|
8
|
+
const common = Math.min(previous.length, next.length)
|
|
9
|
+
for (let i = 0; i < common; i++) diffSnapshot(previous[i], next[i], [...path, i], patches)
|
|
10
|
+
if (next.length < previous.length) patches.push({ op: 'truncate', path, length: next.length })
|
|
11
|
+
if (next.length > previous.length) patches.push({ op: 'append', path, value: next.slice(previous.length) })
|
|
12
|
+
} else if (previous && next && Object.getPrototypeOf(previous) === Object.prototype && Object.getPrototypeOf(next) === Object.prototype) {
|
|
13
|
+
for (const key of Object.keys(previous)) if (!(key in next)) patches.push({ op: 'delete', path: [...path, key] })
|
|
14
|
+
for (const key of Object.keys(next)) diffSnapshot(previous[key], next[key], [...path, key], patches)
|
|
15
|
+
} else patches.push({ op: 'set', path, value: next })
|
|
16
|
+
return patches
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function applyPatches(snapshot, patches) {
|
|
20
|
+
function update(value, path, patch) {
|
|
21
|
+
if (!path.length) {
|
|
22
|
+
if (patch.op === 'appendText') return value + patch.value
|
|
23
|
+
if (patch.op === 'append') return [...value, ...patch.value]
|
|
24
|
+
if (patch.op === 'truncate') return value.slice(0, patch.length)
|
|
25
|
+
return patch.value
|
|
26
|
+
}
|
|
27
|
+
const copy = Array.isArray(value) ? [...value] : { ...value }
|
|
28
|
+
const [key, ...rest] = path
|
|
29
|
+
if (!rest.length && patch.op === 'delete') delete copy[key]
|
|
30
|
+
else copy[key] = update(value[key], rest, patch)
|
|
31
|
+
return copy
|
|
32
|
+
}
|
|
33
|
+
for (const patch of patches) snapshot = update(snapshot, patch.path, patch)
|
|
34
|
+
return snapshot
|
|
35
|
+
}
|
package/src/daemon-runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { plain } from './daemon-wire.js'
|
|
3
|
+
import { diffSnapshot } from './daemon-patch.js'
|
|
3
4
|
import { forkSession, loadSession } from './session.js'
|
|
4
5
|
|
|
5
6
|
const events = ['change', 'derived', 'flash', 'input', 'question', 'turn', 'session', 'resumed', 'mcp', 'shells', 'git', 'project', 'image']
|
|
@@ -37,19 +38,35 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
37
38
|
async function createRecord(client, options) {
|
|
38
39
|
const created = await createSession(options)
|
|
39
40
|
const ctl = created.controller || created
|
|
40
|
-
const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [] }
|
|
41
|
+
const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [], baselines: new Map() }
|
|
41
42
|
sessions.set(record.id, record)
|
|
42
43
|
for (const type of events) record.unsub.push(ctl.on(type, payload => publish(record, type, payload)))
|
|
43
44
|
return record
|
|
44
45
|
}
|
|
46
|
+
function fullSnapshot(record, client) {
|
|
47
|
+
const value = snapshot(record.ctl)
|
|
48
|
+
const sequence = (record.baselines.get(client)?.sequence || 0) + 1
|
|
49
|
+
record.baselines.set(client, { snapshot: value, sequence })
|
|
50
|
+
return { snapshot: value, sequence }
|
|
51
|
+
}
|
|
52
|
+
function flush(record) {
|
|
53
|
+
clearTimeout(record.timer)
|
|
54
|
+
record.timer = null
|
|
55
|
+
const next = snapshot(record.ctl)
|
|
56
|
+
const notifications = record.events.splice(0)
|
|
57
|
+
for (const client of record.views) {
|
|
58
|
+
const baseline = record.baselines.get(client)
|
|
59
|
+
if (!baseline) continue
|
|
60
|
+
const sequence = baseline.sequence + 1
|
|
61
|
+
const patches = diffSnapshot(baseline.snapshot, next)
|
|
62
|
+
record.baselines.set(client, { snapshot: next, sequence })
|
|
63
|
+
client.send({ type: 'update', id: record.id, patches, baseSequence: baseline.sequence, sequence, events: notifications })
|
|
64
|
+
}
|
|
65
|
+
for (const client of clients) client.send({ type: 'workspace' })
|
|
66
|
+
}
|
|
45
67
|
function publish(record, type, payload) {
|
|
46
|
-
if (
|
|
47
|
-
if (!record.timer) record.timer = setTimeout(() =>
|
|
48
|
-
record.timer = null
|
|
49
|
-
const update = { type: 'update', id: record.id, snapshot: snapshot(record.ctl), events: record.events.splice(0) }
|
|
50
|
-
for (const client of record.views) client.send(update)
|
|
51
|
-
for (const client of clients) client.send({ type: 'workspace' })
|
|
52
|
-
}, batchMs)
|
|
68
|
+
if (!['change', 'derived'].includes(type)) record.events.push({ type, payload: plain(type === 'project' ? null : payload) })
|
|
69
|
+
if (!record.timer) record.timer = setTimeout(() => flush(record), batchMs)
|
|
53
70
|
}
|
|
54
71
|
async function dispose(record) {
|
|
55
72
|
if (!sessions.delete(record.id)) return
|
|
@@ -63,6 +80,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
63
80
|
const record = sessions.get(id)
|
|
64
81
|
if (!record) return
|
|
65
82
|
record.views.delete(client)
|
|
83
|
+
record.baselines.delete(client)
|
|
66
84
|
if (!record.views.size && !named(record.ctl)) await dispose(record)
|
|
67
85
|
}
|
|
68
86
|
return {
|
|
@@ -88,11 +106,12 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
88
106
|
})
|
|
89
107
|
if (op === 'create') {
|
|
90
108
|
const record = await createRecord(client, args[0] || {})
|
|
91
|
-
return { id: record.id,
|
|
109
|
+
return { id: record.id, ...fullSnapshot(record, client) }
|
|
92
110
|
}
|
|
93
111
|
const record = sessions.get(id)
|
|
94
112
|
if (!record) throw new Error('session is no longer running')
|
|
95
|
-
if (op === 'attach') { record.views.add(client); return { id,
|
|
113
|
+
if (op === 'attach') { record.views.add(client); return { id, ...fullSnapshot(record, client) } }
|
|
114
|
+
if (op === 'snapshot') { if (!record.views.has(client)) throw new Error('attach first'); return fullSnapshot(record, client) }
|
|
96
115
|
if (op === 'switch') {
|
|
97
116
|
const [method, value] = args
|
|
98
117
|
if (!record.views.has(client)) throw new Error('attach to the session first')
|
|
@@ -102,7 +121,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
102
121
|
if (existing) {
|
|
103
122
|
existing.views.add(client)
|
|
104
123
|
if (existing !== record) await detach(client, id)
|
|
105
|
-
return { id: existing.id,
|
|
124
|
+
return { id: existing.id, ...fullSnapshot(existing, client) }
|
|
106
125
|
}
|
|
107
126
|
const saved = await loadSession(meta.file)
|
|
108
127
|
meta = { ...meta, header: saved.header }
|
|
@@ -125,7 +144,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
125
144
|
if (renamed === false || next.ctl.state.derived?.title !== name) throw new Error(`could not create session named "${name}"`)
|
|
126
145
|
}
|
|
127
146
|
await detach(client, id)
|
|
128
|
-
return { id: next.id,
|
|
147
|
+
return { id: next.id, ...fullSnapshot(next, client) }
|
|
129
148
|
} catch (error) { await dispose(next); throw error }
|
|
130
149
|
}
|
|
131
150
|
if (op === 'detach') { await detach(client, id); return }
|
|
@@ -150,11 +169,12 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
150
169
|
if (target === 'controller' && method === 'compact') {
|
|
151
170
|
Promise.resolve(object[method](...values)).catch(error => publish(record, 'flash', error.message))
|
|
152
171
|
publish(record, 'change')
|
|
153
|
-
return { result: undefined,
|
|
172
|
+
return { result: undefined, incremental: true }
|
|
154
173
|
}
|
|
155
174
|
const result = await object[method](...values)
|
|
156
175
|
publish(record, 'change')
|
|
157
|
-
|
|
176
|
+
flush(record)
|
|
177
|
+
return { result, incremental: true }
|
|
158
178
|
}
|
|
159
179
|
if (op === 'configure') {
|
|
160
180
|
const allowed = ['researchModel', 'shellModel', 'deliberationModel', 'participantAModel', 'participantBModel', 'researchAgentLimit', 'clouds', 'compactToolHistory', 'gitFooter', 'wideSidebar']
|
package/src/daemon-wire.js
CHANGED
|
@@ -32,12 +32,45 @@ export function wire(socket, receive, fail = () => {}) {
|
|
|
32
32
|
} catch (error) { fail(error); socket.destroy() }
|
|
33
33
|
})
|
|
34
34
|
socket.on('error', fail)
|
|
35
|
-
|
|
35
|
+
const updates = new Map()
|
|
36
|
+
let workspacePending = false
|
|
37
|
+
function write(message) {
|
|
38
|
+
if (socket.destroyed) return false
|
|
36
39
|
const body = serialize(plain(message))
|
|
37
|
-
if (body.length > limit
|
|
40
|
+
if (body.length > limit) {
|
|
41
|
+
const error = new Error(`daemon frame exceeds limit (${body.length} bytes)`)
|
|
42
|
+
fail(error)
|
|
43
|
+
socket.destroy(error)
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
38
46
|
const header = Buffer.alloc(4)
|
|
39
47
|
header.writeUInt32BE(body.length)
|
|
40
48
|
socket.write(Buffer.concat([header, body]))
|
|
41
49
|
return true
|
|
42
50
|
}
|
|
51
|
+
socket.on('drain', () => {
|
|
52
|
+
for (const [id, update] of updates) {
|
|
53
|
+
if (socket.writableNeedDrain || socket.destroyed) break
|
|
54
|
+
updates.delete(id)
|
|
55
|
+
write(update)
|
|
56
|
+
}
|
|
57
|
+
if (workspacePending && !socket.writableNeedDrain && !socket.destroyed) {
|
|
58
|
+
workspacePending = false
|
|
59
|
+
write({ type: 'workspace' })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
socket.on('close', () => updates.clear())
|
|
63
|
+
return message => {
|
|
64
|
+
if (socket.destroyed) return false
|
|
65
|
+
if (socket.writableNeedDrain && message.type === 'update') {
|
|
66
|
+
const previous = updates.get(message.id)
|
|
67
|
+
updates.set(message.id, { ...message, ...(message.patches && previous?.patches ? { baseSequence: previous.baseSequence, patches: [...previous.patches, ...message.patches] } : {}), events: [...(previous?.events || []), ...(message.events || [])] })
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
if (socket.writableNeedDrain && message.type === 'workspace') {
|
|
71
|
+
workspacePending = true
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
return write(message)
|
|
75
|
+
}
|
|
43
76
|
}
|
package/src/remote-controller.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyPatches } from './daemon-patch.js'
|
|
1
2
|
import { deriveState } from './derive.js'
|
|
2
3
|
|
|
3
4
|
export async function createRemoteController(connection, { id, options = {}, localBoot = {} } = {}) {
|
|
@@ -6,16 +7,21 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
6
7
|
const state = {}
|
|
7
8
|
const boot = { refs: {}, ...localBoot }
|
|
8
9
|
const listeners = new Map()
|
|
10
|
+
const sequences = new Map([[id, initial.sequence]])
|
|
11
|
+
let recovering = false
|
|
9
12
|
let current
|
|
10
13
|
let previewId = null
|
|
11
14
|
let previewQueue = Promise.resolve()
|
|
12
15
|
const displayedId = () => previewId || id
|
|
13
16
|
const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value) }
|
|
14
|
-
const call =
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const call = (target, method, ...args) => {
|
|
18
|
+
const result = connection.request('call', id, [target, method, ...args]).then(reply => {
|
|
19
|
+
if (reply.snapshot) apply(reply.snapshot)
|
|
20
|
+
emit('change', state)
|
|
21
|
+
return reply.result
|
|
22
|
+
})
|
|
23
|
+
result.catch(error => emit('flash', error.message))
|
|
24
|
+
return result
|
|
19
25
|
}
|
|
20
26
|
function apply(snapshot) {
|
|
21
27
|
current = snapshot
|
|
@@ -61,6 +67,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
61
67
|
if (previousPreview && previousPreview !== nextId) await connection.request('detach', previousPreview)
|
|
62
68
|
id = next.id
|
|
63
69
|
controller.id = id
|
|
70
|
+
sequences.set(next.id, next.sequence)
|
|
64
71
|
apply(next.snapshot)
|
|
65
72
|
await connection.request('detach', previous)
|
|
66
73
|
emit('project', boot)
|
|
@@ -74,6 +81,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
74
81
|
const next = await connection.request('switch', id, [method, value])
|
|
75
82
|
id = next.id
|
|
76
83
|
controller.id = id
|
|
84
|
+
sequences.set(next.id, next.sequence)
|
|
77
85
|
apply(next.snapshot)
|
|
78
86
|
if (method === 'newSession' && typeof value === 'string' && value.trim() && state.derived?.title !== value.trim()) {
|
|
79
87
|
const renamed = await call('controller', 'rename', value.trim())
|
|
@@ -96,6 +104,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
96
104
|
const previous = previewId
|
|
97
105
|
const original = await connection.request('attach', id)
|
|
98
106
|
previewId = null
|
|
107
|
+
sequences.set(id, original.sequence)
|
|
99
108
|
show(original.snapshot)
|
|
100
109
|
await connection.request('detach', previous)
|
|
101
110
|
}
|
|
@@ -106,6 +115,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
106
115
|
const next = await connection.request('attach', nextId)
|
|
107
116
|
const previous = previewId
|
|
108
117
|
previewId = nextId
|
|
118
|
+
sequences.set(nextId, next.sequence)
|
|
109
119
|
show(next.snapshot)
|
|
110
120
|
if (previous) await connection.request('detach', previous)
|
|
111
121
|
})
|
|
@@ -125,7 +135,26 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
125
135
|
const unsubscribe = connection.on(message => {
|
|
126
136
|
if (message.type === 'disconnect') { state.busy = false; state.streaming = null; emit('flash', 'daemon disconnected; work was not restarted'); emit('change', state); return }
|
|
127
137
|
if (message.type !== 'update' || message.id !== displayedId()) return
|
|
128
|
-
|
|
138
|
+
if (message.patches) {
|
|
139
|
+
if (recovering) return
|
|
140
|
+
if (sequences.get(message.id) !== message.baseSequence) {
|
|
141
|
+
recovering = true
|
|
142
|
+
const recoveringId = displayedId()
|
|
143
|
+
connection.request('snapshot', recoveringId).then(reply => {
|
|
144
|
+
if (displayedId() !== recoveringId) return
|
|
145
|
+
sequences.set(recoveringId, reply.sequence)
|
|
146
|
+
apply(reply.snapshot)
|
|
147
|
+
emit('change', state)
|
|
148
|
+
}).catch(error => emit('flash', error.message)).finally(() => { recovering = false })
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
apply(applyPatches(current, message.patches))
|
|
152
|
+
sequences.set(message.id, message.sequence)
|
|
153
|
+
emit('derived', state.derived)
|
|
154
|
+
} else {
|
|
155
|
+
apply(message.snapshot)
|
|
156
|
+
emit('derived', state.derived)
|
|
157
|
+
}
|
|
129
158
|
for (const event of message.events) {
|
|
130
159
|
if (previewId && ['input', 'question', 'resumed', 'flash'].includes(event.type)) continue
|
|
131
160
|
emit(event.type, event.type === 'project' ? boot : event.payload)
|