solidity-argus 0.3.6 → 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.
Files changed (107) hide show
  1. package/AGENTS.md +13 -6
  2. package/README.md +24 -12
  3. package/package.json +7 -3
  4. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
  5. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
  6. package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
  7. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
  8. package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
  9. package/skills/checklists/general-audit/SKILL.md +1 -0
  10. package/skills/methodology/audit-workflow/SKILL.md +1 -0
  11. package/skills/methodology/report-template/SKILL.md +1 -0
  12. package/skills/methodology/severity-classification/SKILL.md +1 -0
  13. package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
  14. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
  15. package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
  16. package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
  17. package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
  18. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
  19. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
  20. package/src/agents/argus-prompt.ts +98 -33
  21. package/src/agents/pythia-prompt.ts +18 -1
  22. package/src/agents/scribe-prompt.ts +32 -10
  23. package/src/agents/sentinel-prompt.ts +19 -0
  24. package/src/agents/themis-prompt.ts +110 -0
  25. package/src/cli/commands/doctor.ts +29 -17
  26. package/src/config/loader.ts +29 -5
  27. package/src/config/schema.ts +45 -45
  28. package/src/constants/defaults.ts +1 -0
  29. package/src/create-hooks.ts +851 -142
  30. package/src/create-managers.ts +4 -2
  31. package/src/create-tools.ts +5 -1
  32. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  33. package/src/features/background-agent/background-manager.ts +32 -5
  34. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  35. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  36. package/src/features/persistent-state/event-sink.ts +96 -25
  37. package/src/features/persistent-state/findings-materializer.ts +57 -3
  38. package/src/features/persistent-state/global-run-index.ts +86 -8
  39. package/src/features/persistent-state/index.ts +7 -1
  40. package/src/features/persistent-state/run-finalizer.ts +116 -7
  41. package/src/features/persistent-state/run-pruner.ts +93 -0
  42. package/src/hooks/agent-tracker.ts +14 -2
  43. package/src/hooks/compaction-hook.ts +7 -16
  44. package/src/hooks/config-handler.ts +83 -29
  45. package/src/hooks/context-budget.ts +4 -5
  46. package/src/hooks/event-hook.ts +213 -57
  47. package/src/hooks/knowledge-sync-hook.ts +2 -3
  48. package/src/hooks/safe-create-hook.ts +13 -1
  49. package/src/hooks/system-prompt-hook.ts +20 -39
  50. package/src/hooks/tool-tracking-hook.ts +606 -326
  51. package/src/index.ts +15 -1
  52. package/src/knowledge/scvd-client.ts +2 -4
  53. package/src/knowledge/scvd-errors.ts +25 -2
  54. package/src/knowledge/scvd-index.ts +7 -5
  55. package/src/knowledge/scvd-sync.ts +6 -6
  56. package/src/managers/types.ts +20 -2
  57. package/src/shared/agent-names.ts +23 -0
  58. package/src/shared/audit-artifact-resolver.ts +8 -3
  59. package/src/shared/audit-phases.ts +12 -0
  60. package/src/shared/cache-paths.ts +41 -0
  61. package/src/shared/drop-diagnostics.ts +2 -2
  62. package/src/shared/forge-errors.ts +31 -0
  63. package/src/shared/forge-runner.ts +30 -0
  64. package/src/shared/format-error.ts +3 -0
  65. package/src/shared/index.ts +9 -0
  66. package/src/shared/key-tools.ts +39 -0
  67. package/src/shared/logger.ts +7 -7
  68. package/src/shared/path-containment.ts +25 -0
  69. package/src/shared/path-utils.ts +11 -0
  70. package/src/shared/report-path-resolver.ts +4 -2
  71. package/src/shared/safe-emit.ts +24 -0
  72. package/src/shared/token-utils.ts +5 -0
  73. package/src/shared/type-guards.ts +8 -0
  74. package/src/shared/validation-constants.ts +52 -0
  75. package/src/skills/analysis/cluster.ts +1 -114
  76. package/src/skills/analysis/normalize.ts +2 -114
  77. package/src/skills/analysis/stopwords.ts +109 -0
  78. package/src/skills/argus-skill-resolver.ts +6 -3
  79. package/src/solodit-lifecycle.ts +153 -37
  80. package/src/state/adapters.ts +60 -66
  81. package/src/state/finding-aggregation.ts +6 -8
  82. package/src/state/finding-fingerprint.ts +1 -1
  83. package/src/state/finding-store.ts +31 -9
  84. package/src/state/index.ts +1 -1
  85. package/src/state/projectors.ts +27 -19
  86. package/src/state/schemas.ts +8 -32
  87. package/src/state/types.ts +3 -0
  88. package/src/tools/contract-analyzer-tool.ts +4 -6
  89. package/src/tools/forge-coverage-tool.ts +10 -35
  90. package/src/tools/forge-fuzz-tool.ts +21 -51
  91. package/src/tools/forge-test-tool.ts +25 -47
  92. package/src/tools/gas-analysis-tool.ts +12 -41
  93. package/src/tools/pattern-checker-tool.ts +37 -15
  94. package/src/tools/pattern-loader.ts +18 -4
  95. package/src/tools/persist-deduped-tool.ts +94 -0
  96. package/src/tools/proxy-detection-tool.ts +35 -34
  97. package/src/tools/read-findings-tool.ts +390 -0
  98. package/src/tools/record-finding-tool.ts +120 -25
  99. package/src/tools/report-generator-tool.ts +396 -328
  100. package/src/tools/report-preflight.ts +5 -1
  101. package/src/tools/slither-tool.ts +55 -16
  102. package/src/tools/solodit-search-tool.ts +260 -112
  103. package/src/tools/sync-knowledge-tool.ts +2 -3
  104. package/src/utils/solidity-parser.ts +39 -24
  105. package/src/features/migration/index.ts +0 -14
  106. package/src/features/migration/migration-adapter.ts +0 -151
  107. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -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
- createSessionRecoveryHandler,
7
- createToolErrorRecoveryHandler,
8
- } from "./features/error-recovery"
9
- import { getMigrationMode } from "./features/migration"
10
- import { adaptLegacyFindings } from "./features/migration/migration-adapter"
11
- import { computeParityMetrics, formatParityReport } from "./features/migration/parity-telemetry"
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"
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"
@@ -41,6 +50,62 @@ export type AgentTrackerRef = {
41
50
 
42
51
  let _agentTrackerRef: AgentTrackerRef | undefined
43
52
 
53
+ const REPORT_METADATA_REGEX = /<!-- argus:report_metadata (.+?) -->/
54
+
55
+ function extractRunIdFromReportToolOutput(result: string): string | undefined {
56
+ try {
57
+ const parsed = JSON.parse(result) as Record<string, unknown>
58
+ if (typeof parsed.run_id === "string" && parsed.run_id.length > 0) {
59
+ return parsed.run_id
60
+ }
61
+
62
+ if (typeof parsed.report === "string") {
63
+ const match = parsed.report.match(REPORT_METADATA_REGEX)
64
+ if (match?.[1]) {
65
+ const metadata = JSON.parse(match[1]) as Record<string, unknown>
66
+ if (typeof metadata.run_id === "string" && metadata.run_id.length > 0) {
67
+ return metadata.run_id
68
+ }
69
+ }
70
+ }
71
+ } catch {
72
+ return undefined
73
+ }
74
+
75
+ return undefined
76
+ }
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
+
44
109
  export function getAgentForSession(sessionID: string): string | undefined {
45
110
  return _agentTrackerRef?.getAgentForSession(sessionID)
46
111
  }
@@ -58,7 +123,10 @@ export type Hooks = Pick<
58
123
  | "experimental.session.compacting"
59
124
  | "tool.execute.after"
60
125
  | "event"
61
- >
126
+ > & {
127
+ /** Release the process-wide instance lock so the plugin can be re-initialized. */
128
+ dispose?: () => void
129
+ }
62
130
 
63
131
  /**
64
132
  * Creates the hook handlers for the Argus plugin.
@@ -77,17 +145,57 @@ export function createHooks(args: {
77
145
  projectDir: string
78
146
  isHookEnabled: (name: HookName) => boolean
79
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
+
80
173
  const { config, managers, projectDir, isHookEnabled } = args
81
- const { auditStateManager, backgroundManager } = managers
174
+ const { auditStateManager } = managers
82
175
  const agentTracker = createAgentTracker()
83
176
  _agentTrackerRef = agentTracker
84
177
 
85
- const migrationMode = getMigrationMode(config)
86
- logger.debug(`Migration mode: ${migrationMode}`)
87
-
88
178
  const contextMonitor = createContextMonitor()
89
- const sessionRecoveryHandler = createSessionRecoveryHandler(auditStateManager)
90
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
+
91
199
  const runJournal = createRunJournal(projectDir)
92
200
  let auditStateGetter: (() => AuditState | null) | undefined
93
201
  const toolErrorRecoveryHandler = createToolErrorRecoveryHandler(
@@ -96,80 +204,380 @@ export function createHooks(args: {
96
204
  )
97
205
  const outputTruncator = createToolOutputTruncator()
98
206
 
99
- let currentEventSink: EventSink | null = null
100
- let currentOpencodeSessionId = ""
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
101
211
 
102
- // Sub-handlers run sequentially. The state persistence handler MUST be first:
103
- // it loads persisted state on session.created, overriding the fresh default.
104
- const {
105
- hook: eventHook,
106
- getAuditState,
107
- setAuditState,
108
- setEventSink,
109
- getLastFinalizationResult,
110
- } = createEventHook(projectDir, [
111
- async ({ type, sessionId, auditState, setAuditState: setState }) => {
112
- if (type === "session.created") {
113
- currentOpencodeSessionId = sessionId ?? ""
114
- const timestamp = Date.now()
115
- let recoveredState: AuditState | null = null
212
+ const eventSinksByOpencodeSession = new Map<string, EventSink>()
213
+ const eventSinksByRunId = new Map<string, EventSink>()
116
214
 
117
- try {
118
- recoveredState = await auditStateManager.load()
119
- } finally {
120
- runJournal.log({
121
- type: "state.loaded",
122
- timestamp,
123
- success: recoveredState !== null,
124
- findingsCount: recoveredState?.findings.length ?? 0,
125
- })
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
+ }
126
242
  }
243
+ }
244
+ }
127
245
 
128
- if (recoveredState) {
129
- setState(recoveredState)
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
130
310
  }
131
311
 
132
- runJournal.log({
133
- type: "session.created",
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,
134
349
  sessionId,
135
- timestamp: Date.now(),
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,
136
385
  })
386
+ }
137
387
 
138
- const effectiveState = recoveredState ?? auditStateManager.get()
139
- if (effectiveState) {
140
- const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
141
- try {
142
- const journalFile = resolver.paths().journalFile
143
- // createEventSink builds the same path internally; the resolver makes it explicit.
144
- currentEventSink = createEventSink(effectiveState.sessionId, projectDir)
145
- setEventSink(currentEventSink)
146
- logger.debug(`Event sink journal path: ${journalFile}`)
147
- } catch (error) {
148
- logger.error(
149
- `Failed to create event sink: ${error instanceof Error ? error.message : String(error)}`,
150
- )
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)
151
424
  }
152
- void recordRun({
153
- runId: effectiveState.sessionId,
154
- opencodeSessionId: sessionId,
155
- projectDir: effectiveState.projectDir,
156
- statePath: resolver.paths().stateFile,
157
- journalPath: resolver.paths().journalFile,
158
- startedAt: effectiveState.startTime,
159
- phase: effectiveState.currentPhase,
160
- findingsCount: effectiveState.findings.length,
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
+ },
161
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
+ }
162
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
+ }
163
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).
164
563
  return
165
564
  }
166
565
 
167
566
  if (type === "session.idle" && auditState) {
168
- await debouncedSave.flush()
567
+ if (sessionId && !activatedSessions.has(sessionId)) return
568
+
569
+ if (sessionId) {
570
+ await getSessionDebouncedSave(sessionId).flush()
571
+ } else {
572
+ await debouncedSave.flush()
573
+ }
169
574
 
170
575
  let saveSuccess = true
171
576
  try {
172
- await auditStateManager.save(auditState)
577
+ const idleManager = sessionId ? sessionManagers.get(sessionId) : auditStateManager
578
+ if (idleManager) {
579
+ await idleManager.save(auditState)
580
+ }
173
581
  } catch {
174
582
  saveSuccess = false
175
583
  } finally {
@@ -191,7 +599,7 @@ export function createHooks(args: {
191
599
  auditState.sessionId,
192
600
  auditState.projectDir,
193
601
  )
194
- void recordRun({
602
+ recordRun({
195
603
  runId: auditState.sessionId,
196
604
  opencodeSessionId: sessionId,
197
605
  projectDir: auditState.projectDir,
@@ -200,30 +608,77 @@ export function createHooks(args: {
200
608
  startedAt: auditState.startTime,
201
609
  phase: auditState.currentPhase,
202
610
  findingsCount: auditState.findings.length,
203
- })
611
+ }).catch((err) =>
612
+ logger.warn(
613
+ `Failed to record run on idle: ${err instanceof Error ? err.message : String(err)}`,
614
+ ),
615
+ )
204
616
 
205
- if (migrationMode !== "legacy") {
206
- try {
207
- const { legacyFindings, canonicalFindings } = adaptLegacyFindings(
208
- auditState,
209
- migrationMode,
210
- auditState.sessionId,
211
- )
212
- const parityMetrics = computeParityMetrics(legacyFindings, canonicalFindings)
213
- logger.debug(formatParityReport(parityMetrics))
214
- } catch (error) {
215
- logger.warn(
216
- `Migration parity check failed: ${error instanceof Error ? error.message : String(error)}`,
217
- )
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
+ }
218
655
  }
219
656
  }
657
+
220
658
  return
221
659
  }
222
660
 
223
661
  if (type === "session.deleted") {
224
- await debouncedSave.flush()
225
- if (auditState) {
226
- await auditStateManager.save(auditState)
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
+ }
227
682
  }
228
683
  runJournal.log({
229
684
  type: "state.saved",
@@ -233,16 +688,26 @@ export function createHooks(args: {
233
688
  }
234
689
  },
235
690
  async ({ type, sessionId, setAuditState: setState }) => {
236
- await sessionRecoveryHandler({ type, sessionId, setAuditState: setState })
237
- },
238
- async ({ type }) => {
239
- if (type === "session.idle") {
240
- backgroundManager.getActiveCount()
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
+ )
241
705
  }
242
706
  },
707
+ async () => {},
243
708
  ])
244
709
 
245
- auditStateGetter = getAuditState
710
+ auditStateGetter = () => getAuditState()
246
711
 
247
712
  const initialState = auditStateManager.get()
248
713
  if (initialState) {
@@ -252,11 +717,11 @@ export function createHooks(args: {
252
717
  const auditEnforcer = createAuditEnforcer()
253
718
 
254
719
  const systemPromptHook = createSystemPromptHook({
255
- getAuditState,
720
+ getAuditState: (sessionId?: string) => getAuditState(sessionId),
256
721
  getAgentForSession: agentTracker.getAgentForSession,
257
722
  isArgusAgent: agentTracker.isArgusAgent,
258
- getContextPressure: (systemText: string) => {
259
- const status = contextMonitor.getContextStatus(systemText, getAuditState())
723
+ getContextPressure: (systemText: string, sessionId?: string) => {
724
+ const status = contextMonitor.getContextStatus(systemText, getAuditState(sessionId))
260
725
  return status.usage
261
726
  },
262
727
  getTokenBudget: getTokenBudgetForAgent,
@@ -286,18 +751,28 @@ export function createHooks(args: {
286
751
  })
287
752
 
288
753
  const compactionHook = isHookEnabled("compaction")
289
- ? safeCreateHook(() => createCompactionHook(getAuditState, getReconContext), "compaction")
754
+ ? safeCreateHook(
755
+ () =>
756
+ createCompactionHook((sessionId?: string) => getAuditState(sessionId), getReconContext),
757
+ "compaction",
758
+ )
290
759
  : undefined
291
760
 
292
761
  const toolTrackingHook = isHookEnabled("tool-tracking")
293
762
  ? safeCreateHook(
294
763
  () =>
295
764
  createToolTrackingHook(
296
- getAuditState,
297
- ({ tool, findingsCount }) => {
298
- const currentState = getAuditState()
765
+ (sessionId?: string) => getAuditState(sessionId),
766
+ ({ tool, findingsCount, sessionId }) => {
767
+ if (sessionId && !activatedSessions.has(sessionId)) return
768
+
769
+ const currentState = getAuditState(sessionId)
299
770
  if (currentState) {
300
- debouncedSave.save(currentState)
771
+ if (sessionId && sessionManagers.has(sessionId)) {
772
+ getSessionDebouncedSave(sessionId).save(currentState)
773
+ } else {
774
+ debouncedSave.save(currentState)
775
+ }
301
776
  }
302
777
 
303
778
  runJournal.log({
@@ -308,14 +783,56 @@ export function createHooks(args: {
308
783
  })
309
784
  },
310
785
  {
311
- getEventSink: () => currentEventSink,
312
- getSessionId: () => currentOpencodeSessionId,
313
- getAgentName: () => {
314
- if (!currentOpencodeSessionId) {
315
- return undefined
786
+ getEventSink: () => {
787
+ const state = getAuditState()
788
+ if (!state || state.sessionId.length === 0) {
789
+ return null
316
790
  }
317
-
318
- const agent = agentTracker.getAgentForSession(currentOpencodeSessionId)
791
+ return eventSinksByRunId.get(state.sessionId) ?? null
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),
828
+ getAgentNameForSession: (sessionId: string) => {
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
319
836
  if (
320
837
  agent === "argus" ||
321
838
  agent === "sentinel" ||
@@ -328,36 +845,60 @@ export function createHooks(args: {
328
845
 
329
846
  return "unknown"
330
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
+ },
331
870
  },
332
871
  ),
333
872
  "tool-tracking",
873
+ { critical: true },
334
874
  )
335
875
  : undefined
336
876
 
337
- const materializeCurrentFindings = async (
338
- trigger: "session.idle" | "tool.execute.after",
877
+ const materializeFindingsForRun = async (
878
+ runId: string,
879
+ projectDirForRun: string,
880
+ sessionIdForRun: string | undefined,
881
+ trigger: "session.idle" | "session.deleted" | "tool.execute.after",
339
882
  failFast = false,
340
883
  ): Promise<void> => {
341
- const state = getAuditState()
342
- if (!state || state.sessionId.length === 0) {
884
+ if (!runId || runId.length === 0) {
343
885
  return
344
886
  }
345
887
 
346
888
  try {
347
- await materializeFindings(
348
- state.sessionId,
349
- state.projectDir,
350
- currentOpencodeSessionId.length > 0 ? currentOpencodeSessionId : undefined,
351
- )
889
+ await materializeFindings(runId, projectDirForRun, sessionIdForRun, {
890
+ validateSessionId: false,
891
+ requireEvents: true,
892
+ })
352
893
  } catch (error) {
353
894
  if (failFast) {
354
895
  throw new Error(
355
- `Failed to materialize findings artifact on ${trigger} for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
896
+ `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`,
356
897
  )
357
898
  }
358
899
 
359
900
  logger.warn(
360
- `Failed to materialize findings artifact on ${trigger} for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
901
+ `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`,
361
902
  )
362
903
  }
363
904
  }
@@ -366,6 +907,7 @@ export function createHooks(args: {
366
907
  ? safeCreateHook(
367
908
  () => async (input: Parameters<typeof eventHook>[0]) => {
368
909
  const isSessionDeleted = input.event.type === "session.deleted"
910
+ const eventSessionId = extractSessionId(input.event)
369
911
  const finalizationBeforeDelete = isSessionDeleted ? getLastFinalizationResult() : null
370
912
 
371
913
  try {
@@ -378,19 +920,76 @@ export function createHooks(args: {
378
920
 
379
921
  if (hasNewFinalization && finalizationResult.runId.length > 0) {
380
922
  try {
381
- await materializeFindings(finalizationResult.runId, projectDir)
923
+ await materializeFindingsForRun(
924
+ finalizationResult.runId,
925
+ projectDir,
926
+ eventSessionId,
927
+ "session.deleted",
928
+ true,
929
+ )
382
930
  } catch (error) {
383
931
  logger.warn(
384
932
  `Failed to materialize findings artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
385
933
  )
386
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
+ }
387
942
  }
388
943
 
389
- await auditStateManager.archive()
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
+ }
390
957
 
391
- const deletedSessionId = input.event.sessionId
392
958
  if (deletedSessionId) {
393
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)
394
993
  }
395
994
 
396
995
  runJournal.log({
@@ -399,54 +998,164 @@ export function createHooks(args: {
399
998
  archived: true,
400
999
  finalizationPassed: finalizationResult?.invariantsPassed ?? null,
401
1000
  })
402
-
403
- currentEventSink = null
404
- currentOpencodeSessionId = ""
405
1001
  }
406
1002
  }
407
1003
  },
408
1004
  "event",
1005
+ { critical: true },
409
1006
  )
410
1007
  : undefined
411
1008
 
412
1009
  return {
413
1010
  config: createConfigHandler(config, projectDir),
414
- "chat.params": async (input) => {
1011
+ "chat.params": async (input, output) => {
415
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
+ }
416
1026
  },
417
1027
  "chat.message": async (input) => {
418
1028
  agentTracker.chatMessageHook(input)
419
1029
  },
420
- "experimental.chat.system.transform": async (input, output) => {
421
- await systemPromptHook(input, output)
422
- },
1030
+ "experimental.chat.system.transform": isHookEnabled("system-prompt")
1031
+ ? async (input, output) => {
1032
+ await systemPromptHook(input, output)
1033
+ }
1034
+ : undefined,
423
1035
  "experimental.session.compacting": compactionHook
424
- ? async (_input, output) => {
425
- const block = await compactionHook({ summary: output.context.join("\n") })
1036
+ ? async (input, output) => {
1037
+ const block = await compactionHook({
1038
+ summary: output.context.join("\n"),
1039
+ sessionId: input.sessionID,
1040
+ })
426
1041
  if (block) output.context.push(block)
427
1042
  }
428
1043
  : undefined,
429
1044
  "tool.execute.after": toolTrackingHook
430
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
+
431
1057
  const recoveryHint = toolErrorRecoveryHandler({
432
- tool: input.tool,
433
- result: output.output,
1058
+ tool: toolName,
1059
+ result: toolOutput,
434
1060
  })
435
1061
 
436
1062
  await toolTrackingHook({
437
- tool: input.tool,
1063
+ tool: toolName,
438
1064
  args: input.args,
439
- result: output.output,
1065
+ result: toolOutput,
1066
+ sessionID: input.sessionID,
1067
+ callID: input.callID,
440
1068
  })
441
1069
 
442
- if (input.tool === "argus_generate_report") {
443
- await materializeCurrentFindings("tool.execute.after", true)
1070
+ if (toolName === "argus_generate_report") {
1071
+ const state = getAuditState(input.sessionID)
1072
+ if (!state || state.sessionId.length === 0) {
1073
+ throw new Error("argus_generate_report completed without active audit state")
1074
+ }
1075
+
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
+
1096
+ await materializeFindingsForRun(
1097
+ state.sessionId,
1098
+ state.projectDir,
1099
+ input.sessionID,
1100
+ "tool.execute.after",
1101
+ true,
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
+ }
444
1150
  }
445
1151
 
446
- const outputWithHint = recoveryHint ? `${output.output}${recoveryHint}` : output.output
447
- output.output = outputTruncator(outputWithHint)
1152
+ if (toolName.startsWith("argus_")) {
1153
+ const outputWithHint = recoveryHint ? `${toolOutput}${recoveryHint}` : toolOutput
1154
+ output.output = outputTruncator(outputWithHint)
1155
+ }
448
1156
  }
449
1157
  : undefined,
450
1158
  event: safeEventHook,
1159
+ dispose: fullDispose,
451
1160
  }
452
1161
  }