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