@kodrunhq/opencode-autopilot 1.2.1 → 1.4.0

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: Run a quick task (skip research/architecture, go straight to plan+build+ship)
3
+ ---
4
+
5
+ Invoke the `oc_quick` tool with the following arguments, skipping research and architecture and going straight to plan, build, and ship:
6
+
7
+ $ARGUMENTS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodrunhq/opencode-autopilot",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Curated agents, skills, and commands for the OpenCode AI coding CLI — autonomous orchestrator, multi-agent code review, model fallback, and in-session asset creation tools.",
5
5
  "main": "src/index.ts",
6
6
  "keywords": [
@@ -0,0 +1,125 @@
1
+ import { access } from "node:fs/promises";
2
+ import type { Config } from "@opencode-ai/plugin";
3
+ import { loadConfig } from "../config";
4
+ import { AGENT_NAMES } from "../orchestrator/handlers/types";
5
+ import { getAssetsDir, getGlobalConfigDir } from "../utils/paths";
6
+ import type { HealthResult } from "./types";
7
+
8
+ /**
9
+ * Check that the plugin config file exists and passes Zod validation.
10
+ * loadConfig returns null when the file is missing, and throws on invalid JSON/schema.
11
+ */
12
+ export async function configHealthCheck(configPath?: string): Promise<HealthResult> {
13
+ try {
14
+ const config = await loadConfig(configPath);
15
+ if (config === null) {
16
+ return Object.freeze({
17
+ name: "config-validity",
18
+ status: "fail" as const,
19
+ message: "Plugin config file not found",
20
+ });
21
+ }
22
+ return Object.freeze({
23
+ name: "config-validity",
24
+ status: "pass" as const,
25
+ message: `Config v${config.version} loaded and valid`,
26
+ });
27
+ } catch (error: unknown) {
28
+ const msg = error instanceof Error ? error.message : String(error);
29
+ return Object.freeze({
30
+ name: "config-validity",
31
+ status: "fail" as const,
32
+ message: `Config validation failed: ${msg}`,
33
+ });
34
+ }
35
+ }
36
+
37
+ /** Standard agent names, derived from the agents barrel export. */
38
+ const STANDARD_AGENT_NAMES: readonly string[] = Object.freeze([
39
+ "researcher",
40
+ "metaprompter",
41
+ "documenter",
42
+ "pr-reviewer",
43
+ "autopilot",
44
+ ]);
45
+
46
+ /** Pipeline agent names, derived from AGENT_NAMES in the orchestrator. */
47
+ const PIPELINE_AGENT_NAMES: readonly string[] = Object.freeze(Object.values(AGENT_NAMES));
48
+
49
+ /** All expected agent names (standard + pipeline). */
50
+ const EXPECTED_AGENTS: readonly string[] = Object.freeze([
51
+ ...STANDARD_AGENT_NAMES,
52
+ ...PIPELINE_AGENT_NAMES,
53
+ ]);
54
+
55
+ /**
56
+ * Check that all expected agents are injected into the OpenCode config.
57
+ * Requires the OpenCode config object (from the config hook).
58
+ */
59
+ export async function agentHealthCheck(config: Config | null): Promise<HealthResult> {
60
+ if (!config?.agent) {
61
+ return Object.freeze({
62
+ name: "agent-injection",
63
+ status: "fail" as const,
64
+ message: "No OpenCode config or agent map available",
65
+ });
66
+ }
67
+
68
+ const agentMap = config.agent;
69
+ const missing = EXPECTED_AGENTS.filter((name) => !(name in agentMap));
70
+
71
+ if (missing.length > 0) {
72
+ return Object.freeze({
73
+ name: "agent-injection",
74
+ status: "fail" as const,
75
+ message: `${missing.length} agent(s) missing: ${missing.join(", ")}`,
76
+ details: Object.freeze(missing),
77
+ });
78
+ }
79
+
80
+ return Object.freeze({
81
+ name: "agent-injection",
82
+ status: "pass" as const,
83
+ message: `All ${EXPECTED_AGENTS.length} agents injected`,
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Check that the source and target asset directories exist and are accessible.
89
+ */
90
+ export async function assetHealthCheck(
91
+ assetsDir?: string,
92
+ targetDir?: string,
93
+ ): Promise<HealthResult> {
94
+ const source = assetsDir ?? getAssetsDir();
95
+ const target = targetDir ?? getGlobalConfigDir();
96
+
97
+ try {
98
+ await access(source);
99
+ } catch (error: unknown) {
100
+ const code = (error as NodeJS.ErrnoException).code;
101
+ const detail = code === "ENOENT" ? "missing" : `inaccessible (${code})`;
102
+ return Object.freeze({
103
+ name: "asset-directories",
104
+ status: "fail" as const,
105
+ message: `Asset source directory ${detail}: ${source}`,
106
+ });
107
+ }
108
+
109
+ try {
110
+ await access(target);
111
+ return Object.freeze({
112
+ name: "asset-directories",
113
+ status: "pass" as const,
114
+ message: `Asset directories exist: source=${source}, target=${target}`,
115
+ });
116
+ } catch (error: unknown) {
117
+ const code = (error as NodeJS.ErrnoException).code;
118
+ const detail = code === "ENOENT" ? "missing" : `inaccessible (${code})`;
119
+ return Object.freeze({
120
+ name: "asset-directories",
121
+ status: "fail" as const,
122
+ message: `Asset target directory ${detail}: ${target}`,
123
+ });
124
+ }
125
+ }
@@ -0,0 +1,3 @@
1
+ export { agentHealthCheck, assetHealthCheck, configHealthCheck } from "./checks";
2
+ export { runHealthChecks } from "./runner";
3
+ export type { HealthReport, HealthResult } from "./types";
@@ -0,0 +1,56 @@
1
+ import type { Config } from "@opencode-ai/plugin";
2
+ import { agentHealthCheck, assetHealthCheck, configHealthCheck } from "./checks";
3
+ import type { HealthReport, HealthResult } from "./types";
4
+
5
+ /**
6
+ * Map a settled promise result to a HealthResult.
7
+ * Fulfilled results pass through; rejected results become fail entries.
8
+ */
9
+ function settledToResult(
10
+ outcome: PromiseSettledResult<HealthResult>,
11
+ fallbackName: string,
12
+ ): HealthResult {
13
+ if (outcome.status === "fulfilled") {
14
+ return outcome.value;
15
+ }
16
+ const msg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
17
+ return Object.freeze({
18
+ name: fallbackName,
19
+ status: "fail" as const,
20
+ message: `Check threw unexpectedly: ${msg}`,
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Run all health checks and aggregate into a HealthReport.
26
+ * Each check runs independently — a failure in one does not skip others.
27
+ * Uses Promise.allSettled so a throwing check cannot kill the entire report.
28
+ */
29
+ export async function runHealthChecks(options?: {
30
+ configPath?: string;
31
+ openCodeConfig?: Config | null;
32
+ assetsDir?: string;
33
+ targetDir?: string;
34
+ }): Promise<HealthReport> {
35
+ const start = Date.now();
36
+
37
+ const settled = await Promise.allSettled([
38
+ configHealthCheck(options?.configPath),
39
+ agentHealthCheck(options?.openCodeConfig ?? null),
40
+ assetHealthCheck(options?.assetsDir, options?.targetDir),
41
+ ]);
42
+
43
+ const fallbackNames = ["config-validity", "agent-injection", "asset-directories"];
44
+ const results: readonly HealthResult[] = Object.freeze(
45
+ settled.map((outcome, i) => settledToResult(outcome, fallbackNames[i])),
46
+ );
47
+
48
+ const allPassed = results.every((r) => r.status === "pass");
49
+ const duration = Date.now() - start;
50
+
51
+ return Object.freeze({
52
+ results,
53
+ allPassed,
54
+ duration,
55
+ });
56
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Health check result for a single diagnostic check.
3
+ * Immutable — frozen on creation by each check function.
4
+ */
5
+ export interface HealthResult {
6
+ readonly name: string;
7
+ readonly status: "pass" | "fail";
8
+ readonly message: string;
9
+ readonly details?: readonly string[];
10
+ }
11
+
12
+ /**
13
+ * Aggregated health report from running all checks.
14
+ * Immutable — frozen on creation by runHealthChecks.
15
+ */
16
+ export interface HealthReport {
17
+ readonly results: readonly HealthResult[];
18
+ readonly allPassed: boolean;
19
+ readonly duration: number;
20
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  import type { Config, Plugin } from "@opencode-ai/plugin";
2
2
  import { configHook } from "./agents";
3
3
  import { isFirstLoad, loadConfig } from "./config";
4
+ import { runHealthChecks } from "./health/runner";
4
5
  import { installAssets } from "./installer";
6
+ import { ContextMonitor } from "./observability/context-monitor";
7
+ import {
8
+ createObservabilityEventHandler,
9
+ createToolExecuteAfterHandler as createObsToolAfterHandler,
10
+ createToolExecuteBeforeHandler,
11
+ } from "./observability/event-handlers";
12
+ import { SessionEventStore } from "./observability/event-store";
13
+ import { writeSessionLog } from "./observability/log-writer";
14
+ import { pruneOldLogs } from "./observability/retention";
15
+ import type { SessionEvent } from "./observability/types";
5
16
  import type { SdkOperations } from "./orchestrator/fallback";
6
17
  import {
7
18
  createChatMessageHandler,
@@ -21,11 +32,17 @@ import {
21
32
  import { ocCreateAgent } from "./tools/create-agent";
22
33
  import { ocCreateCommand } from "./tools/create-command";
23
34
  import { ocCreateSkill } from "./tools/create-skill";
35
+ import { ocDoctor } from "./tools/doctor";
24
36
  import { ocForensics } from "./tools/forensics";
37
+ import { ocLogs } from "./tools/logs";
38
+ import { ocMockFallback } from "./tools/mock-fallback";
25
39
  import { ocOrchestrate } from "./tools/orchestrate";
26
40
  import { ocPhase } from "./tools/phase";
41
+ import { ocPipelineReport } from "./tools/pipeline-report";
27
42
  import { ocPlan } from "./tools/plan";
43
+ import { ocQuick } from "./tools/quick";
28
44
  import { ocReview } from "./tools/review";
45
+ import { ocSessionStats } from "./tools/session-stats";
29
46
  import { ocState } from "./tools/state";
30
47
 
31
48
  let openCodeConfig: Config | null = null;
@@ -62,6 +79,20 @@ const plugin: Plugin = async (input) => {
62
79
  const config = await loadConfig();
63
80
  const fallbackConfig = config?.fallback ?? fallbackDefaults;
64
81
 
82
+ // Self-healing health checks on every load (non-blocking, <100ms target)
83
+ runHealthChecks().catch(() => {
84
+ // Health check failures are non-fatal — oc_doctor provides manual diagnostics
85
+ });
86
+
87
+ // --- Observability subsystem initialization ---
88
+ const eventStore = new SessionEventStore();
89
+ const contextMonitor = new ContextMonitor();
90
+
91
+ // Retention pruning on load (non-blocking per D-14)
92
+ pruneOldLogs().catch((err) => {
93
+ console.error("[opencode-autopilot]", err);
94
+ });
95
+
65
96
  // --- Fallback subsystem initialization ---
66
97
  const sdkOps: SdkOperations = {
67
98
  abortSession: async (sessionID) => {
@@ -115,6 +146,32 @@ const plugin: Plugin = async (input) => {
115
146
  const chatMessageHandler = createChatMessageHandler(manager);
116
147
  const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
117
148
 
149
+ // --- Observability handlers ---
150
+ const toolStartTimes = new Map<string, number>();
151
+ const observabilityEventHandler = createObservabilityEventHandler({
152
+ eventStore,
153
+ contextMonitor,
154
+ showToast: sdkOps.showToast,
155
+ writeSessionLog: async (sessionData) => {
156
+ if (!sessionData) return;
157
+ // Filter to schema-valid event types that match SessionEvent discriminated union
158
+ const schemaEvents: SessionEvent[] = sessionData.events.filter(
159
+ (e): e is SessionEvent =>
160
+ e.type === "fallback" ||
161
+ e.type === "error" ||
162
+ e.type === "decision" ||
163
+ e.type === "model_switch",
164
+ );
165
+ await writeSessionLog({
166
+ sessionId: sessionData.sessionId,
167
+ startedAt: sessionData.startedAt,
168
+ events: schemaEvents,
169
+ });
170
+ },
171
+ });
172
+ const obsToolBeforeHandler = createToolExecuteBeforeHandler(toolStartTimes);
173
+ const obsToolAfterHandler = createObsToolAfterHandler(eventStore, toolStartTimes);
174
+
118
175
  return {
119
176
  tool: {
120
177
  oc_configure: ocConfigure,
@@ -126,10 +183,20 @@ const plugin: Plugin = async (input) => {
126
183
  oc_phase: ocPhase,
127
184
  oc_plan: ocPlan,
128
185
  oc_orchestrate: ocOrchestrate,
186
+ oc_doctor: ocDoctor,
187
+ oc_quick: ocQuick,
129
188
  oc_forensics: ocForensics,
130
189
  oc_review: ocReview,
190
+ oc_logs: ocLogs,
191
+ oc_session_stats: ocSessionStats,
192
+ oc_pipeline_report: ocPipelineReport,
193
+ oc_mock_fallback: ocMockFallback,
131
194
  },
132
195
  event: async ({ event }) => {
196
+ // 1. Observability: collect (pure observer, no side effects on session)
197
+ await observabilityEventHandler({ event });
198
+
199
+ // 2. First-load toast
133
200
  if (event.type === "session.created" && isFirstLoad(config)) {
134
201
  await sdkOps.showToast(
135
202
  "Welcome to OpenCode Autopilot!",
@@ -138,7 +205,7 @@ const plugin: Plugin = async (input) => {
138
205
  );
139
206
  }
140
207
 
141
- // Fallback event handling (runs for all events)
208
+ // 3. Fallback event handling
142
209
  if (fallbackConfig.enabled) {
143
210
  await fallbackEventHandler({ event });
144
211
  }
@@ -163,6 +230,12 @@ const plugin: Plugin = async (input) => {
163
230
  await chatMessageHandler(hookInput, output);
164
231
  }
165
232
  },
233
+ "tool.execute.before": async (
234
+ input: { tool: string; sessionID: string; callID: string },
235
+ output: { args: unknown },
236
+ ) => {
237
+ obsToolBeforeHandler({ ...input, args: output.args });
238
+ },
166
239
  "tool.execute.after": async (
167
240
  hookInput: {
168
241
  readonly tool: string;
@@ -172,6 +245,10 @@ const plugin: Plugin = async (input) => {
172
245
  },
173
246
  output: { title: string; output: string; metadata: unknown },
174
247
  ) => {
248
+ // Observability: record tool execution (pure observer)
249
+ obsToolAfterHandler(hookInput, output);
250
+
251
+ // Fallback handling
175
252
  if (fallbackConfig.enabled) {
176
253
  await toolExecuteAfterHandler(hookInput, output);
177
254
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Context utilization tracking with one-time warning per session.
3
+ *
4
+ * Pure function `checkContextUtilization` computes utilization ratio and warning signal.
5
+ * `ContextMonitor` class tracks per-session warned state and context limits.
6
+ *
7
+ * The toast itself is NOT fired here -- that happens in the event handler
8
+ * (separation of concerns). This module only computes whether to warn.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /** Threshold at which context utilization triggers a warning (80%). */
14
+ const CONTEXT_WARNING_THRESHOLD = 0.8;
15
+
16
+ /**
17
+ * Result of a context utilization check.
18
+ */
19
+ export interface ContextUtilizationResult {
20
+ readonly utilization: number;
21
+ readonly shouldWarn: boolean;
22
+ }
23
+
24
+ /**
25
+ * Pure function that computes context utilization and whether to warn.
26
+ *
27
+ * - Returns utilization as a ratio (0.0 - 1.0)
28
+ * - Returns shouldWarn=true when utilization >= 0.80 and not already warned
29
+ * - Returns shouldWarn=false when already warned (fires once per D-36)
30
+ * - Handles zero contextLimit gracefully (returns 0 utilization)
31
+ *
32
+ * @param latestInputTokens - Current cumulative input tokens for the session
33
+ * @param contextLimit - The model's context window size in tokens
34
+ * @param alreadyWarned - Whether this session has already been warned
35
+ */
36
+ export function checkContextUtilization(
37
+ latestInputTokens: number,
38
+ contextLimit: number,
39
+ alreadyWarned: boolean,
40
+ ): ContextUtilizationResult {
41
+ if (contextLimit <= 0) {
42
+ return { utilization: 0, shouldWarn: false };
43
+ }
44
+
45
+ const utilization = latestInputTokens / contextLimit;
46
+ const shouldWarn = !alreadyWarned && utilization >= CONTEXT_WARNING_THRESHOLD;
47
+
48
+ return { utilization, shouldWarn };
49
+ }
50
+
51
+ /**
52
+ * Per-session state for context monitoring.
53
+ */
54
+ interface SessionContextState {
55
+ readonly contextLimit: number;
56
+ warned: boolean;
57
+ }
58
+
59
+ /**
60
+ * Tracks context utilization per session with one-time warning.
61
+ *
62
+ * - `initSession` sets the context limit for a session
63
+ * - `processMessage` checks utilization and updates warned state
64
+ * - `cleanup` removes session tracking data
65
+ */
66
+ export class ContextMonitor {
67
+ private readonly sessions: Map<string, SessionContextState> = new Map();
68
+
69
+ /**
70
+ * Initializes tracking for a session with its model's context limit.
71
+ */
72
+ initSession(sessionID: string, contextLimit: number): void {
73
+ this.sessions.set(sessionID, { contextLimit, warned: false });
74
+ }
75
+
76
+ /**
77
+ * Checks context utilization for a session and updates warned state.
78
+ * Returns utilization 0 for unknown sessions.
79
+ */
80
+ processMessage(sessionID: string, inputTokens: number): ContextUtilizationResult {
81
+ const state = this.sessions.get(sessionID);
82
+ if (!state) {
83
+ return { utilization: 0, shouldWarn: false };
84
+ }
85
+
86
+ const result = checkContextUtilization(inputTokens, state.contextLimit, state.warned);
87
+
88
+ // Update warned flag if warning triggered (one-time per session)
89
+ if (result.shouldWarn) {
90
+ this.sessions.set(sessionID, { ...state, warned: true });
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * Removes session tracking data.
98
+ */
99
+ cleanup(sessionID: string): void {
100
+ this.sessions.delete(sessionID);
101
+ }
102
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Typed event emitter helper functions for observability events.
3
+ *
4
+ * Each function constructs a frozen ObservabilityEvent with a timestamp.
5
+ * Pure functions: take args, return frozen event object.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { ObservabilityEvent } from "./event-store";
11
+
12
+ /**
13
+ * Constructs a fallback event.
14
+ */
15
+ export function emitFallbackEvent(
16
+ sessionId: string,
17
+ failedModel: string,
18
+ nextModel: string,
19
+ reason: string,
20
+ success: boolean,
21
+ ): ObservabilityEvent {
22
+ return Object.freeze({
23
+ type: "fallback" as const,
24
+ timestamp: new Date().toISOString(),
25
+ sessionId,
26
+ failedModel,
27
+ nextModel,
28
+ reason,
29
+ success,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Constructs an error event.
35
+ */
36
+ export function emitErrorEvent(
37
+ sessionId: string,
38
+ errorType:
39
+ | "rate_limit"
40
+ | "quota_exceeded"
41
+ | "service_unavailable"
42
+ | "missing_api_key"
43
+ | "model_not_found"
44
+ | "content_filter"
45
+ | "context_length"
46
+ | "unknown",
47
+ message: string,
48
+ model = "unknown",
49
+ statusCode?: number,
50
+ ): ObservabilityEvent {
51
+ return Object.freeze({
52
+ type: "error" as const,
53
+ timestamp: new Date().toISOString(),
54
+ sessionId,
55
+ errorType,
56
+ message,
57
+ model,
58
+ ...(statusCode !== undefined ? { statusCode } : {}),
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Constructs a decision event (per D-27, D-28).
64
+ */
65
+ export function emitDecisionEvent(
66
+ sessionId: string,
67
+ phase: string,
68
+ agent: string,
69
+ decision: string,
70
+ rationale: string,
71
+ ): ObservabilityEvent {
72
+ return Object.freeze({
73
+ type: "decision" as const,
74
+ timestamp: new Date().toISOString(),
75
+ sessionId,
76
+ phase,
77
+ agent,
78
+ decision,
79
+ rationale,
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Constructs a model_switch event.
85
+ */
86
+ export function emitModelSwitchEvent(
87
+ sessionId: string,
88
+ fromModel: string,
89
+ toModel: string,
90
+ trigger: "fallback" | "config" | "user",
91
+ ): ObservabilityEvent {
92
+ return Object.freeze({
93
+ type: "model_switch" as const,
94
+ timestamp: new Date().toISOString(),
95
+ sessionId,
96
+ fromModel,
97
+ toModel,
98
+ trigger,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Constructs a tool_complete event.
104
+ */
105
+ export function emitToolCompleteEvent(
106
+ sessionId: string,
107
+ tool: string,
108
+ durationMs: number,
109
+ success: boolean,
110
+ ): ObservabilityEvent {
111
+ return Object.freeze({
112
+ type: "tool_complete" as const,
113
+ timestamp: new Date().toISOString(),
114
+ sessionId,
115
+ tool,
116
+ durationMs,
117
+ success,
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Constructs a phase_transition event.
123
+ */
124
+ export function emitPhaseTransition(
125
+ sessionId: string,
126
+ fromPhase: string,
127
+ toPhase: string,
128
+ ): ObservabilityEvent {
129
+ return Object.freeze({
130
+ type: "phase_transition" as const,
131
+ timestamp: new Date().toISOString(),
132
+ sessionId,
133
+ fromPhase,
134
+ toPhase,
135
+ });
136
+ }