Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/), versions follow
- Version-pinned MCP catalog with manual/optional install states and three additional owner-maintained integrations
- Safe recovery startup with `--safe` and namespaced extension configuration
- Target-selectable source builds and a localhost-only systemd user service template for Kali/Linux deployments
- Always-visible live workbench status with resilient activity streaming, meaningful change badges, and event-driven Mission, Topology, and Memory refresh

### Changed

- MCP tool materialization now filters schemas before conversion and enforces real context-budget eviction
- 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

### Fixed

- Release and local-binary installs now deploy the bundled HackBrowser worker to the runtime data directory
- Web update checks now honor disabled auto-updates, avoiding false upgrade prompts for managed source builds
- Hidden workbench panels no longer miss live changes or depend on visible-only polling; repeated session lifecycle events and reconnect gaps are recovered
- 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

## [1.1.16] — 2026-08-08

Expand Down
568 changes: 124 additions & 444 deletions README.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { WorkbenchProvider } from "@/context/workbench"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@cyberstrike-io/ui/context/dialog"
Expand Down Expand Up @@ -92,7 +93,9 @@ function SessionProviders(props: ParentProps) {
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
<CommentsProvider>
<WorkbenchProvider>{props.children}</WorkbenchProvider>
</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
Expand Down
254 changes: 254 additions & 0 deletions packages/app/src/context/workbench.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { createSimpleContext } from "@cyberstrike-io/ui/context"
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@/context/sdk"
import {
activityChannels,
activityRefreshChannels,
isActivity,
mergeActivity,
type Activity,
type ActivitySource,
type WorkbenchChannel,
} from "@/pages/session/activity"

const blank = () => ({
activity: 0,
mission: 0,
topology: 0,
memory: 0,
mcp: 0,
bolt: 0,
terminal: 0,
vulns: 0,
web: 0,
})

export const { use: useWorkbench, provider: WorkbenchProvider } = createSimpleContext({
name: "Workbench",
init: () => {
const params = useParams()
const sdk = useSDK()
const [events, setEvents] = createStore<Activity[]>([])
const [state, setState] = createStore({
connected: false,
error: "",
changes: blank(),
revision: blank(),
last: blank(),
})

const bump = (channel: WorkbenchChannel, time = Date.now(), changed = true) => {
if (changed) setState("changes", channel, (value) => value + 1)
setState("revision", channel, (value) => value + 1)
setState("last", channel, time)
}

const marked = new Set<string>()
const mark = (event: Activity) => {
if (marked.has(event.id)) return
marked.delete(event.id)
marked.add(event.id)
while (marked.size > 2_000) marked.delete(marked.values().next().value!)
for (const channel of activityChannels(event)) bump(channel, event.time)
for (const channel of activityRefreshChannels(event)) bump(channel, event.time, false)
}

const add = (event: Activity) => {
const index = events.findIndex((item) => item.id === event.id)
if (index !== -1) {
setEvents(index, reconcile(event))
mark(event)
return
}
setEvents(
produce((draft) => {
draft.push(event)
if (draft.length > 2_000) draft.splice(0, draft.length - 2_000)
}),
)
mark(event)
}

createEffect(() => {
const sessionID = params.id
sdk.directory
marked.clear()
setEvents(reconcile([]))
setState({
connected: false,
error: "",
changes: blank(),
revision: blank(),
last: blank(),
})
if (!sessionID) return

const abort = new AbortController()
const client = sdk.createClient({
directory: sdk.directory,
throwOnError: true,
signal: abort.signal,
})
let connected = false
let hydrated = false
let requested = 0
let completed = 0
let syncing: Promise<void> | undefined
let streamError = ""
let historyError = ""
const render = () => setState({ connected, error: historyError || streamError })
const snapshot = async () => {
const known = new Set(untrack(() => events.map((event) => event.id)))
const pages: Activity[] = []
let before: number | undefined
let beforeID: string | undefined
while (pages.length < 2_000) {
const limit = Math.min(500, 2_000 - pages.length)
const response = await client.eventLog.list({ sessionID, before, beforeID, limit })
const page = (response.data ?? []).filter(isActivity)
if (page.length === 0) break
pages.unshift(...page)
if (hydrated && page.some((event) => known.has(event.id))) break
if (page.length < limit) break
const next = page[0]!.time
const nextID = page[0]!.id
if (before === next && beforeID === nextID) break
before = next
beforeID = nextID
}
return { known, incoming: mergeActivity(pages, [], 2_000) }
}
const sync = () => {
if (syncing) return syncing
syncing = (async () => {
while (!abort.signal.aborted) {
const version = requested
try {
const history = await snapshot()
if (abort.signal.aborted) return
const changed = hydrated
setEvents(reconcile(mergeActivity(history.incoming, [...events])))
if (changed) history.incoming.filter((event) => !history.known.has(event.id)).forEach(mark)
hydrated = true
completed = version
historyError = ""
render()
if (requested <= completed) return
} catch (cause) {
if (abort.signal.aborted) return
historyError = cause instanceof Error ? cause.message : String(cause)
render()
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
})().finally(() => {
syncing = undefined
})
return syncing
}
const requestSync = () => {
requested++
return sync()
}
const unsubs = [
sdk.event.on("memory.updated", (event) => {
if (!event.properties.sessionID) bump("memory")
}),
sdk.event.on("mcp.tools.changed", () => bump("mcp")),
sdk.event.on("pty.created", () => bump("terminal")),
sdk.event.on("pty.updated", () => bump("terminal")),
sdk.event.on("pty.exited", () => bump("terminal")),
sdk.event.on("pty.deleted", () => bump("terminal")),
]

void requestSync()
let timer: ReturnType<typeof setTimeout> | undefined
void (async () => {
while (!abort.signal.aborted) {
try {
const response = await client.eventLog.stream(
{ sessionID },
{
onSseError: (cause) => {
if (abort.signal.aborted) return
connected = false
streamError = cause instanceof Error ? cause.message : String(cause)
render()
},
onSseEvent: () => {
if (timer) clearTimeout(timer)
timer = undefined
const recovered = !connected
connected = true
streamError = ""
render()
if (recovered) void requestSync()
},
},
)
timer = setTimeout(() => {
if (abort.signal.aborted) return
streamError = "Live activity stream did not connect"
render()
}, 5_000)
let next = await response.stream.next()
while (!next.done && !abort.signal.aborted) {
if (isActivity(next.value)) add(next.value)
next = await response.stream.next()
}
if (abort.signal.aborted) return
connected = false
streamError = "Live activity stream disconnected"
render()
} catch (cause) {
if (abort.signal.aborted) return
connected = false
streamError = cause instanceof Error ? cause.message : String(cause)
render()
} finally {
if (timer) clearTimeout(timer)
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
})()

onCleanup(() => {
if (timer) clearTimeout(timer)
abort.abort()
unsubs.forEach((unsub) => unsub())
})
})

const latest = createMemo(() => events.at(-1))

return {
get events() {
return events
},
get connected() {
return state.connected
},
get error() {
return state.error
},
latest,
count(source: ActivitySource) {
return events.filter((event) => event.source === source).length
},
changes(channel: WorkbenchChannel) {
return state.changes[channel]
},
revision(channel: WorkbenchChannel) {
return state.revision[channel]
},
last(channel: WorkbenchChannel) {
return state.last[channel]
},
ack(channel: WorkbenchChannel) {
setState("changes", channel, 0)
},
}
},
})
33 changes: 31 additions & 2 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { useServer } from "@/context/server"
import { WorkbenchBar } from "@/pages/session/workbench-bar"

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

const [ui, setUi] = createStore({
activity: false,
responding: false,
pendingMessage: undefined as string | undefined,
scrollGesture: 0,
Expand Down Expand Up @@ -691,6 +693,7 @@ export default function Page() {
setUi("autoCreated", false)
return
}
if (ui.activity) return
if (observer()) return
if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
terminal.new()
Expand All @@ -701,7 +704,7 @@ export default function Page() {
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (prevCount !== undefined && prevCount > 0 && count === 0 && !ui.activity) {
if (view().terminal.opened()) {
view().terminal.toggle()
}
Expand Down Expand Up @@ -737,6 +740,16 @@ export default function Page() {
)

const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const openActivity = () => {
setUi("activity", true)
if (!view().terminal.opened()) view().terminal.open()
}
const newTerminal = () => {
setUi("activity", false)
setUi("autoCreated", true)
view().terminal.open()
terminal.new()
}

createEffect(
on(
Expand All @@ -745,6 +758,7 @@ export default function Page() {
setStore("messageId", undefined)
setStore("expanded", {})
setStore("changes", "session")
setUi("activity", false)
setUi("autoCreated", false)
},
{ defer: true },
Expand Down Expand Up @@ -958,6 +972,7 @@ export default function Page() {
setActiveMessage,
addSelectionToContext,
focusInput,
newTerminal,
})

const openReviewFile = createOpenReviewFile({
Expand Down Expand Up @@ -1572,6 +1587,14 @@ export default function Page() {
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
<WorkbenchBar
busy={status().type === "busy"}
observer={observer()}
onActivity={openActivity}
onMission={() => openTab("mission-panel")}
onTopology={() => openTab("topology-panel")}
onMemory={() => openTab("memory-panel")}
/>
<div
class="flex-1 min-h-0 flex"
classList={{
Expand Down Expand Up @@ -1791,7 +1814,13 @@ export default function Page() {
open={view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}
close={() => {
view().terminal.close()
setUi("activity", false)
}}
activity={ui.activity}
setActivity={(value) => setUi("activity", value)}
newTerminal={newTerminal}
terminal={terminal}
readOnly={observer()}
language={language}
Expand Down
Loading