@agentapprove/opencode 0.1.2 → 0.1.4

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 (2) hide show
  1. package/dist/index.js +423 -86
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { homedir as homedir3 } from "os";
9
9
  import { execSync } from "child_process";
10
10
 
11
11
  // src/e2e-crypto.ts
12
- import { createHash, createHmac, createCipheriv, randomBytes } from "crypto";
12
+ import { createHash, createHmac, createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from "crypto";
13
13
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, copyFileSync } from "fs";
14
14
  import { join as join2 } from "path";
15
15
  import { homedir as homedir2 } from "os";
@@ -62,6 +62,50 @@ function debugLogRawInline(data) {
62
62
  } catch {
63
63
  }
64
64
  }
65
+ function safeStringify(value) {
66
+ try {
67
+ const seen = /* @__PURE__ */ new WeakSet();
68
+ return JSON.stringify(value, (_key, current) => {
69
+ if (typeof current === "bigint") return `BigInt(${current.toString()})`;
70
+ if (typeof current === "function") {
71
+ const fn = current;
72
+ return `[Function ${fn.name || "anonymous"}]`;
73
+ }
74
+ if (typeof current === "symbol") return current.toString();
75
+ if (current instanceof Error) {
76
+ return {
77
+ name: current.name,
78
+ message: current.message,
79
+ stack: current.stack
80
+ };
81
+ }
82
+ if (current && typeof current === "object") {
83
+ const obj = current;
84
+ if (seen.has(obj)) return "[Circular]";
85
+ seen.add(obj);
86
+ }
87
+ return current;
88
+ }) ?? "null";
89
+ } catch (error) {
90
+ const msg = error instanceof Error ? error.message : String(error);
91
+ return `"[Unserializable: ${msg}]"`;
92
+ }
93
+ }
94
+ function debugLogRaw(data, hookName = "opencode-plugin") {
95
+ try {
96
+ ensureLogFile();
97
+ const ts = localTimestamp();
98
+ const rawData = typeof data === "string" ? data : safeStringify(data);
99
+ appendFileSync(
100
+ DEBUG_LOG_PATH,
101
+ `[${ts}] [${hookName}] === RAW INPUT ===
102
+ ${rawData}
103
+ [${ts}] [${hookName}] === END RAW ===
104
+ `
105
+ );
106
+ } catch {
107
+ }
108
+ }
65
109
 
66
110
  // src/e2e-crypto.ts
67
111
  function keyId(keyHex) {
@@ -91,6 +135,33 @@ function e2eEncrypt(keyHex, plaintext) {
91
135
  const hmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest("hex");
92
136
  return `E2E:v1:${kid}:${ivHex}:${ciphertextBase64}:${hmac}`;
93
137
  }
138
+ function isE2EEncrypted(value) {
139
+ return value.startsWith("E2E:v1:");
140
+ }
141
+ function e2eDecrypt(keyHex, encrypted) {
142
+ try {
143
+ if (!isE2EEncrypted(encrypted)) return null;
144
+ const parts = encrypted.split(":");
145
+ if (parts.length !== 6) return null;
146
+ const ivHex = parts[3];
147
+ const ciphertextBase64 = parts[4];
148
+ const hmacHex = parts[5];
149
+ const encKey = deriveEncKey(keyHex);
150
+ const macKey = deriveMacKey(keyHex);
151
+ const iv = Buffer.from(ivHex, "hex");
152
+ const ciphertext = Buffer.from(ciphertextBase64, "base64");
153
+ const expectedHmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest();
154
+ const actualHmac = Buffer.from(hmacHex, "hex");
155
+ if (expectedHmac.length !== actualHmac.length || !timingSafeEqual(expectedHmac, actualHmac)) {
156
+ return null;
157
+ }
158
+ const decipher = createDecipheriv("aes-256-ctr", encKey, iv);
159
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
160
+ return plaintext.toString("utf-8");
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
94
165
  function applyApprovalE2E(payload, userKey, serverKey) {
95
166
  const sensitiveFields = {};
96
167
  for (const field of ["command", "toolInput", "cwd"]) {
@@ -687,6 +758,45 @@ async function sendApprovalRequest(request, config, pluginPath) {
687
758
  processConfigSync(parsed, config);
688
759
  return parsed;
689
760
  }
761
+ async function sendStopRequest(request, config, pluginPath) {
762
+ if (!config.token) {
763
+ throw new Error("No Agent Approve token configured");
764
+ }
765
+ const payload = applyEventPrivacyFilter(
766
+ { ...request },
767
+ config.privacyTier
768
+ );
769
+ const bodyStr = JSON.stringify(payload);
770
+ const pluginHash = getPluginHash(pluginPath, config.debug);
771
+ const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
772
+ const headers = {
773
+ "Authorization": `Bearer ${config.token}`,
774
+ ...hmacHeaders
775
+ };
776
+ const url = `${config.apiUrl}/${config.apiVersion}/stop`;
777
+ const stopTimeoutMs = 3e5;
778
+ if (config.debug) {
779
+ debugLog(`Sending stop request to ${url}`);
780
+ debugLog(`=== SENT TO ${url} ===`);
781
+ debugLogRawInline(bodyStr);
782
+ debugLog("=== END SENT ===");
783
+ }
784
+ const response = await httpPost(url, bodyStr, headers, stopTimeoutMs);
785
+ if (config.debug) {
786
+ debugLog(`Stop response: ${response.body || "<empty>"}`);
787
+ }
788
+ if (response.status !== 200) {
789
+ throw new Error(`Stop API returned status ${response.status}: ${response.body.slice(0, 200)}`);
790
+ }
791
+ let parsed;
792
+ try {
793
+ parsed = JSON.parse(response.body);
794
+ } catch {
795
+ parsed = {};
796
+ }
797
+ processConfigSync(parsed, config);
798
+ return parsed;
799
+ }
690
800
  async function sendEvent(event, config, pluginPath) {
691
801
  if (!config.token) return;
692
802
  const eventType = event.eventType;
@@ -734,25 +844,37 @@ async function sendEvent(event, config, pluginPath) {
734
844
  }
735
845
 
736
846
  // src/index.ts
847
+ var HOOK_PLUGIN = "opencode-plugin";
848
+ var HOOK_PERMISSION = "opencode-permission";
849
+ var HOOK_TOOL_BEFORE = "opencode-tool-before";
850
+ var HOOK_TOOL_AFTER = "opencode-tool-after";
851
+ var HOOK_CHAT_MSG = "opencode-chat-msg";
852
+ var HOOK_EVENT = "opencode-event";
853
+ var HOOK_COMPACTING = "opencode-compacting";
737
854
  var pluginFilePath;
738
855
  try {
739
856
  pluginFilePath = fileURLToPath(import.meta.url);
740
857
  } catch {
741
858
  pluginFilePath = __filename;
742
859
  }
743
- var sessionId = randomBytes2(12).toString("hex");
860
+ var fallbackSessionId = randomBytes2(12).toString("hex");
744
861
  var COMPACTION_DEDUP_WINDOW_MS = 2e3;
745
862
  var openCodeClient = void 0;
746
863
  var lastCompactionEventAt = 0;
864
+ var processingIdleSessions = /* @__PURE__ */ new Set();
865
+ var seenUnmappedEventTypes = /* @__PURE__ */ new Set();
866
+ var sentFinalMessageIds = /* @__PURE__ */ new Set();
867
+ var messageParts = /* @__PURE__ */ new Map();
868
+ var NOISY_EVENT_TYPES = /* @__PURE__ */ new Set(["message.part.delta"]);
747
869
  function classifyTool(toolName) {
748
870
  const lower = toolName.toLowerCase();
749
871
  if (lower === "bash") {
750
872
  return { toolType: "shell_command", displayName: toolName };
751
873
  }
752
- if (lower === "write" || lower === "edit" || lower === "patch") {
874
+ if (lower === "write" || lower === "edit" || lower === "patch" || lower === "apply_patch") {
753
875
  return { toolType: "file_write", displayName: toolName };
754
876
  }
755
- if (lower === "read" || lower === "grep" || lower === "glob" || lower === "list") {
877
+ if (lower === "read" || lower === "grep" || lower === "glob" || lower === "list" || lower === "ls" || lower === "codesearch") {
756
878
  return { toolType: "file_read", displayName: toolName };
757
879
  }
758
880
  if (lower === "webfetch" || lower === "websearch") {
@@ -772,10 +894,10 @@ function extractCommand(toolName, params) {
772
894
  return params.command || void 0;
773
895
  }
774
896
  if (lower === "write" || lower === "edit" || lower === "patch") {
775
- return params.file_path || params.path || void 0;
897
+ return params.file_path || params.filePath || params.path || void 0;
776
898
  }
777
899
  if (lower === "read") {
778
- return params.file_path || params.path || void 0;
900
+ return params.file_path || params.filePath || params.path || void 0;
779
901
  }
780
902
  if (lower === "grep") {
781
903
  return params.pattern || void 0;
@@ -794,9 +916,25 @@ function extractCommand(toolName, params) {
794
916
  }
795
917
  return void 0;
796
918
  }
919
+ function resolveIds(preferredSessionId) {
920
+ const id = preferredSessionId || fallbackSessionId;
921
+ return { sessionId: id, conversationId: id };
922
+ }
923
+ function getEventSessionId(eventName, properties) {
924
+ const sessionID = properties.sessionID;
925
+ if (typeof sessionID === "string") return sessionID;
926
+ const info = properties.info;
927
+ if (info) {
928
+ if (typeof info.sessionID === "string") return info.sessionID;
929
+ if (eventName === "session.created" && typeof info.id === "string") return info.id;
930
+ }
931
+ const part = properties.part;
932
+ if (part && typeof part.sessionID === "string") return part.sessionID;
933
+ return void 0;
934
+ }
797
935
  function handleFailBehavior(config, error, toolName) {
798
936
  if (config.debug) {
799
- debugLog(`API error: ${error.message}, failBehavior: ${config.failBehavior}`);
937
+ debugLog(`API error: ${error.message}, failBehavior: ${config.failBehavior}`, HOOK_PERMISSION);
800
938
  }
801
939
  switch (config.failBehavior) {
802
940
  case "deny":
@@ -809,20 +947,11 @@ function handleFailBehavior(config, error, toolName) {
809
947
  }
810
948
  }
811
949
  function mapEventType(eventName) {
812
- switch (eventName) {
813
- case "session.created":
814
- return "session_start";
815
- case "session.idle":
816
- return "stop";
817
- case "session.compacted":
818
- return "context_compact";
819
- case "session.error":
820
- return "error";
821
- case "message.updated":
822
- return "response";
823
- default:
824
- return void 0;
825
- }
950
+ if (eventName.startsWith("session.created")) return "session_start";
951
+ if (eventName === "session.compacted") return "context_compact";
952
+ if (eventName === "session.updated") return void 0;
953
+ if (eventName.startsWith("session.error") || eventName === "session.event.error") return "error";
954
+ return void 0;
826
955
  }
827
956
  function shouldSkipCompactionEvent() {
828
957
  const now = Date.now();
@@ -832,9 +961,10 @@ function shouldSkipCompactionEvent() {
832
961
  lastCompactionEventAt = now;
833
962
  return false;
834
963
  }
835
- function plugin(context) {
964
+ async function plugin(input) {
836
965
  const config = loadConfig();
837
- openCodeClient = context.client;
966
+ openCodeClient = input.client;
967
+ const projectPath = input.directory || process.cwd();
838
968
  if (!config.token) {
839
969
  console.warn(
840
970
  "Agent Approve: No token found. Run the Agent Approve installer to pair with your account."
@@ -842,17 +972,39 @@ function plugin(context) {
842
972
  return {};
843
973
  }
844
974
  if (config.debug) {
845
- debugLog(`Plugin loaded, API: ${config.apiUrl}, agent: ${config.agentName}`);
975
+ const e2eStatus = !config.e2eEnabled ? "disabled" : !config.e2eUserKey ? "enabled (key missing)" : "enabled";
976
+ debugLog(
977
+ `Plugin loaded: v${config.hookVersion}, api=${config.apiUrl}, privacy=${config.privacyTier}, e2e=${e2eStatus}, debug=${config.debug}`,
978
+ HOOK_PLUGIN
979
+ );
980
+ debugLog(`Full config: agent=${config.agentName}, timeout=${config.timeout}s, fail=${config.failBehavior}`, HOOK_PLUGIN);
846
981
  }
847
982
  return {
848
983
  // -------------------------------------------------------------------
849
984
  // permission.ask: Primary approval gate (blocking)
985
+ //
986
+ // Source signature (opencode/packages/plugin/src/index.ts L179):
987
+ // (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
988
+ //
989
+ // Permission = { id, type, pattern?, sessionID, messageID, callID?,
990
+ // message, metadata, time: { created } }
991
+ //
992
+ // Called from opencode/packages/opencode/src/permission/index.ts L134:
993
+ // Plugin.trigger("permission.ask", info, { status: "ask" })
850
994
  // -------------------------------------------------------------------
851
- "permission.ask": async (input, output) => {
852
- const toolName = input.tool.name;
853
- const params = input.tool.input || {};
995
+ "permission.ask": async (input2, output) => {
996
+ if (config.debug) {
997
+ debugLogRaw({ input: input2, output }, HOOK_PERMISSION);
998
+ debugLog("Started permission.ask hook", HOOK_PERMISSION);
999
+ }
1000
+ const toolName = input2.type;
1001
+ const params = input2.metadata || {};
854
1002
  const { toolType, displayName } = classifyTool(toolName);
855
1003
  const command = extractCommand(toolName, params);
1004
+ if (config.debug) {
1005
+ debugLog(`Tool: ${displayName} (${toolType}) [agent: ${config.agentName}]`, HOOK_PERMISSION);
1006
+ }
1007
+ const ids = resolveIds(input2.sessionID);
856
1008
  const request = {
857
1009
  toolName: displayName,
858
1010
  toolType,
@@ -861,27 +1013,28 @@ function plugin(context) {
861
1013
  agent: config.agentType,
862
1014
  agentName: config.agentName,
863
1015
  hookType: "permission_ask",
864
- sessionId,
865
- conversationId: sessionId,
1016
+ sessionId: ids.sessionId,
1017
+ conversationId: ids.conversationId,
866
1018
  cwd: params.workdir || params.cwd || void 0,
867
- projectPath: process.cwd(),
1019
+ projectPath,
868
1020
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
869
1021
  };
870
1022
  try {
871
1023
  const response = await sendApprovalRequest(request, config, pluginFilePath);
1024
+ if (config.debug) {
1025
+ debugLog(`Decision: ${response.decision}, Reason: ${response.reason || ""}`, HOOK_PERMISSION);
1026
+ }
872
1027
  if (response.decision === "approve" || response.decision === "allow") {
873
1028
  if (config.debug) {
874
- debugLog(`Tool "${toolName}" approved${response.reason ? ": " + response.reason : ""}`);
1029
+ debugLog("Tool approved", HOOK_PERMISSION);
875
1030
  }
876
1031
  output.status = "allow";
877
- if (response.reason) output.reason = response.reason;
878
1032
  return;
879
1033
  }
880
1034
  if (config.debug) {
881
- debugLog(`Tool "${toolName}" denied${response.reason ? ": " + response.reason : ""}`);
1035
+ debugLog("Tool denied", HOOK_PERMISSION);
882
1036
  }
883
1037
  output.status = "deny";
884
- output.reason = response.reason || "Denied by Agent Approve";
885
1038
  } catch (error) {
886
1039
  const result = handleFailBehavior(
887
1040
  config,
@@ -895,10 +1048,24 @@ function plugin(context) {
895
1048
  },
896
1049
  // -------------------------------------------------------------------
897
1050
  // tool.execute.before: Tool start monitoring (non-blocking)
1051
+ //
1052
+ // Source signature (opencode/packages/plugin/src/index.ts L184-187):
1053
+ // (input: { tool: string; sessionID: string; callID: string },
1054
+ // output: { args: any }) => Promise<void>
1055
+ //
1056
+ // Called from opencode/packages/opencode/src/session/prompt.ts L792-802:
1057
+ // Plugin.trigger("tool.execute.before",
1058
+ // { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
1059
+ // { args })
898
1060
  // -------------------------------------------------------------------
899
- "tool.execute.before": async (input) => {
900
- const toolName = input.tool.name;
901
- const params = input.tool.input || {};
1061
+ "tool.execute.before": async (input2, output) => {
1062
+ if (config.debug) {
1063
+ debugLogRaw({ input: input2, output }, HOOK_TOOL_BEFORE);
1064
+ debugLog(`Tool start: ${input2.tool} [session: ${input2.sessionID}]`, HOOK_TOOL_BEFORE);
1065
+ }
1066
+ const ids = resolveIds(input2.sessionID);
1067
+ const toolName = input2.tool;
1068
+ const params = output.args && typeof output.args === "object" ? output.args : {};
902
1069
  const { toolType } = classifyTool(toolName);
903
1070
  void sendEvent({
904
1071
  toolName,
@@ -907,8 +1074,8 @@ function plugin(context) {
907
1074
  agent: config.agentType,
908
1075
  agentName: config.agentName,
909
1076
  hookType: "tool_execute_before",
910
- sessionId,
911
- conversationId: sessionId,
1077
+ sessionId: ids.sessionId,
1078
+ conversationId: ids.conversationId,
912
1079
  command: extractCommand(toolName, params),
913
1080
  toolInput: params,
914
1081
  cwd: params.workdir || params.cwd || void 0,
@@ -917,13 +1084,28 @@ function plugin(context) {
917
1084
  },
918
1085
  // -------------------------------------------------------------------
919
1086
  // tool.execute.after: Tool completion logging (non-blocking)
1087
+ //
1088
+ // Source signature (opencode/packages/plugin/src/index.ts L192-199):
1089
+ // (input: { tool: string; sessionID: string; callID: string; args: any },
1090
+ // output: { title: string; output: string; metadata: any }) => Promise<void>
1091
+ //
1092
+ // Called from opencode/packages/opencode/src/session/prompt.ts L813-822:
1093
+ // Plugin.trigger("tool.execute.after",
1094
+ // { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
1095
+ // output)
920
1096
  // -------------------------------------------------------------------
921
- "tool.execute.after": async (input) => {
922
- const toolName = input.tool.name;
923
- const params = input.tool.input || {};
1097
+ "tool.execute.after": async (input2, output) => {
1098
+ if (config.debug) {
1099
+ debugLogRaw({ input: input2, output }, HOOK_TOOL_AFTER);
1100
+ const resultLen = typeof output?.output === "string" ? output.output.length : 0;
1101
+ debugLog(`Tool complete: ${input2.tool}, output=${resultLen} chars${output?.metadata?.error ? ", ERROR" : ""}`, HOOK_TOOL_AFTER);
1102
+ }
1103
+ const ids = resolveIds(input2.sessionID);
1104
+ const toolName = input2.tool;
1105
+ const params = input2.args && typeof input2.args === "object" ? input2.args : {};
924
1106
  const { toolType } = classifyTool(toolName);
925
- const resultStr = input.result != null ? typeof input.result === "string" ? input.result : JSON.stringify(input.result) : void 0;
926
- const response = input.error || resultStr || void 0;
1107
+ const resultStr = typeof output?.output === "string" ? output.output : void 0;
1108
+ const response = resultStr || void 0;
927
1109
  const responsePreview = resultStr && resultStr.length > 1e3 ? resultStr.slice(0, 1e3) + "..." : resultStr;
928
1110
  void sendEvent({
929
1111
  toolName,
@@ -932,31 +1114,48 @@ function plugin(context) {
932
1114
  agent: config.agentType,
933
1115
  agentName: config.agentName,
934
1116
  hookType: "tool_execute_after",
935
- sessionId,
936
- conversationId: sessionId,
1117
+ sessionId: ids.sessionId,
1118
+ conversationId: ids.conversationId,
937
1119
  command: extractCommand(toolName, params),
938
1120
  toolInput: params,
939
- status: input.error ? "error" : "success",
1121
+ status: output?.metadata?.error ? "error" : "success",
940
1122
  response,
941
1123
  responsePreview,
942
- durationMs: input.durationMs,
943
1124
  cwd: params.workdir || params.cwd || void 0,
944
1125
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
945
1126
  }, config, pluginFilePath);
946
1127
  },
947
1128
  // -------------------------------------------------------------------
948
1129
  // chat.message: User prompt capture (non-blocking)
1130
+ //
1131
+ // Source signature (opencode/packages/plugin/src/index.ts L158-167):
1132
+ // (input: { sessionID: string; agent?: string; model?: {...};
1133
+ // messageID?: string; variant?: string },
1134
+ // output: { message: UserMessage; parts: Part[] }) => Promise<void>
1135
+ //
1136
+ // Called from opencode/packages/opencode/src/session/prompt.ts L1295-1308:
1137
+ // Plugin.trigger("chat.message",
1138
+ // { sessionID, agent, model, messageID, variant },
1139
+ // { message: info, parts })
1140
+ //
1141
+ // Only fires for user messages (inside createUserMessage).
1142
+ // User text is in output.parts where part.type === "text" && !part.synthetic
949
1143
  // -------------------------------------------------------------------
950
- "chat.message": async (input) => {
951
- if (input.role && input.role !== "user") return;
952
- const text = input.content || "";
1144
+ "chat.message": async (input2, output) => {
1145
+ if (config.debug) {
1146
+ debugLogRaw({ input: input2, output }, HOOK_CHAT_MSG);
1147
+ debugLog(`Chat message: session=${input2.sessionID}, parts=${output.parts?.length ?? 0}, agent=${input2.agent || "default"}`, HOOK_CHAT_MSG);
1148
+ }
1149
+ const ids = resolveIds(input2.sessionID);
1150
+ const textParts = (output.parts || []).filter((p) => p.type === "text" && !p.synthetic && p.text).map((p) => p.text);
1151
+ const text = textParts.join("\n");
953
1152
  void sendEvent({
954
1153
  eventType: "user_prompt",
955
1154
  agent: config.agentType,
956
1155
  agentName: config.agentName,
957
1156
  hookType: "chat_message",
958
- sessionId,
959
- conversationId: sessionId,
1157
+ sessionId: ids.sessionId,
1158
+ conversationId: ids.conversationId,
960
1159
  prompt: text,
961
1160
  textLength: text.length,
962
1161
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -964,56 +1163,181 @@ function plugin(context) {
964
1163
  },
965
1164
  // -------------------------------------------------------------------
966
1165
  // event: Catch-all session lifecycle events (blocking for session.idle)
1166
+ //
1167
+ // Source signature (opencode/packages/plugin/src/index.ts L149):
1168
+ // event?: (input: { event: Event }) => Promise<void>
1169
+ //
1170
+ // Dispatched from opencode/packages/opencode/src/plugin/index.ts L134-141:
1171
+ // Bus.subscribeAll(async (input) => {
1172
+ // for (const hook of hooks) { hook["event"]?.({ event: input }) }
1173
+ // })
1174
+ //
1175
+ // Bus events have shape: { type: string, properties: ... }
967
1176
  // -------------------------------------------------------------------
968
- "event": async (input) => {
969
- const eventName = input.type;
970
- const agEventType = mapEventType(eventName);
971
- if (!agEventType) {
972
- if (config.debug) {
973
- debugLog(`Ignoring unmapped event: ${eventName}`);
1177
+ "event": async (input2) => {
1178
+ const eventName = input2.event?.type;
1179
+ if (!eventName) return;
1180
+ if (NOISY_EVENT_TYPES.has(eventName)) return;
1181
+ const eventProperties = input2.event.properties || {};
1182
+ const eventSessionId = getEventSessionId(eventName, eventProperties);
1183
+ const ids = resolveIds(eventSessionId);
1184
+ if (config.debug) {
1185
+ debugLog(`Event received: ${eventName}`, HOOK_EVENT);
1186
+ }
1187
+ if (eventName === "message.part.updated") {
1188
+ const part = eventProperties.part;
1189
+ if (!part) return;
1190
+ const messageID = part.messageID;
1191
+ const partID = part.id;
1192
+ const partType = part.type;
1193
+ if (typeof messageID !== "string" || typeof partID !== "string" || typeof partType !== "string") return;
1194
+ const partsByMessage = messageParts.get(messageID) || /* @__PURE__ */ new Map();
1195
+ partsByMessage.set(partID, {
1196
+ type: partType,
1197
+ text: typeof part.text === "string" ? part.text : void 0,
1198
+ synthetic: part.synthetic === true,
1199
+ ignored: part.ignored === true,
1200
+ sessionID: typeof part.sessionID === "string" ? part.sessionID : void 0
1201
+ });
1202
+ messageParts.set(messageID, partsByMessage);
1203
+ return;
1204
+ }
1205
+ if (eventName === "message.part.removed") {
1206
+ const messageID = eventProperties.messageID;
1207
+ const partID = eventProperties.partID;
1208
+ if (typeof messageID === "string" && typeof partID === "string") {
1209
+ messageParts.get(messageID)?.delete(partID);
974
1210
  }
975
1211
  return;
976
1212
  }
977
- if (agEventType === "context_compact" && shouldSkipCompactionEvent()) {
978
- if (config.debug) {
979
- debugLog(`Skipping duplicate context_compact from ${eventName}`);
1213
+ if (eventName === "message.updated") {
1214
+ const info = eventProperties.info;
1215
+ if (!info) return;
1216
+ if (info.role !== "assistant") return;
1217
+ const messageID = info.id;
1218
+ const finish = info.finish;
1219
+ if (typeof messageID !== "string" || typeof finish !== "string") return;
1220
+ if (finish === "tool-calls" || finish === "unknown") return;
1221
+ if (sentFinalMessageIds.has(messageID)) return;
1222
+ sentFinalMessageIds.add(messageID);
1223
+ const partsByMessage = messageParts.get(messageID);
1224
+ const parts = partsByMessage ? Array.from(partsByMessage.values()) : [];
1225
+ const thoughtText = parts.filter((p) => p.type === "reasoning" && typeof p.text === "string" && p.text.length > 0).map((p) => p.text).join("\n");
1226
+ const responseText = parts.filter((p) => p.type === "text" && !p.synthetic && !p.ignored && typeof p.text === "string" && p.text.length > 0).map((p) => p.text).join("\n");
1227
+ if (thoughtText) {
1228
+ void sendEvent({
1229
+ eventType: "thought",
1230
+ agent: config.agentType,
1231
+ agentName: config.agentName,
1232
+ hookType: "event",
1233
+ sessionId: ids.sessionId,
1234
+ conversationId: ids.conversationId,
1235
+ trigger: eventName,
1236
+ text: thoughtText,
1237
+ textPreview: thoughtText.slice(0, 200),
1238
+ textLength: thoughtText.length,
1239
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1240
+ }, config, pluginFilePath);
980
1241
  }
1242
+ if (responseText) {
1243
+ void sendEvent({
1244
+ eventType: "response",
1245
+ agent: config.agentType,
1246
+ agentName: config.agentName,
1247
+ hookType: "event",
1248
+ sessionId: ids.sessionId,
1249
+ conversationId: ids.conversationId,
1250
+ trigger: eventName,
1251
+ text: responseText,
1252
+ textPreview: responseText.slice(0, 200),
1253
+ textLength: responseText.length,
1254
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1255
+ }, config, pluginFilePath);
1256
+ }
1257
+ messageParts.delete(messageID);
981
1258
  return;
982
1259
  }
983
- if (eventName === "session.idle") {
1260
+ if (eventName === "session.idle") return;
1261
+ const status = eventProperties.status;
1262
+ if (eventName === "session.status" && status?.type !== "idle") {
1263
+ processingIdleSessions.delete(ids.sessionId);
1264
+ return;
1265
+ }
1266
+ if (eventName === "session.status" && status?.type === "idle") {
1267
+ if (processingIdleSessions.has(ids.sessionId)) {
1268
+ if (config.debug) {
1269
+ debugLog("Skipping duplicate session.status idle (already processing)", HOOK_EVENT);
1270
+ }
1271
+ return;
1272
+ }
1273
+ processingIdleSessions.add(ids.sessionId);
984
1274
  if (config.debug) {
985
- debugLog("Session idle detected, sending blocking stop event");
1275
+ debugLog("Session idle detected via session.status, sending stop request", HOOK_EVENT);
986
1276
  }
987
1277
  const stopRequest = {
988
- toolName: "session_idle",
989
- toolType: "session",
990
1278
  agent: config.agentType,
991
1279
  agentName: config.agentName,
992
- hookType: "stop",
993
- sessionId,
994
- conversationId: sessionId,
995
- projectPath: process.cwd(),
996
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1280
+ eventType: "stop",
1281
+ status: "completed",
1282
+ loopCount: 0,
1283
+ sessionId: ids.sessionId,
1284
+ conversationId: ids.conversationId,
1285
+ timestamp: Date.now(),
1286
+ ...config.e2eEnabled && config.e2eUserKey ? { e2eKeyId: keyId(config.e2eUserKey) } : {}
997
1287
  };
998
1288
  try {
999
- const response = await sendApprovalRequest(stopRequest, config, pluginFilePath);
1000
- if ((response.decision === "approve" || response.decision === "allow") && response.reason && openCodeClient) {
1289
+ const response = await sendStopRequest(stopRequest, config, pluginFilePath);
1290
+ if (config.debug) {
1291
+ debugLog(`Stop response: followup=${response.followup_message ? "yes" : "no"}`, HOOK_EVENT);
1292
+ }
1293
+ if (response.followup_message && openCodeClient) {
1294
+ let followupText = response.followup_message;
1295
+ if (config.e2eEnabled && config.e2eUserKey && isE2EEncrypted(followupText)) {
1296
+ const decrypted = e2eDecrypt(config.e2eUserKey, followupText);
1297
+ if (decrypted) {
1298
+ if (config.debug) {
1299
+ debugLog(`Decrypted follow-up: ${decrypted.slice(0, 50)}...`, HOOK_EVENT);
1300
+ }
1301
+ followupText = decrypted;
1302
+ } else {
1303
+ if (config.debug) {
1304
+ debugLog("E2E decryption failed for follow-up message, skipping injection", HOOK_EVENT);
1305
+ }
1306
+ return;
1307
+ }
1308
+ }
1001
1309
  if (config.debug) {
1002
- debugLog(`Stop approved with follow-up input: ${response.reason.slice(0, 100)}`);
1310
+ debugLog(`Injecting user follow-up: ${followupText.slice(0, 100)}`, HOOK_EVENT);
1003
1311
  }
1004
1312
  try {
1005
- await openCodeClient.tui.appendPrompt({ body: { text: response.reason } });
1313
+ await openCodeClient.tui.appendPrompt({ body: { text: followupText } });
1006
1314
  await openCodeClient.tui.submitPrompt();
1007
1315
  } catch (injectionError) {
1008
1316
  if (config.debug) {
1009
- debugLog(`Failed to inject follow-up input: ${injectionError instanceof Error ? injectionError.message : String(injectionError)}`);
1317
+ debugLog(`Failed to inject follow-up: ${injectionError instanceof Error ? injectionError.message : String(injectionError)}`, HOOK_EVENT);
1010
1318
  }
1011
1319
  }
1012
1320
  }
1013
1321
  } catch (error) {
1014
1322
  if (config.debug) {
1015
- debugLog(`Stop event error: ${error instanceof Error ? error.message : String(error)}`);
1323
+ debugLog(`Stop request error: ${error instanceof Error ? error.message : String(error)}`, HOOK_EVENT);
1016
1324
  }
1325
+ } finally {
1326
+ processingIdleSessions.delete(ids.sessionId);
1327
+ }
1328
+ return;
1329
+ }
1330
+ const agEventType = mapEventType(eventName);
1331
+ if (!agEventType) {
1332
+ if (config.debug && !seenUnmappedEventTypes.has(eventName)) {
1333
+ seenUnmappedEventTypes.add(eventName);
1334
+ debugLog(`Ignoring unmapped event type: ${eventName}`, HOOK_EVENT);
1335
+ }
1336
+ return;
1337
+ }
1338
+ if (agEventType === "context_compact" && shouldSkipCompactionEvent()) {
1339
+ if (config.debug) {
1340
+ debugLog(`Skipping duplicate context_compact from ${eventName}`, HOOK_EVENT);
1017
1341
  }
1018
1342
  return;
1019
1343
  }
@@ -1022,34 +1346,47 @@ function plugin(context) {
1022
1346
  agent: config.agentType,
1023
1347
  agentName: config.agentName,
1024
1348
  hookType: "event",
1025
- sessionId,
1026
- conversationId: sessionId,
1349
+ sessionId: ids.sessionId,
1350
+ conversationId: ids.conversationId,
1027
1351
  trigger: eventName,
1028
- text: input.data ? JSON.stringify(input.data).slice(0, 500) : void 0,
1352
+ text: Object.keys(eventProperties).length > 0 ? JSON.stringify(eventProperties).slice(0, 500) : void 0,
1029
1353
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1030
1354
  }, config, pluginFilePath);
1031
1355
  },
1032
1356
  // -------------------------------------------------------------------
1033
1357
  // experimental.session.compacting: Pre-compaction event (non-blocking)
1358
+ //
1359
+ // Source signature (opencode/packages/plugin/src/index.ts L222-225):
1360
+ // (input: { sessionID: string },
1361
+ // output: { context: string[]; prompt?: string }) => Promise<void>
1362
+ //
1363
+ // Called from opencode/packages/opencode/src/session/compaction.ts L146-150:
1364
+ // Plugin.trigger("experimental.session.compacting",
1365
+ // { sessionID: input.sessionID },
1366
+ // { context: [], prompt: undefined })
1034
1367
  // -------------------------------------------------------------------
1035
- "experimental.session.compacting": async (input) => {
1368
+ "experimental.session.compacting": async (input2, _output) => {
1369
+ if (config.debug) {
1370
+ debugLogRaw({ input: input2 }, HOOK_COMPACTING);
1371
+ }
1372
+ const ids = resolveIds(input2.sessionID);
1036
1373
  if (shouldSkipCompactionEvent()) {
1037
1374
  if (config.debug) {
1038
- debugLog("Skipping duplicate context_compact from experimental.session.compacting");
1375
+ debugLog("Skipping duplicate context_compact from experimental.session.compacting", HOOK_COMPACTING);
1039
1376
  }
1040
1377
  return;
1041
1378
  }
1042
1379
  if (config.debug) {
1043
- debugLog(`Context compaction: ${input.messageCount ?? "?"} messages${input.tokenCount ? `, ${input.tokenCount} tokens` : ""}`);
1380
+ debugLog(`Context compaction starting for session ${input2.sessionID}`, HOOK_COMPACTING);
1044
1381
  }
1045
1382
  void sendEvent({
1046
1383
  eventType: "context_compact",
1047
1384
  agent: config.agentType,
1048
1385
  agentName: config.agentName,
1049
1386
  hookType: "session_compacting",
1050
- sessionId,
1051
- conversationId: sessionId,
1052
- trigger: `${input.messageCount ?? "?"} messages${input.tokenCount ? `, ${input.tokenCount} tokens` : ""}`,
1387
+ sessionId: ids.sessionId,
1388
+ conversationId: ids.conversationId,
1389
+ trigger: "experimental.session.compacting",
1053
1390
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1054
1391
  }, config, pluginFilePath);
1055
1392
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentapprove/opencode",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Agent Approve plugin for OpenCode - approve or deny AI agent tool calls from your iPhone and Apple Watch",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {