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.
Files changed (108) 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 +24 -2
  22. package/src/agents/scribe-prompt.ts +34 -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/cli/commands/install.ts +74 -33
  27. package/src/config/loader.ts +29 -5
  28. package/src/config/schema.ts +45 -45
  29. package/src/constants/defaults.ts +1 -0
  30. package/src/create-hooks.ts +806 -173
  31. package/src/create-managers.ts +4 -2
  32. package/src/create-tools.ts +5 -1
  33. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  34. package/src/features/background-agent/background-manager.ts +32 -5
  35. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  36. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  37. package/src/features/persistent-state/event-sink.ts +96 -25
  38. package/src/features/persistent-state/findings-materializer.ts +68 -2
  39. package/src/features/persistent-state/global-run-index.ts +86 -8
  40. package/src/features/persistent-state/index.ts +7 -1
  41. package/src/features/persistent-state/run-finalizer.ts +116 -7
  42. package/src/features/persistent-state/run-pruner.ts +93 -0
  43. package/src/hooks/agent-tracker.ts +14 -2
  44. package/src/hooks/compaction-hook.ts +7 -16
  45. package/src/hooks/config-handler.ts +83 -29
  46. package/src/hooks/context-budget.ts +4 -5
  47. package/src/hooks/event-hook.ts +213 -57
  48. package/src/hooks/knowledge-sync-hook.ts +2 -3
  49. package/src/hooks/safe-create-hook.ts +13 -1
  50. package/src/hooks/system-prompt-hook.ts +20 -39
  51. package/src/hooks/tool-tracking-hook.ts +602 -323
  52. package/src/index.ts +15 -1
  53. package/src/knowledge/scvd-client.ts +2 -4
  54. package/src/knowledge/scvd-errors.ts +25 -2
  55. package/src/knowledge/scvd-index.ts +7 -5
  56. package/src/knowledge/scvd-sync.ts +6 -6
  57. package/src/managers/types.ts +20 -2
  58. package/src/shared/agent-names.ts +23 -0
  59. package/src/shared/audit-artifact-resolver.ts +8 -3
  60. package/src/shared/audit-phases.ts +12 -0
  61. package/src/shared/cache-paths.ts +41 -0
  62. package/src/shared/drop-diagnostics.ts +2 -2
  63. package/src/shared/forge-errors.ts +31 -0
  64. package/src/shared/forge-runner.ts +30 -0
  65. package/src/shared/format-error.ts +3 -0
  66. package/src/shared/index.ts +9 -0
  67. package/src/shared/key-tools.ts +39 -0
  68. package/src/shared/logger.ts +7 -7
  69. package/src/shared/path-containment.ts +25 -0
  70. package/src/shared/path-utils.ts +11 -0
  71. package/src/shared/report-path-resolver.ts +4 -2
  72. package/src/shared/safe-emit.ts +24 -0
  73. package/src/shared/token-utils.ts +5 -0
  74. package/src/shared/type-guards.ts +8 -0
  75. package/src/shared/validation-constants.ts +52 -0
  76. package/src/skills/analysis/cluster.ts +1 -114
  77. package/src/skills/analysis/normalize.ts +2 -114
  78. package/src/skills/analysis/stopwords.ts +109 -0
  79. package/src/skills/argus-skill-resolver.ts +6 -3
  80. package/src/solodit-lifecycle.ts +153 -37
  81. package/src/state/adapters.ts +60 -66
  82. package/src/state/finding-aggregation.ts +6 -8
  83. package/src/state/finding-fingerprint.ts +1 -1
  84. package/src/state/finding-store.ts +31 -9
  85. package/src/state/index.ts +1 -1
  86. package/src/state/projectors.ts +27 -19
  87. package/src/state/schemas.ts +8 -32
  88. package/src/state/types.ts +3 -0
  89. package/src/tools/contract-analyzer-tool.ts +4 -6
  90. package/src/tools/forge-coverage-tool.ts +10 -35
  91. package/src/tools/forge-fuzz-tool.ts +21 -51
  92. package/src/tools/forge-test-tool.ts +25 -47
  93. package/src/tools/gas-analysis-tool.ts +12 -41
  94. package/src/tools/pattern-checker-tool.ts +37 -15
  95. package/src/tools/pattern-loader.ts +18 -4
  96. package/src/tools/persist-deduped-tool.ts +94 -0
  97. package/src/tools/proxy-detection-tool.ts +35 -34
  98. package/src/tools/read-findings-tool.ts +390 -0
  99. package/src/tools/record-finding-tool.ts +130 -25
  100. package/src/tools/report-generator-tool.ts +475 -327
  101. package/src/tools/report-preflight.ts +5 -1
  102. package/src/tools/slither-tool.ts +55 -16
  103. package/src/tools/solodit-search-tool.ts +260 -112
  104. package/src/tools/sync-knowledge-tool.ts +2 -3
  105. package/src/utils/solidity-parser.ts +39 -24
  106. package/src/features/migration/index.ts +0 -14
  107. package/src/features/migration/migration-adapter.ts +0 -151
  108. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -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
- 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"
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, backgroundManager } = managers
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
- let currentEventSink: EventSink | null = null
125
- let currentOpencodeSessionId = ""
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
- // Sub-handlers run sequentially. The state persistence handler MUST be first:
128
- // it loads persisted state on session.created, overriding the fresh default.
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
- try {
143
- recoveredState = await auditStateManager.load()
144
- } finally {
145
- runJournal.log({
146
- type: "state.loaded",
147
- timestamp,
148
- success: recoveredState !== null,
149
- findingsCount: recoveredState?.findings.length ?? 0,
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
- if (recoveredState) {
154
- setState(recoveredState)
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
- runJournal.log({
158
- type: "session.created",
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
- timestamp: Date.now(),
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
- const effectiveState = recoveredState ?? auditStateManager.get()
164
- if (effectiveState) {
165
- const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
166
- try {
167
- const journalFile = resolver.paths().journalFile
168
- // createEventSink builds the same path internally; the resolver makes it explicit.
169
- currentEventSink = createEventSink(effectiveState.sessionId, projectDir)
170
- setEventSink(currentEventSink)
171
- logger.debug(`Event sink journal path: ${journalFile}`)
172
- } catch (error) {
173
- logger.error(
174
- `Failed to create event sink: ${error instanceof Error ? error.message : String(error)}`,
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
- void recordRun({
178
- runId: effectiveState.sessionId,
179
- opencodeSessionId: sessionId,
180
- projectDir: effectiveState.projectDir,
181
- statePath: resolver.paths().stateFile,
182
- journalPath: resolver.paths().journalFile,
183
- startedAt: effectiveState.startTime,
184
- phase: effectiveState.currentPhase,
185
- findingsCount: effectiveState.findings.length,
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
- await debouncedSave.flush()
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
- await auditStateManager.save(auditState)
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
- void recordRun({
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
- if (migrationMode !== "legacy") {
231
- try {
232
- const { legacyFindings, canonicalFindings } = adaptLegacyFindings(
233
- auditState,
234
- migrationMode,
235
- auditState.sessionId,
236
- )
237
- const parityMetrics = computeParityMetrics(legacyFindings, canonicalFindings)
238
- logger.debug(formatParityReport(parityMetrics))
239
- } catch (error) {
240
- logger.warn(
241
- `Migration parity check failed: ${error instanceof Error ? error.message : String(error)}`,
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
- await debouncedSave.flush()
250
- if (auditState) {
251
- await auditStateManager.save(auditState)
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
- await sessionRecoveryHandler({ type, sessionId, setAuditState: setState })
262
- },
263
- async ({ type }) => {
264
- if (type === "session.idle") {
265
- backgroundManager.getActiveCount()
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(() => createCompactionHook(getAuditState, getReconContext), "compaction")
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
- const currentState = getAuditState()
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
- debouncedSave.save(currentState)
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: () => currentEventSink,
337
- getSessionId: () => currentOpencodeSessionId,
338
- getAgentName: () => {
339
- if (!currentOpencodeSessionId) {
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 agent = agentTracker.getAgentForSession(sessionId)
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 materializeFindingsForRun = async (
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
- if (!runId || runId.length === 0) {
384
- return
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 materializeFindingsForRun(
907
+ await runMaterializeFindings(
422
908
  finalizationResult.runId,
423
909
  projectDir,
424
- input.event.sessionId,
425
- "session.idle",
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
- await auditStateManager.archive()
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": async (input, output) => {
467
- await systemPromptHook(input, output)
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 (_input, output) => {
471
- const block = await compactionHook({ summary: output.context.join("\n") })
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: input.tool,
479
- result: output.output,
1042
+ tool: toolName,
1043
+ result: toolOutput,
480
1044
  })
481
1045
 
482
1046
  await toolTrackingHook({
483
- tool: input.tool,
1047
+ tool: toolName,
484
1048
  args: input.args,
485
- result: output.output,
1049
+ result: toolOutput,
486
1050
  sessionID: input.sessionID,
487
1051
  callID: input.callID,
488
1052
  })
489
1053
 
490
- if (input.tool === "argus_generate_report") {
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 runId = extractRunIdFromReportToolOutput(output.output) ?? state.sessionId
497
- await materializeFindingsForRun(
498
- runId,
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
- true,
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
- const outputWithHint = recoveryHint ? `${output.output}${recoveryHint}` : output.output
507
- output.output = outputTruncator(outputWithHint)
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
  }