Skip to content

Commit b1b4f41

Browse files
authored
Merge pull request #3 from spetro511/mirror/upstream-134-live-workbench
feat: publish the personal CyberStrike operational workbench
2 parents 6465108 + 58b97d0 commit b1b4f41

29 files changed

Lines changed: 1326 additions & 675 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/), versions follow
1818
- Version-pinned MCP catalog with manual/optional install states and three additional owner-maintained integrations
1919
- Safe recovery startup with `--safe` and namespaced extension configuration
2020
- Target-selectable source builds and a localhost-only systemd user service template for Kali/Linux deployments
21+
- Always-visible live workbench status with resilient activity streaming, meaningful change badges, and event-driven Mission, Topology, and Memory refresh
2122

2223
### Changed
2324

2425
- MCP tool materialization now filters schemas before conversion and enforces real context-budget eviction
26+
- Agent methodology now routes Nmap through the approval-gated `nmap_scan` system of record and explicitly records verified discoveries, findings, and reusable memory for workbench projection
2527

2628
### Fixed
2729

2830
- Release and local-binary installs now deploy the bundled HackBrowser worker to the runtime data directory
2931
- Web update checks now honor disabled auto-updates, avoiding false upgrade prompts for managed source builds
32+
- Hidden workbench panels no longer miss live changes or depend on visible-only polling; repeated session lifecycle events and reconnect gaps are recovered
33+
- Nmap permissions now identify active/elevated execution, privileged profiles use non-interactive sudo when required, and Topology explains why generic shell output is not graphable
3034

3135
## [1.1.16] — 2026-08-08
3236

README.md

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

packages/app/src/app.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { TerminalProvider } from "@/context/terminal"
2121
import { PromptProvider } from "@/context/prompt"
2222
import { FileProvider } from "@/context/file"
2323
import { CommentsProvider } from "@/context/comments"
24+
import { WorkbenchProvider } from "@/context/workbench"
2425
import { NotificationProvider } from "@/context/notification"
2526
import { ModelsProvider } from "@/context/models"
2627
import { DialogProvider } from "@cyberstrike-io/ui/context/dialog"
@@ -92,7 +93,9 @@ function SessionProviders(props: ParentProps) {
9293
<TerminalProvider>
9394
<FileProvider>
9495
<PromptProvider>
95-
<CommentsProvider>{props.children}</CommentsProvider>
96+
<CommentsProvider>
97+
<WorkbenchProvider>{props.children}</WorkbenchProvider>
98+
</CommentsProvider>
9699
</PromptProvider>
97100
</FileProvider>
98101
</TerminalProvider>
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { createSimpleContext } from "@cyberstrike-io/ui/context"
2+
import { useParams } from "@solidjs/router"
3+
import { createEffect, createMemo, onCleanup, untrack } from "solid-js"
4+
import { createStore, produce, reconcile } from "solid-js/store"
5+
import { useSDK } from "@/context/sdk"
6+
import {
7+
activityChannels,
8+
activityRefreshChannels,
9+
isActivity,
10+
mergeActivity,
11+
type Activity,
12+
type ActivitySource,
13+
type WorkbenchChannel,
14+
} from "@/pages/session/activity"
15+
16+
const blank = () => ({
17+
activity: 0,
18+
mission: 0,
19+
topology: 0,
20+
memory: 0,
21+
mcp: 0,
22+
bolt: 0,
23+
terminal: 0,
24+
vulns: 0,
25+
web: 0,
26+
})
27+
28+
export const { use: useWorkbench, provider: WorkbenchProvider } = createSimpleContext({
29+
name: "Workbench",
30+
init: () => {
31+
const params = useParams()
32+
const sdk = useSDK()
33+
const [events, setEvents] = createStore<Activity[]>([])
34+
const [state, setState] = createStore({
35+
connected: false,
36+
error: "",
37+
changes: blank(),
38+
revision: blank(),
39+
last: blank(),
40+
})
41+
42+
const bump = (channel: WorkbenchChannel, time = Date.now(), changed = true) => {
43+
if (changed) setState("changes", channel, (value) => value + 1)
44+
setState("revision", channel, (value) => value + 1)
45+
setState("last", channel, time)
46+
}
47+
48+
const marked = new Set<string>()
49+
const mark = (event: Activity) => {
50+
if (marked.has(event.id)) return
51+
marked.delete(event.id)
52+
marked.add(event.id)
53+
while (marked.size > 2_000) marked.delete(marked.values().next().value!)
54+
for (const channel of activityChannels(event)) bump(channel, event.time)
55+
for (const channel of activityRefreshChannels(event)) bump(channel, event.time, false)
56+
}
57+
58+
const add = (event: Activity) => {
59+
const index = events.findIndex((item) => item.id === event.id)
60+
if (index !== -1) {
61+
setEvents(index, reconcile(event))
62+
mark(event)
63+
return
64+
}
65+
setEvents(
66+
produce((draft) => {
67+
draft.push(event)
68+
if (draft.length > 2_000) draft.splice(0, draft.length - 2_000)
69+
}),
70+
)
71+
mark(event)
72+
}
73+
74+
createEffect(() => {
75+
const sessionID = params.id
76+
sdk.directory
77+
marked.clear()
78+
setEvents(reconcile([]))
79+
setState({
80+
connected: false,
81+
error: "",
82+
changes: blank(),
83+
revision: blank(),
84+
last: blank(),
85+
})
86+
if (!sessionID) return
87+
88+
const abort = new AbortController()
89+
const client = sdk.createClient({
90+
directory: sdk.directory,
91+
throwOnError: true,
92+
signal: abort.signal,
93+
})
94+
let connected = false
95+
let hydrated = false
96+
let requested = 0
97+
let completed = 0
98+
let syncing: Promise<void> | undefined
99+
let streamError = ""
100+
let historyError = ""
101+
const render = () => setState({ connected, error: historyError || streamError })
102+
const snapshot = async () => {
103+
const known = new Set(untrack(() => events.map((event) => event.id)))
104+
const pages: Activity[] = []
105+
let before: number | undefined
106+
let beforeID: string | undefined
107+
while (pages.length < 2_000) {
108+
const limit = Math.min(500, 2_000 - pages.length)
109+
const response = await client.eventLog.list({ sessionID, before, beforeID, limit })
110+
const page = (response.data ?? []).filter(isActivity)
111+
if (page.length === 0) break
112+
pages.unshift(...page)
113+
if (hydrated && page.some((event) => known.has(event.id))) break
114+
if (page.length < limit) break
115+
const next = page[0]!.time
116+
const nextID = page[0]!.id
117+
if (before === next && beforeID === nextID) break
118+
before = next
119+
beforeID = nextID
120+
}
121+
return { known, incoming: mergeActivity(pages, [], 2_000) }
122+
}
123+
const sync = () => {
124+
if (syncing) return syncing
125+
syncing = (async () => {
126+
while (!abort.signal.aborted) {
127+
const version = requested
128+
try {
129+
const history = await snapshot()
130+
if (abort.signal.aborted) return
131+
const changed = hydrated
132+
setEvents(reconcile(mergeActivity(history.incoming, [...events])))
133+
if (changed) history.incoming.filter((event) => !history.known.has(event.id)).forEach(mark)
134+
hydrated = true
135+
completed = version
136+
historyError = ""
137+
render()
138+
if (requested <= completed) return
139+
} catch (cause) {
140+
if (abort.signal.aborted) return
141+
historyError = cause instanceof Error ? cause.message : String(cause)
142+
render()
143+
await new Promise((resolve) => setTimeout(resolve, 500))
144+
}
145+
}
146+
})().finally(() => {
147+
syncing = undefined
148+
})
149+
return syncing
150+
}
151+
const requestSync = () => {
152+
requested++
153+
return sync()
154+
}
155+
const unsubs = [
156+
sdk.event.on("memory.updated", (event) => {
157+
if (!event.properties.sessionID) bump("memory")
158+
}),
159+
sdk.event.on("mcp.tools.changed", () => bump("mcp")),
160+
sdk.event.on("pty.created", () => bump("terminal")),
161+
sdk.event.on("pty.updated", () => bump("terminal")),
162+
sdk.event.on("pty.exited", () => bump("terminal")),
163+
sdk.event.on("pty.deleted", () => bump("terminal")),
164+
]
165+
166+
void requestSync()
167+
let timer: ReturnType<typeof setTimeout> | undefined
168+
void (async () => {
169+
while (!abort.signal.aborted) {
170+
try {
171+
const response = await client.eventLog.stream(
172+
{ sessionID },
173+
{
174+
onSseError: (cause) => {
175+
if (abort.signal.aborted) return
176+
connected = false
177+
streamError = cause instanceof Error ? cause.message : String(cause)
178+
render()
179+
},
180+
onSseEvent: () => {
181+
if (timer) clearTimeout(timer)
182+
timer = undefined
183+
const recovered = !connected
184+
connected = true
185+
streamError = ""
186+
render()
187+
if (recovered) void requestSync()
188+
},
189+
},
190+
)
191+
timer = setTimeout(() => {
192+
if (abort.signal.aborted) return
193+
streamError = "Live activity stream did not connect"
194+
render()
195+
}, 5_000)
196+
let next = await response.stream.next()
197+
while (!next.done && !abort.signal.aborted) {
198+
if (isActivity(next.value)) add(next.value)
199+
next = await response.stream.next()
200+
}
201+
if (abort.signal.aborted) return
202+
connected = false
203+
streamError = "Live activity stream disconnected"
204+
render()
205+
} catch (cause) {
206+
if (abort.signal.aborted) return
207+
connected = false
208+
streamError = cause instanceof Error ? cause.message : String(cause)
209+
render()
210+
} finally {
211+
if (timer) clearTimeout(timer)
212+
}
213+
await new Promise((resolve) => setTimeout(resolve, 500))
214+
}
215+
})()
216+
217+
onCleanup(() => {
218+
if (timer) clearTimeout(timer)
219+
abort.abort()
220+
unsubs.forEach((unsub) => unsub())
221+
})
222+
})
223+
224+
const latest = createMemo(() => events.at(-1))
225+
226+
return {
227+
get events() {
228+
return events
229+
},
230+
get connected() {
231+
return state.connected
232+
},
233+
get error() {
234+
return state.error
235+
},
236+
latest,
237+
count(source: ActivitySource) {
238+
return events.filter((event) => event.source === source).length
239+
},
240+
changes(channel: WorkbenchChannel) {
241+
return state.changes[channel]
242+
},
243+
revision(channel: WorkbenchChannel) {
244+
return state.revision[channel]
245+
},
246+
last(channel: WorkbenchChannel) {
247+
return state.last[channel]
248+
},
249+
ack(channel: WorkbenchChannel) {
250+
setState("changes", channel, 0)
251+
},
252+
}
253+
},
254+
})

packages/app/src/pages/session.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
5959
import { SessionSidePanel } from "@/pages/session/session-side-panel"
6060
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
6161
import { useServer } from "@/context/server"
62+
import { WorkbenchBar } from "@/pages/session/workbench-bar"
6263

6364
type HandoffSession = {
6465
prompt: string
@@ -121,6 +122,7 @@ export default function Page() {
121122
const blocked = createMemo(() => !!permRequest() || !!questionRequest())
122123

123124
const [ui, setUi] = createStore({
125+
activity: false,
124126
responding: false,
125127
pendingMessage: undefined as string | undefined,
126128
scrollGesture: 0,
@@ -691,6 +693,7 @@ export default function Page() {
691693
setUi("autoCreated", false)
692694
return
693695
}
696+
if (ui.activity) return
694697
if (observer()) return
695698
if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
696699
terminal.new()
@@ -701,7 +704,7 @@ export default function Page() {
701704
on(
702705
() => terminal.all().length,
703706
(count, prevCount) => {
704-
if (prevCount !== undefined && prevCount > 0 && count === 0) {
707+
if (prevCount !== undefined && prevCount > 0 && count === 0 && !ui.activity) {
705708
if (view().terminal.opened()) {
706709
view().terminal.toggle()
707710
}
@@ -737,6 +740,16 @@ export default function Page() {
737740
)
738741

739742
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
743+
const openActivity = () => {
744+
setUi("activity", true)
745+
if (!view().terminal.opened()) view().terminal.open()
746+
}
747+
const newTerminal = () => {
748+
setUi("activity", false)
749+
setUi("autoCreated", true)
750+
view().terminal.open()
751+
terminal.new()
752+
}
740753

741754
createEffect(
742755
on(
@@ -745,6 +758,7 @@ export default function Page() {
745758
setStore("messageId", undefined)
746759
setStore("expanded", {})
747760
setStore("changes", "session")
761+
setUi("activity", false)
748762
setUi("autoCreated", false)
749763
},
750764
{ defer: true },
@@ -958,6 +972,7 @@ export default function Page() {
958972
setActiveMessage,
959973
addSelectionToContext,
960974
focusInput,
975+
newTerminal,
961976
})
962977

963978
const openReviewFile = createOpenReviewFile({
@@ -1572,6 +1587,14 @@ export default function Page() {
15721587
return (
15731588
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
15741589
<SessionHeader />
1590+
<WorkbenchBar
1591+
busy={status().type === "busy"}
1592+
observer={observer()}
1593+
onActivity={openActivity}
1594+
onMission={() => openTab("mission-panel")}
1595+
onTopology={() => openTab("topology-panel")}
1596+
onMemory={() => openTab("memory-panel")}
1597+
/>
15751598
<div
15761599
class="flex-1 min-h-0 flex"
15771600
classList={{
@@ -1791,7 +1814,13 @@ export default function Page() {
17911814
open={view().terminal.opened()}
17921815
height={layout.terminal.height()}
17931816
resize={layout.terminal.resize}
1794-
close={view().terminal.close}
1817+
close={() => {
1818+
view().terminal.close()
1819+
setUi("activity", false)
1820+
}}
1821+
activity={ui.activity}
1822+
setActivity={(value) => setUi("activity", value)}
1823+
newTerminal={newTerminal}
17951824
terminal={terminal}
17961825
readOnly={observer()}
17971826
language={language}

0 commit comments

Comments
 (0)