solidity-argus 0.3.7 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +797 -148
  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 +34 -2
  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 +597 -323
  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 +394 -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"
@@ -66,6 +75,37 @@ function extractRunIdFromReportToolOutput(result: string): string | undefined {
66
75
  return undefined
67
76
  }
68
77
 
78
+ function extractReportFilePathFromToolOutput(result: string): string | undefined {
79
+ try {
80
+ const parsed = JSON.parse(result) as Record<string, unknown>
81
+ if (typeof parsed.filePath === "string" && parsed.filePath.length > 0) {
82
+ return parsed.filePath
83
+ }
84
+ } catch {
85
+ return undefined
86
+ }
87
+
88
+ return undefined
89
+ }
90
+
91
+ function extractReportErrorFromToolOutput(result: string): string | undefined {
92
+ try {
93
+ const parsed = JSON.parse(result) as Record<string, unknown>
94
+ const error = parsed.error
95
+ if (typeof error === "object" && error !== null && !Array.isArray(error)) {
96
+ const message = (error as Record<string, unknown>).message
97
+ if (typeof message === "string" && message.length > 0) {
98
+ return message
99
+ }
100
+ return "argus_generate_report returned an unknown error"
101
+ }
102
+ } catch {
103
+ return "argus_generate_report output was not valid JSON"
104
+ }
105
+
106
+ return undefined
107
+ }
108
+
69
109
  export function getAgentForSession(sessionID: string): string | undefined {
70
110
  return _agentTrackerRef?.getAgentForSession(sessionID)
71
111
  }
@@ -83,7 +123,10 @@ export type Hooks = Pick<
83
123
  | "experimental.session.compacting"
84
124
  | "tool.execute.after"
85
125
  | "event"
86
- >
126
+ > & {
127
+ /** Release the process-wide instance lock so the plugin can be re-initialized. */
128
+ dispose?: () => void
129
+ }
87
130
 
88
131
  /**
89
132
  * Creates the hook handlers for the Argus plugin.
@@ -102,17 +145,57 @@ export function createHooks(args: {
102
145
  projectDir: string
103
146
  isHookEnabled: (name: HookName) => boolean
104
147
  }): Hooks {
148
+ // Instance-level mutex: when OpenCode loads the plugin multiple times in the
149
+ // same process (e.g. re-adding "solidity-argus" to global config), only the
150
+ // first instance runs full initialization. Subsequent calls get inert hooks
151
+ // with only the config handler active (agent/MCP registration is idempotent).
152
+ const INSTANCE_LOCK = Symbol.for("solidity-argus:instance-lock")
153
+ const globals = globalThis as unknown as Record<symbol, boolean>
154
+ const releaseInstanceLock = () => {
155
+ delete globals[INSTANCE_LOCK]
156
+ }
157
+
158
+ if (globals[INSTANCE_LOCK]) {
159
+ logger.debug("[plugin] Duplicate instance detected — returning inert hooks")
160
+ return {
161
+ config: createConfigHandler(args.config, args.projectDir),
162
+ "chat.params": undefined,
163
+ "chat.message": undefined,
164
+ "experimental.chat.system.transform": undefined,
165
+ "experimental.session.compacting": undefined,
166
+ "tool.execute.after": undefined,
167
+ event: undefined,
168
+ dispose: releaseInstanceLock,
169
+ }
170
+ }
171
+ globals[INSTANCE_LOCK] = true
172
+
105
173
  const { config, managers, projectDir, isHookEnabled } = args
106
- const { auditStateManager, backgroundManager } = managers
174
+ const { auditStateManager } = managers
107
175
  const agentTracker = createAgentTracker()
108
176
  _agentTrackerRef = agentTracker
109
177
 
110
- const migrationMode = getMigrationMode(config)
111
- logger.debug(`Migration mode: ${migrationMode}`)
112
-
113
178
  const contextMonitor = createContextMonitor()
114
- const sessionRecoveryHandler = createSessionRecoveryHandler(auditStateManager)
115
179
  const debouncedSave = createDebouncedSave(auditStateManager.save)
180
+
181
+ const exitHandler = () => {
182
+ try {
183
+ debouncedSave.dispose()
184
+ for (const sessionDebouncedSave of debouncedSavesBySession.values()) {
185
+ sessionDebouncedSave.dispose()
186
+ }
187
+ } catch {
188
+ /* noop */
189
+ }
190
+ }
191
+ process.on("exit", exitHandler)
192
+
193
+ const fullDispose = () => {
194
+ _agentTrackerRef = undefined
195
+ process.removeListener("exit", exitHandler)
196
+ releaseInstanceLock()
197
+ }
198
+
116
199
  const runJournal = createRunJournal(projectDir)
117
200
  let auditStateGetter: (() => AuditState | null) | undefined
118
201
  const toolErrorRecoveryHandler = createToolErrorRecoveryHandler(
@@ -121,80 +204,380 @@ export function createHooks(args: {
121
204
  )
122
205
  const outputTruncator = createToolOutputTruncator()
123
206
 
124
- let currentEventSink: EventSink | null = null
125
- 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
126
211
 
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
212
+ const eventSinksByOpencodeSession = new Map<string, EventSink>()
213
+ const eventSinksByRunId = new Map<string, EventSink>()
141
214
 
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
- })
215
+ const sinkCreatedAtBySession = new Map<string, number>()
216
+ const sinkCreatedAtByRunId = new Map<string, number>()
217
+
218
+ const pendingSinkCreations = new Set<string>()
219
+ const activatedSessions = new Set<string>()
220
+ const sessionManagers = new Map<string, AuditStateManager>()
221
+ const debouncedSavesBySession = new Map<string, ReturnType<typeof createDebouncedSave>>()
222
+
223
+ const pendingActivations = new Set<string>()
224
+
225
+ function getSessionManager(sessionId: string): AuditStateManager {
226
+ let manager = sessionManagers.get(sessionId)
227
+ if (!manager) {
228
+ manager = createAuditStateManager(projectDir)
229
+ manager.bindSession(sessionId)
230
+ sessionManagers.set(sessionId, manager)
231
+
232
+ if (sessionManagers.size > MAX_SESSION_TRACKING) {
233
+ const oldest = sessionManagers.keys().next()
234
+ if (!oldest.done) {
235
+ const oldestSessionId = oldest.value
236
+ if (oldestSessionId !== sessionId) {
237
+ const oldestDebouncedSave = debouncedSavesBySession.get(oldestSessionId)
238
+ oldestDebouncedSave?.dispose()
239
+ debouncedSavesBySession.delete(oldestSessionId)
240
+ sessionManagers.delete(oldestSessionId)
241
+ }
151
242
  }
243
+ }
244
+ }
152
245
 
153
- if (recoveredState) {
154
- 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
155
310
  }
156
311
 
157
- runJournal.log({
158
- 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,
159
349
  sessionId,
160
- 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,
161
385
  })
386
+ }
162
387
 
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
- )
388
+ const STALE_STATE_TTL_MS = 24 * 60 * 60 * 1000
389
+ if (recoveredState) {
390
+ const isStale =
391
+ typeof recoveredState.startTime === "number" &&
392
+ timestamp - recoveredState.startTime > STALE_STATE_TTL_MS
393
+ const isCompleted = recoveredState.reportGenerated === true
394
+ if (isStale || isCompleted) {
395
+ logger.debug(
396
+ `Discarding recovered state for run ${recoveredState.sessionId}: ${isCompleted ? "report already generated" : "stale (>24h)"}`,
397
+ )
398
+ recoveredState = null
399
+ }
400
+ }
401
+
402
+ if (recoveredState && auditState) {
403
+ setAuditState(
404
+ {
405
+ ...recoveredState,
406
+ sessionId: auditState.sessionId,
407
+ projectDir: auditState.projectDir,
408
+ startTime: auditState.startTime,
409
+ },
410
+ sessionId,
411
+ )
412
+ } else if (recoveredState) {
413
+ setAuditState(recoveredState, sessionId)
414
+ }
415
+
416
+ const effectiveState = getAuditState(sessionId) ?? recoveredState
417
+ if (effectiveState) {
418
+ const raceSink = eventSinksByOpencodeSession.get(sessionId)
419
+ if (raceSink) {
420
+ setEventSink(raceSink, sessionId)
421
+ setBoundedSink(eventSinksByRunId, sinkCreatedAtByRunId, raceSink.runId, raceSink)
422
+ if (auditState) {
423
+ setAuditState({ ...auditState, sessionId: raceSink.runId }, sessionId)
176
424
  }
177
- 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,
425
+ runJournal.log({ type: "state.loaded", timestamp, success: true, findingsCount: 0 })
426
+ sessionActivated = true
427
+ return
428
+ }
429
+
430
+ const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
431
+ try {
432
+ const sink = createEventSink(effectiveState.sessionId, projectDir)
433
+ setEventSink(sink, sessionId)
434
+ setBoundedSink(eventSinksByOpencodeSession, sinkCreatedAtBySession, sessionId, sink)
435
+ setBoundedSink(eventSinksByRunId, sinkCreatedAtByRunId, effectiveState.sessionId, sink)
436
+
437
+ await sink.append({
438
+ type: "session.created",
439
+ run_id: effectiveState.sessionId,
440
+ seq: 0,
441
+ session_id: sessionId,
442
+ source: "create-hooks",
443
+ schema_version: SCHEMA_VERSION,
444
+ timestamp,
445
+ payload: {
446
+ projectDir: effectiveState.projectDir,
447
+ sessionId: effectiveState.sessionId,
448
+ plugin_version: ARGUS_PLUGIN_VERSION,
449
+ scope: effectiveState.scope,
450
+ },
186
451
  })
452
+ } catch (error) {
453
+ logger.warn(
454
+ `EventSink creation failed: ${error instanceof Error ? error.message : String(error)}`,
455
+ )
456
+ }
457
+ recordRun({
458
+ runId: effectiveState.sessionId,
459
+ opencodeSessionId: sessionId,
460
+ projectDir: effectiveState.projectDir,
461
+ statePath: resolver.paths().stateFile,
462
+ journalPath: resolver.paths().journalFile,
463
+ startedAt: effectiveState.startTime,
464
+ phase: effectiveState.currentPhase,
465
+ findingsCount: effectiveState.findings.length,
466
+ status: "active",
467
+ }).catch((err) =>
468
+ logger.warn(`Failed to record run: ${err instanceof Error ? err.message : String(err)}`),
469
+ )
470
+
471
+ pruneStaleRuns(effectiveState.projectDir).catch((err) =>
472
+ logger.warn(
473
+ `Failed to prune stale runs: ${err instanceof Error ? err.message : String(err)}`,
474
+ ),
475
+ )
476
+ }
477
+
478
+ sessionActivated = true
479
+ } finally {
480
+ if (sessionActivated) {
481
+ activatedSessions.add(sessionId)
482
+ }
483
+ pendingActivations.delete(sessionId)
484
+ pendingSinkCreations.delete(sessionId)
485
+ }
486
+ }
487
+
488
+ /** Evict the oldest entry from a bounded EventSink map and its companion timestamp map. */
489
+ function evictOldestSink(
490
+ sinkMap: Map<string, EventSink>,
491
+ timestampMap: Map<string, number>,
492
+ ): void {
493
+ const oldestKey = sinkMap.keys().next().value
494
+ if (oldestKey === undefined) return
495
+ const sink = sinkMap.get(oldestKey)
496
+ if (sink && !sink.isFinalized) {
497
+ try {
498
+ sink.markFinalized()
499
+ } catch {
500
+ /* noop — best-effort finalization */
501
+ }
502
+ }
503
+ sinkMap.delete(oldestKey)
504
+ timestampMap.delete(oldestKey)
505
+ }
506
+
507
+ /** Evict any entries older than SINK_TTL_MS from a bounded EventSink map. */
508
+ function evictStaleSinks(
509
+ sinkMap: Map<string, EventSink>,
510
+ timestampMap: Map<string, number>,
511
+ ): void {
512
+ const now = Date.now()
513
+ for (const [key, createdAt] of timestampMap) {
514
+ if (now - createdAt > SINK_TTL_MS) {
515
+ const sink = sinkMap.get(key)
516
+ if (sink && !sink.isFinalized) {
517
+ try {
518
+ sink.markFinalized()
519
+ } catch {
520
+ /* noop */
521
+ }
187
522
  }
523
+ sinkMap.delete(key)
524
+ timestampMap.delete(key)
525
+ }
526
+ }
527
+ }
528
+
529
+ /** Add a sink to a bounded map, evicting oldest entries if the limit is reached. */
530
+ function setBoundedSink(
531
+ sinkMap: Map<string, EventSink>,
532
+ timestampMap: Map<string, number>,
533
+ key: string,
534
+ sink: EventSink,
535
+ ): void {
536
+ evictStaleSinks(sinkMap, timestampMap)
537
+ if (sinkMap.size >= MAX_SINKS && !sinkMap.has(key)) {
538
+ evictOldestSink(sinkMap, timestampMap)
539
+ }
540
+ sinkMap.set(key, sink)
541
+ if (!timestampMap.has(key)) {
542
+ timestampMap.set(key, Date.now())
543
+ }
544
+ trimSessionSets()
545
+ }
188
546
 
547
+ // Sub-handlers run sequentially. The state persistence handler MUST be first:
548
+ // it loads persisted state on session.created, overriding the fresh default.
549
+ const {
550
+ hook: eventHook,
551
+ getAuditState,
552
+ setAuditState,
553
+ setEventSink,
554
+ getLastFinalizationResult,
555
+ } = createEventHook(projectDir, [
556
+ async ({ type, sessionId, auditState, setAuditState: _setState }) => {
557
+ if (type === "session.created") {
558
+ // Lazy activation: on session.created we don't yet know which agent
559
+ // the user will select (chat.params fires later). We only create
560
+ // in-memory state here; all disk I/O (EventSink, state persistence,
561
+ // run recording) is deferred to activateSession() which is triggered
562
+ // by chat.params (Argus agent) or tool.execute.after (argus_* tool).
189
563
  return
190
564
  }
191
565
 
192
566
  if (type === "session.idle" && auditState) {
193
- 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
+ }
194
574
 
195
575
  let saveSuccess = true
196
576
  try {
197
- await auditStateManager.save(auditState)
577
+ const idleManager = sessionId ? sessionManagers.get(sessionId) : auditStateManager
578
+ if (idleManager) {
579
+ await idleManager.save(auditState)
580
+ }
198
581
  } catch {
199
582
  saveSuccess = false
200
583
  } finally {
@@ -216,7 +599,7 @@ export function createHooks(args: {
216
599
  auditState.sessionId,
217
600
  auditState.projectDir,
218
601
  )
219
- void recordRun({
602
+ recordRun({
220
603
  runId: auditState.sessionId,
221
604
  opencodeSessionId: sessionId,
222
605
  projectDir: auditState.projectDir,
@@ -225,30 +608,77 @@ export function createHooks(args: {
225
608
  startedAt: auditState.startTime,
226
609
  phase: auditState.currentPhase,
227
610
  findingsCount: auditState.findings.length,
228
- })
611
+ }).catch((err) =>
612
+ logger.warn(
613
+ `Failed to record run on idle: ${err instanceof Error ? err.message : String(err)}`,
614
+ ),
615
+ )
229
616
 
230
- 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
- )
617
+ try {
618
+ await materializeReportInput(auditState.sessionId, auditState.projectDir, sessionId)
619
+ } catch (error) {
620
+ logger.warn(
621
+ `Failed to materialize report-input artifact on session.idle for run ${auditState.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
622
+ )
623
+ }
624
+
625
+ if (auditState.reportGenerated) {
626
+ const runSink =
627
+ eventSinksByRunId.get(auditState.sessionId) ??
628
+ (sessionId ? (eventSinksByOpencodeSession.get(sessionId) ?? null) : null)
629
+
630
+ if (runSink && !runSink.isFinalized) {
631
+ try {
632
+ const idleFinalization = await finalizeRun(
633
+ auditState.sessionId,
634
+ auditState.projectDir,
635
+ runSink,
636
+ )
637
+ updateRunStatus(
638
+ auditState.sessionId,
639
+ idleFinalization.invariantsPassed ? "finalized" : "failed",
640
+ ).catch((err) =>
641
+ logger.warn(
642
+ `Failed to update run status: ${err instanceof Error ? err.message : String(err)}`,
643
+ ),
644
+ )
645
+ if (!idleFinalization.invariantsPassed) {
646
+ logger.warn(
647
+ `Idle finalization for run ${auditState.sessionId} has invariant errors: ${idleFinalization.errors.join("; ")}`,
648
+ )
649
+ }
650
+ } catch (error) {
651
+ logger.warn(
652
+ `Failed to finalize run ${auditState.sessionId} on session.idle: ${error instanceof Error ? error.message : String(error)}`,
653
+ )
654
+ }
243
655
  }
244
656
  }
657
+
245
658
  return
246
659
  }
247
660
 
248
661
  if (type === "session.deleted") {
249
- await debouncedSave.flush()
250
- if (auditState) {
251
- 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
+ }
252
682
  }
253
683
  runJournal.log({
254
684
  type: "state.saved",
@@ -258,16 +688,26 @@ export function createHooks(args: {
258
688
  }
259
689
  },
260
690
  async ({ type, sessionId, setAuditState: setState }) => {
261
- await sessionRecoveryHandler({ type, sessionId, setAuditState: setState })
262
- },
263
- async ({ type }) => {
264
- if (type === "session.idle") {
265
- 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
+ )
266
705
  }
267
706
  },
707
+ async () => {},
268
708
  ])
269
709
 
270
- auditStateGetter = getAuditState
710
+ auditStateGetter = () => getAuditState()
271
711
 
272
712
  const initialState = auditStateManager.get()
273
713
  if (initialState) {
@@ -277,11 +717,11 @@ export function createHooks(args: {
277
717
  const auditEnforcer = createAuditEnforcer()
278
718
 
279
719
  const systemPromptHook = createSystemPromptHook({
280
- getAuditState,
720
+ getAuditState: (sessionId?: string) => getAuditState(sessionId),
281
721
  getAgentForSession: agentTracker.getAgentForSession,
282
722
  isArgusAgent: agentTracker.isArgusAgent,
283
- getContextPressure: (systemText: string) => {
284
- const status = contextMonitor.getContextStatus(systemText, getAuditState())
723
+ getContextPressure: (systemText: string, sessionId?: string) => {
724
+ const status = contextMonitor.getContextStatus(systemText, getAuditState(sessionId))
285
725
  return status.usage
286
726
  },
287
727
  getTokenBudget: getTokenBudgetForAgent,
@@ -311,18 +751,28 @@ export function createHooks(args: {
311
751
  })
312
752
 
313
753
  const compactionHook = isHookEnabled("compaction")
314
- ? safeCreateHook(() => createCompactionHook(getAuditState, getReconContext), "compaction")
754
+ ? safeCreateHook(
755
+ () =>
756
+ createCompactionHook((sessionId?: string) => getAuditState(sessionId), getReconContext),
757
+ "compaction",
758
+ )
315
759
  : undefined
316
760
 
317
761
  const toolTrackingHook = isHookEnabled("tool-tracking")
318
762
  ? safeCreateHook(
319
763
  () =>
320
764
  createToolTrackingHook(
321
- getAuditState,
322
- ({ tool, findingsCount }) => {
323
- 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)
324
770
  if (currentState) {
325
- debouncedSave.save(currentState)
771
+ if (sessionId && sessionManagers.has(sessionId)) {
772
+ getSessionDebouncedSave(sessionId).save(currentState)
773
+ } else {
774
+ debouncedSave.save(currentState)
775
+ }
326
776
  }
327
777
 
328
778
  runJournal.log({
@@ -333,28 +783,56 @@ export function createHooks(args: {
333
783
  })
334
784
  },
335
785
  {
336
- getEventSink: () => currentEventSink,
337
- getSessionId: () => currentOpencodeSessionId,
338
- getAgentName: () => {
339
- if (!currentOpencodeSessionId) {
340
- return undefined
341
- }
342
-
343
- const agent = agentTracker.getAgentForSession(currentOpencodeSessionId)
344
- if (
345
- agent === "argus" ||
346
- agent === "sentinel" ||
347
- agent === "pythia" ||
348
- agent === "scribe" ||
349
- agent === "unknown"
350
- ) {
351
- return agent
786
+ getEventSink: () => {
787
+ const state = getAuditState()
788
+ if (!state || state.sessionId.length === 0) {
789
+ return null
352
790
  }
353
-
354
- return "unknown"
791
+ return eventSinksByRunId.get(state.sessionId) ?? null
355
792
  },
793
+ getEventSinkForSession: (sessionId: string) =>
794
+ eventSinksByOpencodeSession.get(sessionId) ??
795
+ (() => {
796
+ const parentSessionId = agentTracker.getParentSession(sessionId)
797
+ if (parentSessionId) {
798
+ const parentSink = eventSinksByOpencodeSession.get(parentSessionId)
799
+ if (parentSink) {
800
+ setBoundedSink(
801
+ eventSinksByOpencodeSession,
802
+ sinkCreatedAtBySession,
803
+ sessionId,
804
+ parentSink,
805
+ )
806
+ return parentSink
807
+ }
808
+ }
809
+ const state = getAuditState(sessionId)
810
+ if (state && state.sessionId.length > 0) {
811
+ const runSink = eventSinksByRunId.get(state.sessionId)
812
+ if (runSink) {
813
+ setBoundedSink(
814
+ eventSinksByOpencodeSession,
815
+ sinkCreatedAtBySession,
816
+ sessionId,
817
+ runSink,
818
+ )
819
+ return runSink
820
+ }
821
+ }
822
+ return null
823
+ })(),
824
+ getEventSinkForRun: (runId: string) => eventSinksByRunId.get(runId) ?? null,
825
+ projectDir,
826
+ getActiveRunSinks: () =>
827
+ Array.from(eventSinksByRunId.values()).filter((s) => !s.isFinalized),
356
828
  getAgentNameForSession: (sessionId: string) => {
357
- const agent = agentTracker.getAgentForSession(sessionId)
829
+ const directAgent = agentTracker.getAgentForSession(sessionId)
830
+ const parentSessionId = agentTracker.getParentSession(sessionId)
831
+ const inheritedAgent =
832
+ !directAgent && parentSessionId
833
+ ? agentTracker.getAgentForSession(parentSessionId)
834
+ : undefined
835
+ const agent = directAgent ?? inheritedAgent
358
836
  if (
359
837
  agent === "argus" ||
360
838
  agent === "sentinel" ||
@@ -367,9 +845,32 @@ export function createHooks(args: {
367
845
 
368
846
  return "unknown"
369
847
  },
848
+ onChildSessionDetected: (parentSessionId: string, childSessionId: string) => {
849
+ if (parentSessionId && childSessionId) {
850
+ agentTracker.trackChildSession(parentSessionId, childSessionId)
851
+
852
+ const parentSink = eventSinksByOpencodeSession.get(parentSessionId)
853
+ if (parentSink && toolTrackingHook) {
854
+ setBoundedSink(
855
+ eventSinksByOpencodeSession,
856
+ sinkCreatedAtBySession,
857
+ childSessionId,
858
+ parentSink,
859
+ )
860
+ void toolTrackingHook
861
+ .flushOrphanEvents(childSessionId, parentSink)
862
+ .catch((error: unknown) => {
863
+ logger.warn(
864
+ `Failed to flush orphan events for child session ${childSessionId}: ${error instanceof Error ? error.message : String(error)}`,
865
+ )
866
+ })
867
+ }
868
+ }
869
+ },
370
870
  },
371
871
  ),
372
872
  "tool-tracking",
873
+ { critical: true },
373
874
  )
374
875
  : undefined
375
876
 
@@ -377,7 +878,7 @@ export function createHooks(args: {
377
878
  runId: string,
378
879
  projectDirForRun: string,
379
880
  sessionIdForRun: string | undefined,
380
- trigger: "session.idle" | "tool.execute.after",
881
+ trigger: "session.idle" | "session.deleted" | "tool.execute.after",
381
882
  failFast = false,
382
883
  ): Promise<void> => {
383
884
  if (!runId || runId.length === 0) {
@@ -386,7 +887,7 @@ export function createHooks(args: {
386
887
 
387
888
  try {
388
889
  await materializeFindings(runId, projectDirForRun, sessionIdForRun, {
389
- validateSessionId: sessionIdForRun != null && sessionIdForRun.length > 0,
890
+ validateSessionId: false,
390
891
  requireEvents: true,
391
892
  })
392
893
  } catch (error) {
@@ -406,6 +907,7 @@ export function createHooks(args: {
406
907
  ? safeCreateHook(
407
908
  () => async (input: Parameters<typeof eventHook>[0]) => {
408
909
  const isSessionDeleted = input.event.type === "session.deleted"
910
+ const eventSessionId = extractSessionId(input.event)
409
911
  const finalizationBeforeDelete = isSessionDeleted ? getLastFinalizationResult() : null
410
912
 
411
913
  try {
@@ -421,8 +923,8 @@ export function createHooks(args: {
421
923
  await materializeFindingsForRun(
422
924
  finalizationResult.runId,
423
925
  projectDir,
424
- input.event.sessionId,
425
- "session.idle",
926
+ eventSessionId,
927
+ "session.deleted",
426
928
  true,
427
929
  )
428
930
  } catch (error) {
@@ -430,13 +932,64 @@ export function createHooks(args: {
430
932
  `Failed to materialize findings artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
431
933
  )
432
934
  }
935
+ try {
936
+ await materializeReportInput(finalizationResult.runId, projectDir, eventSessionId)
937
+ } catch (error) {
938
+ logger.warn(
939
+ `Failed to materialize report-input artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
940
+ )
941
+ }
433
942
  }
434
943
 
435
- 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
+ }
436
957
 
437
- const deletedSessionId = input.event.sessionId
438
958
  if (deletedSessionId) {
439
959
  agentTracker.clearSession(deletedSessionId)
960
+ eventSinksByOpencodeSession.delete(deletedSessionId)
961
+ pendingSinkCreations.delete(deletedSessionId)
962
+ pendingActivations.delete(deletedSessionId)
963
+ activatedSessions.delete(deletedSessionId)
964
+ const deletedDebouncedSave = debouncedSavesBySession.get(deletedSessionId)
965
+ deletedDebouncedSave?.dispose()
966
+ debouncedSavesBySession.delete(deletedSessionId)
967
+ sessionManagers.delete(deletedSessionId)
968
+
969
+ if (sessionManagers.size > MAX_SESSION_TRACKING) {
970
+ const oldest = sessionManagers.keys().next()
971
+ if (!oldest.done) {
972
+ const oldestSessionId = oldest.value
973
+ const oldestDebouncedSave = debouncedSavesBySession.get(oldestSessionId)
974
+ oldestDebouncedSave?.dispose()
975
+ debouncedSavesBySession.delete(oldestSessionId)
976
+ sessionManagers.delete(oldestSessionId)
977
+ }
978
+ }
979
+ }
980
+
981
+ const activeRunIds = new Set(
982
+ Array.from(eventSinksByOpencodeSession.values()).map((sink) => sink.runId),
983
+ )
984
+ for (const trackedRunId of Array.from(eventSinksByRunId.keys())) {
985
+ if (!activeRunIds.has(trackedRunId)) {
986
+ releaseEventSink(trackedRunId)
987
+ eventSinksByRunId.delete(trackedRunId)
988
+ }
989
+ }
990
+
991
+ if (finalizationResult && finalizationResult.runId.length > 0) {
992
+ releaseEventSink(finalizationResult.runId)
440
993
  }
441
994
 
442
995
  runJournal.log({
@@ -445,68 +998,164 @@ export function createHooks(args: {
445
998
  archived: true,
446
999
  finalizationPassed: finalizationResult?.invariantsPassed ?? null,
447
1000
  })
448
-
449
- currentEventSink = null
450
- currentOpencodeSessionId = ""
451
1001
  }
452
1002
  }
453
1003
  },
454
1004
  "event",
1005
+ { critical: true },
455
1006
  )
456
1007
  : undefined
457
1008
 
458
1009
  return {
459
1010
  config: createConfigHandler(config, projectDir),
460
- "chat.params": async (input) => {
1011
+ "chat.params": async (input, output) => {
461
1012
  agentTracker.chatParamsHook(input)
1013
+
1014
+ // Enforce deterministic LLM output for Argus-family agents (temperature=0).
1015
+ // Per-agent overrides are supported via config.agents.<name>.temperature.
1016
+ // Non-Argus sessions are left untouched so other plugins are not affected.
1017
+ if (agentTracker.isArgusAgent(input.sessionID)) {
1018
+ const agentName = agentTracker.getAgentForSession(input.sessionID)
1019
+ const agentConfig = agentName
1020
+ ? config.agents?.[agentName as keyof typeof config.agents]
1021
+ : undefined
1022
+ output.temperature = agentConfig?.temperature ?? 0
1023
+
1024
+ await activateSession(input.sessionID)
1025
+ }
462
1026
  },
463
1027
  "chat.message": async (input) => {
464
1028
  agentTracker.chatMessageHook(input)
465
1029
  },
466
- "experimental.chat.system.transform": async (input, output) => {
467
- await systemPromptHook(input, output)
468
- },
1030
+ "experimental.chat.system.transform": isHookEnabled("system-prompt")
1031
+ ? async (input, output) => {
1032
+ await systemPromptHook(input, output)
1033
+ }
1034
+ : undefined,
469
1035
  "experimental.session.compacting": compactionHook
470
- ? async (_input, output) => {
471
- 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
+ })
472
1041
  if (block) output.context.push(block)
473
1042
  }
474
1043
  : undefined,
475
1044
  "tool.execute.after": toolTrackingHook
476
1045
  ? async (input, output) => {
1046
+ const toolName = typeof input.tool === "string" ? input.tool : ""
1047
+ if (!toolName.startsWith("argus_") && toolName !== "task") {
1048
+ return
1049
+ }
1050
+
1051
+ if (toolName.startsWith("argus_") && input.sessionID) {
1052
+ await activateSession(input.sessionID)
1053
+ }
1054
+
1055
+ const toolOutput = typeof output.output === "string" ? output.output : ""
1056
+
477
1057
  const recoveryHint = toolErrorRecoveryHandler({
478
- tool: input.tool,
479
- result: output.output,
1058
+ tool: toolName,
1059
+ result: toolOutput,
480
1060
  })
481
1061
 
482
1062
  await toolTrackingHook({
483
- tool: input.tool,
1063
+ tool: toolName,
484
1064
  args: input.args,
485
- result: output.output,
1065
+ result: toolOutput,
486
1066
  sessionID: input.sessionID,
487
1067
  callID: input.callID,
488
1068
  })
489
1069
 
490
- if (input.tool === "argus_generate_report") {
491
- const state = getAuditState()
1070
+ if (toolName === "argus_generate_report") {
1071
+ const state = getAuditState(input.sessionID)
492
1072
  if (!state || state.sessionId.length === 0) {
493
1073
  throw new Error("argus_generate_report completed without active audit state")
494
1074
  }
495
1075
 
496
- const runId = extractRunIdFromReportToolOutput(output.output) ?? state.sessionId
1076
+ const reportedError = extractReportErrorFromToolOutput(toolOutput)
1077
+ if (reportedError) {
1078
+ throw new Error(`argus_generate_report failed: ${reportedError}`)
1079
+ }
1080
+
1081
+ const reportFilePath = extractReportFilePathFromToolOutput(toolOutput)
1082
+ if (!reportFilePath) {
1083
+ throw new Error("argus_generate_report completed without report filePath")
1084
+ }
1085
+
1086
+ const extractedRunId = extractRunIdFromReportToolOutput(toolOutput)
1087
+ if (!extractedRunId) {
1088
+ throw new Error("argus_generate_report completed without run_id")
1089
+ }
1090
+ if (extractedRunId !== state.sessionId) {
1091
+ logger.warn(
1092
+ `argus_generate_report run_id ${extractedRunId} differs from state.sessionId ${state.sessionId} — proceeding with report`,
1093
+ )
1094
+ }
1095
+
497
1096
  await materializeFindingsForRun(
498
- runId,
1097
+ state.sessionId,
499
1098
  state.projectDir,
500
1099
  input.sessionID,
501
1100
  "tool.execute.after",
502
1101
  true,
503
1102
  )
1103
+
1104
+ try {
1105
+ await materializeReportInput(state.sessionId, state.projectDir, input.sessionID)
1106
+ } catch (error) {
1107
+ logger.warn(
1108
+ `Failed to materialize report-input artifact for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
1109
+ )
1110
+ }
1111
+
1112
+ // Trigger finalization immediately after report generation.
1113
+ // The session.idle handler also checks reportGenerated, but in
1114
+ // `opencode run` mode the process may exit before another idle
1115
+ // event fires. Finalizing here guarantees the run is closed.
1116
+ if (state.reportGenerated) {
1117
+ const runSink =
1118
+ eventSinksByRunId.get(state.sessionId) ??
1119
+ (input.sessionID
1120
+ ? (eventSinksByOpencodeSession.get(input.sessionID) ?? null)
1121
+ : null)
1122
+
1123
+ if (runSink) {
1124
+ try {
1125
+ const reportFinalization = await finalizeRun(
1126
+ state.sessionId,
1127
+ state.projectDir,
1128
+ runSink,
1129
+ )
1130
+ updateRunStatus(
1131
+ state.sessionId,
1132
+ reportFinalization.invariantsPassed ? "finalized" : "failed",
1133
+ ).catch((err) =>
1134
+ logger.warn(
1135
+ `Failed to update run status: ${err instanceof Error ? err.message : String(err)}`,
1136
+ ),
1137
+ )
1138
+ if (!reportFinalization.invariantsPassed) {
1139
+ logger.warn(
1140
+ `Report-triggered finalization for run ${state.sessionId} has invariant errors: ${reportFinalization.errors.join("; ")}`,
1141
+ )
1142
+ }
1143
+ } catch (error) {
1144
+ logger.warn(
1145
+ `Report-triggered finalization failed for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
1146
+ )
1147
+ }
1148
+ }
1149
+ }
504
1150
  }
505
1151
 
506
- const outputWithHint = recoveryHint ? `${output.output}${recoveryHint}` : output.output
507
- output.output = outputTruncator(outputWithHint)
1152
+ if (toolName.startsWith("argus_")) {
1153
+ const outputWithHint = recoveryHint ? `${toolOutput}${recoveryHint}` : toolOutput
1154
+ output.output = outputTruncator(outputWithHint)
1155
+ }
508
1156
  }
509
1157
  : undefined,
510
1158
  event: safeEventHook,
1159
+ dispose: fullDispose,
511
1160
  }
512
1161
  }