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