solidity-argus 0.3.7 → 0.5.6
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/AGENTS.md +13 -6
- package/README.md +24 -12
- package/package.json +7 -3
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
- package/skills/checklists/general-audit/SKILL.md +1 -0
- package/skills/methodology/audit-workflow/SKILL.md +1 -0
- package/skills/methodology/report-template/SKILL.md +1 -0
- package/skills/methodology/severity-classification/SKILL.md +1 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
- package/src/agents/argus-prompt.ts +98 -33
- package/src/agents/pythia-prompt.ts +18 -1
- package/src/agents/scribe-prompt.ts +32 -10
- package/src/agents/sentinel-prompt.ts +19 -0
- package/src/agents/themis-prompt.ts +110 -0
- package/src/cli/commands/doctor.ts +29 -17
- package/src/config/loader.ts +29 -5
- package/src/config/schema.ts +45 -45
- package/src/constants/defaults.ts +1 -0
- package/src/create-hooks.ts +797 -148
- package/src/create-managers.ts +4 -2
- package/src/create-tools.ts +5 -1
- package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
- package/src/features/background-agent/background-manager.ts +32 -5
- package/src/features/error-recovery/tool-error-recovery.ts +1 -0
- package/src/features/persistent-state/audit-state-manager.ts +272 -29
- package/src/features/persistent-state/event-sink.ts +96 -25
- package/src/features/persistent-state/findings-materializer.ts +34 -2
- package/src/features/persistent-state/global-run-index.ts +86 -8
- package/src/features/persistent-state/index.ts +7 -1
- package/src/features/persistent-state/run-finalizer.ts +116 -7
- package/src/features/persistent-state/run-pruner.ts +93 -0
- package/src/hooks/agent-tracker.ts +14 -2
- package/src/hooks/compaction-hook.ts +7 -16
- package/src/hooks/config-handler.ts +83 -29
- package/src/hooks/context-budget.ts +4 -5
- package/src/hooks/event-hook.ts +213 -57
- package/src/hooks/knowledge-sync-hook.ts +2 -3
- package/src/hooks/safe-create-hook.ts +13 -1
- package/src/hooks/system-prompt-hook.ts +20 -39
- package/src/hooks/tool-tracking-hook.ts +597 -323
- package/src/index.ts +15 -1
- package/src/knowledge/scvd-client.ts +2 -4
- package/src/knowledge/scvd-errors.ts +25 -2
- package/src/knowledge/scvd-index.ts +7 -5
- package/src/knowledge/scvd-sync.ts +6 -6
- package/src/managers/types.ts +20 -2
- package/src/shared/agent-names.ts +23 -0
- package/src/shared/audit-artifact-resolver.ts +8 -3
- package/src/shared/audit-phases.ts +12 -0
- package/src/shared/cache-paths.ts +41 -0
- package/src/shared/drop-diagnostics.ts +2 -2
- package/src/shared/forge-errors.ts +31 -0
- package/src/shared/forge-runner.ts +30 -0
- package/src/shared/format-error.ts +3 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/key-tools.ts +39 -0
- package/src/shared/logger.ts +7 -7
- package/src/shared/path-containment.ts +25 -0
- package/src/shared/path-utils.ts +11 -0
- package/src/shared/report-path-resolver.ts +4 -2
- package/src/shared/safe-emit.ts +24 -0
- package/src/shared/token-utils.ts +5 -0
- package/src/shared/type-guards.ts +8 -0
- package/src/shared/validation-constants.ts +52 -0
- package/src/skills/analysis/cluster.ts +1 -114
- package/src/skills/analysis/normalize.ts +2 -114
- package/src/skills/analysis/stopwords.ts +109 -0
- package/src/skills/argus-skill-resolver.ts +6 -3
- package/src/solodit-lifecycle.ts +153 -37
- package/src/state/adapters.ts +60 -66
- package/src/state/finding-aggregation.ts +6 -8
- package/src/state/finding-fingerprint.ts +1 -1
- package/src/state/finding-store.ts +31 -9
- package/src/state/index.ts +1 -1
- package/src/state/projectors.ts +27 -19
- package/src/state/schemas.ts +8 -32
- package/src/state/types.ts +3 -0
- package/src/tools/contract-analyzer-tool.ts +4 -6
- package/src/tools/forge-coverage-tool.ts +10 -35
- package/src/tools/forge-fuzz-tool.ts +21 -51
- package/src/tools/forge-test-tool.ts +25 -47
- package/src/tools/gas-analysis-tool.ts +12 -41
- package/src/tools/pattern-checker-tool.ts +37 -15
- package/src/tools/pattern-loader.ts +18 -4
- package/src/tools/persist-deduped-tool.ts +94 -0
- package/src/tools/proxy-detection-tool.ts +35 -34
- package/src/tools/read-findings-tool.ts +390 -0
- package/src/tools/record-finding-tool.ts +120 -25
- package/src/tools/report-generator-tool.ts +394 -328
- package/src/tools/report-preflight.ts +5 -1
- package/src/tools/slither-tool.ts +55 -16
- package/src/tools/solodit-search-tool.ts +260 -112
- package/src/tools/sync-knowledge-tool.ts +2 -3
- package/src/utils/solidity-parser.ts +39 -24
- package/src/features/migration/index.ts +0 -14
- package/src/features/migration/migration-adapter.ts +0 -151
- package/src/features/migration/parity-telemetry.ts +0 -133
package/src/create-hooks.ts
CHANGED
|
@@ -2,32 +2,41 @@ import type { Hooks as PluginHooks } from "@opencode-ai/plugin"
|
|
|
2
2
|
import type { ArgusConfig } from "./config/types"
|
|
3
3
|
import { createAuditEnforcer } from "./features/audit-enforcer/audit-enforcer"
|
|
4
4
|
import { createContextMonitor, createToolOutputTruncator } from "./features/context-monitor"
|
|
5
|
+
import { createToolErrorRecoveryHandler } from "./features/error-recovery"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createAuditStateManager,
|
|
9
|
+
createDebouncedSave,
|
|
10
|
+
} from "./features/persistent-state/audit-state-manager"
|
|
5
11
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
12
|
+
createEventSink,
|
|
13
|
+
type EventSink,
|
|
14
|
+
releaseEventSink,
|
|
15
|
+
} from "./features/persistent-state/event-sink"
|
|
16
|
+
import {
|
|
17
|
+
materializeFindings,
|
|
18
|
+
materializeReportInput,
|
|
19
|
+
} from "./features/persistent-state/findings-materializer"
|
|
20
|
+
import { recordRun, updateRunStatus } from "./features/persistent-state/global-run-index"
|
|
21
|
+
import { finalizeRun } from "./features/persistent-state/run-finalizer"
|
|
16
22
|
import { createRunJournal } from "./features/persistent-state/run-journal"
|
|
23
|
+
import { pruneStaleRuns } from "./features/persistent-state/run-pruner"
|
|
17
24
|
import { createAgentTracker } from "./hooks/agent-tracker"
|
|
18
25
|
import { createCompactionHook } from "./hooks/compaction-hook"
|
|
19
26
|
import { createConfigHandler } from "./hooks/config-handler"
|
|
20
27
|
import { getTokenBudgetForAgent } from "./hooks/context-budget"
|
|
21
|
-
import { createEventHook } from "./hooks/event-hook"
|
|
28
|
+
import { createEventHook, extractSessionId } from "./hooks/event-hook"
|
|
22
29
|
import type { ReconContext } from "./hooks/recon-context-builder"
|
|
23
30
|
import { buildReconContextBlock } from "./hooks/recon-context-builder"
|
|
24
31
|
import { safeCreateHook } from "./hooks/safe-create-hook"
|
|
25
32
|
import { createSystemPromptHook } from "./hooks/system-prompt-hook"
|
|
26
33
|
import { createToolTrackingHook } from "./hooks/tool-tracking-hook"
|
|
27
34
|
import type { HookName } from "./hooks/types"
|
|
28
|
-
import type { Managers } from "./managers/types"
|
|
35
|
+
import type { AuditStateManager, Managers } from "./managers/types"
|
|
29
36
|
import { createAuditArtifactResolver } from "./shared/audit-artifact-resolver"
|
|
30
37
|
import { createLogger } from "./shared/logger"
|
|
38
|
+
import { ARGUS_PLUGIN_VERSION } from "./shared/plugin-metadata"
|
|
39
|
+
import { SCHEMA_VERSION } from "./state/schemas"
|
|
31
40
|
import type { AuditState } from "./state/types"
|
|
32
41
|
import { detectAuditArtifacts } from "./utils/audit-artifact-detector"
|
|
33
42
|
import { detectProject, type ProjectConfig } from "./utils/project-detector"
|
|
@@ -66,6 +75,37 @@ function extractRunIdFromReportToolOutput(result: string): string | undefined {
|
|
|
66
75
|
return undefined
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
function extractReportFilePathFromToolOutput(result: string): string | undefined {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(result) as Record<string, unknown>
|
|
81
|
+
if (typeof parsed.filePath === "string" && parsed.filePath.length > 0) {
|
|
82
|
+
return parsed.filePath
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractReportErrorFromToolOutput(result: string): string | undefined {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(result) as Record<string, unknown>
|
|
94
|
+
const error = parsed.error
|
|
95
|
+
if (typeof error === "object" && error !== null && !Array.isArray(error)) {
|
|
96
|
+
const message = (error as Record<string, unknown>).message
|
|
97
|
+
if (typeof message === "string" && message.length > 0) {
|
|
98
|
+
return message
|
|
99
|
+
}
|
|
100
|
+
return "argus_generate_report returned an unknown error"
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
return "argus_generate_report output was not valid JSON"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
export function getAgentForSession(sessionID: string): string | undefined {
|
|
70
110
|
return _agentTrackerRef?.getAgentForSession(sessionID)
|
|
71
111
|
}
|
|
@@ -83,7 +123,10 @@ export type Hooks = Pick<
|
|
|
83
123
|
| "experimental.session.compacting"
|
|
84
124
|
| "tool.execute.after"
|
|
85
125
|
| "event"
|
|
86
|
-
>
|
|
126
|
+
> & {
|
|
127
|
+
/** Release the process-wide instance lock so the plugin can be re-initialized. */
|
|
128
|
+
dispose?: () => void
|
|
129
|
+
}
|
|
87
130
|
|
|
88
131
|
/**
|
|
89
132
|
* Creates the hook handlers for the Argus plugin.
|
|
@@ -102,17 +145,57 @@ export function createHooks(args: {
|
|
|
102
145
|
projectDir: string
|
|
103
146
|
isHookEnabled: (name: HookName) => boolean
|
|
104
147
|
}): Hooks {
|
|
148
|
+
// Instance-level mutex: when OpenCode loads the plugin multiple times in the
|
|
149
|
+
// same process (e.g. re-adding "solidity-argus" to global config), only the
|
|
150
|
+
// first instance runs full initialization. Subsequent calls get inert hooks
|
|
151
|
+
// with only the config handler active (agent/MCP registration is idempotent).
|
|
152
|
+
const INSTANCE_LOCK = Symbol.for("solidity-argus:instance-lock")
|
|
153
|
+
const globals = globalThis as unknown as Record<symbol, boolean>
|
|
154
|
+
const releaseInstanceLock = () => {
|
|
155
|
+
delete globals[INSTANCE_LOCK]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (globals[INSTANCE_LOCK]) {
|
|
159
|
+
logger.debug("[plugin] Duplicate instance detected — returning inert hooks")
|
|
160
|
+
return {
|
|
161
|
+
config: createConfigHandler(args.config, args.projectDir),
|
|
162
|
+
"chat.params": undefined,
|
|
163
|
+
"chat.message": undefined,
|
|
164
|
+
"experimental.chat.system.transform": undefined,
|
|
165
|
+
"experimental.session.compacting": undefined,
|
|
166
|
+
"tool.execute.after": undefined,
|
|
167
|
+
event: undefined,
|
|
168
|
+
dispose: releaseInstanceLock,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
globals[INSTANCE_LOCK] = true
|
|
172
|
+
|
|
105
173
|
const { config, managers, projectDir, isHookEnabled } = args
|
|
106
|
-
const { auditStateManager
|
|
174
|
+
const { auditStateManager } = managers
|
|
107
175
|
const agentTracker = createAgentTracker()
|
|
108
176
|
_agentTrackerRef = agentTracker
|
|
109
177
|
|
|
110
|
-
const migrationMode = getMigrationMode(config)
|
|
111
|
-
logger.debug(`Migration mode: ${migrationMode}`)
|
|
112
|
-
|
|
113
178
|
const contextMonitor = createContextMonitor()
|
|
114
|
-
const sessionRecoveryHandler = createSessionRecoveryHandler(auditStateManager)
|
|
115
179
|
const debouncedSave = createDebouncedSave(auditStateManager.save)
|
|
180
|
+
|
|
181
|
+
const exitHandler = () => {
|
|
182
|
+
try {
|
|
183
|
+
debouncedSave.dispose()
|
|
184
|
+
for (const sessionDebouncedSave of debouncedSavesBySession.values()) {
|
|
185
|
+
sessionDebouncedSave.dispose()
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
/* noop */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
process.on("exit", exitHandler)
|
|
192
|
+
|
|
193
|
+
const fullDispose = () => {
|
|
194
|
+
_agentTrackerRef = undefined
|
|
195
|
+
process.removeListener("exit", exitHandler)
|
|
196
|
+
releaseInstanceLock()
|
|
197
|
+
}
|
|
198
|
+
|
|
116
199
|
const runJournal = createRunJournal(projectDir)
|
|
117
200
|
let auditStateGetter: (() => AuditState | null) | undefined
|
|
118
201
|
const toolErrorRecoveryHandler = createToolErrorRecoveryHandler(
|
|
@@ -121,80 +204,380 @@ export function createHooks(args: {
|
|
|
121
204
|
)
|
|
122
205
|
const outputTruncator = createToolOutputTruncator()
|
|
123
206
|
|
|
124
|
-
|
|
125
|
-
|
|
207
|
+
// Memory-leak guard: cap unbounded EventSink maps at 100 entries with 24-hour TTL.
|
|
208
|
+
const MAX_SINKS = 100
|
|
209
|
+
const MAX_SESSION_TRACKING = 500
|
|
210
|
+
const SINK_TTL_MS = 24 * 60 * 60 * 1000
|
|
126
211
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const {
|
|
130
|
-
hook: eventHook,
|
|
131
|
-
getAuditState,
|
|
132
|
-
setAuditState,
|
|
133
|
-
setEventSink,
|
|
134
|
-
getLastFinalizationResult,
|
|
135
|
-
} = createEventHook(projectDir, [
|
|
136
|
-
async ({ type, sessionId, auditState, setAuditState: setState }) => {
|
|
137
|
-
if (type === "session.created") {
|
|
138
|
-
currentOpencodeSessionId = sessionId ?? ""
|
|
139
|
-
const timestamp = Date.now()
|
|
140
|
-
let recoveredState: AuditState | null = null
|
|
212
|
+
const eventSinksByOpencodeSession = new Map<string, EventSink>()
|
|
213
|
+
const eventSinksByRunId = new Map<string, EventSink>()
|
|
141
214
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
215
|
+
const sinkCreatedAtBySession = new Map<string, number>()
|
|
216
|
+
const sinkCreatedAtByRunId = new Map<string, number>()
|
|
217
|
+
|
|
218
|
+
const pendingSinkCreations = new Set<string>()
|
|
219
|
+
const activatedSessions = new Set<string>()
|
|
220
|
+
const sessionManagers = new Map<string, AuditStateManager>()
|
|
221
|
+
const debouncedSavesBySession = new Map<string, ReturnType<typeof createDebouncedSave>>()
|
|
222
|
+
|
|
223
|
+
const pendingActivations = new Set<string>()
|
|
224
|
+
|
|
225
|
+
function getSessionManager(sessionId: string): AuditStateManager {
|
|
226
|
+
let manager = sessionManagers.get(sessionId)
|
|
227
|
+
if (!manager) {
|
|
228
|
+
manager = createAuditStateManager(projectDir)
|
|
229
|
+
manager.bindSession(sessionId)
|
|
230
|
+
sessionManagers.set(sessionId, manager)
|
|
231
|
+
|
|
232
|
+
if (sessionManagers.size > MAX_SESSION_TRACKING) {
|
|
233
|
+
const oldest = sessionManagers.keys().next()
|
|
234
|
+
if (!oldest.done) {
|
|
235
|
+
const oldestSessionId = oldest.value
|
|
236
|
+
if (oldestSessionId !== sessionId) {
|
|
237
|
+
const oldestDebouncedSave = debouncedSavesBySession.get(oldestSessionId)
|
|
238
|
+
oldestDebouncedSave?.dispose()
|
|
239
|
+
debouncedSavesBySession.delete(oldestSessionId)
|
|
240
|
+
sessionManagers.delete(oldestSessionId)
|
|
241
|
+
}
|
|
151
242
|
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
152
245
|
|
|
153
|
-
|
|
154
|
-
|
|
246
|
+
return manager
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getSessionDebouncedSave(sessionId: string): ReturnType<typeof createDebouncedSave> {
|
|
250
|
+
let sessionDebouncedSave = debouncedSavesBySession.get(sessionId)
|
|
251
|
+
if (!sessionDebouncedSave) {
|
|
252
|
+
sessionDebouncedSave = createDebouncedSave(getSessionManager(sessionId).save)
|
|
253
|
+
debouncedSavesBySession.set(sessionId, sessionDebouncedSave)
|
|
254
|
+
}
|
|
255
|
+
return sessionDebouncedSave
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Prevent session-tracking Sets from growing unboundedly in long-running processes.
|
|
260
|
+
*
|
|
261
|
+
* activatedSessions uses FIFO eviction because it is a permanent dedup guard —
|
|
262
|
+
* losing an entry could cause a redundant (but harmless) re-activation.
|
|
263
|
+
*
|
|
264
|
+
* pendingSinkCreations and pendingActivations are transient guards that are
|
|
265
|
+
* removed after their async operation completes. If they overflow, .clear() is
|
|
266
|
+
* safe — the worst case is a redundant activation attempt that the rest of the
|
|
267
|
+
* pipeline handles idempotently.
|
|
268
|
+
*/
|
|
269
|
+
function trimSessionSets(): void {
|
|
270
|
+
if (activatedSessions.size > MAX_SESSION_TRACKING) {
|
|
271
|
+
const excess = activatedSessions.size - MAX_SESSION_TRACKING
|
|
272
|
+
const iterator = activatedSessions.values()
|
|
273
|
+
for (let i = 0; i < excess; i++) {
|
|
274
|
+
const next = iterator.next()
|
|
275
|
+
if (!next.done) activatedSessions.delete(next.value)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (pendingSinkCreations.size > MAX_SESSION_TRACKING) {
|
|
279
|
+
pendingSinkCreations.clear()
|
|
280
|
+
}
|
|
281
|
+
if (pendingActivations.size > MAX_SESSION_TRACKING) {
|
|
282
|
+
pendingActivations.clear()
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function activateSession(sessionId: string): Promise<void> {
|
|
287
|
+
if (activatedSessions.has(sessionId)) return
|
|
288
|
+
if (pendingActivations.has(sessionId)) return
|
|
289
|
+
|
|
290
|
+
const auditState = getAuditState(sessionId)
|
|
291
|
+
if (!auditState) return
|
|
292
|
+
|
|
293
|
+
pendingActivations.add(sessionId)
|
|
294
|
+
// Must be set BEFORE the try block — if two concurrent activateSession calls race,
|
|
295
|
+
// the second must see this guard immediately to prevent duplicate sink creation.
|
|
296
|
+
pendingSinkCreations.add(sessionId)
|
|
297
|
+
let sessionActivated = false
|
|
298
|
+
try {
|
|
299
|
+
const timestamp = Date.now()
|
|
300
|
+
const sessionManager = getSessionManager(sessionId)
|
|
301
|
+
|
|
302
|
+
const existingSink = (() => {
|
|
303
|
+
const directSink = eventSinksByOpencodeSession.get(sessionId)
|
|
304
|
+
if (directSink) return directSink
|
|
305
|
+
|
|
306
|
+
const parentSessionId = agentTracker.getParentSession(sessionId)
|
|
307
|
+
if (parentSessionId) {
|
|
308
|
+
const parentSink = eventSinksByOpencodeSession.get(parentSessionId)
|
|
309
|
+
if (parentSink) return parentSink
|
|
155
310
|
}
|
|
156
311
|
|
|
157
|
-
|
|
158
|
-
|
|
312
|
+
const activeSinks = Array.from(eventSinksByRunId.values()).filter((s) => !s.isFinalized)
|
|
313
|
+
if (activeSinks.length === 1) return activeSinks[0] ?? null
|
|
314
|
+
if (activeSinks.length > 1) {
|
|
315
|
+
// Multiple active sinks — pick the most recently created one.
|
|
316
|
+
// This handles the case where a stale run's sink was never finalized.
|
|
317
|
+
const sorted = [...sinkCreatedAtByRunId.entries()]
|
|
318
|
+
.filter(([rid]) => {
|
|
319
|
+
const s = eventSinksByRunId.get(rid)
|
|
320
|
+
return s != null && !s.isFinalized
|
|
321
|
+
})
|
|
322
|
+
.sort((a, b) => b[1] - a[1])
|
|
323
|
+
const newest = sorted[0]
|
|
324
|
+
return newest ? (eventSinksByRunId.get(newest[0]) ?? null) : null
|
|
325
|
+
}
|
|
326
|
+
return null
|
|
327
|
+
})()
|
|
328
|
+
|
|
329
|
+
// Fallback: if no existing sink found via direct/parent/heuristic lookup,
|
|
330
|
+
// try inheriting the parent's run ID via audit state → eventSinksByRunId.
|
|
331
|
+
// This handles the timing race where the child's activateSession fires before
|
|
332
|
+
// the parent's sink is registered in eventSinksByOpencodeSession.
|
|
333
|
+
const coalescedSink =
|
|
334
|
+
existingSink ??
|
|
335
|
+
(() => {
|
|
336
|
+
const parentSessionId = agentTracker.getParentSession(sessionId)
|
|
337
|
+
if (!parentSessionId) return null
|
|
338
|
+
const parentState = getAuditState(parentSessionId)
|
|
339
|
+
if (!parentState || parentState.sessionId.length === 0) return null
|
|
340
|
+
const parentSink = eventSinksByRunId.get(parentState.sessionId)
|
|
341
|
+
return parentSink && !parentSink.isFinalized ? parentSink : null
|
|
342
|
+
})()
|
|
343
|
+
|
|
344
|
+
if (coalescedSink) {
|
|
345
|
+
setEventSink(coalescedSink, sessionId)
|
|
346
|
+
setBoundedSink(
|
|
347
|
+
eventSinksByOpencodeSession,
|
|
348
|
+
sinkCreatedAtBySession,
|
|
159
349
|
sessionId,
|
|
160
|
-
|
|
350
|
+
coalescedSink,
|
|
351
|
+
)
|
|
352
|
+
setBoundedSink(eventSinksByRunId, sinkCreatedAtByRunId, coalescedSink.runId, coalescedSink)
|
|
353
|
+
|
|
354
|
+
const existingResolver = createAuditArtifactResolver(coalescedSink.runId, projectDir)
|
|
355
|
+
recordRun({
|
|
356
|
+
runId: coalescedSink.runId,
|
|
357
|
+
opencodeSessionId: sessionId,
|
|
358
|
+
projectDir: auditState?.projectDir ?? projectDir,
|
|
359
|
+
statePath: existingResolver.paths().stateFile,
|
|
360
|
+
journalPath: existingResolver.paths().journalFile,
|
|
361
|
+
startedAt: auditState?.startTime ?? timestamp,
|
|
362
|
+
phase: auditState?.currentPhase ?? "reconnaissance",
|
|
363
|
+
findingsCount: auditState?.findings.length ?? 0,
|
|
364
|
+
}).catch((err) =>
|
|
365
|
+
logger.warn(`Failed to record run: ${err instanceof Error ? err.message : String(err)}`),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if (auditState) {
|
|
369
|
+
setAuditState({ ...auditState, sessionId: coalescedSink.runId }, sessionId)
|
|
370
|
+
}
|
|
371
|
+
runJournal.log({ type: "state.loaded", timestamp, success: true, findingsCount: 0 })
|
|
372
|
+
sessionActivated = true
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let recoveredState: AuditState | null = null
|
|
377
|
+
try {
|
|
378
|
+
recoveredState = await sessionManager.load()
|
|
379
|
+
} finally {
|
|
380
|
+
runJournal.log({
|
|
381
|
+
type: "state.loaded",
|
|
382
|
+
timestamp,
|
|
383
|
+
success: recoveredState !== null,
|
|
384
|
+
findingsCount: recoveredState?.findings.length ?? 0,
|
|
161
385
|
})
|
|
386
|
+
}
|
|
162
387
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
388
|
+
const STALE_STATE_TTL_MS = 24 * 60 * 60 * 1000
|
|
389
|
+
if (recoveredState) {
|
|
390
|
+
const isStale =
|
|
391
|
+
typeof recoveredState.startTime === "number" &&
|
|
392
|
+
timestamp - recoveredState.startTime > STALE_STATE_TTL_MS
|
|
393
|
+
const isCompleted = recoveredState.reportGenerated === true
|
|
394
|
+
if (isStale || isCompleted) {
|
|
395
|
+
logger.debug(
|
|
396
|
+
`Discarding recovered state for run ${recoveredState.sessionId}: ${isCompleted ? "report already generated" : "stale (>24h)"}`,
|
|
397
|
+
)
|
|
398
|
+
recoveredState = null
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (recoveredState && auditState) {
|
|
403
|
+
setAuditState(
|
|
404
|
+
{
|
|
405
|
+
...recoveredState,
|
|
406
|
+
sessionId: auditState.sessionId,
|
|
407
|
+
projectDir: auditState.projectDir,
|
|
408
|
+
startTime: auditState.startTime,
|
|
409
|
+
},
|
|
410
|
+
sessionId,
|
|
411
|
+
)
|
|
412
|
+
} else if (recoveredState) {
|
|
413
|
+
setAuditState(recoveredState, sessionId)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const effectiveState = getAuditState(sessionId) ?? recoveredState
|
|
417
|
+
if (effectiveState) {
|
|
418
|
+
const raceSink = eventSinksByOpencodeSession.get(sessionId)
|
|
419
|
+
if (raceSink) {
|
|
420
|
+
setEventSink(raceSink, sessionId)
|
|
421
|
+
setBoundedSink(eventSinksByRunId, sinkCreatedAtByRunId, raceSink.runId, raceSink)
|
|
422
|
+
if (auditState) {
|
|
423
|
+
setAuditState({ ...auditState, sessionId: raceSink.runId }, sessionId)
|
|
176
424
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
425
|
+
runJournal.log({ type: "state.loaded", timestamp, success: true, findingsCount: 0 })
|
|
426
|
+
sessionActivated = true
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
|
|
431
|
+
try {
|
|
432
|
+
const sink = createEventSink(effectiveState.sessionId, projectDir)
|
|
433
|
+
setEventSink(sink, sessionId)
|
|
434
|
+
setBoundedSink(eventSinksByOpencodeSession, sinkCreatedAtBySession, sessionId, sink)
|
|
435
|
+
setBoundedSink(eventSinksByRunId, sinkCreatedAtByRunId, effectiveState.sessionId, sink)
|
|
436
|
+
|
|
437
|
+
await sink.append({
|
|
438
|
+
type: "session.created",
|
|
439
|
+
run_id: effectiveState.sessionId,
|
|
440
|
+
seq: 0,
|
|
441
|
+
session_id: sessionId,
|
|
442
|
+
source: "create-hooks",
|
|
443
|
+
schema_version: SCHEMA_VERSION,
|
|
444
|
+
timestamp,
|
|
445
|
+
payload: {
|
|
446
|
+
projectDir: effectiveState.projectDir,
|
|
447
|
+
sessionId: effectiveState.sessionId,
|
|
448
|
+
plugin_version: ARGUS_PLUGIN_VERSION,
|
|
449
|
+
scope: effectiveState.scope,
|
|
450
|
+
},
|
|
186
451
|
})
|
|
452
|
+
} catch (error) {
|
|
453
|
+
logger.warn(
|
|
454
|
+
`EventSink creation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
recordRun({
|
|
458
|
+
runId: effectiveState.sessionId,
|
|
459
|
+
opencodeSessionId: sessionId,
|
|
460
|
+
projectDir: effectiveState.projectDir,
|
|
461
|
+
statePath: resolver.paths().stateFile,
|
|
462
|
+
journalPath: resolver.paths().journalFile,
|
|
463
|
+
startedAt: effectiveState.startTime,
|
|
464
|
+
phase: effectiveState.currentPhase,
|
|
465
|
+
findingsCount: effectiveState.findings.length,
|
|
466
|
+
status: "active",
|
|
467
|
+
}).catch((err) =>
|
|
468
|
+
logger.warn(`Failed to record run: ${err instanceof Error ? err.message : String(err)}`),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
pruneStaleRuns(effectiveState.projectDir).catch((err) =>
|
|
472
|
+
logger.warn(
|
|
473
|
+
`Failed to prune stale runs: ${err instanceof Error ? err.message : String(err)}`,
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
sessionActivated = true
|
|
479
|
+
} finally {
|
|
480
|
+
if (sessionActivated) {
|
|
481
|
+
activatedSessions.add(sessionId)
|
|
482
|
+
}
|
|
483
|
+
pendingActivations.delete(sessionId)
|
|
484
|
+
pendingSinkCreations.delete(sessionId)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Evict the oldest entry from a bounded EventSink map and its companion timestamp map. */
|
|
489
|
+
function evictOldestSink(
|
|
490
|
+
sinkMap: Map<string, EventSink>,
|
|
491
|
+
timestampMap: Map<string, number>,
|
|
492
|
+
): void {
|
|
493
|
+
const oldestKey = sinkMap.keys().next().value
|
|
494
|
+
if (oldestKey === undefined) return
|
|
495
|
+
const sink = sinkMap.get(oldestKey)
|
|
496
|
+
if (sink && !sink.isFinalized) {
|
|
497
|
+
try {
|
|
498
|
+
sink.markFinalized()
|
|
499
|
+
} catch {
|
|
500
|
+
/* noop — best-effort finalization */
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
sinkMap.delete(oldestKey)
|
|
504
|
+
timestampMap.delete(oldestKey)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Evict any entries older than SINK_TTL_MS from a bounded EventSink map. */
|
|
508
|
+
function evictStaleSinks(
|
|
509
|
+
sinkMap: Map<string, EventSink>,
|
|
510
|
+
timestampMap: Map<string, number>,
|
|
511
|
+
): void {
|
|
512
|
+
const now = Date.now()
|
|
513
|
+
for (const [key, createdAt] of timestampMap) {
|
|
514
|
+
if (now - createdAt > SINK_TTL_MS) {
|
|
515
|
+
const sink = sinkMap.get(key)
|
|
516
|
+
if (sink && !sink.isFinalized) {
|
|
517
|
+
try {
|
|
518
|
+
sink.markFinalized()
|
|
519
|
+
} catch {
|
|
520
|
+
/* noop */
|
|
521
|
+
}
|
|
187
522
|
}
|
|
523
|
+
sinkMap.delete(key)
|
|
524
|
+
timestampMap.delete(key)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Add a sink to a bounded map, evicting oldest entries if the limit is reached. */
|
|
530
|
+
function setBoundedSink(
|
|
531
|
+
sinkMap: Map<string, EventSink>,
|
|
532
|
+
timestampMap: Map<string, number>,
|
|
533
|
+
key: string,
|
|
534
|
+
sink: EventSink,
|
|
535
|
+
): void {
|
|
536
|
+
evictStaleSinks(sinkMap, timestampMap)
|
|
537
|
+
if (sinkMap.size >= MAX_SINKS && !sinkMap.has(key)) {
|
|
538
|
+
evictOldestSink(sinkMap, timestampMap)
|
|
539
|
+
}
|
|
540
|
+
sinkMap.set(key, sink)
|
|
541
|
+
if (!timestampMap.has(key)) {
|
|
542
|
+
timestampMap.set(key, Date.now())
|
|
543
|
+
}
|
|
544
|
+
trimSessionSets()
|
|
545
|
+
}
|
|
188
546
|
|
|
547
|
+
// Sub-handlers run sequentially. The state persistence handler MUST be first:
|
|
548
|
+
// it loads persisted state on session.created, overriding the fresh default.
|
|
549
|
+
const {
|
|
550
|
+
hook: eventHook,
|
|
551
|
+
getAuditState,
|
|
552
|
+
setAuditState,
|
|
553
|
+
setEventSink,
|
|
554
|
+
getLastFinalizationResult,
|
|
555
|
+
} = createEventHook(projectDir, [
|
|
556
|
+
async ({ type, sessionId, auditState, setAuditState: _setState }) => {
|
|
557
|
+
if (type === "session.created") {
|
|
558
|
+
// Lazy activation: on session.created we don't yet know which agent
|
|
559
|
+
// the user will select (chat.params fires later). We only create
|
|
560
|
+
// in-memory state here; all disk I/O (EventSink, state persistence,
|
|
561
|
+
// run recording) is deferred to activateSession() which is triggered
|
|
562
|
+
// by chat.params (Argus agent) or tool.execute.after (argus_* tool).
|
|
189
563
|
return
|
|
190
564
|
}
|
|
191
565
|
|
|
192
566
|
if (type === "session.idle" && auditState) {
|
|
193
|
-
|
|
567
|
+
if (sessionId && !activatedSessions.has(sessionId)) return
|
|
568
|
+
|
|
569
|
+
if (sessionId) {
|
|
570
|
+
await getSessionDebouncedSave(sessionId).flush()
|
|
571
|
+
} else {
|
|
572
|
+
await debouncedSave.flush()
|
|
573
|
+
}
|
|
194
574
|
|
|
195
575
|
let saveSuccess = true
|
|
196
576
|
try {
|
|
197
|
-
|
|
577
|
+
const idleManager = sessionId ? sessionManagers.get(sessionId) : auditStateManager
|
|
578
|
+
if (idleManager) {
|
|
579
|
+
await idleManager.save(auditState)
|
|
580
|
+
}
|
|
198
581
|
} catch {
|
|
199
582
|
saveSuccess = false
|
|
200
583
|
} finally {
|
|
@@ -216,7 +599,7 @@ export function createHooks(args: {
|
|
|
216
599
|
auditState.sessionId,
|
|
217
600
|
auditState.projectDir,
|
|
218
601
|
)
|
|
219
|
-
|
|
602
|
+
recordRun({
|
|
220
603
|
runId: auditState.sessionId,
|
|
221
604
|
opencodeSessionId: sessionId,
|
|
222
605
|
projectDir: auditState.projectDir,
|
|
@@ -225,30 +608,77 @@ export function createHooks(args: {
|
|
|
225
608
|
startedAt: auditState.startTime,
|
|
226
609
|
phase: auditState.currentPhase,
|
|
227
610
|
findingsCount: auditState.findings.length,
|
|
228
|
-
})
|
|
611
|
+
}).catch((err) =>
|
|
612
|
+
logger.warn(
|
|
613
|
+
`Failed to record run on idle: ${err instanceof Error ? err.message : String(err)}`,
|
|
614
|
+
),
|
|
615
|
+
)
|
|
229
616
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
617
|
+
try {
|
|
618
|
+
await materializeReportInput(auditState.sessionId, auditState.projectDir, sessionId)
|
|
619
|
+
} catch (error) {
|
|
620
|
+
logger.warn(
|
|
621
|
+
`Failed to materialize report-input artifact on session.idle for run ${auditState.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (auditState.reportGenerated) {
|
|
626
|
+
const runSink =
|
|
627
|
+
eventSinksByRunId.get(auditState.sessionId) ??
|
|
628
|
+
(sessionId ? (eventSinksByOpencodeSession.get(sessionId) ?? null) : null)
|
|
629
|
+
|
|
630
|
+
if (runSink && !runSink.isFinalized) {
|
|
631
|
+
try {
|
|
632
|
+
const idleFinalization = await finalizeRun(
|
|
633
|
+
auditState.sessionId,
|
|
634
|
+
auditState.projectDir,
|
|
635
|
+
runSink,
|
|
636
|
+
)
|
|
637
|
+
updateRunStatus(
|
|
638
|
+
auditState.sessionId,
|
|
639
|
+
idleFinalization.invariantsPassed ? "finalized" : "failed",
|
|
640
|
+
).catch((err) =>
|
|
641
|
+
logger.warn(
|
|
642
|
+
`Failed to update run status: ${err instanceof Error ? err.message : String(err)}`,
|
|
643
|
+
),
|
|
644
|
+
)
|
|
645
|
+
if (!idleFinalization.invariantsPassed) {
|
|
646
|
+
logger.warn(
|
|
647
|
+
`Idle finalization for run ${auditState.sessionId} has invariant errors: ${idleFinalization.errors.join("; ")}`,
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
} catch (error) {
|
|
651
|
+
logger.warn(
|
|
652
|
+
`Failed to finalize run ${auditState.sessionId} on session.idle: ${error instanceof Error ? error.message : String(error)}`,
|
|
653
|
+
)
|
|
654
|
+
}
|
|
243
655
|
}
|
|
244
656
|
}
|
|
657
|
+
|
|
245
658
|
return
|
|
246
659
|
}
|
|
247
660
|
|
|
248
661
|
if (type === "session.deleted") {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
662
|
+
if (sessionId && !activatedSessions.has(sessionId)) return
|
|
663
|
+
|
|
664
|
+
if (sessionId) {
|
|
665
|
+
await getSessionDebouncedSave(sessionId).flush()
|
|
666
|
+
} else {
|
|
667
|
+
await debouncedSave.flush()
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const deletedManager = sessionId ? sessionManagers.get(sessionId) : auditStateManager
|
|
671
|
+
if (deletedManager) {
|
|
672
|
+
if (auditState) {
|
|
673
|
+
await deletedManager.save(auditState)
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
await deletedManager.dispose()
|
|
677
|
+
} catch (error) {
|
|
678
|
+
logger.warn(
|
|
679
|
+
`State manager dispose failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
680
|
+
)
|
|
681
|
+
}
|
|
252
682
|
}
|
|
253
683
|
runJournal.log({
|
|
254
684
|
type: "state.saved",
|
|
@@ -258,16 +688,26 @@ export function createHooks(args: {
|
|
|
258
688
|
}
|
|
259
689
|
},
|
|
260
690
|
async ({ type, sessionId, setAuditState: setState }) => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
691
|
+
if (type !== "session.error") {
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const recoveryManager = sessionId ? getSessionManager(sessionId) : auditStateManager
|
|
696
|
+
try {
|
|
697
|
+
const recoveredState = await recoveryManager.load()
|
|
698
|
+
if (recoveredState) {
|
|
699
|
+
setState(recoveredState)
|
|
700
|
+
}
|
|
701
|
+
} catch (error) {
|
|
702
|
+
logger.warn(
|
|
703
|
+
`Session recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
704
|
+
)
|
|
266
705
|
}
|
|
267
706
|
},
|
|
707
|
+
async () => {},
|
|
268
708
|
])
|
|
269
709
|
|
|
270
|
-
auditStateGetter = getAuditState
|
|
710
|
+
auditStateGetter = () => getAuditState()
|
|
271
711
|
|
|
272
712
|
const initialState = auditStateManager.get()
|
|
273
713
|
if (initialState) {
|
|
@@ -277,11 +717,11 @@ export function createHooks(args: {
|
|
|
277
717
|
const auditEnforcer = createAuditEnforcer()
|
|
278
718
|
|
|
279
719
|
const systemPromptHook = createSystemPromptHook({
|
|
280
|
-
getAuditState,
|
|
720
|
+
getAuditState: (sessionId?: string) => getAuditState(sessionId),
|
|
281
721
|
getAgentForSession: agentTracker.getAgentForSession,
|
|
282
722
|
isArgusAgent: agentTracker.isArgusAgent,
|
|
283
|
-
getContextPressure: (systemText: string) => {
|
|
284
|
-
const status = contextMonitor.getContextStatus(systemText, getAuditState())
|
|
723
|
+
getContextPressure: (systemText: string, sessionId?: string) => {
|
|
724
|
+
const status = contextMonitor.getContextStatus(systemText, getAuditState(sessionId))
|
|
285
725
|
return status.usage
|
|
286
726
|
},
|
|
287
727
|
getTokenBudget: getTokenBudgetForAgent,
|
|
@@ -311,18 +751,28 @@ export function createHooks(args: {
|
|
|
311
751
|
})
|
|
312
752
|
|
|
313
753
|
const compactionHook = isHookEnabled("compaction")
|
|
314
|
-
? safeCreateHook(
|
|
754
|
+
? safeCreateHook(
|
|
755
|
+
() =>
|
|
756
|
+
createCompactionHook((sessionId?: string) => getAuditState(sessionId), getReconContext),
|
|
757
|
+
"compaction",
|
|
758
|
+
)
|
|
315
759
|
: undefined
|
|
316
760
|
|
|
317
761
|
const toolTrackingHook = isHookEnabled("tool-tracking")
|
|
318
762
|
? safeCreateHook(
|
|
319
763
|
() =>
|
|
320
764
|
createToolTrackingHook(
|
|
321
|
-
getAuditState,
|
|
322
|
-
({ tool, findingsCount }) => {
|
|
323
|
-
|
|
765
|
+
(sessionId?: string) => getAuditState(sessionId),
|
|
766
|
+
({ tool, findingsCount, sessionId }) => {
|
|
767
|
+
if (sessionId && !activatedSessions.has(sessionId)) return
|
|
768
|
+
|
|
769
|
+
const currentState = getAuditState(sessionId)
|
|
324
770
|
if (currentState) {
|
|
325
|
-
|
|
771
|
+
if (sessionId && sessionManagers.has(sessionId)) {
|
|
772
|
+
getSessionDebouncedSave(sessionId).save(currentState)
|
|
773
|
+
} else {
|
|
774
|
+
debouncedSave.save(currentState)
|
|
775
|
+
}
|
|
326
776
|
}
|
|
327
777
|
|
|
328
778
|
runJournal.log({
|
|
@@ -333,28 +783,56 @@ export function createHooks(args: {
|
|
|
333
783
|
})
|
|
334
784
|
},
|
|
335
785
|
{
|
|
336
|
-
getEventSink: () =>
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return undefined
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const agent = agentTracker.getAgentForSession(currentOpencodeSessionId)
|
|
344
|
-
if (
|
|
345
|
-
agent === "argus" ||
|
|
346
|
-
agent === "sentinel" ||
|
|
347
|
-
agent === "pythia" ||
|
|
348
|
-
agent === "scribe" ||
|
|
349
|
-
agent === "unknown"
|
|
350
|
-
) {
|
|
351
|
-
return agent
|
|
786
|
+
getEventSink: () => {
|
|
787
|
+
const state = getAuditState()
|
|
788
|
+
if (!state || state.sessionId.length === 0) {
|
|
789
|
+
return null
|
|
352
790
|
}
|
|
353
|
-
|
|
354
|
-
return "unknown"
|
|
791
|
+
return eventSinksByRunId.get(state.sessionId) ?? null
|
|
355
792
|
},
|
|
793
|
+
getEventSinkForSession: (sessionId: string) =>
|
|
794
|
+
eventSinksByOpencodeSession.get(sessionId) ??
|
|
795
|
+
(() => {
|
|
796
|
+
const parentSessionId = agentTracker.getParentSession(sessionId)
|
|
797
|
+
if (parentSessionId) {
|
|
798
|
+
const parentSink = eventSinksByOpencodeSession.get(parentSessionId)
|
|
799
|
+
if (parentSink) {
|
|
800
|
+
setBoundedSink(
|
|
801
|
+
eventSinksByOpencodeSession,
|
|
802
|
+
sinkCreatedAtBySession,
|
|
803
|
+
sessionId,
|
|
804
|
+
parentSink,
|
|
805
|
+
)
|
|
806
|
+
return parentSink
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const state = getAuditState(sessionId)
|
|
810
|
+
if (state && state.sessionId.length > 0) {
|
|
811
|
+
const runSink = eventSinksByRunId.get(state.sessionId)
|
|
812
|
+
if (runSink) {
|
|
813
|
+
setBoundedSink(
|
|
814
|
+
eventSinksByOpencodeSession,
|
|
815
|
+
sinkCreatedAtBySession,
|
|
816
|
+
sessionId,
|
|
817
|
+
runSink,
|
|
818
|
+
)
|
|
819
|
+
return runSink
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return null
|
|
823
|
+
})(),
|
|
824
|
+
getEventSinkForRun: (runId: string) => eventSinksByRunId.get(runId) ?? null,
|
|
825
|
+
projectDir,
|
|
826
|
+
getActiveRunSinks: () =>
|
|
827
|
+
Array.from(eventSinksByRunId.values()).filter((s) => !s.isFinalized),
|
|
356
828
|
getAgentNameForSession: (sessionId: string) => {
|
|
357
|
-
const
|
|
829
|
+
const directAgent = agentTracker.getAgentForSession(sessionId)
|
|
830
|
+
const parentSessionId = agentTracker.getParentSession(sessionId)
|
|
831
|
+
const inheritedAgent =
|
|
832
|
+
!directAgent && parentSessionId
|
|
833
|
+
? agentTracker.getAgentForSession(parentSessionId)
|
|
834
|
+
: undefined
|
|
835
|
+
const agent = directAgent ?? inheritedAgent
|
|
358
836
|
if (
|
|
359
837
|
agent === "argus" ||
|
|
360
838
|
agent === "sentinel" ||
|
|
@@ -367,9 +845,32 @@ export function createHooks(args: {
|
|
|
367
845
|
|
|
368
846
|
return "unknown"
|
|
369
847
|
},
|
|
848
|
+
onChildSessionDetected: (parentSessionId: string, childSessionId: string) => {
|
|
849
|
+
if (parentSessionId && childSessionId) {
|
|
850
|
+
agentTracker.trackChildSession(parentSessionId, childSessionId)
|
|
851
|
+
|
|
852
|
+
const parentSink = eventSinksByOpencodeSession.get(parentSessionId)
|
|
853
|
+
if (parentSink && toolTrackingHook) {
|
|
854
|
+
setBoundedSink(
|
|
855
|
+
eventSinksByOpencodeSession,
|
|
856
|
+
sinkCreatedAtBySession,
|
|
857
|
+
childSessionId,
|
|
858
|
+
parentSink,
|
|
859
|
+
)
|
|
860
|
+
void toolTrackingHook
|
|
861
|
+
.flushOrphanEvents(childSessionId, parentSink)
|
|
862
|
+
.catch((error: unknown) => {
|
|
863
|
+
logger.warn(
|
|
864
|
+
`Failed to flush orphan events for child session ${childSessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
865
|
+
)
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
},
|
|
370
870
|
},
|
|
371
871
|
),
|
|
372
872
|
"tool-tracking",
|
|
873
|
+
{ critical: true },
|
|
373
874
|
)
|
|
374
875
|
: undefined
|
|
375
876
|
|
|
@@ -377,7 +878,7 @@ export function createHooks(args: {
|
|
|
377
878
|
runId: string,
|
|
378
879
|
projectDirForRun: string,
|
|
379
880
|
sessionIdForRun: string | undefined,
|
|
380
|
-
trigger: "session.idle" | "tool.execute.after",
|
|
881
|
+
trigger: "session.idle" | "session.deleted" | "tool.execute.after",
|
|
381
882
|
failFast = false,
|
|
382
883
|
): Promise<void> => {
|
|
383
884
|
if (!runId || runId.length === 0) {
|
|
@@ -386,7 +887,7 @@ export function createHooks(args: {
|
|
|
386
887
|
|
|
387
888
|
try {
|
|
388
889
|
await materializeFindings(runId, projectDirForRun, sessionIdForRun, {
|
|
389
|
-
validateSessionId:
|
|
890
|
+
validateSessionId: false,
|
|
390
891
|
requireEvents: true,
|
|
391
892
|
})
|
|
392
893
|
} catch (error) {
|
|
@@ -406,6 +907,7 @@ export function createHooks(args: {
|
|
|
406
907
|
? safeCreateHook(
|
|
407
908
|
() => async (input: Parameters<typeof eventHook>[0]) => {
|
|
408
909
|
const isSessionDeleted = input.event.type === "session.deleted"
|
|
910
|
+
const eventSessionId = extractSessionId(input.event)
|
|
409
911
|
const finalizationBeforeDelete = isSessionDeleted ? getLastFinalizationResult() : null
|
|
410
912
|
|
|
411
913
|
try {
|
|
@@ -421,8 +923,8 @@ export function createHooks(args: {
|
|
|
421
923
|
await materializeFindingsForRun(
|
|
422
924
|
finalizationResult.runId,
|
|
423
925
|
projectDir,
|
|
424
|
-
|
|
425
|
-
"session.
|
|
926
|
+
eventSessionId,
|
|
927
|
+
"session.deleted",
|
|
426
928
|
true,
|
|
427
929
|
)
|
|
428
930
|
} catch (error) {
|
|
@@ -430,13 +932,64 @@ export function createHooks(args: {
|
|
|
430
932
|
`Failed to materialize findings artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
431
933
|
)
|
|
432
934
|
}
|
|
935
|
+
try {
|
|
936
|
+
await materializeReportInput(finalizationResult.runId, projectDir, eventSessionId)
|
|
937
|
+
} catch (error) {
|
|
938
|
+
logger.warn(
|
|
939
|
+
`Failed to materialize report-input artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
940
|
+
)
|
|
941
|
+
}
|
|
433
942
|
}
|
|
434
943
|
|
|
435
|
-
|
|
944
|
+
// Only archive audit state when the root session is deleted.
|
|
945
|
+
// Child sessions (sentinel/pythia/scribe) may end before the parent
|
|
946
|
+
// audit completes — archiving here would wipe live state.
|
|
947
|
+
const deletedSessionId = eventSessionId
|
|
948
|
+
const isChildSession =
|
|
949
|
+
deletedSessionId != null && agentTracker.getParentSession(deletedSessionId) != null
|
|
950
|
+
if (!isChildSession) {
|
|
951
|
+
const deletedManager =
|
|
952
|
+
deletedSessionId != null
|
|
953
|
+
? (sessionManagers.get(deletedSessionId) ?? auditStateManager)
|
|
954
|
+
: auditStateManager
|
|
955
|
+
await deletedManager.archive()
|
|
956
|
+
}
|
|
436
957
|
|
|
437
|
-
const deletedSessionId = input.event.sessionId
|
|
438
958
|
if (deletedSessionId) {
|
|
439
959
|
agentTracker.clearSession(deletedSessionId)
|
|
960
|
+
eventSinksByOpencodeSession.delete(deletedSessionId)
|
|
961
|
+
pendingSinkCreations.delete(deletedSessionId)
|
|
962
|
+
pendingActivations.delete(deletedSessionId)
|
|
963
|
+
activatedSessions.delete(deletedSessionId)
|
|
964
|
+
const deletedDebouncedSave = debouncedSavesBySession.get(deletedSessionId)
|
|
965
|
+
deletedDebouncedSave?.dispose()
|
|
966
|
+
debouncedSavesBySession.delete(deletedSessionId)
|
|
967
|
+
sessionManagers.delete(deletedSessionId)
|
|
968
|
+
|
|
969
|
+
if (sessionManagers.size > MAX_SESSION_TRACKING) {
|
|
970
|
+
const oldest = sessionManagers.keys().next()
|
|
971
|
+
if (!oldest.done) {
|
|
972
|
+
const oldestSessionId = oldest.value
|
|
973
|
+
const oldestDebouncedSave = debouncedSavesBySession.get(oldestSessionId)
|
|
974
|
+
oldestDebouncedSave?.dispose()
|
|
975
|
+
debouncedSavesBySession.delete(oldestSessionId)
|
|
976
|
+
sessionManagers.delete(oldestSessionId)
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const activeRunIds = new Set(
|
|
982
|
+
Array.from(eventSinksByOpencodeSession.values()).map((sink) => sink.runId),
|
|
983
|
+
)
|
|
984
|
+
for (const trackedRunId of Array.from(eventSinksByRunId.keys())) {
|
|
985
|
+
if (!activeRunIds.has(trackedRunId)) {
|
|
986
|
+
releaseEventSink(trackedRunId)
|
|
987
|
+
eventSinksByRunId.delete(trackedRunId)
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (finalizationResult && finalizationResult.runId.length > 0) {
|
|
992
|
+
releaseEventSink(finalizationResult.runId)
|
|
440
993
|
}
|
|
441
994
|
|
|
442
995
|
runJournal.log({
|
|
@@ -445,68 +998,164 @@ export function createHooks(args: {
|
|
|
445
998
|
archived: true,
|
|
446
999
|
finalizationPassed: finalizationResult?.invariantsPassed ?? null,
|
|
447
1000
|
})
|
|
448
|
-
|
|
449
|
-
currentEventSink = null
|
|
450
|
-
currentOpencodeSessionId = ""
|
|
451
1001
|
}
|
|
452
1002
|
}
|
|
453
1003
|
},
|
|
454
1004
|
"event",
|
|
1005
|
+
{ critical: true },
|
|
455
1006
|
)
|
|
456
1007
|
: undefined
|
|
457
1008
|
|
|
458
1009
|
return {
|
|
459
1010
|
config: createConfigHandler(config, projectDir),
|
|
460
|
-
"chat.params": async (input) => {
|
|
1011
|
+
"chat.params": async (input, output) => {
|
|
461
1012
|
agentTracker.chatParamsHook(input)
|
|
1013
|
+
|
|
1014
|
+
// Enforce deterministic LLM output for Argus-family agents (temperature=0).
|
|
1015
|
+
// Per-agent overrides are supported via config.agents.<name>.temperature.
|
|
1016
|
+
// Non-Argus sessions are left untouched so other plugins are not affected.
|
|
1017
|
+
if (agentTracker.isArgusAgent(input.sessionID)) {
|
|
1018
|
+
const agentName = agentTracker.getAgentForSession(input.sessionID)
|
|
1019
|
+
const agentConfig = agentName
|
|
1020
|
+
? config.agents?.[agentName as keyof typeof config.agents]
|
|
1021
|
+
: undefined
|
|
1022
|
+
output.temperature = agentConfig?.temperature ?? 0
|
|
1023
|
+
|
|
1024
|
+
await activateSession(input.sessionID)
|
|
1025
|
+
}
|
|
462
1026
|
},
|
|
463
1027
|
"chat.message": async (input) => {
|
|
464
1028
|
agentTracker.chatMessageHook(input)
|
|
465
1029
|
},
|
|
466
|
-
"experimental.chat.system.transform":
|
|
467
|
-
|
|
468
|
-
|
|
1030
|
+
"experimental.chat.system.transform": isHookEnabled("system-prompt")
|
|
1031
|
+
? async (input, output) => {
|
|
1032
|
+
await systemPromptHook(input, output)
|
|
1033
|
+
}
|
|
1034
|
+
: undefined,
|
|
469
1035
|
"experimental.session.compacting": compactionHook
|
|
470
|
-
? async (
|
|
471
|
-
const block = await compactionHook({
|
|
1036
|
+
? async (input, output) => {
|
|
1037
|
+
const block = await compactionHook({
|
|
1038
|
+
summary: output.context.join("\n"),
|
|
1039
|
+
sessionId: input.sessionID,
|
|
1040
|
+
})
|
|
472
1041
|
if (block) output.context.push(block)
|
|
473
1042
|
}
|
|
474
1043
|
: undefined,
|
|
475
1044
|
"tool.execute.after": toolTrackingHook
|
|
476
1045
|
? async (input, output) => {
|
|
1046
|
+
const toolName = typeof input.tool === "string" ? input.tool : ""
|
|
1047
|
+
if (!toolName.startsWith("argus_") && toolName !== "task") {
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (toolName.startsWith("argus_") && input.sessionID) {
|
|
1052
|
+
await activateSession(input.sessionID)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const toolOutput = typeof output.output === "string" ? output.output : ""
|
|
1056
|
+
|
|
477
1057
|
const recoveryHint = toolErrorRecoveryHandler({
|
|
478
|
-
tool:
|
|
479
|
-
result:
|
|
1058
|
+
tool: toolName,
|
|
1059
|
+
result: toolOutput,
|
|
480
1060
|
})
|
|
481
1061
|
|
|
482
1062
|
await toolTrackingHook({
|
|
483
|
-
tool:
|
|
1063
|
+
tool: toolName,
|
|
484
1064
|
args: input.args,
|
|
485
|
-
result:
|
|
1065
|
+
result: toolOutput,
|
|
486
1066
|
sessionID: input.sessionID,
|
|
487
1067
|
callID: input.callID,
|
|
488
1068
|
})
|
|
489
1069
|
|
|
490
|
-
if (
|
|
491
|
-
const state = getAuditState()
|
|
1070
|
+
if (toolName === "argus_generate_report") {
|
|
1071
|
+
const state = getAuditState(input.sessionID)
|
|
492
1072
|
if (!state || state.sessionId.length === 0) {
|
|
493
1073
|
throw new Error("argus_generate_report completed without active audit state")
|
|
494
1074
|
}
|
|
495
1075
|
|
|
496
|
-
const
|
|
1076
|
+
const reportedError = extractReportErrorFromToolOutput(toolOutput)
|
|
1077
|
+
if (reportedError) {
|
|
1078
|
+
throw new Error(`argus_generate_report failed: ${reportedError}`)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const reportFilePath = extractReportFilePathFromToolOutput(toolOutput)
|
|
1082
|
+
if (!reportFilePath) {
|
|
1083
|
+
throw new Error("argus_generate_report completed without report filePath")
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const extractedRunId = extractRunIdFromReportToolOutput(toolOutput)
|
|
1087
|
+
if (!extractedRunId) {
|
|
1088
|
+
throw new Error("argus_generate_report completed without run_id")
|
|
1089
|
+
}
|
|
1090
|
+
if (extractedRunId !== state.sessionId) {
|
|
1091
|
+
logger.warn(
|
|
1092
|
+
`argus_generate_report run_id ${extractedRunId} differs from state.sessionId ${state.sessionId} — proceeding with report`,
|
|
1093
|
+
)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
497
1096
|
await materializeFindingsForRun(
|
|
498
|
-
|
|
1097
|
+
state.sessionId,
|
|
499
1098
|
state.projectDir,
|
|
500
1099
|
input.sessionID,
|
|
501
1100
|
"tool.execute.after",
|
|
502
1101
|
true,
|
|
503
1102
|
)
|
|
1103
|
+
|
|
1104
|
+
try {
|
|
1105
|
+
await materializeReportInput(state.sessionId, state.projectDir, input.sessionID)
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
logger.warn(
|
|
1108
|
+
`Failed to materialize report-input artifact for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1109
|
+
)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Trigger finalization immediately after report generation.
|
|
1113
|
+
// The session.idle handler also checks reportGenerated, but in
|
|
1114
|
+
// `opencode run` mode the process may exit before another idle
|
|
1115
|
+
// event fires. Finalizing here guarantees the run is closed.
|
|
1116
|
+
if (state.reportGenerated) {
|
|
1117
|
+
const runSink =
|
|
1118
|
+
eventSinksByRunId.get(state.sessionId) ??
|
|
1119
|
+
(input.sessionID
|
|
1120
|
+
? (eventSinksByOpencodeSession.get(input.sessionID) ?? null)
|
|
1121
|
+
: null)
|
|
1122
|
+
|
|
1123
|
+
if (runSink) {
|
|
1124
|
+
try {
|
|
1125
|
+
const reportFinalization = await finalizeRun(
|
|
1126
|
+
state.sessionId,
|
|
1127
|
+
state.projectDir,
|
|
1128
|
+
runSink,
|
|
1129
|
+
)
|
|
1130
|
+
updateRunStatus(
|
|
1131
|
+
state.sessionId,
|
|
1132
|
+
reportFinalization.invariantsPassed ? "finalized" : "failed",
|
|
1133
|
+
).catch((err) =>
|
|
1134
|
+
logger.warn(
|
|
1135
|
+
`Failed to update run status: ${err instanceof Error ? err.message : String(err)}`,
|
|
1136
|
+
),
|
|
1137
|
+
)
|
|
1138
|
+
if (!reportFinalization.invariantsPassed) {
|
|
1139
|
+
logger.warn(
|
|
1140
|
+
`Report-triggered finalization for run ${state.sessionId} has invariant errors: ${reportFinalization.errors.join("; ")}`,
|
|
1141
|
+
)
|
|
1142
|
+
}
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
logger.warn(
|
|
1145
|
+
`Report-triggered finalization failed for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
504
1150
|
}
|
|
505
1151
|
|
|
506
|
-
|
|
507
|
-
|
|
1152
|
+
if (toolName.startsWith("argus_")) {
|
|
1153
|
+
const outputWithHint = recoveryHint ? `${toolOutput}${recoveryHint}` : toolOutput
|
|
1154
|
+
output.output = outputTruncator(outputWithHint)
|
|
1155
|
+
}
|
|
508
1156
|
}
|
|
509
1157
|
: undefined,
|
|
510
1158
|
event: safeEventHook,
|
|
1159
|
+
dispose: fullDispose,
|
|
511
1160
|
}
|
|
512
1161
|
}
|