Skip to content

Commit 0c8bb88

Browse files
feat(dashboard): live session view and plan amendment diffs (#96)
* feat(dashboard): live session view and plan amendment diffs * fix(dashboard): guard loop POST routes and release SSE streams * fix(sandbox): pin pnpm store via PNPM_CONFIG_STORE_DIR pnpm 11 no longer reads npm_config_* environment variables, so the sandbox image's npm_config_store_dir was ignored and pnpm fell back to placing the store inside the msb-mounted project directory, which then got committed by loop teardown. Rename it to PNPM_CONFIG_STORE_DIR and add a regression test. * fix(dashboard): harden loopback host checks and live view interactions * chore: bump version to 0.9.1 * fix(dashboard): render live model summary at primary text strength
1 parent 68acc13 commit 0c8bb88

29 files changed

Lines changed: 3635 additions & 165 deletions

container/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ RUN curl -fsSL https://astral.sh/uv/install.sh | bash \
6161
# HOME and every tool cache/store are pinned to fixed, world-writable paths so
6262
# the layout is stable regardless of the executing UID.
6363
#
64-
# npm_config_store_dir pins pnpm's content-addressable store to a
64+
# PNPM_CONFIG_STORE_DIR pins pnpm's content-addressable store to a
6565
# container-internal path. Without it, pnpm places the store on the project
6666
# filesystem (the msb-mounted project directory), which floods the host file
6767
# watcher and is slow over the macOS bind mount.
@@ -71,7 +71,7 @@ ENV HOME=/opt/forge \
7171
PNPM_HOME=/opt/forge/.local/share/pnpm \
7272
PATH=/opt/forge/.local/share/pnpm/bin:/opt/forge/.local/share/pnpm:$PATH \
7373
npm_config_cache=/opt/forge/.npm \
74-
npm_config_store_dir=/opt/forge/.local/share/pnpm/store
74+
PNPM_CONFIG_STORE_DIR=/opt/forge/.local/share/pnpm/store
7575
RUN mkdir -p /opt/forge/.cache /opt/forge/.local/share/pnpm/store /opt/forge/.npm \
7676
&& chmod -R 0777 /opt/forge
7777

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-forge",
3-
"version": "0.9.0",
3+
"version": "0.9.1",
44
"type": "module",
55
"oc-plugin": [
66
"server",

src/client/port.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export type TuiSelectSessionParams = NonNullable<Parameters<V2['tui']['selectSes
5454
export type SyncStartParams = NonNullable<Parameters<V2['sync']['start']>[0]>
5555

5656
// ── Event types ──────────────────────────────────────────────────────────────
57+
export type EventSubscribeParams = NonNullable<Parameters<V2['event']['subscribe']>[0]>
58+
/** `{ stream: AsyncGenerator<Event> }` — the live server-sent event feed. */
59+
export type EventSubscription = Awaited<ReturnType<V2['event']['subscribe']>>
5760

5861
// ── Error model ──────────────────────────────────────────────────────────────
5962

@@ -116,4 +119,11 @@ export interface ForgeClient {
116119
sync: {
117120
start(params?: SyncStartParams): Promise<void>
118121
}
122+
event: {
123+
/**
124+
* Live event feed for the host. The caller owns the returned generator and
125+
* must call `stream.return()` to close the underlying connection.
126+
*/
127+
subscribe(params?: EventSubscribeParams): Promise<EventSubscription>
128+
}
119129
}

src/client/sdk-adapter.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,25 @@ export function createForgeClient(v2: OpencodeClient): ForgeClient {
181181
},
182182
}
183183

184-
return { session, workspace, project, provider, tui, sync }
184+
// ── event namespace ──────────────────────────────────────────────────────
185+
const event: ForgeClient['event'] = {
186+
subscribe: async (params) => {
187+
if (!v2.event || typeof v2.event.subscribe !== 'function') {
188+
throw new ForgeClientError({
189+
kind: 'unavailable',
190+
method: 'event.subscribe',
191+
message: 'event.subscribe not available on this host',
192+
})
193+
}
194+
try {
195+
return await v2.event.subscribe(params)
196+
} catch (err: unknown) {
197+
throw classify(err, 'event.subscribe')
198+
}
199+
},
200+
}
201+
202+
return { session, workspace, project, provider, tui, sync, event }
185203
}
186204

187205
// ── Combined factory ─────────────────────────────────────────────────────────

src/dashboard/amendment-diff.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* Diffing for the plan-amendment audit trail. `plan_amendments` stores a full
3+
* `{ index, title, content }[]` snapshot of the affected sections either side of
4+
* an adjustment; this module turns a pair of those snapshots into what the
5+
* dashboard renders. Kept free of storage and HTTP concerns so it is unit
6+
* testable, and split into a summary pass (cheap enough for every poll) and a
7+
* full line diff (computed on demand).
8+
*/
9+
10+
interface AmendmentSnapshotSection {
11+
index: number
12+
title: string
13+
content: string
14+
}
15+
16+
export type AmendmentSectionChange = 'added' | 'removed' | 'modified' | 'unchanged'
17+
18+
export interface AmendmentDiffLine {
19+
kind: 'add' | 'remove' | 'context' | 'gap'
20+
text: string
21+
}
22+
23+
export interface AmendmentSectionDiff {
24+
index: number
25+
change: AmendmentSectionChange
26+
title: string
27+
/** Set only when the section survived the adjustment under a new title. */
28+
previousTitle: string | null
29+
lines: AmendmentDiffLine[]
30+
}
31+
32+
export interface AmendmentChangeSummary {
33+
added: number
34+
removed: number
35+
modified: number
36+
}
37+
38+
export interface AmendmentDiff {
39+
sections: AmendmentSectionDiff[]
40+
summary: AmendmentChangeSummary
41+
}
42+
43+
/** Unchanged lines kept either side of a change before a run is collapsed. */
44+
const CONTEXT_LINES = 3
45+
46+
/**
47+
* Ceiling on the LCS matrix, in lines per side, after common prefix/suffix
48+
* trimming. Beyond it a section renders as a wholesale replace instead of a
49+
* line diff. Plan sections are prose and never approach this; raising it costs
50+
* quadratic memory, so a smarter diff (histogram/patience) would be the upgrade
51+
* path rather than a bigger matrix.
52+
*/
53+
const MAX_DIFF_LINES = 800
54+
55+
function parseSnapshot(json: string): Map<number, AmendmentSnapshotSection> {
56+
const sections = new Map<number, AmendmentSnapshotSection>()
57+
let parsed: unknown
58+
try {
59+
parsed = JSON.parse(json)
60+
} catch {
61+
return sections
62+
}
63+
if (!Array.isArray(parsed)) return sections
64+
for (const entry of parsed) {
65+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
66+
const row = entry as Record<string, unknown>
67+
if (typeof row.index !== 'number' || !Number.isFinite(row.index)) continue
68+
sections.set(row.index, {
69+
index: row.index,
70+
title: typeof row.title === 'string' ? row.title : '',
71+
content: typeof row.content === 'string' ? row.content : '',
72+
})
73+
}
74+
return sections
75+
}
76+
77+
interface SectionPair {
78+
index: number
79+
before: AmendmentSnapshotSection | null
80+
after: AmendmentSnapshotSection | null
81+
change: AmendmentSectionChange
82+
}
83+
84+
function classify(
85+
before: AmendmentSnapshotSection | null,
86+
after: AmendmentSnapshotSection | null,
87+
): AmendmentSectionChange {
88+
if (!before) return 'added'
89+
if (!after) return 'removed'
90+
return before.title === after.title && before.content === after.content ? 'unchanged' : 'modified'
91+
}
92+
93+
/**
94+
* Pair the two snapshots by section index. Index is the identity: an adjustment
95+
* replaces a positional suffix of the plan, so "#4 used to be X, now it is Y" is
96+
* the change the reader cares about.
97+
*/
98+
function pairSections(beforeJson: string, afterJson: string): SectionPair[] {
99+
const before = parseSnapshot(beforeJson)
100+
const after = parseSnapshot(afterJson)
101+
const indexes = [...new Set([...before.keys(), ...after.keys()])].sort((a, b) => a - b)
102+
return indexes.map(index => {
103+
const b = before.get(index) ?? null
104+
const a = after.get(index) ?? null
105+
return { index, before: b, after: a, change: classify(b, a) }
106+
})
107+
}
108+
109+
function summarize(pairs: SectionPair[]): AmendmentChangeSummary {
110+
const summary: AmendmentChangeSummary = { added: 0, removed: 0, modified: 0 }
111+
for (const pair of pairs) {
112+
if (pair.change === 'added') summary.added += 1
113+
else if (pair.change === 'removed') summary.removed += 1
114+
else if (pair.change === 'modified') summary.modified += 1
115+
}
116+
return summary
117+
}
118+
119+
function line(kind: AmendmentDiffLine['kind'], text: string): AmendmentDiffLine {
120+
return { kind, text }
121+
}
122+
123+
function splitLines(content: string): string[] {
124+
return content.length === 0 ? [] : content.split('\n')
125+
}
126+
127+
function lcsDiff(before: string[], after: string[]): AmendmentDiffLine[] {
128+
const n = before.length
129+
const m = after.length
130+
const width = m + 1
131+
const lengths = new Uint32Array((n + 1) * width)
132+
for (let i = n - 1; i >= 0; i -= 1) {
133+
for (let j = m - 1; j >= 0; j -= 1) {
134+
lengths[i * width + j] = before[i] === after[j]
135+
? lengths[(i + 1) * width + (j + 1)] + 1
136+
: Math.max(lengths[(i + 1) * width + j], lengths[i * width + (j + 1)])
137+
}
138+
}
139+
140+
const lines: AmendmentDiffLine[] = []
141+
let i = 0
142+
let j = 0
143+
while (i < n && j < m) {
144+
if (before[i] === after[j]) {
145+
lines.push(line('context', before[i]))
146+
i += 1
147+
j += 1
148+
} else if (lengths[(i + 1) * width + j] >= lengths[i * width + (j + 1)]) {
149+
lines.push(line('remove', before[i]))
150+
i += 1
151+
} else {
152+
lines.push(line('add', after[j]))
153+
j += 1
154+
}
155+
}
156+
while (i < n) {
157+
lines.push(line('remove', before[i]))
158+
i += 1
159+
}
160+
while (j < m) {
161+
lines.push(line('add', after[j]))
162+
j += 1
163+
}
164+
return lines
165+
}
166+
167+
/**
168+
* Replace long runs of unchanged lines with a single gap marker, keeping a few
169+
* lines of context around each change. Runs are left intact when collapsing
170+
* them would not actually shorten the output.
171+
*/
172+
function collapseContext(lines: AmendmentDiffLine[]): AmendmentDiffLine[] {
173+
const out: AmendmentDiffLine[] = []
174+
let run: AmendmentDiffLine[] = []
175+
176+
const flush = (atEnd: boolean): void => {
177+
if (run.length === 0) return
178+
const keepBefore = out.length === 0 ? 0 : CONTEXT_LINES
179+
const keepAfter = atEnd ? 0 : CONTEXT_LINES
180+
const hidden = run.length - keepBefore - keepAfter
181+
if (hidden <= 1) {
182+
out.push(...run)
183+
} else {
184+
out.push(...run.slice(0, keepBefore))
185+
out.push(line('gap', `${hidden} unchanged lines`))
186+
out.push(...run.slice(run.length - keepAfter))
187+
}
188+
run = []
189+
}
190+
191+
for (const entry of lines) {
192+
if (entry.kind === 'context') {
193+
run.push(entry)
194+
continue
195+
}
196+
flush(false)
197+
out.push(entry)
198+
}
199+
flush(true)
200+
return out
201+
}
202+
203+
function diffContent(beforeText: string, afterText: string): AmendmentDiffLine[] {
204+
if (beforeText === afterText) return []
205+
const before = splitLines(beforeText)
206+
const after = splitLines(afterText)
207+
208+
let start = 0
209+
while (start < before.length && start < after.length && before[start] === after[start]) start += 1
210+
let endBefore = before.length
211+
let endAfter = after.length
212+
while (endBefore > start && endAfter > start && before[endBefore - 1] === after[endAfter - 1]) {
213+
endBefore -= 1
214+
endAfter -= 1
215+
}
216+
217+
const middleBefore = before.slice(start, endBefore)
218+
const middleAfter = after.slice(start, endAfter)
219+
const middle = middleBefore.length > MAX_DIFF_LINES || middleAfter.length > MAX_DIFF_LINES
220+
? [
221+
...middleBefore.map(text => line('remove', text)),
222+
...middleAfter.map(text => line('add', text)),
223+
]
224+
: lcsDiff(middleBefore, middleAfter)
225+
226+
return collapseContext([
227+
...before.slice(0, start).map(text => line('context', text)),
228+
...middle,
229+
...before.slice(endBefore).map(text => line('context', text)),
230+
])
231+
}
232+
233+
/** Section-level change counts only; no line diffing. */
234+
export function summarizeAmendmentSnapshots(beforeJson: string, afterJson: string): AmendmentChangeSummary {
235+
return summarize(pairSections(beforeJson, afterJson))
236+
}
237+
238+
/** Full per-section diff, including line-level changes for modified sections. */
239+
export function diffAmendmentSnapshots(beforeJson: string, afterJson: string): AmendmentDiff {
240+
const pairs = pairSections(beforeJson, afterJson)
241+
const sections = pairs.map(pair => ({
242+
index: pair.index,
243+
change: pair.change,
244+
title: (pair.change === 'removed' ? pair.before?.title : pair.after?.title) ?? '',
245+
previousTitle: pair.before && pair.after && pair.before.title !== pair.after.title
246+
? pair.before.title
247+
: null,
248+
lines: pair.change === 'unchanged'
249+
? []
250+
: diffContent(pair.before?.content ?? '', pair.after?.content ?? ''),
251+
}))
252+
return { sections, summary: summarize(pairs) }
253+
}

src/dashboard/app-bundle.ts

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)